โ† All posts
GUIDE August 9, 2026 ยท 8 min read

Monitoring Flutter apps end-to-end with Sentrinel

SR
Sentrinel Team
Product & engineering

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:

Prerequisites

  1. A Sentrinel account (free at sentrinel.dev)
  2. A Flutter project (Dart SDK >=3.0.0)
  3. 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

  1. Go to app.sentrinel.dev
  2. Click Connect on your app (or create a new one)
  3. Click Create API Key
  4. 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:


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:

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.

Next Steps