Mobile: Flutter, Dart, and the native SDKs

Three packages, because they answer to different constraints.

Package Depends on Use it when
sentrinel http only Any Dart: Flutter, a CLI, a server-side job
sentrinel_flutter flutter, path_provider, sentrinel A Flutter app
sentrinel_swift Foundation only (SPM) A native iOS / macOS app
sentrinel_kotlin Kotlin stdlib only (Gradle) A native Android / JVM app

The core deliberately takes no Flutter dependency โ€” adding one would stop it working outside Flutter, which is where a good part of its use is. Everything needing the framework lives in the integration package instead. The native SDKs follow the same rule: no third-party runtime dependency, just Foundation and the Kotlin stdlib respectively. Sentry splits the same way, for the same reason.


Install

dependencies:
  sentrinel_flutter:
    git:
      url: https://github.com/Zaga-ltd/sentinel_packages
      path: sentrinel_flutter_integration

The whole setup

void main() => SentrinelFlutter.run(
      options: SentrinelOptions(
        serverUrl: 'https://api.sentrinel.dev',
        appName: 'mobile-app',
        env: 'prod',
        release: '1.4.2',
        apiKey: const String.fromEnvironment('SENTRINEL_API_KEY'),
      ),
      app: () => runApp(const MyApp()),
    );

That single call starts a guarded zone, finds a storage directory that survives a restart, installs both error handlers, and begins tracking frames and app start.

runApp goes inside the app callback rather than after the call. Asynchronous errors are only caught within the zone, so calling runApp outside it silently misses most of them.

Add the navigator observer for screen context and navigation breadcrumbs:

MaterialApp(navigatorObservers: [SentrinelNavigatorObserver()])

Two values worth getting right

release โ€” crash-free rate is per release. Without it every build is unknown and a regression is invisible, which is the entire point of the page.

The key should be a mobile key (API Keys โ†’ Generate โ†’ Mobile app). It ships inside your bundle, which is public, so it is issued to send only what a phone sends โ€” a leaked one cannot post a session replay, reach the database collector, or read anything. A server key works too, but exposes more if it leaks.

env โ€” must match the environment the API key was issued for. Ingest checks env against the key and answers 403 on a mismatch, which the SDK reports once at startup and then goes quiet. A wrong value looks exactly like "monitoring is fine, we just have no traffic."

appName and module โ€” the app is the whole project, not the phone. Use the same appName as your backend, a mobile key issued for that app, and module: 'mobile'. The phone's requests, errors and spans then land beside the backend's, and a tap can be followed into the server work it caused as one trace.


What gets captured

Crashes

Uncaught Dart errors are captured automatically and written to disk before the process dies, then delivered on the next launch. That last part is what makes it crash reporting rather than error reporting: the ordinary buffer flushes on a 30-second timer, which a crashing app never reaches.

Source Mechanism Covered
Async errors escaping to the zone runZonedGuarded โœ…
Flutter build/layout/paint errors FlutterError.onError โœ…
Errors reaching the engine PlatformDispatcher.onError โœ…
Errors on isolates you spawn Isolate.spawn(onError:) โœ… one line needed
Uncaught ObjC exceptions / fatal signals (iOS) sentrinel_swift โœ…
Uncaught Kotlin/Java exceptions (Android) sentrinel_kotlin โœ…

Isolates need one extra argument

Isolate.spawn does not inherit the spawning isolate's error listeners. A worker started the ordinary way prints its stack to stderr and reports nothing โ€” the worst-behaved class of crash, because from the dashboard a failed background job looks identical to one that never ran. Pass the port:

import 'dart:isolate';
import 'package:sentrinel/sentrinel.dart';

await Isolate.spawn(parseLargePayload, bytes, onError: isolateErrorPort);

isolateErrorPort is a top-level getter, null on web and before init. Isolate.spawn accepts a null onError, so it is safe to pass unconditionally. Isolate crashes are recorded as fatal, which means they take the crash path: written to disk, delivered on the next launch.

Every report carries the OS and version, locale, core count, the session id, the screen, and the last 25 breadcrumbs. The Issues page renders all of it: a fatal badge, the mechanism that caught it, the device, and the trail as an expandable list, oldest first so it reads as the sequence that led there.

Breadcrumbs fill themselves from HTTP requests and navigation. Add your own for taps and business events:

Sentrinel.addBreadcrumb('tapped pay', category: 'ui', data: {'amount': 42});

Release health

One session per app launch, which is the denominator crash-free rate needs. See GUIDE.md for how to read the page.

Sessions end up in one of four states:

Status Meaning
ok Running, or ended cleanly
crashed A fatal error was written before the process died
abnormal Ended without a clean shutdown and without a crash report โ€” force-quit, OOM kill, flat battery
errored Survived, but reported a handled error

abnormal is kept apart from crashed deliberately. Counting a user swiping the app away as a crash against your release would make the number worthless.

Performance

Both arrive as structured logs with category: performance, so they are searchable and available in the field explorer.

Requests, and the trace that joins them

SentrinelHttpClient records every call and sends traceparent, so the backend plugin continues the same trace rather than starting a new one โ€” a tap and the server work it caused land on one timeline. That join is the thing most tools do not do well.

final client = Sentrinel.httpClient();
await client.get(Uri.parse('https://api.example.com/orders'));

What a recorded call carries

Every request the SDK's HTTP client makes is recorded with more than its timing:

Field Where it comes from
route The path with ids collapsed โ€” /orders/{id}, not /orders/8f3a1b2c
host The host that was called
consumerIdentifier Your consumerIdentifier from init
traceId The same trace the backend continues, via traceparent

route is why your endpoint list stays readable. The server groups endpoints on route || path, so without it every distinct id becomes an endpoint of its own โ€” an unbounded endpoints table and an "active endpoints" count in the hundreds for an app that calls thirty routes.

The collapsing is deliberately conservative. A segment becomes {id} only when it is obviously an identifier: a UUID, all digits, or 8+ hex/opaque characters containing at least one digit. That last condition is what keeps facade, decade and notifications intact โ€” merging real endpoints together would be far worse than leaving one stray id uncollapsed. Pass route yourself if the guess is ever wrong.

Log lines carry consumerIdentifier too, so "everything this user did" finds them without joining through a request that may already have expired.


Who your traffic belongs to

The Consumers page answers "which of my users is this happening to". It is driven by one value, and getting it wrong is the most common way that page ends up useless.

// At startup you do not know who this is yet. Name the install, not a person โ€”
// this is the FALLBACK, used until somebody signs in and again after they
// sign out.
Sentrinel.init(
  serverUrl: 'https://api.sentrinel.dev',
  appName: 'shop-app',
  env: 'prod',
  apiKey: const String.fromEnvironment('SENTRINEL_API_KEY'),
  consumerIdentifier: 'mobile_android',   // a platform, a build โ€” not a user
);

// On sign-in. From here, requests, logs, errors and events are filed under
// this user.
Sentrinel.identify(fan.uid, properties: {'name': fan.name, 'region': fan.region});

// On sign-out โ€” back to 'mobile_android', not to nobody.
Sentrinel.identify(null);

If you set consumerIdentifier and never call identify(), the Consumers page lists platforms. Rows like mobile_android and mobile_ios with thousands of requests each are the symptom: every user on a platform has collapsed into one consumer. The fix is a single identify() call wherever your login succeeds.

For something that is not the signed-in user โ€” a tenant, a device, an API client โ€” use Sentrinel.setConsumer('tenant_7'). Passing null returns to the init value.

Product events and funnels

Errors tell you what broke. Events tell you what people did.

Sentrinel.screen('Cart');
Sentrinel.track('checkout_started', properties: {'cart_value': 42, 'tier': 'pro'});

// When they sign in:
Sentrinel.identify('user_42');

Sentrinel.track('purchase_completed', properties: {'revenue': 129.0});

These feed the Product pages โ€” funnels, retention, top screens โ€” and are deliberately not logs. A log line is written for a human to read while debugging; an event is a row in a funnel, counted and grouped. Sending events through log() either drowns the funnel in debug noise or loses the events among it.

Call Kind Use it for
track(name, properties:) track A funnel step, a feature used, a purchase
screen(name) screen The mobile pageview
identify(userId) identify Attaching a real person, on sign-in โ€” and attributing their requests, logs and errors to them
setConsumer(id) โ€” Naming something that is not the signed-in user: a tenant, a device, an API client

Identity, and why it persists

Every event needs an identity or the server drops it โ€” an event with nobody attached cannot appear in a funnel.

Sentrinel.anonymousId is minted once and written to disk, next to the crash spool. That persistence is the whole point: without it every launch would be a new person, so retention would read flat, funnels would never complete across a restart, and "daily actives" would really mean "daily launches".

identify() also decides who the Consumers page lists. It moves the consumer as well as the event identity, because saying who someone is is the statement that this traffic is theirs. Requests, logs and errors recorded after it are filed under that user; signing out returns to whatever consumerIdentifier was passed to init, so anonymous traffic stays attributable to something.

That makes the consumerIdentifier at init a fallback, not the answer. Set it to something true of an unidentified install โ€” a platform, a build โ€” and let identify() name the person once there is one. Set it and never call identify() and the Consumers page lists platforms, which is the one view whose whole job is naming people.

identify() attaches a real user id. Both ids travel in the same payload, so the server can stitch what someone did before signing in to their account โ€” which is exactly what makes a signup funnel measurable. identify(null) signs them out again.

On web there is no filesystem, so the anonymous id lasts one page load. Use the browser SDK when web analytics matter; it has real storage.

Keep properties small

The server caps them at 50 keys and 4KB per event, and a batch at 500 events. Properties are dimensions you group by โ€” tier, cart_value, plan โ€” not a payload. Anything larger is a log.

Requires nothing beyond Sentrinel.init. Events are buffered like everything else and flushed on the same timer; an unreachable server drops them rather than growing without bound.


Custom metrics

The numbers only your app knows โ€” videos started, seconds buffered, items added to a cart. Same idea as the backend counting tokens, and the same storage.

Sentrinel.count('video.started', 1, {'quality': 'hd'});
Sentrinel.gauge('cart.items', cart.length);
Sentrinel.histogram('video.time_to_first_frame', ms, {'cdn': 'edge-1'}, 'ms');
Kind Means Read back as
count A running total โ€” sales, retries, starts Sum over the window
gauge A level that moves both ways โ€” queue depth, cache size The last reading
histogram A distribution โ€” durations, sizes p50 / p95 / p99

Increments fold in memory and leave as one row per (name, labels) per flush, not one row per call. That is what makes count() safe inside a build method or a scroll listener: a thousand calls in a frame are one row on the wire.

A gauge reports the last value in the window, because a gauge is a reading rather than a total. A histogram ships percentiles, because an average hides the one user whose experience was fifty times worse.

Labels are the dimensions you group by, so they are bounded on purpose: at most 2,000 distinct series and 512 samples per histogram window, with the samples reservoir-sampled past that. Put a user id in a label and you will hit the series cap immediately โ€” that is a log, not a metric.

Needs a mobile (or server) API key, and a ClickHouse telemetry store. A deployment on Postgres refuses custom metrics with a 501 rather than discarding them quietly, so an integration that looks connected is never silently empty.


Not covered

These are one project rather than two: native traces without symbolication are unreadable hex.


Seeing it without building an app

API=https://api.sentrinel.dev KEY=snt_mobile_โ€ฆ APP=mobile-app \
  bun run examples/mobile-telemetry.ts

Sends the same shapes the SDK does โ€” two releases where one is measurably worse, crashes with breadcrumbs and device context, and frame/app-start records.

See also


Troubleshooting

TROUBLESHOOTING.md has a mobile section covering the usual causes: a missing storagePath, guard() without the Flutter handlers, a missing release, and sessions that all report as abnormal.