Flutter RUM
Middleware’s Flutter RUM SDK (middleware_flutter_opentelemetry) instruments your Flutter app using OpenTelemetry, so you can see how real users experience your app. You can track which screens they visit, where performance drops, and what errors they hit, with optional session replay for visual debugging.
Because it’s built on OpenTelemetry, the data is standards-compliant and can be exported via OTLP, while still fitting cleanly into Middleware’s RUM experience.
Before you begin#
Make sure you have:
- A Flutter app running on Android, iOS, Web, or Desktop (the SDK supports all, with platform-specific transport notes).
- Flutter 3.7.0+ and Dart 3.7.0+.
- Your Middleware Account Key (the SDK notes this comes from your RUM Flutter installation page).
- Your Middleware endpoint in the form
https://<account>.middleware.io.
As of SDK 1.1.x, the package is a Flutter plugin that embeds the native Middleware SDKs (Android 3.x, iOS 2.1+) for native crash reporting, ANR detection, and next-generation session replay. This adds host requirements on mobile:
- Android:
minSdk24+,compileSdk35+, Kotlin (KGP) 2.0+, and core library desugaring enabled in your app module (see Step 2 below). - iOS: minimum deployment target 13.0. The plugin's pod is a static framework.
- Add this override to your app's
pubspec.yaml(newerpath_provider_androidversions pull an incompatiblejniplugin):1dependency_overrides: 2 path_provider_android: 2.2.12
Install and initialise the SDK#
1 Add the Dependency#
Add the package in pubspec.yaml (together with the path_provider_android override above). The latest release is 2.1.0:
1dependencies:
2 middleware_flutter_opentelemetry: ^2.1.0Or from the terminal:
1flutter pub add middleware_flutter_opentelemetry:^2.1.02 Enable Core Library Desugaring (Android)#
The plugin embeds the native Middleware Android SDK, which requires core library desugaring (and minSdk 24) in your app module (android/app/build.gradle.kts, or build.gradle in older projects). Without it the Android build fails at checkAarMetadata with "requires core library desugaring to be enabled for :app", and devices below Android 8.0 (API 26) crash at runtime. Use desugar_jdk_libs 2.1.5 or newer.
1android {
2 defaultConfig {
3 minSdk = 24
4 }
5 compileOptions {
6 isCoreLibraryDesugaringEnabled = true
7 }
8}
9
10dependencies {
11 coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
12}1android {
2 defaultConfig {
3 minSdk 24
4 }
5 compileOptions {
6 coreLibraryDesugaringEnabled true
7 }
8}
9
10dependencies {
11 coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
12} 3 Initialise early in main()#
RUM works best when it starts as early as possible because it can capture first navigation events, early lifecycle transitions, and crashes that happen near app startup. This call is the only code the SDK needs: everything listed in What the SDK tracks automatically is on by default. The app version is picked up from the platform automatically.
1import 'package:flutter/material.dart';
2import 'package:middleware_flutter_opentelemetry/middleware_flutter_opentelemetry.dart';
3
4Future<void> main() async {
5 WidgetsFlutterBinding.ensureInitialized();
6
7 await FlutterOTel.initialize(
8 serviceName: 'my-flutter-app',
9 endpoint: 'https://<account>.middleware.io',
10 middlewareAccountKey: '<MW_API_KEY>', // from the RUM Flutter installation page
11 deploymentEnvironment: 'production',
12 );
13
14 runApp(const MyApp());
15}4 Track navigation#
Add the route observer so screen changes are recorded and label the session replay timeline:
1MaterialApp(
2 navigatorObservers: [FlutterOTel.routeObserver],
3 // ...
4);
5
6// or, with go_router:
7final router = GoRouter(
8 observers: [FlutterOTel.routeObserver],
9 routes: [/* ... */],
10);5 Manual HTTP Instrumentation#
On Flutter web (SDK 2.1.0+) you can skip this step: every fetch and XMLHttpRequest is instrumented automatically, including requests made by package:http and dio (see Flutter Web: Browser Instrumentation). On Android, iOS, and desktop, instrument network calls manually.
To manually instrument network calls, create a custom span name HTTP <METHOD> <URL> with event.type as xhr along with http attributes:
1Future<void> _testHttpCall() async {
2 const url = 'https://jsonplaceholder.typicode.com/todos/1';
3 final parsedUrl = Uri.parse(url);
4 final attributes = {'event.type': 'xhr', 'url.full': url};
5 final httpSpan = OTel.tracer().startSpan('HTTP GET $url');
6 try {
7 final client = http.Client();
8 final response = await client.get(
9 Uri.parse(url),
10 );
11 if (response.statusCode >= 400) {
12 httpSpan.setStatus(SpanStatusCode.Error, 'HTTP ${response.statusCode}');
13 } else {
14 httpSpan.setStatus(SpanStatusCode.Ok);
15 }
16 attributes.putIfAbsent(
17 'http.status_code', response.statusCode as String Function());
18 if (response.statusCode == 200) {
19 final jsonData = json.decode(response.body);
20 debugPrint('Dummy JSON Response: $jsonData');
21 } else {
22 debugPrint('Request failed: ${response.statusCode}');
23 }
24 } catch (e) {
25 debugPrint('HTTP error: $e');
26 httpSpan.setStatus(SpanStatusCode.Error, 'HTTP error ${e.toString()}');
27 attributes.putIfAbsent('http.status_code', '0' as String Function());
28 }
29 if (parsedUrl.path.isNotEmpty) {
30 attributes.putIfAbsent(
31 'url.path', parsedUrl.path.toString() as String Function());
32 }
33 if (parsedUrl.scheme.isNotEmpty) {
34 attributes.putIfAbsent(
35 'url.scheme', parsedUrl.scheme.toString() as String Function());
36 }
37 attributes.putIfAbsent('url.port', parsedUrl.port as String Function());
38 httpSpan.addAttributes(OTel.attributesFromMap(attributes));
39 httpSpan.end();
40 }This setup is doing two important things:
- It ensures errors don’t escape your telemetry pipeline.
- It gives the SDK a consistent place to attach context (service name, version, environment) to every event.
What the SDK tracks automatically #
After initialization, the SDK automatically instruments common RUM fundamentals so you get useful data without custom code. Everything below is on by default (SDK 2.1.0+) except performance metrics; pass false to the matching initialize option to turn one off. That includes:
- Navigation: Route changes and user flows, so you can reason about what screen led to an issue. Route names also label the native session replay timeline.
- App lifecycle: Foreground and background transitions, useful for crashes after resume or stalls.
- Performance metrics (opt-in): frame rate and rendering metrics, useful for “jank” investigations. Enable them with
enableMetrics: true. - Errors: Flutter framework errors (
FlutterError.onError) and uncaught async errors (PlatformDispatcher.onError), with context so you can tie errors back to sessions (autoCaptureErrors). Handlers you already installed keep working, and an error reported by both is recorded once. UseFlutterOTel.reportError(...)for errors you catch yourself. - User interactions: Taps, scrolls, and swipes, with the tapped widget and its text (
enableAutomaticUserInteractions). Taps feed the click heatmap, and rapid repeated taps are flagged as rage clicks. - Session replay: On every platform, with no extra code. See Session Replay.
- Native crashes & ANRs (Android/iOS): captured by the embedded native SDKs and linked to the same session as your Dart telemetry.
- Browser telemetry (Web, SDK
2.1.0+): page loads, network requests, Core Web Vitals, long tasks, page views, JavaScript errors, and console logs. See Flutter Web: Browser Instrumentation.
Sessions rotate after 15 minutes of inactivity and are capped at 4 hours, consistent with all other Middleware RUM SDKs.
Flutter Web: Browser Instrumentation #
On Flutter web (SDK 2.1.0+), the SDK also instruments the browser in the same way as the Middleware Browser RUM SDK. It emits the same events and attributes, so a Flutter web session shows page loads, API calls, Web Vitals, and errors just like any other web application. Everything below except request/response capture is enabled by default when the app runs in a browser. There is no effect on Android, iOS, or desktop, and no code change is needed.
| Instrumentation | Default | What it captures |
|---|---|---|
| Document load | On | A documentLoad trace built from Navigation Timing, with a documentFetch span and a span per resource (scripts, fonts, images). Resources Flutter loads after the page load event (CanvasKit, fonts, assets) are captured too, marked resource.post_load=true. |
| Network | On | A span for every fetch and XMLHttpRequest, named <METHOD> <host><path>, with status code and DNS, connect, TLS, time-to-first-byte, and download timings. Because instrumentation happens in the browser, package:http, dio, and Flutter asset loading are all covered without wrapping any client. |
| Core Web Vitals | On | LCP, FCP, CLS, INP, and TTFB, with rating (good, needs-improvement, poor) and attribution. |
| Long tasks | On | Main-thread tasks longer than 50 ms. |
| Page tracking | On | A pageview on every URL change, including hash routes such as /#/details, plus pageleave when the tab is hidden. The current root.url, page.href, and page.title are added to every event, so you can filter any view by page. |
| JavaScript errors | On | Uncaught JavaScript errors, unhandled promise rejections, failed resource loads, and console.error, with parsed stack traces for source mapping. |
| Console logs | On | console.log, info, warn, and debug calls from JavaScript, limited to 100 messages per second. Dart print and debugPrint output is not captured here. |
| Taps | On | Taps appear on the heatmap (on every platform), each control on a screen as its own point. API calls made within a second of a tap are linked to it. |
| Rage clicks | On | Rapid repeated taps on the same spot are marked frustration.type=rage_click. |
| Bot filtering | On | No data is sent for crawlers and headless browsers. |
| WebSocket | On | WebSocket connect, send, and receive spans. |
| Request/response capture | Off | Request and response headers and bodies on network spans. Off because they can contain credentials and personal data. |
To change the defaults, pass the webInstrumentation option to initialize:
1await FlutterOTel.initialize(
2 serviceName: 'my-flutter-app',
3 endpoint: 'https://<account>.middleware.io',
4 middlewareAccountKey: '<MW_API_KEY>',
5 webInstrumentation: WebInstrumentationOptions(
6 // Also send trace headers to your API on another origin
7 tracePropagationTargets: [RegExp(r'api\.example\.com')],
8 // Skip requests you don't want to see
9 ignoreUrls: [RegExp(r'/healthz')],
10 ),
11);Distributed tracing: requests to the app's own origin always carry traceparent and b3 trace headers, which connect them to your backend traces in APM. Add other origins to tracePropagationTargets only if their CORS policy allows those headers, or the browser will block the requests. tracePropagationFormat selects w3c, b3, or all (the default).
Other options:
advanceNetworkCapture: truerecords request and response headers and bodies. It is off by default because bodies can contain personal data. Exclude headers you never want recorded withignoreHeaders: {'authorization', 'cookie'}.consoleRateLimitchanges the console log limit (use0for no limit).- To turn off a single instrumentation, set its flag to
false, for exampleWebInstrumentationOptions(webVitals: false). To turn off all browser instrumentation, useWebInstrumentationOptions(enabled: false).
Add User Information#
Add user attributes after FlutterOTel.initialize has run, usually after login or when the user's identity becomes available. FlutterOTel.setAttributes merges the key–value pairs into the SDK's global attributes, and they are added to every span started after the call.
1FlutterOTel.setAttributes({
2 'username': 'John Doe',
3 'email': 'john@example.com',
4 'user_type': 'admin', // arbitrary key-value pairs
5});Call setAttributes again whenever the identity changes, for example when the user switches accounts. On logout, remove a single key with FlutterOTel.removeAttribute('email') or clear everything with FlutterOTel.clearAttributes(). Keep PII to a minimum and prefer IDs or hashed values where you can.
Session Replay #
Session replay is useful when an error report isn’t enough and you want to see what the user saw. It starts automatically on every platform when initialize runs with your account key. No widget wrapping or start/stop calls are needed. Recordings play back in the Middleware session player. Disable replay with enableSessionRecording: false on initialize.
- Android & iOS (SDK
1.1.0+): replay is handled by the embedded native SDKs' rrweb-based pipeline. Platform views (maps, web views, video), which Flutter-side capture cannot see, are included. - Web and desktop (SDK
2.1.0+): the Dart recorder captures the whole app window, starting after the first frame, at the same standard quality as the native SDKs (one frame per second, 640 px short edge). No frame is captured while nothing on screen changes. Tune it withrecordingOptions: RecordingOptions(qualityValue: ..., minShortSidePx: ..., screenshotInterval: ...). Earlier SDK versions upload web recordings in a format the session player can't play, and needstartSessionRecording()plus aRepaintBoundary, so upgrade to2.1.0or later.
To record only part of the UI on web or desktop, wrap that part in a RepaintBoundary with the SDK key, and the recorder captures it instead of the whole window:
1RepaintBoundary(
2 key: FlutterOTel.repaintBoundaryKey,
3 child: /* the part of the app to record */,
4)To control recording at runtime, for example to record only a checkout flow, use:
FlutterOTel.stopSessionRecording()FlutterOTel.startSessionRecording()
Both survive session rotation until you call the other one. startSessionRecording() also works when initialize ran with enableSessionRecording: false.
Add Lightweight UI Interaction Tracking#
Taps, scrolls, and swipes are captured automatically. When you want named, structured breadcrumbs for specific widgets (for example, Submit button pressed, then API call, then error), add widget-level tracking.
The SDK provides widget extensions for common patterns like buttons, text fields, and error boundaries.
1ElevatedButton(
2 onPressed: handleSubmit,
3 child: const Text('Submit'),
4).withOTelButtonTracking('submit_form');
5
6TextField(
7 decoration: const InputDecoration(
8 labelText: 'Enter something',
9 border: OutlineInputBorder(),
10 ),
11).withOTelTextFieldTracking('demo_text_field');
12
13RiskyWidget().withOTelErrorBoundary('risky_operation');This gives you structured interaction breadcrumbs without forcing you to manually instrument every click and input.
Instrument Important Business Operations with Custom Spans#
Automatic RUM is great for baseline visibility, but custom spans are how you make RUM diagnostic. Typical examples include login, checkout, sync data, and fetch profile.
The SDK exposes a tracer (FlutterOTel.tracer) so you can create spans around key flows and attach useful attributes.
1final tracer = FlutterOTel.tracer;
2
3final span = tracer.startSpan('fetch_user_data', attributes: {
4 'user.id': userId,
5 'api.endpoint': '/users',
6});
7
8try {
9 final result = await apiClient.getUser(userId);
10 span.setStatus(SpanStatusCode.Ok);
11 return result;
12} catch (e, stackTrace) {
13 span.recordException(e, stackTrace: stackTrace);
14 span.setStatus(SpanStatusCode.Error, e.toString());
15 rethrow;
16} finally {
17 span.end();
18}Configure via --dart-define#
For production apps, you’ll often want different service names, endpoints, or headers per environment (dev, staging, prod) without changing code.
The SDK supports standard OpenTelemetry environment variables, and shows Flutter usage using --dart-define.
1flutter run \
2 --dart-define=OTEL_SERVICE_NAME=my-flutter-app \
3 --dart-define=OTEL_SERVICE_VERSION=1.0.0 \
4 --dart-define=OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector:4317 \
5 --dart-define=OTEL_EXPORTER_OTLP_PROTOCOL=grpc \
6 --dart-define=OTEL_EXPORTER_OTLP_HEADERS=Authorization=your-api-keyThis is especially useful in CI/CD pipelines where you want one build artifact and environment-specific configuration.
HTTP Client Instrumentation#
dart:io is not available on the web. On Flutter web, network requests are instrumented automatically (see Flutter Web: Browser Instrumentation).
If your Flutter app makes outbound requests using dart:io’s HttpClient, you can wrap it with OTelHttpClient to automatically:
- create an HTTP span for each request
- inject W3C Trace Context headers (
traceparent,tracestate) - capture request and response metadata (method, URL, status, timings, errors)
- correlate client spans with downstream services
1. Wrap HttpClient#
1import 'dart:io';
2import 'package:middleware_dart_opentelemetry/middleware_dart_opentelemetry.dart';
3
4void main() async {
5 await OTel.initialize(serviceName: 'http-client-demo');
6
7 final client = OTelHttpClient(HttpClient());
8
9 final request = await client.getUrl(Uri.parse('https://api.example.com/data'));
10 final response = await request.close();
11
12 print('Status: ${response.statusCode}');
13}At this point, spans are generated automatically and exported using your configured OTLP exporter.
2. Recommended: make HTTP calls a child of a parent span#
If you want network calls to appear under a meaningful business span (for example, checkout or load_home_screen), create a parent span and run the request inside its context:
1final tracer = OTel.tracer();
2final client = OTelHttpClient(HttpClient());
3
4final span = tracer.startSpan('demo-operation');
5await Context.withSpan(span, () async {
6 final request = await client.getUrl(Uri.parse('https://middleware.io'));
7 final response = await request.close();
8 print('Status: ${response.statusCode}');
9});
10span.end();This produces:
- a parent span (
demo-operation) - plus a child HTTP client span for the request
- with correct W3C context propagation
Verify your data in Middleware#
After shipping the build:
- Open RUM and confirm sessions and events are flowing. You should see navigation and performance data when the app is being used. On Flutter web, you should also see page views, network requests, and Web Vitals.
- Trigger an intentional error in a dev build and confirm it appears with session context.
- If session replay is enabled, open a session and confirm replay data is attached.
Need assistance or want to learn more about Middleware? Get in touch with us via our Contact Us or join our Slack channel.