Skip to main content

Flutter RUM with Flutterific OpenTelemetry

Full Real User Monitoring (RUM) for Flutter apps using OpenTelemetry. Traces and metrics are exported to any OTLP-compatible collector endpoint. Attribute names follow OTel semantic conventions.

TL;DR

Add flutterrific_opentelemetry, create a lib/otel/ directory with the files below, wrap your main.dart, and you get automatic session, device, navigation, cold start, jank, ANR, breadcrumbs, battery-aware sampling, W3C trace propagation, and flush-on-background telemetry on every span.

Looking for the manual SDK approach?

If you prefer lower-level control with the opentelemetry Dart SDK, see the Flutter OpenTelemetry guide.

Architecture

Flutter RUM Architecture

Mobile devices send OTLP telemetry through a load balancer / API gateway (with authentication and rate limiting) to an OTel collector (with server-side sampling), which forwards to Base14.

What You Get

SignalSpan / MetricAutomatic?
Sessionsession.id, session.start, session.duration_ms on every spanYes
Devicedevice.model.identifier, device.model.name, device.manufacturer, device.idYes
Batterydevice.battery.level, device.battery.state on every spanYes
Appservice.version, app.build_id, app.installation.id, service.nameYes
Networknetwork.type (wifi / cellular / ethernet / none) — live updatesYes
Current Screenapp.screen.name on every spanYes
Cold Startapp.cold_start span + app.cold_start_ms histogramYes
Screen Loadscreen.load span + screen.load_time_ms histogramYes
Screen Dwellscreen.dwell span + screen.dwell_time_ms histogramYes
Navigationnavigation.push / pop / replace / remove spansYes
BreadcrumbsLast 20 user actions attached to error spans as error.breadcrumbsYes
App Lifecycleapp_lifecycle.changed spans (active, inactive, paused, etc.)Yes
Jank / ANRjank.frame spans + anr.detected spans + counters + histogramsYes
Flutter ErrorsError spans with screen context, session ID, and breadcrumbsYes
Flush on BackgroundPending spans flushed when app enters backgroundYes
Battery-Aware SamplingReduces telemetry when battery is low (50% at 10–20%, 20% below 10%)Yes
W3C Trace Contexttraceparent header injected on all HTTP requestsYes
Error BoundaryCatches render-time errors with retry UI + error_boundary.caught spanManual
User Identityenduser.id, enduser.email, enduser.role on all spans (when set)Manual
Button Clicksinteraction.*.click spansManual
List Selectionsinteraction.*.list_selection spansManual
Rage Clicksrage_click.detected spans + rage_click.count counterManual
Custom Eventscustom_event.* spansManual
HTTP Requestshttp.* spans with URL, status code, size, traceparentManual

Add Dependencies

pubspec.yaml
dependencies:
flutterrific_opentelemetry: ^0.3.2
device_info_plus: ^11.0.0
package_info_plus: ^8.0.0
connectivity_plus: ^6.0.0
battery_plus: ^6.0.0
flutter pub get

Create the lib/otel/ Directory

All instrumentation code lives in lib/otel/. Create these files:

rum_session.dart — Central RUM State

Singleton that holds session, user, device, app, screen, network, battery, and breadcrumb context. Every span gets a snapshot of this state via getCommonAttributes().

Attribute names follow OTel semantic conventions:

lib/otel/rum_session.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:battery_plus/battery_plus.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutterrific_opentelemetry/flutterrific_opentelemetry.dart';
import 'package:package_info_plus/package_info_plus.dart';

class RumSession {
RumSession._();
static final RumSession instance = RumSession._();

// --- Session (semconv: session.*) ---
String sessionId = 'pending';
DateTime sessionStart = DateTime.now();

// --- User ---
String? _userId;
String? _userEmail;
String? _userRole;

// --- Current Screen (semconv: app.screen.*) ---
String _currentScreen = '/';
DateTime _screenEnteredAt = DateTime.now();

// --- Device (semconv: device.*) ---
String _deviceModelIdentifier = 'unknown';
String _deviceModelName = 'unknown';
String _deviceManufacturer = 'unknown';
String _deviceId = 'unknown';

// --- App (semconv: app.*, service.*) ---
String _appVersion = 'unknown';
String _appBuildId = 'unknown';
String _appPackageName = 'unknown';
String _appInstallationId = 'unknown';

// --- Network ---
String _networkType = 'unknown';
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;

// --- Cold Start ---
Duration? coldStartDuration;

// --- Breadcrumbs ---
static const int _maxBreadcrumbs = 20;
final List<Map<String, String>> _breadcrumbs = [];

// --- Battery ---
final Battery _battery = Battery();
int _batteryLevel = 100;
String _batteryState = 'unknown';
StreamSubscription<BatteryState>? _batterySub;
bool _forceSample = false;
final Random _random = Random();

Future<void> initialize() async {
sessionId = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
sessionStart = DateTime.now();
await _loadDeviceInfo();
await _loadPackageInfo();
await _initConnectivity();
await _initBattery();
}

// --- User identification API ---

void setUser({String? id, String? email, String? role}) {
_userId = id;
_userEmail = email;
_userRole = role;
}

void clearUser() {
_userId = null;
_userEmail = null;
_userRole = null;
}

// --- Screen tracking ---

void setCurrentScreen(String screen) {
_currentScreen = screen;
_screenEnteredAt = DateTime.now();
}

String get currentScreen => _currentScreen;

Duration get currentScreenDwellTime =>
DateTime.now().difference(_screenEnteredAt);

// --- Breadcrumb API ---

/// Records a breadcrumb. Keeps the last [_maxBreadcrumbs] entries (FIFO).
void recordBreadcrumb(String type, String label,
[Map<String, String>? data]) {
final crumb = <String, String>{
'ts': DateTime.now().toIso8601String(),
'type': type,
'label': label,
};
if (data != null) crumb.addAll(data);

_breadcrumbs.add(crumb);
if (_breadcrumbs.length > _maxBreadcrumbs) {
_breadcrumbs.removeAt(0);
}
}

/// Returns JSON-encoded breadcrumb list for attaching to error spans.
String getBreadcrumbString() => jsonEncode(_breadcrumbs);

// --- Battery-aware sampling ---

/// Returns true if this span should be sampled based on battery level.
/// Error spans should call [forceNextSample] beforehand to guarantee capture.
bool shouldSample() {
if (_forceSample) {
_forceSample = false;
return true;
}
if (_batteryState == 'charging' || _batteryLevel > 20) {
return true; // 100% sampling
}
if (_batteryLevel > 10) {
return _random.nextDouble() < 0.5; // 50% sampling
}
return _random.nextDouble() < 0.2; // 20% sampling
}

/// Ensures the next call to [shouldSample] returns true.
void forceNextSample() => _forceSample = true;

/// Refreshes battery level on demand (e.g. when app resumes).
Future<void> refreshBatteryState() async {
_batteryLevel = await _battery.batteryLevel;
}

// --- Common attributes for every span (OTel semconv) ---

Attributes getCommonAttributes() {
final map = <String, Object>{
// Session — semconv: session.*
'session.id': sessionId,
'session.start': sessionStart.toIso8601String(),
'session.duration_ms':
DateTime.now().difference(sessionStart).inMilliseconds,

// Current screen — semconv: app.screen.*
'app.screen.name': _currentScreen,

// Device — semconv: device.*
'device.model.identifier': _deviceModelIdentifier,
'device.model.name': _deviceModelName,
'device.manufacturer': _deviceManufacturer,
'device.id': _deviceId,
'os.type': Platform.operatingSystem,
'os.version': Platform.operatingSystemVersion,

// App — semconv: app.*, service.*
'service.version': _appVersion,
'app.build_id': _appBuildId,
'app.installation.id': _appInstallationId,
'service.name': _appPackageName,

// Network
'network.type': _networkType,

// Battery
'device.battery.level': _batteryLevel,
'device.battery.state': _batteryState,
};

if (_userId != null) map['enduser.id'] = _userId!;
if (_userEmail != null) map['enduser.email'] = _userEmail!;
if (_userRole != null) map['enduser.role'] = _userRole!;

if (coldStartDuration != null) {
map['app.cold_start_ms'] = coldStartDuration!.inMilliseconds;
}

return map.toAttributes();
}

Future<void> _loadDeviceInfo() async {
final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
_deviceModelIdentifier = android.model;
_deviceModelName = android.model;
_deviceManufacturer = android.manufacturer;
_deviceId = android.id;
_appInstallationId = android.id;
} else if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo;
_deviceModelIdentifier = ios.utsname.machine;
_deviceModelName = ios.name;
_deviceManufacturer = 'Apple';
_deviceId = ios.identifierForVendor ?? 'unknown';
_appInstallationId = ios.identifierForVendor ?? 'unknown';
}
}

Future<void> _loadPackageInfo() async {
final info = await PackageInfo.fromPlatform();
_appVersion = info.version;
_appBuildId = info.buildNumber;
_appPackageName = info.packageName;
}

Future<void> _initConnectivity() async {
final connectivity = Connectivity();
final results = await connectivity.checkConnectivity();
_updateNetworkType(results);
_connectivitySub =
connectivity.onConnectivityChanged.listen(_updateNetworkType);
}

void _updateNetworkType(List<ConnectivityResult> results) {
if (results.contains(ConnectivityResult.wifi)) {
_networkType = 'wifi';
} else if (results.contains(ConnectivityResult.mobile)) {
_networkType = 'cellular';
} else if (results.contains(ConnectivityResult.ethernet)) {
_networkType = 'ethernet';
} else if (results.contains(ConnectivityResult.none)) {
_networkType = 'none';
} else {
_networkType = 'other';
}
}

Future<void> _initBattery() async {
try {
_batteryLevel = await _battery.batteryLevel;
final state = await _battery.batteryState;
_updateBatteryState(state);
_batterySub =
_battery.onBatteryStateChanged.listen(_updateBatteryState);
} catch (_) {
// Battery info unavailable (e.g. emulator) — keep defaults.
}
}

void _updateBatteryState(BatteryState state) {
switch (state) {
case BatteryState.charging:
_batteryState = 'charging';
case BatteryState.discharging:
_batteryState = 'discharging';
case BatteryState.full:
_batteryState = 'full';
case BatteryState.connectedNotCharging:
_batteryState = 'connected_not_charging';
case BatteryState.unknown:
_batteryState = 'unknown';
}
}

void dispose() {
_connectivitySub?.cancel();
_batterySub?.cancel();
}
}

rum_span_processor.dart — Span Enrichment + Battery-Aware Sampling

Wraps the real BatchSpanProcessor and injects RUM context into every span at onStart. Also implements battery-aware sampling: when battery is low, non-error spans may be dropped to conserve power.

Battery StateSampling Rate
Charging or above 20%100%
10–20%50%
Below 10%20%
Error spansAlways 100% (use forceNextSample())
lib/otel/rum_span_processor.dart
// ignore: depend_on_referenced_packages
import 'package:dartastic_opentelemetry/dartastic_opentelemetry.dart' as sdk;
// ignore: depend_on_referenced_packages
import 'package:dartastic_opentelemetry_api/dartastic_opentelemetry_api.dart';
import 'rum_session.dart';

class RumSpanProcessor implements sdk.SpanProcessor {
RumSpanProcessor(this._delegate);

final sdk.SpanProcessor _delegate;
final Set<int> _droppedSpans = {};


Future<void> onStart(sdk.Span span, Context? parentContext) async {
// Battery-aware sampling — drop non-essential spans when battery is low.
if (!RumSession.instance.shouldSample()) {
_droppedSpans.add(span.hashCode);
return;
}

final rumAttributes = RumSession.instance.getCommonAttributes();
span.addAttributes(rumAttributes);
return _delegate.onStart(span, parentContext);
}


Future<void> onEnd(sdk.Span span) {
if (_droppedSpans.remove(span.hashCode)) {
return Future.value();
}
return _delegate.onEnd(span);
}


Future<void> onNameUpdate(sdk.Span span, String newName) =>
_delegate.onNameUpdate(span, newName);


Future<void> shutdown() => _delegate.shutdown();


Future<void> forceFlush() => _delegate.forceFlush();
}

rum_route_observer.dart — Navigation + Screen Load/Dwell + Breadcrumbs

Attach to MaterialApp.navigatorObservers. Automatically tracks:

  • navigation.push / pop / replace / remove spans with route names
  • screen.load — time from Navigator.push to first frame rendered
  • screen.dwell — time user spent on each screen
  • Breadcrumbs — records every navigation event for crash context
lib/otel/rum_route_observer.dart
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:flutterrific_opentelemetry/flutterrific_opentelemetry.dart';
import 'rum_session.dart';

class RumRouteObserver extends NavigatorObserver {
final _tracer = FlutterOTel.tracer;
final Map<String, DateTime> _screenPushTimes = {};
final Map<String, DateTime> _dwellStartTimes = {};


void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
final routeName = route.settings.name ?? 'unknown';
final previousName = previousRoute?.settings.name;
_endDwellSpan(previousName);

RumSession.instance.setCurrentScreen(routeName);
RumSession.instance.recordBreadcrumb('navigation', 'push $routeName');

_screenPushTimes[routeName] = DateTime.now();

final span = _tracer.startSpan('navigation.push');
span.setStringAttribute<String>('app.navigation.action', 'push');
span.setStringAttribute<String>('app.screen.name', routeName);
if (previousName != null) {
span.setStringAttribute<String>(
'app.screen.previous_name', previousName);
}
span.end();

_startDwellTracking(routeName);
SchedulerBinding.instance.addPostFrameCallback((_) {
_recordScreenLoadTime(routeName);
});
}


void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
final routeName = route.settings.name ?? 'unknown';
final previousName = previousRoute?.settings.name;
_endDwellSpan(routeName);
_screenPushTimes.remove(routeName);

RumSession.instance.recordBreadcrumb('navigation', 'pop $routeName');

if (previousName != null) {
RumSession.instance.setCurrentScreen(previousName);
_startDwellTracking(previousName);
}

final span = _tracer.startSpan('navigation.pop');
span.setStringAttribute<String>('app.navigation.action', 'pop');
span.setStringAttribute<String>('app.screen.name', routeName);
if (previousName != null) {
span.setStringAttribute<String>(
'app.screen.previous_name', previousName);
}
span.end();
}


void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
final oldName = oldRoute?.settings.name;
final newName = newRoute?.settings.name ?? 'unknown';
_endDwellSpan(oldName);

RumSession.instance.setCurrentScreen(newName);
_startDwellTracking(newName);
RumSession.instance.recordBreadcrumb('navigation', 'replace to $newName');

final span = _tracer.startSpan('navigation.replace');
span.setStringAttribute<String>('app.navigation.action', 'replace');
span.setStringAttribute<String>('app.screen.name', newName);
if (oldName != null) {
span.setStringAttribute<String>('app.screen.previous_name', oldName);
}
span.end();
}


void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
final routeName = route.settings.name ?? 'unknown';
_endDwellSpan(routeName);

final span = _tracer.startSpan('navigation.remove');
span.setStringAttribute<String>('app.navigation.action', 'remove');
span.setStringAttribute<String>('app.screen.name', routeName);
span.end();
}

void _startDwellTracking(String routeName) {
_dwellStartTimes[routeName] = DateTime.now();
}

void _endDwellSpan(String? routeName) {
if (routeName == null) return;
final startTime = _dwellStartTimes.remove(routeName);
if (startTime == null) return;

final dwellMs = DateTime.now().difference(startTime).inMilliseconds;
final span = _tracer.startSpan('screen.dwell');
span.setStringAttribute<String>('app.screen.name', routeName);
span.setIntAttribute('app.screen.dwell_time_ms', dwellMs);
span.end();

FlutterOTel.meter(name: 'rum.screen')
.createHistogram<double>(
name: 'screen.dwell_time_ms',
unit: 'ms',
description: 'Time user spent on screen',
)
.record(dwellMs.toDouble());
}

void _recordScreenLoadTime(String routeName) {
final pushTime = _screenPushTimes[routeName];
if (pushTime == null) return;

final loadMs = DateTime.now().difference(pushTime).inMilliseconds;
final span = _tracer.startSpan('screen.load');
span.setStringAttribute<String>('app.screen.name', routeName);
span.setIntAttribute('app.screen.load_time_ms', loadMs);
span.end();

FlutterOTel.meter(name: 'rum.screen')
.createHistogram<double>(
name: 'screen.load_time_ms',
unit: 'ms',
description: 'Time from navigation push to first frame rendered',
)
.record(loadMs.toDouble());
}
}

rum_cold_start.dart — Startup Time

Measures time from main() entry to the first frame painted on screen.

lib/otel/rum_cold_start.dart
import 'package:flutter/scheduler.dart';
import 'package:flutterrific_opentelemetry/flutterrific_opentelemetry.dart';
import 'rum_session.dart';

class RumColdStart {
RumColdStart._();
static DateTime? _mainStartTime;

/// Call as the VERY FIRST line in main().
static void markMainStart() {
_mainStartTime = DateTime.now();
}

/// Call after runApp(). Schedules a post-frame callback to measure total
/// cold start duration and emit a span + metric.
static void measureFirstFrame() {
if (_mainStartTime == null) return;
SchedulerBinding.instance.addPostFrameCallback((_) {
final duration = DateTime.now().difference(_mainStartTime!);
RumSession.instance.coldStartDuration = duration;

final tracer = FlutterOTel.tracer;
final span = tracer.startSpan('app.cold_start');
span.setIntAttribute('app.cold_start_ms', duration.inMilliseconds);
span.setStringAttribute<String>('app.start_type', 'cold');
span.end();

FlutterOTel.meter(name: 'rum.app')
.createHistogram<double>(
name: 'app.cold_start_ms',
unit: 'ms',
description: 'Time from main() to first frame rendered',
)
.record(duration.inMilliseconds.toDouble());
});
}
}

jank_detector.dart — Frame Jank + ANR Detection

Monitors every frame for jank (above 16 ms) and runs a background isolate watchdog for ANR (main thread blocked over 5 s).

lib/otel/jank_detector.dart
import 'dart:async';
import 'dart:isolate';
// ignore: depend_on_referenced_packages
import 'package:dartastic_opentelemetry_api/dartastic_opentelemetry_api.dart'
as api;
import 'package:flutter/scheduler.dart';
import 'package:flutterrific_opentelemetry/flutterrific_opentelemetry.dart';

class JankDetector {
JankDetector({
required UITracer tracer,
required UIMeter meter,
this.jankThresholdMs = 16.0,
this.severeJankThresholdMs = 100.0,
this.anrThresholdMs = 5000.0,
}) : _tracer = tracer,
_meter = meter;

final UITracer _tracer;
final UIMeter _meter;
final double jankThresholdMs;
final double severeJankThresholdMs;
final double anrThresholdMs;

late final api.APICounter<int> _jankCounter;
late final api.APICounter<int> _severeJankCounter;
late final api.APICounter<int> _anrCounter;
late final api.APIHistogram<double> _buildDurationHistogram;
late final api.APIHistogram<double> _rasterDurationHistogram;

Isolate? _watchdogIsolate;
SendPort? _heartbeatPort;
Timer? _heartbeatTimer;
ReceivePort? _anrReceivePort;
bool _paused = false;

void start() {
_initMetrics();
_startFrameTimingCallback();
_startAnrWatchdog();
}

void stop() {
_heartbeatTimer?.cancel();
_watchdogIsolate?.kill(priority: Isolate.immediate);
_anrReceivePort?.close();
}

void pause() {
_paused = true;
_heartbeatTimer?.cancel();
}

void resume() {
_paused = false;
_startHeartbeats();
}

void _initMetrics() {
_jankCounter = _meter.createCounter<int>(
name: 'app.jank.count',
description: 'Number of janky frames (>16ms)',
);
_severeJankCounter = _meter.createCounter<int>(
name: 'app.jank.severe.count',
description: 'Number of severely janky frames (>100ms)',
);
_anrCounter = _meter.createCounter<int>(
name: 'app.anr.count',
description: 'Number of ANR events (main thread blocked >5s)',
);
_buildDurationHistogram = _meter.createHistogram<double>(
name: 'app.frame.build_duration_ms',
unit: 'ms',
description: 'Frame build phase duration in milliseconds',
);
_rasterDurationHistogram = _meter.createHistogram<double>(
name: 'app.frame.raster_duration_ms',
unit: 'ms',
description: 'Frame raster phase duration in milliseconds',
);
}

void _startFrameTimingCallback() {
SchedulerBinding.instance.addTimingsCallback((timings) {
for (final timing in timings) {
final buildMs = timing.buildDuration.inMicroseconds / 1000.0;
final rasterMs = timing.rasterDuration.inMicroseconds / 1000.0;
final totalMs = buildMs + rasterMs;

_buildDurationHistogram.record(buildMs);
_rasterDurationHistogram.record(rasterMs);

if (totalMs > jankThresholdMs) {
_jankCounter.add(1);
final span = _tracer.startSpan('jank.frame');
span.setDoubleAttribute('frame.build_duration_ms', buildMs);
span.setDoubleAttribute('frame.raster_duration_ms', rasterMs);
span.setDoubleAttribute('frame.total_duration_ms', totalMs);

if (totalMs > severeJankThresholdMs) {
_severeJankCounter.add(1);
span.setStringAttribute<String>('jank.severity', 'severe');
span.setStatus(SpanStatusCode.Error, 'Severe jank detected');
} else {
span.setStringAttribute<String>('jank.severity', 'minor');
}
span.end();
}
}
});
}

Future<void> _startAnrWatchdog() async {
_anrReceivePort = ReceivePort();
_watchdogIsolate = await Isolate.spawn(
_watchdogEntryPoint,
_WatchdogConfig(
mainSendPort: _anrReceivePort!.sendPort,
anrThresholdMs: anrThresholdMs,
),
);
_anrReceivePort!.listen((message) {
if (message is SendPort) {
_heartbeatPort = message;
_startHeartbeats();
} else if (message == 'ANR') {
_onAnrDetected();
}
});
}

void _startHeartbeats() {
_heartbeatTimer?.cancel();
if (_paused) return;
_heartbeatTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _heartbeatPort?.send('heartbeat'),
);
}

void _onAnrDetected() {
_anrCounter.add(1);
final span = _tracer.startSpan('anr.detected');
span.setDoubleAttribute('anr.threshold_ms', anrThresholdMs);
span.setStatus(SpanStatusCode.Error, 'ANR: main thread unresponsive');
span.end();

FlutterOTel.reportError(
'ANR detected: main thread unresponsive for '
'>${anrThresholdMs.toInt()}ms',
Exception('ANR detected'),
StackTrace.current,
);
}

static void _watchdogEntryPoint(_WatchdogConfig config) {
final receivePort = ReceivePort();
config.mainSendPort.send(receivePort.sendPort);

DateTime lastHeartbeat = DateTime.now();
receivePort.listen((message) {
if (message == 'heartbeat') {
lastHeartbeat = DateTime.now();
}
});

Timer.periodic(const Duration(seconds: 1), (_) {
final elapsed =
DateTime.now().difference(lastHeartbeat).inMilliseconds;
if (elapsed > config.anrThresholdMs) {
config.mainSendPort.send('ANR');
lastHeartbeat = DateTime.now();
}
});
}
}

class _WatchdogConfig {
const _WatchdogConfig({
required this.mainSendPort,
required this.anrThresholdMs,
});
final SendPort mainSendPort;
final double anrThresholdMs;
}

rum_http_client.dart — Instrumented HTTP Client + W3C Trace Context

Drop-in replacement for http.Client. Creates OTel spans around every HTTP request and injects W3C traceparent headers for distributed tracing.

lib/otel/rum_http_client.dart
import 'package:flutterrific_opentelemetry/flutterrific_opentelemetry.dart';
// ignore: depend_on_referenced_packages
import 'package:http/http.dart' as http;
import 'rum_session.dart';

class RumHttpClient extends http.BaseClient {
RumHttpClient([http.Client? inner]) : _inner = inner ?? http.Client();
final http.Client _inner;


Future<http.StreamedResponse> send(http.BaseRequest request) async {
final tracer = FlutterOTel.tracer;
final span = tracer.startSpan('http.${request.method.toLowerCase()}');

span.setStringAttribute<String>('http.request.method', request.method);
span.setStringAttribute<String>('url.full', request.url.toString());
span.setStringAttribute<String>('server.address', request.url.host);
span.setStringAttribute<String>('url.path', request.url.path);
if (request.contentLength != null && request.contentLength! > 0) {
span.setIntAttribute('http.request.body.size', request.contentLength!);
}

// W3C Trace Context propagation — inject traceparent header
// Format: version-traceId-spanId-traceFlags (00-{32hex}-{16hex}-01)
final traceId = span.spanContext.traceId.hexString;
final spanId = span.spanContext.spanId.hexString;
request.headers['traceparent'] = '00-$traceId-$spanId-01';
request.headers['tracestate'] = '';

// Record breadcrumb for this HTTP request
RumSession.instance.recordBreadcrumb(
'http',
'${request.method} ${request.url.host}${request.url.path}',
);

try {
final response = await _inner.send(request);
span.setIntAttribute('http.response.status_code', response.statusCode);
if (response.contentLength != null) {
span.setIntAttribute(
'http.response.body.size', response.contentLength!);
}
if (response.statusCode >= 400) {
span.setStatus(
SpanStatusCode.Error,
'HTTP ${response.statusCode} ${response.reasonPhrase}',
);
}
span.end();
return response;
} catch (error, stackTrace) {
span.setStringAttribute<String>(
'error.type', error.runtimeType.toString());
span.setStringAttribute<String>('error.message', error.toString());
span.setStatus(SpanStatusCode.Error, error.toString());
FlutterOTel.reportError(
'HTTP request failed: ${request.method} ${request.url}',
error,
stackTrace,
);
span.end();
rethrow;
}
}


void close() {
_inner.close();
super.close();
}
}

rum_rage_click_detector.dart — Frustration Signal

Detects rapid repeated taps on the same UI element (3+ within 2 seconds).

lib/otel/rum_rage_click_detector.dart
import 'package:flutterrific_opentelemetry/flutterrific_opentelemetry.dart';

class RumRageClickDetector {
RumRageClickDetector._();
static final RumRageClickDetector instance = RumRageClickDetector._();

static const int _rageThreshold = 3;
static const Duration _rageWindow = Duration(seconds: 2);
final Map<String, List<DateTime>> _clickHistory = {};

/// Record a tap on [elementId]. Returns true if rage click was detected.
bool recordClick(String elementId) {
final now = DateTime.now();
final history = _clickHistory.putIfAbsent(elementId, () => []);
history.removeWhere((t) => now.difference(t) > _rageWindow);
history.add(now);

if (history.length >= _rageThreshold) {
_emitRageClick(elementId, history.length);
history.clear();
return true;
}
return false;
}

void _emitRageClick(String elementId, int clickCount) {
final tracer = FlutterOTel.tracer;
final span = tracer.startSpan('rage_click.detected');
span.setStringAttribute<String>('rage_click.element_id', elementId);
span.setIntAttribute('rage_click.count', clickCount);
span.setIntAttribute(
'rage_click.window_ms', _rageWindow.inMilliseconds);
span.setStatus(
SpanStatusCode.Error,
'Rage click detected on $elementId',
);
span.end();

FlutterOTel.meter(name: 'rum.interaction')
.createCounter<int>(
name: 'rage_click.count',
description: 'Number of rage click events detected',
)
.add(1);
}
}

rum_events.dart — Custom Business Events

Fire-and-forget API for custom business events. RUM context is automatically attached.

lib/otel/rum_events.dart
import 'package:flutterrific_opentelemetry/flutterrific_opentelemetry.dart';

class RumEvents {
RumEvents._();

static void logEvent(String name, {Map<String, Object>? attributes}) {
final tracer = FlutterOTel.tracer;
final span = tracer.startSpan('custom_event.$name');
span.setStringAttribute<String>('event.name', name);
span.setStringAttribute<String>('event.domain', 'business');

if (attributes != null) {
for (final entry in attributes.entries) {
final value = entry.value;
if (value is String) {
span.setStringAttribute<String>(entry.key, value);
} else if (value is int) {
span.setIntAttribute(entry.key, value);
} else if (value is double) {
span.setDoubleAttribute(entry.key, value);
}
}
}
span.end();
}

static void logTimedEvent(
String name,
Duration duration, {
Map<String, Object>? attributes,
}) {
final allAttrs = <String, Object>{
'event.duration_ms': duration.inMilliseconds,
...?attributes,
};
logEvent(name, attributes: allAttrs);
}
}

error_boundary_widget.dart — Error Boundary

Catches render-time errors in a subtree and shows a fallback UI with a retry button. Records an error_boundary.caught span with the error message, current screen, and breadcrumb trail.

lib/otel/error_boundary_widget.dart
import 'package:flutter/material.dart';
import 'package:flutterrific_opentelemetry/flutterrific_opentelemetry.dart';
import 'rum_session.dart';

class ErrorBoundaryWidget extends StatefulWidget {
const ErrorBoundaryWidget({
super.key,
required this.child,
this.fallbackBuilder,
});

final Widget child;

/// Custom fallback UI builder. Receives the error and a retry callback.
/// If null, a default error card with retry button is shown.
final Widget Function(Object error, VoidCallback retry)? fallbackBuilder;


State<ErrorBoundaryWidget> createState() => _ErrorBoundaryWidgetState();
}

class _ErrorBoundaryWidgetState extends State<ErrorBoundaryWidget> {
Object? _error;
bool _hasError = false;


Widget build(BuildContext context) {
if (_hasError) {
if (widget.fallbackBuilder != null) {
return widget.fallbackBuilder!(_error!, _retry);
}
return _defaultFallback();
}
return widget.child;
}

void handleError(Object error, StackTrace stack) {
setState(() {
_error = error;
_hasError = true;
});

final tracer = FlutterOTel.tracer;
final span = tracer.startSpan('error_boundary.caught');
span.setStringAttribute<String>(
'error.type', error.runtimeType.toString());
span.setStringAttribute<String>('error.message', error.toString());
span.setStringAttribute<String>(
'app.screen.name', RumSession.instance.currentScreen);
span.setStringAttribute<String>(
'error.breadcrumbs', RumSession.instance.getBreadcrumbString());
span.setStatus(SpanStatusCode.Error, error.toString());
span.end();

RumSession.instance.recordBreadcrumb(
'error',
'error_boundary caught: ${error.runtimeType}',
);
}

void _retry() {
RumSession.instance.recordBreadcrumb('ui', 'error_boundary retry');
setState(() {
_error = null;
_hasError = false;
});
}

Widget _defaultFallback() {
return Center(
child: Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
const Text(
'Something went wrong',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
_error.toString(),
style: const TextStyle(fontSize: 12, color: Colors.grey),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
),
);
}
}

otel_config.dart — Wire Everything Together

Central initialization. Call OTelConfig.initialize() once before runApp().

Replace the endpoint URLs with your OTLP collector endpoint.

lib/otel/otel_config.dart
import 'package:flutter/widgets.dart';
import 'package:flutterrific_opentelemetry/flutterrific_opentelemetry.dart';
import 'jank_detector.dart';
import 'rum_http_client.dart';
import 'rum_route_observer.dart';
import 'rum_session.dart';
import 'rum_span_processor.dart';

class OTelConfig {
OTelConfig._();
static JankDetector? _jankDetector;
static RumHttpClient? _httpClient;
static RumSpanProcessor? _rumProcessor;

/// Call once before runApp().
static Future<void> initialize() async {
WidgetsFlutterBinding.ensureInitialized();

// ── Configure your collector endpoint ──────────────────────────
// Replace these with your public OTel collector URL.
const traceEndpoint = String.fromEnvironment(
'OTEL_TRACE_ENDPOINT',
defaultValue: 'https://otel-collector.example.com',
);
const metricEndpoint = String.fromEnvironment(
'OTEL_METRIC_ENDPOINT',
defaultValue: 'https://otel-collector.example.com',
);
// ───────────────────────────────────────────────────────────────

// Initialize RUM session FIRST — before FlutterOTel, because
// FlutterOTel.initialize() creates lifecycle spans that trigger
// RumSpanProcessor, which needs RumSession to be ready.
await RumSession.instance.initialize();

// Trace exporter (OTLP/HTTP)
final spanExporter = OtlpHttpSpanExporter(
OtlpHttpExporterConfig(endpoint: traceEndpoint),
);
final batchProcessor = BatchSpanProcessor(spanExporter);

// Wrap in RumSpanProcessor to enrich ALL spans with RUM context
// and apply battery-aware sampling.
_rumProcessor = RumSpanProcessor(batchProcessor);

// Metric exporter (OTLP/gRPC)
final metricExporter = OtlpGrpcMetricExporter(
OtlpGrpcMetricExporterConfig(
endpoint: metricEndpoint,
insecure: false, // set true for non-TLS endpoints
),
);

await FlutterOTel.initialize(
serviceName: 'your-app-name',
serviceVersion: '1.0.0',
tracerName: 'your-app',
spanProcessor: _rumProcessor!,
metricExporter: metricExporter,
enableMetrics: true,
secure: true, // set false for non-TLS endpoints
);

// Start jank/ANR detection.
_jankDetector = JankDetector(
tracer: FlutterOTel.tracer,
meter: FlutterOTel.meter(name: 'jank_detector'),
);
_jankDetector!.start();

// Create instrumented HTTP client.
_httpClient = RumHttpClient();
}

/// Attach to MaterialApp.navigatorObservers.
static RumRouteObserver get routeObserver => RumRouteObserver();

static OTelLifecycleObserver get lifecycleObserver =>
FlutterOTel.lifecycleObserver;

static OTelInteractionTracker get interactionTracker =>
FlutterOTel.interactionTracker;

/// Use this for all HTTP requests instead of http.Client().
static RumHttpClient get httpClient => _httpClient ?? RumHttpClient();

static void pauseJankDetection() => _jankDetector?.pause();
static void resumeJankDetection() => _jankDetector?.resume();

/// Force-flush all pending spans to the collector.
static Future<void> flush() async {
await _rumProcessor?.forceFlush();
}

/// Flush and shut down the span processor.
static Future<void> shutdown() async {
await flush();
await _rumProcessor?.shutdown();
}

static void dispose() {
_jankDetector?.stop();
_httpClient?.close();
RumSession.instance.dispose();
}
}

Create the Instrumented Entry Point

Create lib/main_otel.dart — a wrapper around your existing main.dart that adds OTel initialization, error handlers with breadcrumbs, cold start measurement, lifecycle-aware flushing, and battery refresh.

lib/main_otel.dart
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutterrific_opentelemetry/flutterrific_opentelemetry.dart';
import 'main.dart'; // Your existing app
import 'otel/otel_config.dart';
import 'otel/rum_cold_start.dart';
import 'otel/rum_session.dart';

Future<void> main() async {
RumColdStart.markMainStart(); // FIRST LINE — records main() entry time.

// Capture Flutter framework errors with breadcrumbs + flush.
FlutterError.onError = (details) {
FlutterError.presentError(details);
RumSession.instance.forceNextSample();
RumSession.instance.recordBreadcrumb(
'error',
'flutter_error: ${details.exceptionAsString()}',
);
FlutterOTel.reportError(
details.exceptionAsString(),
details.exception,
details.stack,
attributes: {
'app.screen.name': RumSession.instance.currentScreen,
'session.id': RumSession.instance.sessionId,
'error.breadcrumbs': RumSession.instance.getBreadcrumbString(),
},
);
OTelConfig.flush(); // Fire-and-forget flush for crash data.
};

// Capture uncaught async errors.
PlatformDispatcher.instance.onError = (error, stack) {
RumSession.instance.forceNextSample();
RumSession.instance.recordBreadcrumb(
'error',
'uncaught_error: ${error.runtimeType}',
);
FlutterOTel.reportError(
'Uncaught error',
error,
stack,
attributes: {
'app.screen.name': RumSession.instance.currentScreen,
'session.id': RumSession.instance.sessionId,
'error.breadcrumbs': RumSession.instance.getBreadcrumbString(),
},
);
OTelConfig.flush();
return true;
};

await OTelConfig.initialize();
WidgetsBinding.instance.addObserver(OTelConfig.lifecycleObserver);

// Lifecycle listener — flush on background, shutdown on exit.
AppLifecycleListener(
onPause: () {
OTelConfig.flush();
OTelConfig.pauseJankDetection();
},
onResume: () {
OTelConfig.resumeJankDetection();
RumSession.instance.refreshBatteryState();
},
onExitRequested: () async {
await OTelConfig.shutdown();
return AppExitResponse.exit;
},
);

runApp(const MyApp());
RumColdStart.measureFirstFrame();
}

Run with:

flutter run --target=lib/main_otel.dart

Wire Into Your App

Add the route observer to your MaterialApp (or CupertinoApp):

MaterialApp(
navigatorObservers: [OTelConfig.routeObserver],
// ...
)

Named Routes

For screen load/dwell tracking to work, every Navigator.push must include a RouteSettings with a name:

Navigator.push<void>(
context,
MaterialPageRoute(
settings: const RouteSettings(name: '/song_detail'),
builder: (context) => const SongDetailPage(),
),
);

Without RouteSettings, the route name defaults to 'unknown'.

Manual Instrumentation

Everything above is automatic once wired. The following are opt-in for richer telemetry.

User Identification

Call after login:

RumSession.instance.setUser(
id: 'user_123',
email: 'user@example.com',
role: 'premium',
);

Call on logout:

RumSession.instance.clearUser();

Once set, enduser.id, enduser.email, and enduser.role appear on every subsequent span.

Error Boundary

Wrap any widget subtree to catch render-time errors with a retry UI:

ErrorBoundaryWidget(
child: MyFragileWidget(),
)

With a custom fallback:

ErrorBoundaryWidget(
fallbackBuilder: (error, retry) => Column(
children: [
Text('Error: $error'),
TextButton(onPressed: retry, child: const Text('Try again')),
],
),
child: MyFragileWidget(),
)

Breadcrumbs are recorded automatically for navigation and HTTP requests. You can also record custom breadcrumbs:

RumSession.instance.recordBreadcrumb('ui', 'tapped checkout button');
RumSession.instance.recordBreadcrumb('api', 'fetched user profile', {
'user_id': '123',
});

The last 20 breadcrumbs are attached to every error span as a JSON array in error.breadcrumbs.

Interaction Tracking

Use OTelConfig.interactionTracker in onPressed / onTap callbacks:

// Button click
ElevatedButton(
onPressed: () {
OTelConfig.interactionTracker
.trackButtonClick(context, 'checkout_button');
// ... your logic
},
child: const Text('Checkout'),
)

// List item selection
ListView.builder(
itemBuilder: (context, index) {
return ListTile(
onTap: () {
OTelConfig.interactionTracker
.trackListItemSelected(context, 'product_list', index);
// ... your logic
},
);
},
)

Rage Click Detection

Add alongside interaction tracking for elements users might frustration-tap:

onTap: () {
OTelConfig.interactionTracker
.trackListItemSelected(context, 'song_list', index);
RumRageClickDetector.instance.recordClick('song_card_$index');
// ... your logic
}

Custom Business Events

// Simple event
RumEvents.logEvent('purchase_completed', attributes: {
'item.id': 'SKU-123',
'item.price': 29.99,
'payment.method': 'credit_card',
});

// Timed event (e.g., how long a search took)
final stopwatch = Stopwatch()..start();
final results = await searchApi(query);
stopwatch.stop();
RumEvents.logTimedEvent('search_completed', stopwatch.elapsed, attributes: {
'search.query': query,
'search.result_count': results.length,
});

Instrumented HTTP Client

Use OTelConfig.httpClient instead of http.Client():

final response = await OTelConfig.httpClient.get(
Uri.parse('https://api.example.com/songs'),
);

Every request automatically gets an http.get (or http.post, etc.) span with URL, status code, response size, and a traceparent header for distributed tracing. RUM context is attached by the RumSpanProcessor.

Configuration

Collector Endpoint

Set at build time via --dart-define:

flutter run \
--dart-define=OTEL_TRACE_ENDPOINT=https://otel.yourcompany.com \
--dart-define=OTEL_METRIC_ENDPOINT=https://otel.yourcompany.com

Or hardcode in otel_config.dart.

Jank Thresholds

In otel_config.dart, customize the JankDetector:

_jankDetector = JankDetector(
tracer: FlutterOTel.tracer,
meter: FlutterOTel.meter(name: 'jank_detector'),
jankThresholdMs: 16.0, // Minimum frame duration to flag
severeJankThresholdMs: 100.0, // Threshold for "severe" jank
anrThresholdMs: 5000.0, // Main thread blocked threshold
);

Service Name

Change serviceName and tracerName in OTelConfig.initialize():

await FlutterOTel.initialize(
serviceName: 'my-flutter-app', // Appears as service.name in traces
serviceVersion: '2.1.0',
tracerName: 'my-flutter-app',
// ...
);

Initialization Order

The order matters. RumSession.initialize() must be called before FlutterOTel.initialize() because FlutterOTel.initialize() creates lifecycle spans during startup, which trigger RumSpanProcessor.onStart(), which calls RumSession.instance.getCommonAttributes(). If the session isn't ready, you get stale default values.

  1. RumColdStart.markMainStart() — records timestamp
  2. Set error handlers — catches errors during init, attaches breadcrumbs
  3. RumSession.instance.initialize() — loads device info, network, battery, session ID
  4. FlutterOTel.initialize(...) — creates lifecycle spans (RumSession must be ready)
  5. JankDetector.start() — frame monitoring begins
  6. WidgetsBinding.addObserver(...) — lifecycle observer
  7. AppLifecycleListener — flush on background, shutdown on exit
  8. runApp(...) — app starts
  9. RumColdStart.measureFirstFrame() — schedules post-frame callback

Telemetry Reference

Spans

Span NameSourceKey Attributes
app.cold_startRumColdStartapp.cold_start_ms, app.start_type
navigation.pushRumRouteObserverapp.screen.name, app.screen.previous_name, app.navigation.action
navigation.popRumRouteObserverapp.screen.name, app.screen.previous_name, app.navigation.action
navigation.replaceRumRouteObserverapp.screen.name, app.screen.previous_name, app.navigation.action
navigation.removeRumRouteObserverapp.screen.name, app.navigation.action
screen.loadRumRouteObserverapp.screen.name, app.screen.load_time_ms
screen.dwellRumRouteObserverapp.screen.name, app.screen.dwell_time_ms
app_lifecycle.changedOTelLifecycleObserverapp_lifecycle.state, app_lifecycle.previous_state
jank.frameJankDetectorframe.build_duration_ms, frame.raster_duration_ms, jank.severity
anr.detectedJankDetectoranr.threshold_ms
http.<method>RumHttpClienthttp.request.method, url.full, http.response.status_code, http.response.body.size
error_boundary.caughtErrorBoundaryWidgeterror.type, error.message, app.screen.name, error.breadcrumbs
interaction.*.clickOTelInteractionTrackerinteraction.target, interaction.type
interaction.*.list_selectionOTelInteractionTrackerinteraction.target, list_selected_index
rage_click.detectedRumRageClickDetectorrage_click.element_id, rage_click.count
custom_event.<name>RumEventsevent.name, event.domain, custom attributes
error.*Error handlersapp.screen.name, session.id, error.breadcrumbs

Attributes on Every Span (via RumSpanProcessor)

Attribute names follow OTel semantic conventions.

AttributeSemconv SourceExample Value
session.idsessionhgbat8zso5
session.start2026-03-03T18:26:04.137259
session.duration_ms14614
app.screen.nameapp/song_detail
device.model.identifierdeviceakita
device.model.namedevicePixel 8a
device.manufacturerdeviceGoogle
device.iddeviceBP4A.260105.004.E1
device.battery.level78
device.battery.statedischarging
os.typeandroid
os.version15
service.version1.0.0
app.build_idapp1
app.installation.idappBP4A.260105.004.E1
service.namedev.flutter.platform_design
network.typewifi
app.cold_start_ms1305
enduser.iduser_123 (when set)
enduser.emailuser@example.com (when set)
enduser.rolepremium (when set)

Metrics

Metric NameTypeUnit
app.cold_start_msHistogramms
screen.load_time_msHistogramms
screen.dwell_time_msHistogramms
app.jank.countCounter
app.jank.severe.countCounter
app.anr.countCounter
app.frame.build_duration_msHistogramms
app.frame.raster_duration_msHistogramms
rage_click.countCounter

File Structure

lib/
├── main.dart # Original app (no OTel imports needed)
├── main_otel.dart # Instrumented entry point
└── otel/
├── otel_config.dart # Central initialization + flush/shutdown
├── rum_session.dart # Session/device/user/screen/network/battery/breadcrumbs
├── rum_span_processor.dart # Enriches spans + battery-aware sampling
├── rum_route_observer.dart # Navigation + screen load/dwell + breadcrumbs
├── rum_cold_start.dart # Cold start measurement
├── rum_http_client.dart # HTTP client + W3C traceparent
├── rum_rage_click_detector.dart # Frustration signal detection
├── rum_events.dart # Custom business events
├── error_boundary_widget.dart # Error boundary with retry UI
└── jank_detector.dart # Frame jank + ANR detection

Quick Start Checklist

  • Add dependencies to pubspec.yaml (including battery_plus) and run flutter pub get
  • Copy the lib/otel/ directory into your project
  • Update otel_config.dart with your collector endpoint and service name
  • Create main_otel.dart wrapping your existing app
  • Add navigatorObservers: [OTelConfig.routeObserver] to MaterialApp
  • Add RouteSettings(name: '/route_name') to all Navigator.push calls
  • Wrap fragile widgets in ErrorBoundaryWidget
  • Add OTelConfig.interactionTracker.trackButtonClick(...) to key buttons
  • Use OTelConfig.httpClient for all HTTP requests
  • Run with flutter run --target=lib/main_otel.dart
  • Verify spans in your collector/backend

Next Steps

Was this page helpful?