Skip to main content

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

CapabilitySignalMechanism
Tap trackinguser_interaction span (type=tap, target, name_source, permanent_id, x/y)Global GestureBinding.instance.pointerRouter interception
Screen / route navigationscreen_view span with view.id, view.loading_type, view.referrer, view.is_activeAutoNameNavigatorObserver attached to every Navigator
Screen load timescreen_load span with screen.load_timeFirst-frame measurement after route push
App startupapp_startup span with app_startup.type = cold | warm, app_startup.durationWidgetsBinding.addPostFrameCallback on first frame
FBC vital (First Build Complete)app_vital span with vital.name = fbcEmitted 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_screenTap timestamp correlated with next screen_view within 5 s
Errors (Flutter framework)error span with error.id, error.fingerprint, error.handled, breadcrumbsFlutterError.onError + PlatformDispatcher.instance.onError
Manual error reportingerror spanScoutFlutter.reportError(e, stackTrace)
Native crashes (iOS)native_crash span with crash.reason, registers (FAR/ESR), mach_exception, callstack_tree, binary_imagesKSCrash 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/RSSCustom NDK signal handler + ApplicationExitInfo (API 30+; reflective subReason on API 31+)
ANRanr span with anr.duration, anr.threshold, anr.thread_count, anr.threads_json, anr.main_thread_stack, and breadcrumbsiOS: 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.thresholdiOS-only sub-ANR watchdog at 250 ms (configurable). Complements KSCrash mainThreadDeadlock and the 5 s ANR detector
Long taskslong_task span with long_task.duration, long_task.thresholdDart isolate event-loop polling
HTTP requestshttp.request span with method, URL, status, duration, headersHttpOverrides global wrap + Dio interceptor (optional)
Distributed tracingW3C traceparent header injected into outgoing requests to hosts in firstPartyHostsWrap on the HTTP client
Scroll depthdisplay.scroll.max_depth, display.scroll.max_depth_scroll_top, display.scroll.max_scroll_height, display.scroll.max_scroll_height_time_ms on screen_viewScoutScrollObserver widget wrapping NotificationListener<ScrollNotification>
Lifecycleapp_paused, app_resumed spans + force-flush on backgroundAppLifecycleListener
Frame metricsflutter.frame.build_time, flutter.frame.raster_time histogramsWidgetsBinding.instance.addTimingsCallback
Memory + CPUflutter.memory.usage, flutter.cpu.usage gaugesPlatform channel poll
Network connectivitynetwork.connection.type resource attribute (wifi, cellular, none)connectivity_plus listener
Batterydevice.battery.level, device.battery.state, device.battery.discharge_rate resource attributesbattery_plus + platform channel
Device orientationdevice.orientation resource attribute (portrait / landscape)Orientation-change listener
Device integritydevice.is_jail_broken resource attributeJailbreak / root heuristic via platform channel
LogsOTLP logsScoutFlutter.log*() and (opt-in) print / debugPrint capture
Anonymous user iduser.anonymous_id on every spanUUID v4 minted on first launch, persisted to temp dir
WebView bridgeEmbedded 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

RequirementVersion
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()));
}

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

FieldTypeDefaultDescription
serviceNameString(required)Logical app identifier. Used as service.name.
endpointString(required)OTLP-HTTP collector URL. /v1/traces, /v1/metrics, /v1/logs are appended automatically.
serviceVersionString?nullMaps to service.version. Set to your app build version.
securebooltrueWhen endpoint has no scheme, prefix https:// (true) or http:// (false).
headersMap<String, String>?nullExtra HTTP headers on every OTLP export. Use for auth.
resourceAttributesMap<String, String>?nullExtra attributes merged into every signal's Resource. Use for deployment.region, team, etc. Static — set once at init.

Network

FieldTypeDefaultDescription
enableNetworkTrackingbooltrueWraps HttpOverrides globally. Disable if you wire ScoutFlutter.dioInterceptor manually.
firstPartyHostsList<String>?nullHosts that receive a W3C traceparent header for distributed tracing. Supports exact match or *.host wildcards.
ignoreUrlPatternsList<RegExp>?nullURLs matching any pattern are not auto-instrumented.

Sessions

FieldTypeDefaultDescription
sessionTimeoutMinutesint30Inactivity timeout before a new session.id is minted.
maxSessionDurationMinutesint60Hard cap on session lifetime; rotates on the next ID read past this age regardless of activity. 0 disables.
sessionSampleRatedouble (0-100)1.0Percent of sessions sampled — default 1%. Below 100, full sessions are dropped (not individual events) so session traces stay coherent.
alwaysCaptureErrorsbooltrueError / crash / ANR-class spans bypass sessionSampleRate and are always exported. Set false to subject them to the same gate.

Thresholds

FieldTypeDefaultMinDescription
longTaskThresholdMsint10020Dart isolate task duration that qualifies as a long_task span.
anrThresholdMsint50001000Main-thread block duration that fires an anr span.
iosHangThresholdMsint25050 (or 0 to disable)iOS only — sub-ANR ui_hang watchdog. Complements ANR (5 s) and KSCrash mainThreadDeadlock (5 s+). Catches micro-stutter / jank.
maxTombstoneBytesint1310724096Max 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.

FieldTypeDefaultDescription
offlineBufferEnabledbooltrueMaster toggle. Set false for strict at-most-once delivery.
offlineMaxTraceItemsint5000FIFO cap on persisted span items. Oldest evicted first.
offlineMaxMetricItemsint2000Same, for metric data points.
offlineMaxLogItemsint5000Same, for log records.
maxOfflineStorageMbint5Coarse 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.

ToggleDefaultWhat you lose when set to false
enableAutoTapTrackingtrueAll user_interaction spans.
enableErrorTrackingtrueerror spans from FlutterError.onError and PlatformDispatcher.onError. Manual reportError() still works.
enableLifecycleTrackingtrueapp_paused / app_resumed spans and the background-flush hook. Heavy loss — recommend leaving on.
enableStartupTrackingtrueapp_startup cold/warm spans and the FBC vital.
enableConnectivityTrackingtruenetwork.connection.type resource attr updates on network transitions.
enablePerformanceMetricstrueflutter.memory.usage and flutter.cpu.usage gauges.
enableLongTaskDetectiontruelong_task spans. Tune with longTaskThresholdMs instead of disabling.
enableAnrDetectiontrueanr spans and the iOS ui_hang watchdog.
enableNetworkTrackingtruehttp.request spans + traceparent injection.
enableLoggingtrueScoutFlutter.log*() calls become no-ops.
capturePrintStatementsfalse(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 iosHangThresholdMs watchdog)

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.kernel
  • crash.registers_json — full CPU register dump including FAR and ESR
  • crash.mach_exception, crash.mach_code, crash.mach_subcode
  • crash.nsexception_name (when applicable)
  • crash.callstack_tree_json — symbolicated stack tree of every thread
  • crash.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_kb
  • crash.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:

  1. Registers a JavaScript channel (default name ScoutBridge) the page can postMessage into.
  2. Injects a JS shim that polls window.Scout and calls setWebViewBridge({sessionId, anonymousId, send}) once the page's web SDK appears.
  3. 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

FailureWhat Scout does
Network blip / 5xx / 429 / 408Exponential-backoff retry with full jitter (default 3 attempts, initial 1 s, max 30 s).
Retries exhausted, offlineBufferEnabled = trueBatch persisted to disk under the app's temp directory. Replayed on next initialize() and on app foreground.
Retries exhausted, offlineBufferEnabled = falseBatch 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-writeThat 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

SymptomLikely cause + fix
ui_hang never fires on iOSiosHangThresholdMs: 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 navYou 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 crashKSCrash 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 < 30ApplicationExitInfo requires API 30+. Older devices only get whatever the in-process NDK handler caught.
WebView spans not tagged span.source = webviewEither 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 assertionnavigatorObserver 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 traceparentThe 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 SIGSEGVYou'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. sessionSampleRate drops 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 beforeSend to redact attributes (event.remove('user.email')) or drop entire events (return null). 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 beforeSend before the batch hits the buffer.
  • TLS. Set secure: true (default) or pass an explicit https:// 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

References

Was this page helpful?