Mobile: Flutter and Dart
Two 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 |
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. Sentry splits the same way, for the same reason.
Install
dependencies:
sentrinel_flutter:
git:
url: https://github.com/Zaga-ltd/sentinel_packages
path: packages/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.
env โ must match the environment the API key was issued for. Ingest checks
appName + 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."
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 other isolates | Isolate.onError |
โ |
| Native iOS/Android crashes, ANRs | โ | โ |
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'));
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; there is no upload for dSYMs, ProGuard mappings or Dart symbol files.
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_live_โฆ 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.
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.