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
app.start_to_first_frame_msโ measured fromSentrinelFlutter.init. It is not cold start: it excludes process spawn and engine boot, which on a slow device is most of the wait. The record says so, inapp.start_measured_from.frames.total/frames.slow/frames.frozenโ slow is over the 16 ms budget; frozen is 700 ms or more, where the app has visibly stopped rather than stuttered. Summarised once a minute, because 60fps is 3,600 frames a minute and one record each would cost more battery than the insight is worth.
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
- Native crashes and ANRs. A segfault in an iOS or Android library kills the process below Dart, leaving nothing for the SDK to report.
- Symbolication. An obfuscated release build produces traces this cannot
un-obfuscate. dSYMs, ProGuard mappings and Dart symbol files upload and are
stored โ
POST /api/ingest/symbolswithkind: "dsym" | "proguard" | "symbols", the same route the web SDK uses for source maps โ but nothing reads them back yet, so a stored artifact changes no stack. Onlykind: "sourcemap"rewrites a trace today.
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
- FULL_CONFIG.md โ complete configuration reference covering backend and Flutter side by side, with every option and signal
- BROWSER.md โ the same signals from a web app
- PLUGIN.md โ backend plugin deep dive
- GUIDE.md โ what each dashboard page shows and needs
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.