Flutter
scout_flutter is a single Dart package that ships zero-config
OpenTelemetry RUM for Flutter on iOS, Android, macOS, and web. Auto-
captures the full Real User Monitoring event set (except Session Replay
and Profiling) and exports it as OTLP traces, metrics, and logs to a
Scout collector.
await ScoutFlutter.initialize(
config: ScoutFlutterConfig(
serviceName: 'my-app',
endpoint: 'https://otel.example.com',
),
);
runApp(const MyApp());
That's all the code you write. Every tap, navigation, HTTP request,
error, crash, scroll, and frame metric is gathered automatically — no
manual Scout.track(...) calls anywhere in your app.
What You Get
| Capability | Signal | Mechanism |
|---|---|---|
| Tap tracking | user_interaction span (type=tap, target, name_source, permanent_id, x/y) | Global GestureBinding.instance.pointerRouter interception |
| Screen / route navigation | screen_view span with view.id, view.loading_type, view.referrer, view.is_active | AutoNameNavigatorObserver attached to every Navigator |
| Screen load time | screen_load span with screen.load_time | First-frame measurement after route push |
| App startup | app_startup span with app_startup.type = cold | warm, app_startup.duration | WidgetsBinding.addPostFrameCallback on first frame |
| FBC vital (First Build Complete) | app_vital span with vital.name = fbc | Emitted alongside cold-start; ready for dashboards as a first-class vital |
| INV vital (Interaction → Next View) | app_vital span with vital.name = inv, vital.from_screen, vital.to_screen | Tap timestamp correlated with next screen_view within 5 s |
| Errors (Flutter framework) | error span with error.id, error.fingerprint, error.handled, breadcrumbs | FlutterError.onError + PlatformDispatcher.instance.onError |
| Manual error reporting | error span | ScoutFlutter.reportError(e, stackTrace) |
| Native crashes (iOS) | native_crash span with crash.reason, registers (FAR/ESR), mach_exception, callstack_tree, binary_images | KSCrash 2.x all five monitors + MetricKit MXCrashDiagnostic / MXHangDiagnostic |
| Native crashes (Android) | native_crash span with crash.reason, signal info, tombstone (≤ 32 KB), crash.os_reason_*, PSS/RSS | Custom NDK signal handler + ApplicationExitInfo (API 30+; reflective subReason on API 31+) |
| ANR | anr span with anr.duration, anr.threshold, anr.thread_count, anr.threads_json, anr.main_thread_stack, and breadcrumbs | iOS: AppHangWatchdog (5 s default). Android: Choreographer + ApplicationExitInfo REASON_ANR. Captures a full thread dump at detection time. |
| UI hang (iOS) | ui_hang span with ui_hang.duration, ui_hang.threshold | iOS-only sub-ANR watchdog at 250 ms (configurable). Complements KSCrash mainThreadDeadlock and the 5 s ANR detector |
| Long tasks | long_task span with long_task.duration, long_task.threshold | Dart isolate event-loop polling |
| HTTP requests | http.request span with method, URL, status, duration, headers | HttpOverrides global wrap + Dio interceptor (optional) |
| Distributed tracing | W3C traceparent header injected into outgoing requests to hosts in firstPartyHosts | Wrap on the HTTP client |
| Scroll depth | display.scroll.max_depth, display.scroll.max_depth_scroll_top, display.scroll.max_scroll_height, display.scroll.max_scroll_height_time_ms on screen_view | ScoutScrollObserver widget wrapping NotificationListener<ScrollNotification> |
| Lifecycle | app_paused, app_resumed spans + force-flush on background | AppLifecycleListener |
| Frame metrics | flutter.frame.build_time, flutter.frame.raster_time histograms | WidgetsBinding.instance.addTimingsCallback |
| Memory + CPU | flutter.memory.usage, flutter.cpu.usage gauges | Platform channel poll |
| Network connectivity | network.connection.type resource attribute (wifi, cellular, none) | connectivity_plus listener |
| Battery | device.battery.level, device.battery.state, device.battery.discharge_rate resource attributes | battery_plus + platform channel |
| Device orientation | device.orientation resource attribute (portrait / landscape) | Orientation-change listener |
| Device integrity | device.is_jail_broken resource attribute | Jailbreak / root heuristic via platform channel |
| Logs | OTLP logs | ScoutFlutter.log*() and (opt-in) print / debugPrint capture |
| Anonymous user id | user.anonymous_id on every span | UUID v4 minted on first launch, persisted to temp dir |
| WebView bridge | Embedded web pages adopt the native session.id + user.anonymous_id; their spans flow back as span.source = "webview" | ScoutWebViewBridge.attach() + injectShim() on every page finish |
Prerequisites
| Requirement | Version |
|---|---|
| Flutter SDK | ≥ 3.7.0 |
| Dart SDK | ≥ 3.7.0 |
| iOS deployment target | ≥ 12.0 |
Android minSdkVersion | ≥ 21 (ApplicationExitInfo features activate from API 30+) |
compileSdkVersion | ≥ 34 (recommended) |
| CocoaPods | ≥ 1.11 |
| NDK (Android, for native crash) | ≥ 25 (matches Flutter default) |
Installation
scout_flutter is published on pub.dev.
Add it to your pubspec.yaml:
# pubspec.yaml
dependencies:
scout_flutter: ^0.1.20
Or:
flutter pub add scout_flutter
iOS — CocoaPods install
The first build after adding scout_flutter triggers a pod install for
the KSCrash 2.x and MetricKit dependencies. Make sure your iOS
Podfile has platform :ios, '12.0' or higher:
# ios/Podfile
platform :ios, '12.0'
Then:
cd ios && pod install --repo-update && cd ..
Android — NDK setup
The native signal handler is built automatically as part of the
plugin's Gradle build. No app-side configuration needed beyond
ensuring your project has the NDK available (flutter doctor will
warn if not).
Initialization
In your main.dart, before runApp():
import 'package:flutter/material.dart';
import 'package:scout_flutter/scout_flutter.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Fire-and-forget — never block app startup on SDK init.
unawaited(
ScoutFlutter.initialize(
config: ScoutFlutterConfig(
serviceName: 'my-app',
serviceVersion: '1.0.0',
endpoint: 'https://otel.example.com',
headers: {'Authorization': 'Bearer …'},
),
),
);
// Wrap your root widget with the scroll observer so per-screen
// scroll metrics decorate the active screen_view span.
runApp(ScoutFlutter.observeScroll(child: const MyApp()));
}
Navigation tracking
Attach ScoutFlutter.navigatorObserver to every Navigator in your
app — the root MaterialApp / CupertinoApp plus each nested
Navigator (commonly inside CupertinoTabView, Navigator widgets
used for bottom-sheet stacks, etc.):
MaterialApp(
navigatorObservers: [ScoutFlutter.navigatorObserver],
// …
);
// And per CupertinoTabView:
CupertinoTabView(
navigatorObservers: [ScoutFlutter.navigatorObserver],
builder: (context) => const SongsTab(),
);
navigatorObserver returns a fresh instance on every read, so
attaching it to multiple Navigators does not trip Flutter's
"observer already has a navigator" assertion. All instances funnel
events into shared static state so dashboards see one coherent screen
timeline regardless of which Navigator pushed a route.
Setting user identity
ScoutFlutter.setUser(
id: 'user-123',
attributes: {
'email': 'jane@example.com', // → user.email
'plan': 'pro', // → user.plan
'role': 'admin', // → user.role
},
);
// On logout:
ScoutFlutter.clearUser();
id is optional. user.id and every attribute ride on every span
until cleared — bare attribute keys are auto-prefixed with user., and
keys already starting with user. pass through unchanged. setUser
replaces the whole user map, so pass everything you want each call.
Setting session attributes
Session attributes attach to every subsequent span, metric, and log for the rest of the session. Keys are stored verbatim (no auto-prefix).
// Replaces all existing session attributes.
ScoutFlutter.setSessionAttributes({'tenant': 'acme', 'ab_bucket': 'B'});
// Add one without clobbering the rest — merge with the current view:
ScoutFlutter.setSessionAttributes({
...ScoutFlutter.sessionAttributes,
'feature.new_checkout': 'true',
});
// Clear all.
ScoutFlutter.clearSessionAttributes();
They persist until you call clearSessionAttributes() — they are not
cleared automatically on session rotation.
Configuration
ScoutFlutterConfig is the single config object. Required fields
are flagged below; everything else has a sensible default and is
opt-in.
Identity
| Field | Type | Default | Description |
|---|---|---|---|
serviceName | String | (required) | Logical app identifier. Used as service.name. |
endpoint | String | (required) | OTLP-HTTP collector URL. /v1/traces, /v1/metrics, /v1/logs are appended automatically. |
serviceVersion | String? | null | Maps to service.version. Set to your app build version. |
secure | bool | true | When endpoint has no scheme, prefix https:// (true) or http:// (false). |
headers | Map<String, String>? | null | Extra HTTP headers on every OTLP export. Use for auth. |
resourceAttributes | Map<String, String>? | null | Extra attributes merged into every signal's Resource. Use for deployment.region, team, etc. Static — set once at init. |
Network
| Field | Type | Default | Description |
|---|---|---|---|
enableNetworkTracking | bool | true | Wraps HttpOverrides globally. Disable if you wire ScoutFlutter.dioInterceptor manually. |
firstPartyHosts | List<String>? | null | Hosts that receive a W3C traceparent header for distributed tracing. Supports exact match or *.host wildcards. |
ignoreUrlPatterns | List<RegExp>? | null | URLs matching any pattern are not auto-instrumented. |
Sessions
| Field | Type | Default | Description |
|---|---|---|---|
sessionTimeoutMinutes | int | 30 | Inactivity timeout before a new session.id is minted. |
maxSessionDurationMinutes | int | 60 | Hard cap on session lifetime; rotates on the next ID read past this age regardless of activity. 0 disables. |
sessionSampleRate | double (0-100) | 1.0 | Percent of sessions sampled — default 1%. Below 100, full sessions are dropped (not individual events) so session traces stay coherent. |
alwaysCaptureErrors | bool | true | Error / crash / ANR-class spans bypass sessionSampleRate and are always exported. Set false to subject them to the same gate. |
Thresholds
| Field | Type | Default | Min | Description |
|---|---|---|---|---|
longTaskThresholdMs | int | 100 | 20 | Dart isolate task duration that qualifies as a long_task span. |
anrThresholdMs | int | 5000 | 1000 | Main-thread block duration that fires an anr span. |
iosHangThresholdMs | int | 250 | 50 (or 0 to disable) | iOS only — sub-ANR ui_hang watchdog. Complements ANR (5 s) and KSCrash mainThreadDeadlock (5 s+). Catches micro-stutter / jank. |
maxTombstoneBytes | int | 131072 | 4096 | Max bytes of Android ApplicationExitInfo tombstone (ANR / native post-mortem) captured per report. |
Offline buffer
When in-memory retry is exhausted, batches are persisted to disk and
replayed on next initialize() or on app resume.
| Field | Type | Default | Description |
|---|---|---|---|
offlineBufferEnabled | bool | true | Master toggle. Set false for strict at-most-once delivery. |
offlineMaxTraceItems | int | 5000 | FIFO cap on persisted span items. Oldest evicted first. |
offlineMaxMetricItems | int | 2000 | Same, for metric data points. |
offlineMaxLogItems | int | 5000 | Same, for log records. |
maxOfflineStorageMb | int | 5 | Coarse total-disk cap that runs alongside the per-signal offlineMax*Items caps — whichever limit is reached first wins. |
Auto-instrumentation toggles
Every auto-instrumentation can be turned off independently. All
default to true except capturePrintStatements.
| Toggle | Default | What you lose when set to false |
|---|---|---|
enableAutoTapTracking | true | All user_interaction spans. |
enableErrorTracking | true | error spans from FlutterError.onError and PlatformDispatcher.onError. Manual reportError() still works. |
enableLifecycleTracking | true | app_paused / app_resumed spans and the background-flush hook. Heavy loss — recommend leaving on. |
enableStartupTracking | true | app_startup cold/warm spans and the FBC vital. |
enableConnectivityTracking | true | network.connection.type resource attr updates on network transitions. |
enablePerformanceMetrics | true | flutter.memory.usage and flutter.cpu.usage gauges. |
enableLongTaskDetection | true | long_task spans. Tune with longTaskThresholdMs instead of disabling. |
enableAnrDetection | true | anr spans and the iOS ui_hang watchdog. |
enableNetworkTracking | true | http.request spans + traceparent injection. |
enableLogging | true | ScoutFlutter.log*() calls become no-ops. |
capturePrintStatements | false | (Off by default) When true, mirrors print / debugPrint calls to OTLP logs. Original console output is preserved. |
Filtering — beforeSend
ScoutFlutterConfig(
// …
beforeSend: (event) {
// event keys: 'type' ('span'|'metric'|'log'), 'name', plus
// per-span attributes. Return null to drop the event.
if ((event['http.url'] as String?)?.contains('/health') == true) {
return null;
}
event.remove('user.email');
return event;
},
)
Sees per-span attributes only. Resource attributes set on the OTel
Resource (e.g. service.name, os.name, device.*) are not in
the event payload.
Native crash setup
iOS — KSCrash + MetricKit
The plugin auto-installs KSCrash 2.5+ with all five monitors:
- Mach exceptions
- POSIX signals
- C++ exceptions
- NSException
- Main-thread deadlock (5 s+ — complementary to the 250 ms
iosHangThresholdMswatchdog)
On every launch, scout_flutter drains any persisted KSCrash reports
from the previous run and emits them as native_crash spans
carrying:
crash.reason,crash.type,crash.signal,crash.os_name,crash.os_version,crash.kernelcrash.registers_json— full CPU register dump including FAR and ESRcrash.mach_exception,crash.mach_code,crash.mach_subcodecrash.nsexception_name(when applicable)crash.callstack_tree_json— symbolicated stack tree of every threadcrash.binary_images_json— loaded image list for offline symbolication- The prior session's last 20 breadcrumbs
In parallel, an MXMetricManagerSubscriber collects asynchronous
MXCrashDiagnostic and MXHangDiagnostic payloads that Apple
delivers the morning after a crash — useful for catching kernel-killed
crashes that KSCrash couldn't intercept.
Triggering a crash (testing)
scout_flutter does not ship a "simulate crash" API. To validate
end-to-end capture, trigger a real fault — and never use exit(),
which is a graceful shutdown that no crash reporter intercepts:
// Uncaught Dart error → `error` span (and, if fatal, `app_crash` on relaunch).
throw StateError('test crash');
For a true native signal (SIGSEGV on Android, fatalError() on iOS),
add a small platform-channel method on the app side. The repo's
example/ app and the
flutter/samples/platform_design
sample ship ready-made crash / ANR / deadlock buttons for exactly this.
Android — NDK signal handler + ApplicationExitInfo
The plugin auto-installs a custom NDK signal handler that catches
SIGSEGV / SIGABRT / SIGBUS / SIGILL / SIGFPE before they kill the
process. A tombstone (up to 32 KB) plus signal info is persisted to
disk and emitted on next launch as a native_crash span.
In parallel, on API 30+ (Android 11+), scout_flutter polls
ActivityManager.getHistoricalProcessExitReasons and emits a
native_crash span for any OS-recorded death newer than the persisted
watermark. This captures:
crash.os_reason_code/crash.os_reason_name(crash,crash_native,anr,low_memory,excessive_resource_usage,initialization_failure,signaled)crash.os_reason_subcode(API 31+ via reflection)crash.exit_status,crash.importance,crash.death_timestamp_ms,crash.process_name,crash.pid,crash.pss_kb,crash.rss_kbcrash.tombstone— full thread dump (capped at 32 KB)
The two pipelines complement each other: NDK fires in-process at crash time, ApplicationExitInfo catches deaths that the OS killed before in-process handlers could write to disk (OOM, hard watchdog, etc.).
Background flush
scout_flutter calls forceFlush() on every signal provider when the
app transitions to AppLifecycleState.paused / inactive / hidden.
This drains the BatchSpanProcessor, metric reader, and log processor
before the OS suspends the process — without it, events emitted in
the last few seconds (the ones leading up to a crash) would die with
the in-memory batch queue.
If the in-memory exporter still doesn't deliver in time (OS kills us
mid-POST), the offline buffer persists the batch to disk and
replays it on next initialize().
WebView bridge
Embed a WebView showing a page instrumented with @base14/scout-react
(web entry) v0.1.5+, and scout_flutter will flatten the WebView's
RUM session into the native session — both runtimes share one
session.id and user.anonymous_id, and the embedded page's spans
flow back into the native pipeline tagged with
span.source = "webview".
import 'package:scout_flutter/scout_flutter.dart';
import 'package:webview_flutter/webview_flutter.dart';
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (_) {
// Re-inject the shim on every navigation. The shim has a
// sentinel so re-injecting in the same page is a no-op.
ScoutWebViewBridge.injectShim(
runJavaScript: controller.runJavaScript,
);
},
),
);
ScoutWebViewBridge.attach(
addJavaScriptChannel: (name, onMessage) {
controller.addJavaScriptChannel(
name,
onMessageReceived: (m) => onMessage(m.message),
);
},
);
await controller.loadRequest(Uri.parse('https://app.example.com'));
The bridge:
- Registers a JavaScript channel (default name
ScoutBridge) the page canpostMessageinto. - Injects a JS shim that polls
window.Scoutand callssetWebViewBridge({sessionId, anonymousId, send})once the page's web SDK appears. - Receives bridged span payloads via the channel and re-emits them
as native spans with
span.source = "webview",session.id,user.anonymous_id, and the rest of the native common attributes.
The bridge is currently a parallel transport — both the web SDK's
own OTLP exporter and the native bridge ship a copy of each span.
Configure the web SDK with an unreachable endpoint, or filter
everything with beforeSend returning null, to make the bridge the
sole transport.
The bridge is generic — attach() accepts bare callbacks rather than
a typed WebViewController, so it works with webview_flutter,
flutter_inappwebview, or any future plugin. Adapt the integration
glue (~6 lines) per your plugin of choice.
What happens when export fails
| Failure | What Scout does |
|---|---|
| Network blip / 5xx / 429 / 408 | Exponential-backoff retry with full jitter (default 3 attempts, initial 1 s, max 30 s). |
Retries exhausted, offlineBufferEnabled = true | Batch persisted to disk under the app's temp directory. Replayed on next initialize() and on app foreground. |
Retries exhausted, offlineBufferEnabled = false | Batch dropped silently. |
| 4xx (non-retryable) | Batch dropped immediately so retry budget isn't burned on a permanent error. |
| Disk write fails (quota, permissions) | Caught and swallowed; batch dropped. |
| App crash mid-write | That batch is lost. |
Running the example app
The repo ships a runnable example at example/. For physical-device
testing of the full diagnostic suite (UI hang, ANR, real SIGSEGV
crash, WebView bridge) the
flutter/samples/platform_design
sample is a good starting point.
# iOS simulator
cd example
flutter run -d "iPhone 17"
# Android emulator / physical device
flutter run -d <device-id>
# For physical Android: reverse-forward your local collector
adb reverse tcp:34318 tcp:34318
Troubleshooting
| Symptom | Likely cause + fix |
|---|---|
ui_hang never fires on iOS | iosHangThresholdMs: 0 disables it; check your config. Or the main thread genuinely isn't hanging — try a 300 ms while loop to confirm the watchdog is armed. |
Two screen_view per nav | You attached ScoutFlutter.navigatorObserver to both the root MaterialApp Navigator and a nested CupertinoTabView Navigator. That's correct — each Navigator emits its own screen_view. Filter dashboards by view.id to dedupe. |
native_crash not appearing after iOS crash | KSCrash writes asynchronously; the report drains on the next launch. Force-quit and relaunch the app, then check the collector log. |
Android native_crash empty on API < 30 | ApplicationExitInfo requires API 30+. Older devices only get whatever the in-process NDK handler caught. |
WebView spans not tagged span.source = webview | Either the embedded page isn't @base14/scout-react v0.1.5+ (no window.Scout), or your NavigationDelegate.onPageFinished is missing the injectShim(...) call. |
Observer already has a Navigator assertion | navigatorObserver returns a fresh instance on every read, so attaching it to multiple Navigators is fine. If you hit this, you're caching one instance and reusing it — read ScoutFlutter.navigatorObserver afresh per Navigator. |
HTTP requests not getting traceparent | The host isn't in firstPartyHosts. Add it explicitly (e.g. 'api.example.com') or use a wildcard ('*.example.com'). |
| Crash button gives a graceful shutdown instead of SIGSEGV | You're calling exit(), which is graceful — no crash reporter intercepts it. Trigger a real fault instead (an uncaught error, or a native null-deref via your own platform-channel method). |
Performance considerations
- Batched OTLP HTTP. Spans flush every 5 s (configurable). At default settings, telemetry overhead in normal use is 2–4 KB/s.
- Disk-backed offline buffer. Worst-case disk footprint with default per-signal caps is ~25–35 MB. Lower the caps on low-end Android devices if needed.
- Sampling.
sessionSampleRatedrops full sessions, never individual events — session traces stay coherent. - Async init.
ScoutFlutter.initialize()is fire-and-forget. The app boot does not wait on it.
Security considerations
- PII scrubbing. Use
beforeSendto redact attributes (event.remove('user.email')) or drop entire events (returnnull). It runs synchronously on every span / metric / log before export. - Custom headers for auth. Pass
headers: {'Authorization': 'Bearer …'}to authenticate the OTLP export. Headers are sent on every request including offline-replay POSTs. - No telemetry-to-disk PII by default. The offline buffer writes
the same OTLP JSON your live exporter would have sent — it doesn't
add anything extra. If you don't want sensitive attrs on disk,
scrub them in
beforeSendbefore the batch hits the buffer. - TLS. Set
secure: true(default) or pass an explicithttps://endpoint. No CA pinning by default; if you need it, wrap the outbound HTTP client yourself.
FAQ
Does scout_flutter work on macOS / web / Linux / Windows desktop?
iOS and Android are fully supported, including native crash capture.
macOS works for the Dart-side instrumentation (taps, navigation,
HTTP, errors, lifecycle, logs) but the KSCrash / MetricKit /
ApplicationExitInfo pipelines are mobile-only. Web works for
Dart-side instrumentation — for richer web RUM, use
@base14/scout-react directly in a web app and (for hybrid apps)
bridge with the WebView bridge.
Will the SDK ever block my app's boot?
No. ScoutFlutter.initialize() is async and fire-and-forget — wrap
it in unawaited(...) as shown above. If init fails (network down,
disk full, etc.) the error is swallowed; your app keeps running.
How big are crash reports on the wire?
A KSCrash report with full register dump + callstack tree typically serializes to 30–80 KB. ApplicationExitInfo tombstones are capped at 32 KB. They're sent as part of the next launch's first batch.
Can I add custom spans?
Yes — the underlying OTel Tracer is accessible. Custom spans go through the same beforeSend / sampling / export pipeline as auto-instrumented ones.
Can I emit metrics or logs manually?
Yes:
ScoutFlutter.logInfo('checkout started', attributes: {'cart.size': 3});
ScoutFlutter.logError('payment failed', attributes: {'order.id': 'ord-1'});
// For an error with a stack trace, use reportError (emits an `error` span):
ScoutFlutter.reportError(e, st);
What's next
- Configure your collector
to receive OTLP-HTTP on
:4318 - Look at React Native + React Web instrumentation for the JavaScript equivalent (and the WebView bridge counterpart)
References
- scout_flutter repo: github.com/base-14/scout-flutter
- scout-react repo (web + RN companion): github.com/base-14/scout-react
- KSCrash: github.com/kstenerud/KSCrash
- Apple MetricKit: developer.apple.com/documentation/metrickit
- Android ApplicationExitInfo: developer.android.com/reference/android/app/ApplicationExitInfo