Skip to content
Last9
Book demo

Flutter

Instrument your Flutter application with OpenTelemetry to send distributed traces to Last9 using the dio HTTP client

Use OpenTelemetry to instrument your Flutter application and send distributed traces to Last9. This guide covers two approaches: header-only trace propagation using dio, and full SDK instrumentation with the opentelemetry Dart package.

Prerequisites

Before setting up Flutter instrumentation, ensure you have:

  • Flutter: 3.x with Dart 3.x
  • dio: ^5.0.0 (HTTP client)
  • Last9 Account: With OpenTelemetry integration credentials (only for Approach B)

No SDK dependency needed. A single dio interceptor generates a W3C traceparent header on every outbound request. Downstream services pick it up and continue the same trace.

Flutter app Backend Service A Backend Service B
│ │ │
│──traceparent: 00-{same-trace-id}──────────────▶│
│ │ │
[single trace visible in Last9 across all services]
  1. Create the interceptor

    Create lib/telemetry/traceparent_interceptor.dart:

    import 'dart:math';
    import 'package:dio/dio.dart';
    /// Injects a W3C traceparent header into every outbound HTTP request.
    /// Downstream services will continue the same trace automatically.
    class TraceparentInterceptor extends Interceptor {
    final Random _rng = Random.secure();
    String _randomHex(int bytes) => List.generate(
    bytes,
    (_) => _rng.nextInt(256).toRadixString(16).padLeft(2, '0'),
    ).join();
    @override
    void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    // Format: 00-{16-byte trace-id}-{8-byte span-id}-01
    // 01 = sampled flag (tell downstream to record this trace)
    final traceId = _randomHex(16); // 32 hex chars
    final spanId = _randomHex(8); // 16 hex chars
    options.headers['traceparent'] = '00-$traceId-$spanId-01';
    handler.next(options);
    }
    }
  2. Register the interceptor

    Add it to your Dio instance. If your app uses a single shared Dio instance (common with providers or service classes), add the interceptor there:

    import 'package:dio/dio.dart';
    import 'telemetry/traceparent_interceptor.dart';
    final dio = Dio();
    dio.interceptors.add(TraceparentInterceptor());
  3. Verify

    After making a request, check Last9 Traces. Look for the trace ID in downstream service spans — the backend span should show traceparent in its incoming HTTP attributes, and all services will share the same trace ID.

Approach B: Full OTel SDK

Use this when you need visibility inside the Flutter app itself: HTTP call durations, error details, retry counts, or user-journey traces.

  1. Install dependencies

    # pubspec.yaml
    dependencies:
    opentelemetry: ^0.18.10
    dio: ^5.0.0
  2. Initialize OpenTelemetry

    Create lib/telemetry/otel_setup.dart:

    import 'package:opentelemetry/api.dart';
    import 'package:opentelemetry/sdk.dart';
    void initTelemetry({
    required Uri otlpEndpoint,
    required String authHeader,
    String serviceName = 'your-flutter-app',
    String environment = 'production',
    }) {
    final exporter = CollectorExporter(
    otlpEndpoint,
    headers: {'authorization': authHeader},
    );
    final provider = TracerProviderBase(
    processors: [BatchSpanProcessor(exporter)],
    resource: Resource([
    Attribute.fromString('service.name', serviceName),
    Attribute.fromString('deployment.environment', environment),
    ]),
    );
    registerGlobalTracerProvider(provider);
    }

    Call it from main.dart before runApp:

    void main() {
    WidgetsFlutterBinding.ensureInitialized();
    initTelemetry(
    otlpEndpoint: Uri.parse('https://otlp.last9.io'),
    authHeader: 'Basic YOUR_BASE64_CREDENTIALS',
    );
    runApp(const MyApp());
    }
  3. Create the instrumented dio interceptor

    Create lib/telemetry/otel_dio_interceptor.dart:

    import 'package:dio/dio.dart';
    import 'package:opentelemetry/api.dart';
    /// TextMapSetter implementation for dio headers.
    class _DioHeaderSetter implements TextMapSetter<Map<String, dynamic>> {
    @override
    void set(Map<String, dynamic> carrier, String key, String value) {
    carrier[key] = value;
    }
    }
    class OtelDioInterceptor extends Interceptor {
    final Tracer _tracer = globalTracerProvider.getTracer('flutter-http');
    final Map<RequestOptions, Span> _spans = {};
    final _setter = _DioHeaderSetter();
    @override
    void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final span = _tracer.startSpan(
    '${options.method} ${options.path}',
    kind: SpanKind.client,
    attributes: [
    Attribute.fromString('http.method', options.method),
    Attribute.fromString('http.url', options.uri.toString()),
    Attribute.fromString('http.host', options.uri.host),
    ],
    );
    // Inject W3C traceparent for downstream propagation
    final ctx = contextWithSpan(Context.current, span);
    W3CTraceContextPropagator().inject(ctx, options.headers, _setter);
    _spans[options] = span;
    handler.next(options);
    }
    @override
    void onResponse(Response response, ResponseInterceptorHandler handler) {
    final span = _spans.remove(response.requestOptions);
    if (span != null) {
    span.setAttribute(
    Attribute.fromInt('http.status_code', response.statusCode ?? 0));
    span.end();
    }
    handler.next(response);
    }
    @override
    void onError(DioException err, ErrorInterceptorHandler handler) {
    final span = _spans.remove(err.requestOptions);
    if (span != null) {
    span.setAttribute(
    Attribute.fromString('error.message', err.message ?? 'unknown'));
    if (err.response?.statusCode != null) {
    span.setAttribute(
    Attribute.fromInt('http.status_code', err.response!.statusCode!));
    }
    span.setStatus(StatusCode.error, err.message ?? '');
    span.end();
    }
    handler.next(err);
    }
    }
  4. Register the interceptor

    dio.interceptors.add(OtelDioInterceptor());
  5. Verify

    In Last9 Traces, filter by service.name = your-flutter-app. You should see spans for each HTTP call with method, URL, status code, and duration. Downstream services will share the same trace ID.

Choosing Between the Two Approaches

Use Approach A (traceparent injection) when you need distributed traces across Flutter and backend services without adding new dependencies.

Use Approach B (full OTel SDK) when you need spans from inside the Flutter app — HTTP call durations, error details, retry visibility, or user-journey tracing.

Troubleshooting

Traces Not Connecting Across Services

traceparent header not reaching backend:

If your backend is behind a reverse proxy or load balancer, ensure it forwards custom HTTP headers. Some proxies strip unknown headers by default — check your proxy’s header passthrough configuration.

Traces appear disconnected:

Ensure the traceparent header format is correct: 00-{32 hex}-{16 hex}-01. The version must be 00, and the flags byte 01 means “sampled”. If flags is 00, downstream services may not record the trace.

Full SDK Issues (Approach B)

Export fails on mobile:

The CollectorExporter uses HTTP/protobuf which works on most mobile networks. If you see connection timeouts, check that your app has internet permissions and the endpoint is reachable from the device network.

High memory usage from span tracking:

The _spans map in OtelDioInterceptor grows if responses are never received (e.g., cancelled requests). Add cleanup logic for stale spans or use dio’s cancel token to clean up properly.

Production Considerations

Crash Recording

Wrap your app entry point to capture unhandled exceptions as spans:

void main() {
runZonedGuarded(() async {
WidgetsFlutterBinding.ensureInitialized();
await initTelemetry(); // Approach B only
runApp(const MyApp());
}, (error, stack) {
// Record crash as a span (Approach B)
final tracer = globalTracerProvider.getTracer('flutter-app');
final span = tracer.startSpan('app.crash');
span.setStatus(StatusCode.error, error.toString());
span.setAttribute(Attribute.fromString('error.stack', stack.toString()));
span.end();
});
}

Flush on Background

Ensure telemetry is flushed before the app goes to the background. Undelivered spans are lost if the OS kills the app:

class MyApp extends StatefulWidget {
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
// Force flush pending spans before backgrounding
(globalTracerProvider as TracerProviderBase).forceFlush();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}

Need Help?

If you encounter any issues or have questions: