A step-by-step guide to adding real-time monitoring, error tracking, and distributed tracing to your Flutter application.
Why Monitor Your Flutter App?
When your mobile app talks to a backend, problems can originate on either side. A user reports "the app is slow" โ but is it the network, the API, or the database? Without connected telemetry, you're guessing.
Sentrinel gives you:
- Every outgoing HTTP request recorded with timing, status, and size
- Errors and crashes with full stack traces
- Distributed tracing โ a tap in the app and the backend work it triggered land on one timeline
- Structured logs correlated to specific requests
- Zero blocking โ nothing waits on the network
Prerequisites
- A Sentrinel account (free at sentrinel.dev)
- A Flutter project (Dart SDK >=3.0.0)
- A backend already monitored by Sentrinel (optional but recommended for full traces)
Step 1: Add the Package
Add to your pubspec.yaml:
dependencies:
sentrinel:
git:
url: https://github.com/Zaga-ltd/sentinel_packages.git
path: sentrinel_flutter
Run:
flutter pub get
Step 2: Create an API Key
- Go to app.sentrinel.dev
- Click Connect on your app (or create a new one)
- Click Create API Key
- Copy the key โ and if you lose it, Copy key on the keys table gets it back rather than making you mint a replacement
Step 3: Initialize in main.dart
import 'package:flutter/material.dart';
import 'package:sentrinel/sentrinel.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
Sentrinel.init(
serverUrl: 'https://api.sentrinel.dev',
appName: 'my-flutter-app',
env: kReleaseMode ? 'prod' : 'dev',
apiKey: const String.fromEnvironment('SENTRINEL_API_KEY'),
consumerIdentifier: 'ios_app', // or 'android_app', user id, etc.
);
runApp(const MyApp());
}
Tip: Use --dart-define to pass the API key at build time:
flutter run --dart-define=SENTRINEL_API_KEY=your_key_here
Step 4: Wrap Your HTTP Client
Replace your http.Client with Sentrinel.httpClient():
import 'package:sentrinel/sentrinel.dart';
import 'package:http/http.dart' as http;
// Before:
final client = http.Client();
// After:
final client = Sentrinel.httpClient();
Every request through this client is now automatically recorded.
Using Dio?
Wrap Dio's HTTP client adapter:
import 'package:dio/dio.dart';
import 'package:sentrinel/sentrinel.dart';
final dio = Dio();
dio.httpClientAdapter = SentrinelHttpClientAdapter();
Step 5: Capture Errors
Wrap your app in a zone handler to catch uncaught errors:
import 'dart:async';
import 'package:sentrinel/sentrinel.dart';
void main() {
runZonedGuarded(() {
WidgetsFlutterBinding.ensureInitialized();
Sentrinel.init(/* ... */);
runApp(const MyApp());
}, (error, stack) {
Sentrinel.captureError(error, stack, path: 'main');
});
}
For caught errors in your code:
try {
await api.submitPost(post);
} catch (e, stack) {
Sentrinel.captureError(e, stack, path: 'submit_post');
rethrow;
}
Background isolates need one extra argument
A zone handler covers the isolate it runs on. Isolate.spawn does not inherit
it, and does not inherit the error listeners either โ so a worker that dies
prints to stderr and reports nothing. Pass the port:
import 'dart:isolate';
await Isolate.spawn(parseLargePayload, bytes, onError: isolateErrorPort);
Easy to miss, because the failure mode is silence: a crashed background job looks exactly like one that never ran.
Step 6: Add Context
Attach user or session info to every record:
// After login
Sentrinel.setContext({
'userId': user.id,
'tier': user.subscriptionTier,
'locale': Localizations.localeOf(context).languageCode,
});
// On logout
Sentrinel.clearContext();
Step 7: Flush on Background
When the app goes to the background, flush buffered telemetry so nothing is lost:
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
Sentrinel.flush();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}
Step 8: Structured Logs
Write structured logs that appear in the Sentrinel dashboard:
Sentrinel.info('User signed in', category: 'auth', attributes: {
'method': 'google',
});
Sentrinel.warn('Cache miss', category: 'cache', attributes: {
'key': 'feed_v2',
});
Sentrinel.error('Payment failed', category: 'billing', attributes: {
'orderId': order.id,
'reason': 'insufficient_funds',
});
Full Example
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:sentrinel/sentrinel.dart';
import 'package:http/http.dart' as http;
void main() {
runZonedGuarded(() {
WidgetsFlutterBinding.ensureInitialized();
Sentrinel.init(
serverUrl: 'https://api.sentrinel.dev',
appName: 'my-app',
env: kReleaseMode ? 'prod' : 'dev',
apiKey: const String.fromEnvironment('SENTRINEL_API_KEY'),
);
runApp(const MyApp());
}, (error, stack) {
Sentrinel.captureError(error, stack, path: 'main');
});
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
Sentrinel.flush();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () async {
final client = Sentrinel.httpClient();
final res = await client.get(
Uri.parse('https://api.example.com/posts'),
);
// Process response...
},
child: const Text('Load Posts'),
),
),
),
);
}
}
Distributed Tracing
When your Flutter app calls a backend that also uses the Sentrinel plugin, the SDK automatically sends a traceparent header. This connects the mobile request to the server-side trace โ you can see the full journey from tap to database query in one timeline.
No extra configuration needed. Just make sure both sides use the same appName or that the backend can correlate by trace ID.
What You Get in the Dashboard
After integrating, you'll see:
- Traffic page: Request volume from your mobile app, split by endpoint
- Errors page: Crashes and HTTP errors with stack traces
- Consumers page: Usage breakdown by app version, platform, or user segment
- Traces page: End-to-end traces spanning mobile โ backend
- Logs page: Structured logs correlated to specific requests
Full configuration โ collect everything
The steps above give you the basics. To collect every signal โ HTTP requests, crashes, release health, performance frames, structured logs, context, and distributed tracing โ use this complete configuration:
Using SentrinelFlutter (recommended)
import 'package:flutter/material.dart';
import 'package:sentrinel_flutter/sentrinel_flutter.dart';
void main() => SentrinelFlutter.run(
options: SentrinelOptions(
serverUrl: 'https://api.sentrinel.dev',
appName: 'my-app',
env: 'prod',
apiKey: const String.fromEnvironment('SENTRINEL_API_KEY'),
release: '1.4.2', // critical for release health
flushInterval: Duration(seconds: 30),
),
app: () => runApp(const MyApp()),
);
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Flush on background so crash reports are never lost
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
Sentrinel.flush();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
// Navigation tracking + breadcrumbs
navigatorObservers: [SentrinelNavigatorObserver()],
home: Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () async {
final client = Sentrinel.httpClient();
final res = await client.get(
Uri.parse('https://api.example.com/posts'),
);
},
child: const Text('Load Posts'),
),
),
),
);
}
}
What each piece gives you
| Setup step | What it enables | Dashboard page |
|---|---|---|
SentrinelFlutter.run() with release |
Crash-free rate per release | Release Health |
SentrinelNavigatorObserver() |
Screen navigation breadcrumbs | Issues (breadcrumb trail) |
Sentrinel.httpClient() |
HTTP request recording + traceparent |
Traffic, Traces, Request Logs |
WidgetsBindingObserver + flush() |
Crash reports survive background death | Issues (crash reports) |
Sentrinel.setContext({...}) |
User/tier/locale on every record | Request Logs (Details tab) |
Sentrinel.info/warn/error(...) |
Structured logs | Logs |
Sentrinel.captureError(...) |
Caught exceptions with stack traces | Issues |
Capturing errors
// Uncaught errors โ in your zone handler
runZonedGuarded(() {
// ...
}, (error, stack) {
Sentrinel.captureError(error, stack, path: 'main');
});
// Caught errors
try {
await api.submitPost(post);
} catch (e, stack) {
Sentrinel.captureError(e, stack, path: 'submit_post');
rethrow;
}
Adding context
// After login โ attaches to every later record
Sentrinel.setContext({
'userId': user.id,
'tier': user.subscriptionTier,
'locale': Localizations.localeOf(context).languageCode,
});
// On logout
Sentrinel.clearContext();
Structured logs
Sentrinel.info('User signed in', category: 'auth', attributes: {
'method': 'google',
});
Sentrinel.warn('Cache miss', category: 'cache', attributes: {
'key': 'feed_v2',
});
Sentrinel.error('Payment failed', category: 'billing', attributes: {
'orderId': order.id,
'reason': 'insufficient_funds',
});
Custom breadcrumbs
Sentrinel.addBreadcrumb('tapped pay', category: 'ui', data: {'amount': 42});
Backend setup (for connected traces)
For the full mobile โ backend trace to work, your backend also needs the Sentrinel plugin. Here's the Elysia config that pairs with the Flutter setup:
import { Elysia } from "elysia";
import { sentrinelPlugin } from "@sentrinel/plugin";
new Elysia()
.use(sentrinelPlugin({
serverUrl: "http://localhost:3001",
appName: "my-api",
env: "prod",
apiKey: process.env.SENTRINEL_API_KEY,
version: process.env.GIT_SHA,
requestLogging: {
enabled: true,
sampleRate: 1.0,
slowRequestThresholdMs: 500,
logRequestHeaders: true,
logRequestBody: true,
logResponseBody: true,
},
logCapture: { enabled: true, minLevel: "debug" },
logging: { minLevel: "debug", echo: true },
consumerIdentifier: (ctx) =>
ctx.request.headers.get("x-consumer-id") ?? "unknown",
}))
.get("/", () => "hello")
.listen(3000);
The Flutter SDK sends traceparent automatically โ the backend picks it up and
continues the same trace. No extra configuration needed on either side.
For the complete reference covering every option, every signal, and every dashboard page โ see FULL_CONFIG.md.
Since this was written
The Flutter SDK now covers the questions a mobile team actually asks after a release. Each of these is in the mobile guide; the short version:
Is this release crashing? Pass release: to init and every session and crash is recorded against the version. Crash-free sessions and crash-free users, per release, against the previous one.
Sentrinel.init(
endpoint: 'https://api.sentrinel.dev',
apiKey: 'snt_mobile_โฆ',
appName: 'shop-app',
env: 'prod',
release: '2.4.1+318',
);
The crash survives the crash. An uncaught Dart error is written to disk synchronously and uploaded on the next launch, with the last twenty-five breadcrumbs that led to it.
Start-up and frames. Start-to-first-frame, and slow and frozen frames, per session and per release.
What people did. track, screen and identify feed funnels, retention and top screens โ deliberately separate from logs, and with an anonymous id that persists on disk so a user is the same user across launches:
Sentrinel.screen('Cart');
Sentrinel.track('checkout_started', properties: {'cart_value': 42});
Sentrinel.identify('user_42');
A mobile key. The key in your bundle is public, so issue a Mobile app key: it can send only what a phone sends, and a leaked one cannot post a replay, reach the database collector, or read anything. Keys issued before kinds existed keep working as they were. Why keys are bound to one integration.
The longer treatment is Release health for Flutter.