> ## Documentation Index
> Fetch the complete documentation index at: https://launchdarkly.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Flutter SDK observability reference

This topic documents how to get started with the LaunchDarkly observability plugin for the Flutter SDK.

The `launchdarkly_flutter_observability` package provides error monitoring, logging, tracing, and session replay through a single public facade named `LDObserve`.

<Note>
  **SDK quick links**

  LaunchDarkly SDKs are open source. In addition to this reference guide, we provide source, API reference documentation, and a sample application:

  <table>
    <thead>
      <tr>
        <th>Resource</th>
        <th>Location</th>
      </tr>
    </thead>

    <tbody>
      <tr>
        <td>GitHub repository</td>
        <td>[@launchdarkly/flutter observability](https://github.com/launchdarkly/observability-sdk/tree/main/sdk/%40launchdarkly/flutter/packages/observability)</td>
      </tr>

      <tr>
        <td>Sample application</td>
        <td>[Flutter example app](https://github.com/launchdarkly/observability-sdk/tree/main/sdk/%40launchdarkly/flutter/example)</td>
      </tr>
    </tbody>
  </table>
</Note>

## Prerequisites and dependencies

This reference guide assumes you are familiar with the LaunchDarkly [Flutter SDK](/docs/sdk/client-side/flutter).

The observability plugin requires:

* The LaunchDarkly Flutter SDK version 4.18.0 or later
* Flutter 3.27 or later
* An iOS deployment target of 15.0 or later
* Android SDK version 24 or later (Android 7.0, Nougat)

The Flutter observability plugin is compatible with iOS, Android, and web platforms. Session replay is supported only on the iOS and Android platforms. Session replay is not supported on web.

## Get started

Follow these steps to get started:

* [Install the package](#install-the-package)
* [Initialize observability](#initialize-observability)
* [Configure plugin options](#configure-plugin-options)
* [Configure session replay](#configure-session-replay)
* [Record screen views](#record-screen-views)
* [Record taps](#record-taps)
* [Manually instrument your app](#manual-instrumentation)
* [Explore supported features](#explore-supported-features)
* [Review observability data in LaunchDarkly](#review-observability-data-in-launchdarkly)

## Install the package

Add both the LaunchDarkly Flutter SDK and the observability package to your `pubspec.yaml`:

<CodeGroup>
  ```yaml title="pubspec.yaml" lines wrap theme={null}
  dependencies:
    launchdarkly_flutter_client_sdk: ^4.18.0
    launchdarkly_flutter_observability: ^1.0.0
  ```
</CodeGroup>

Then run:

<CodeGroup>
  ```bash title="Install dependencies" lines wrap theme={null}
  flutter pub get
  ```
</CodeGroup>

For iOS, set the deployment target to 15.0 or later before you install the pods. If you use CocoaPods, set it in your `ios/Podfile`:

<CodeGroup>
  ```ruby title="ios/Podfile" lines wrap theme={null}
  platform :ios, '15.0'
  ```
</CodeGroup>

In Xcode, set **iOS Deployment Target** to 15.0 for the Runner target. Then install the native pod dependencies from your app's `ios/` directory:

<CodeGroup>
  ```bash title="iOS pod install" lines wrap theme={null}
  cd ios && pod install && cd ..
  ```
</CodeGroup>

No extra setup is required for Android. Gradle resolves the plugin automatically.

After you install the dependencies, import the packages into your code:

<CodeGroup>
  ```dart title="Import" lines wrap theme={null}
  import 'package:launchdarkly_flutter_client_sdk/launchdarkly_flutter_client_sdk.dart';
  import 'package:launchdarkly_flutter_observability/launchdarkly_flutter_observability.dart';
  ```
</CodeGroup>

## Initialize observability

To initialize, you need your LaunchDarkly environment's mobile key. This authorizes your application to connect to a particular environment within LaunchDarkly. To learn more, read [Initialize the client](/docs/sdk/client-side/flutter#initialize-the-client) in the Flutter SDK reference guide.

<Warning>
  **Flutter observability mobile and web builds use different credential types**

  The Flutter observability SDK uses a mobile key for iOS and Android builds. For web builds, use a client-side ID instead. Keys are specific to each project and environment. They are available on the **SDK keys** page under **Settings**. To learn more about key types, read [Keys](/docs/sdk/concepts/client-side-server-side#keys-and-credentials).

  Mobile keys are not secret and you can expose them in your client-side code without risk. However, never embed a server-side SDK key into a client-side application.
</Warning>

There are two initialization variants. Use `LDObserve.init` if you are using the LaunchDarkly client, or `LDObserve.initStandalone` to initialize without a client.

Both variants return a `Future<bool>` that completes with `true` when observability is ready. Awaiting the future is optional, but the plugin drops anything that you record before it completes, except for the most recent screen view. The plugin replays it after startup finishes. Await the future before you record anything that must not be lost.

The future never completes with an error. It completes with `false` if startup fails, or if you already called `LDObserve.shutdown()`. If startup fails, the plugin logs the error, and you can call `init` again to retry the startup. Only the first successful call takes effect. The plugin logs and ignores any later call, including its options, and reports only the first call's outcome.

### Initialize with a LaunchDarkly client

Pass your `LDClient` to `LDObserve.init`. This registers the observability plugin on the client so feature flag evaluations are correlated with your telemetry:

<CodeGroup>
  ```dart title="Initialize with LDClient" expandable lines wrap theme={null}
  import 'dart:async';
  import 'package:flutter/material.dart';
  import 'package:launchdarkly_flutter_client_sdk/launchdarkly_flutter_client_sdk.dart';
  import 'package:launchdarkly_flutter_observability/launchdarkly_flutter_observability.dart';

  void main() {
    runZonedGuarded(
      () {
        WidgetsFlutterBinding.ensureInitialized();

        final client = LDClient(
          LDConfig(
            'example-mobile-key',
            AutoEnvAttributes.enabled,
          ),
          LDContextBuilder().kind('user', 'example-context-key').build(),
        );
        client.start();

        LDObserve.init(
          client,
          observability: const ObservabilityOptions(
            serviceName: 'my-flutter-app',
          ),
        );

        // Report errors caught by the Flutter framework
        FlutterError.onError = (FlutterErrorDetails details) {
          LDObserve.recordException(details.exception, stackTrace: details.stack);
        };

        runApp(const MyApp());
      },
      (err, stack) {
        // Report uncaught errors from the zone
        LDObserve.recordException(err, stackTrace: stack);
      },
    );
  }
  ```
</CodeGroup>

### Initialize standalone

To initialize without creating a LaunchDarkly client, pass your mobile key directly to `LDObserve.initStandalone`:

<CodeGroup>
  ```dart title="Initialize standalone" lines wrap theme={null}
  await LDObserve.initStandalone(
    'example-mobile-key',
    observability: const ObservabilityOptions(
      serviceName: 'my-flutter-app',
    ),
  );
  ```
</CodeGroup>

Because `initStandalone` has no LaunchDarkly client to register a hook on, it does not record a span for each feature flag evaluation. Every other kind of telemetry works in the same way it does with `LDObserve.init`.

## Configure plugin options

Pass an `ObservabilityOptions` object to `LDObserve.init` or `LDObserve.initStandalone` to configure observability behavior:

<CodeGroup>
  ```dart title="ObservabilityOptions" lines wrap theme={null}
  LDObserve.init(
    client,
    observability: ObservabilityOptions(
      serviceName: 'my-flutter-app',
      // Recommended: set to the latest deployed git SHA or semantic version
      serviceVersion: 'example-sha',
      instrumentation: InstrumentationOptions(
        launchTimes: true,
        debugPrint: DebugPrintSetting.releaseOnly(),
      ),
    ),
  );
  ```
</CodeGroup>

<View title="Developer" />

<View title="Federal docs" />

<View title="EU docs">
  <Tip>
    **Set the EU endpoints explicitly**

    The observability plugin defaults to the LaunchDarkly commercial endpoints. In the LaunchDarkly EU instance, set <code>otlpEndpoint</code> to <code>[https://otel.observability.app.eu.launchdarkly.com:4318](https://otel.observability.app.eu.launchdarkly.com:4318)</code> and <code>backendUrl</code> to <code>[https://pub.observability.app.eu.launchdarkly.com](https://pub.observability.app.eu.launchdarkly.com)</code>.
  </Tip>
</View>

These `ObservabilityOptions` configuration options are available on all platforms:

* **isEnabled**: Enables or disables observability telemetry. Defaults to `true`. When `false`, the plugin records no spans, logs, errors, flag evaluation spans, lifecycle spans, or `debugPrint` output, and exports nothing on web. Session replay is controlled separately with `SessionReplayOptions.isEnabled`. While session replay is on, screen views, taps, and track events still reach the replay timeline.
* **serviceName**: The service name for telemetry. Defaults to `"observability-flutter"`, which names the plugin rather than your app. Set it to identify your app.
* **serviceVersion**: The service version, commonly a Git SHA or semantic version string. This option is optional. If you omit it, Android and iOS report your app's own version, which Flutter sets from the `version` in your `pubspec.yaml`, and web omits `service.version`.
* **instrumentation**: An `InstrumentationOptions` object that controls automatic instrumentation. To learn more, read [Instrumentation options](#instrumentation-options).
* **otlpEndpoint**: The OpenTelemetry Protocol (OTLP) endpoint for reporting OpenTelemetry data. Defaults to `https://otel.observability.app.launchdarkly.com:4318`. You do not need to change this for most configurations.
* **backendUrl**: The LaunchDarkly back-end URL. Defaults to `https://pub.observability.app.launchdarkly.com`. You do not need to change this for most configurations.
* **contextFriendlyName**: A display name to identify the user's session in the observability UI.
* **attributes**: A map of additional OpenTelemetry resource attributes to attach to every signal the plugin exports. For example, `{'deployment.environment': 'staging'}`. If one of your attributes uses the same key as an attribute the plugin sets, such as a `service.*` key or the project identifier, the plugin's value wins.
* **analytics**: An `AnalyticsOptions` object that controls telemetry for product analytics events. Use `AnalyticsOptions.enabled` to enable all types or `AnalyticsOptions.disabled` to disable all:
  * **taps**: Emits a `click` span for each user tap. Defaults to `true`. Setting this to `false` also stops tap detection, rather than detecting taps without publishing them. To learn more, read [Record taps](#record-taps).
  * **views**: Emits a `screen_view` span for each screen view. Defaults to `true`. To learn more, read [Record screen views](#record-screen-views).
  * **trackEvents**: Emits a `track` span when a custom event is tracked with `track()`. Defaults to `true`.
  * **appLifecycle**: Emits app lifecycle spans. Defaults to `true`. On every platform, including web, this option gates the Dart `device.app.lifecycle` span that the plugin emits on each Flutter `AppLifecycleState` change. On Android and iOS, it also gates the native `app_foreground` and `app_background` spans.
  * **appLaunch** (Android and iOS): Emits an `app_launch` span once per process launch. The span carries the launch type, which is `install`, `update`, or `relaunch`, along with version information and an `app.start` span event that records the cold or warm startup dimension. Defaults to `true`. `InstrumentationOptions.launchTimes` separately controls launch time measurement.
  * **customClickTargetResolver**: A function that names your own widget types as tap targets. To learn more, read [Name your own widget types](#name-your-own-widget-types).
    To learn more, read [Product analytics events](/docs/home/observability/product-analytics).

The following `ObservabilityOptions` configuration options are available only on Android and iOS:

* **customHeaders**: Extra HTTP headers added to OTLP exports, for example for proxies or authentication. Defaults to `{}`.
* **sessionBackgroundTimeout**: How long the app can stay in the background before the session ends. Defaults to 15 minutes.
* **logsApiLevel**: The minimum severity of logs forwarded to the logs pipeline. Use `ObservabilityLogLevel.none` to disable logs. Defaults to `ObservabilityLogLevel.info`.
* **traces**: A `TracesOptions` object that controls automatic trace generation. Toggles `includeErrors` and `includeSpans`. Both default to `true`.
* **metricsEnabled**: Whether metrics are exported. Defaults to `true`.

For more information on plugin options, read [Configuration for client-side observability](/docs/sdk/features/observability-config-client-side).

### Instrumentation options

The `InstrumentationOptions` class controls which automatic instrumentation features are active. Pass it to the `instrumentation` parameter of `ObservabilityOptions`.

<CodeGroup>
  ```dart title="InstrumentationOptions" lines wrap theme={null}
  ObservabilityOptions(
    instrumentation: InstrumentationOptions(
      launchTimes: true,
      debugPrint: DebugPrintSetting.always(),
    ),
  )
  ```
</CodeGroup>

These instrumentation options are available on all platforms:

* **launchTimes**: When `true`, measures and reports application launch time. Defaults to `true`.
* **debugPrint**: Controls whether `debugPrint` calls are automatically captured as log events:
  * `DebugPrintSetting.releaseOnly()` (the default) captures `debugPrint` calls only in release builds.
  * `DebugPrintSetting.always()` captures `debugPrint` calls in all build configurations. When enabled, `debugPrint` output does not appear in the Flutter console.
  * `DebugPrintSetting.disabled()` does not instrument `debugPrint`.

The following `InstrumentationOptions` configuration option is available only on Android and iOS:

* **crashReporting**: When `true`, reports uncaught exceptions as errors. Defaults to `true`.

<Note>
  **The plugin does not instrument HTTP requests automatically**

  Flutter's HTTP clients go through `dart:io`, which the native network instrumentation in the Android and iOS observability SDKs cannot observe. To trace an HTTP request, wrap it in `LDObserve.withSpan`. To learn more, read [Record scoped spans](#record-scoped-spans).
</Note>

### Intercept print statements

To capture the output from `print` statements, pass `LDObserve.zoneSpecification()` to `runZonedGuarded`:

<CodeGroup>
  ```dart title="Intercept print" lines wrap theme={null}
  void main() {
    runZonedGuarded(
      () {
        // Initialize and run your app
      },
      (err, stack) {
        LDObserve.recordException(err, stackTrace: stack);
      },
      zoneSpecification: LDObserve.zoneSpecification(),
    );
  }
  ```
</CodeGroup>

### Map contexts to friendly names

Use `contextFriendlyName` to set a human-readable display name for the user's session when displayed in the observability User Interface (UI):

<CodeGroup>
  ```dart title="contextFriendlyName" lines wrap theme={null}
  ObservabilityOptions(
    contextFriendlyName: 'Bob Smith',
  )
  ```
</CodeGroup>

### Configure product analytics event collection

The observability SDK for Flutter can record the following product analytics events as OpenTelemetry spans:

* **Track events** (manual, all platforms): A `track` span recorded when your code calls `LDObserve.track()`. The `trackEvents` flag in `AnalyticsOptions` controls whether the span is emitted. To learn more, read [Recording product analytics events](#recording-product-analytics-events).
* **Taps** (automatic, all platforms): A `click` span for each user tap, with the widget type, identifier, visible label, widget ancestry path, and coordinates. Enabled by default. Tap capture requires the `SessionReplayCapture` widget. To learn more, read [Record taps](#record-taps).
* **Screen views** (automatic or manual, all platforms): A `screen_view` span when your app shows a screen, with the screen name and optional details such as the screen class, screen identifier, and category. Enabled by default. Screen views are reported from your Dart code, either by the `LDNavigatorObserver` or by a call to `LDObserve.trackScreenView`. To learn more, read [Record screen views](#record-screen-views).
* **App lifecycle** (automatic, all platforms): A `device.app.lifecycle` span on each Flutter `AppLifecycleState` change. On Android and iOS, the plugin also emits a native `app_foreground` or `app_background` span as the app moves between the foreground and background states. Enabled by default.
* **App launches** (automatic, Android and iOS): An `app_launch` span once per process launch. The span carries the launch type, which is `install`, `update`, or `relaunch`, along with version information and an `app.start` span event that records the cold or warm startup dimension. Enabled by default. To learn more, read [App launch events](/docs/home/observability/product-analytics#app-launch-events).

All product analytics span events also include information about the LaunchDarkly context that generated the event.

Use the generated span events to create custom product analytics charts, such as time series and funnels. To learn more, read [Product analytics events](/docs/home/observability/product-analytics).

To enable all compatible product analytics events, set `analytics` to `AnalyticsOptions.enabled` in `ObservabilityOptions`:

<CodeGroup>
  ```dart title="Enable all product analytics events" lines wrap theme={null}
  ObservabilityOptions(
    analytics: AnalyticsOptions.enabled,
  )
  ```
</CodeGroup>

To enable or disable individual event types, use `AnalyticsOptions` instead:

<CodeGroup>
  ```dart title="Enable individual event types" lines wrap theme={null}
  ObservabilityOptions(
    analytics: AnalyticsOptions(
      trackEvents: true,   // all platforms
      taps: true,          // all platforms
      views: true,         // all platforms
      appLifecycle: true,  // all platforms
      appLaunch: true,     // Android and iOS
    ),
  )
  ```
</CodeGroup>

<Note>
  **Default values for product analytics**

  Track event, tap, screen view, app lifecycle, and app launch collection are all enabled by default. Use `AnalyticsOptions.disabled` to disable all product analytics spans at once.
</Note>

The `taps` and `views` options behave differently on mobile than they do on web:

* **taps**: Gates the Dart-side tap detection as well as the `click` span. Setting it to `false` stops the plugin from resolving tapped widgets at all. On Android and iOS, the session replay **Click** timeline event is recorded regardless of this setting.
* **views**: Gates the `screen_view` span only. On Android and iOS, the session replay **Navigate** timeline event is recorded regardless of this setting.
* **appLifecycle**: Gates the Dart `device.app.lifecycle` span on every platform. On Android and iOS, it also gates the native `app_foreground` and `app_background` spans.

`AnalyticsOptions` also accepts a `customClickTargetResolver`, which names your own widget types as tap targets. To learn more, read [Name your own widget types](#name-your-own-widget-types).

## Configure session replay

Session replay captures screen recordings of user actions to help you understand how users interact with your application. It is included in the `launchdarkly_flutter_observability` package and uses native iOS and Android libraries to capture and upload recordings.

Session replay for Flutter is supported only on iOS and Android. It is not supported on web.

### Initialize session replay

To enable session replay, pass a `SessionReplayOptions` object to the `replay` parameter of `LDObserve.init` or `LDObserve.initStandalone`, and wrap your app in `SessionReplayCapture`:

<CodeGroup>
  ```dart title="Initialize with session replay" expandable lines wrap theme={null}
  import 'dart:async';
  import 'package:flutter/material.dart';
  import 'package:launchdarkly_flutter_client_sdk/launchdarkly_flutter_client_sdk.dart';
  import 'package:launchdarkly_flutter_observability/launchdarkly_flutter_observability.dart';

  void main() {
    runZonedGuarded(
      () {
        WidgetsFlutterBinding.ensureInitialized();

        final client = LDClient(
          LDConfig(
            'example-mobile-key',
            AutoEnvAttributes.enabled,
          ),
          LDContextBuilder().kind('user', 'example-context-key').build(),
        );
        client.start();

        LDObserve.init(
          client,
          observability: const ObservabilityOptions(
            serviceName: 'my-flutter-app',
          ),
          replay: const SessionReplayOptions(
            isEnabled: true,
            privacy: PrivacyOptions(maskTextInputs: true),
          ),
        );

        FlutterError.onError = (FlutterErrorDetails details) {
          LDObserve.recordException(details.exception, stackTrace: details.stack);
        };

        // Wrap your app in SessionReplayCapture to enable screen recording
        runApp(const SessionReplayCapture(child: MyApp()));
      },
      (err, stack) {
        LDObserve.recordException(err, stackTrace: stack);
      },
    );
  }
  ```
</CodeGroup>

### SessionReplayCapture widget

Wrap your top-level widget in `SessionReplayCapture` to allow the native session replay library to capture screen content. Because session replay is not supported on web, the widget records nothing there, but it still hosts tap capture on every platform. Wrapping your app with it is safe and useful on every platform:

<CodeGroup>
  ```dart title="SessionReplayCapture" lines wrap theme={null}
  runApp(const SessionReplayCapture(child: MyApp()));
  ```
</CodeGroup>

Wrap your app as high in the widget tree as you can, ideally around `MaterialApp`. Dialogs, bottom sheets, and other routes are children of the app's `Navigator`, so a wrapper further down the tree excludes taps on anything your app pushes above it. To learn more, read [Record taps](#record-taps).

### Session replay configuration options

Pass a `SessionReplayOptions` object to `LDObserve.init` or `LDObserve.initStandalone` to control session replay behavior:

<CodeGroup>
  ```dart title="SessionReplayOptions" lines wrap theme={null}
  const SessionReplayOptions(
    isEnabled: true,
    privacy: PrivacyOptions(
      maskTextInputs: true,
      maskWebViews: false,
      maskLabels: false,
      maskImages: false,
    ),
  )
  ```
</CodeGroup>

If you omit the `replay` parameter, session replay does not start. The `isEnabled` default applies only to the `SessionReplayOptions` object you pass in.

These `SessionReplayOptions` configuration options are available on all platforms:

* **isEnabled**: Controls whether session recording starts on Android and iOS. Defaults to `true`. This option has no effect on web, where session replay is not supported.
* **privacy**: A `PrivacyOptions` object that controls which UI elements are masked. To learn more, read [Privacy options](#privacy-options).
* **serviceName**: The service name reported for session replay telemetry. Defaults to `"sessionreplay-flutter"`.

The following `SessionReplayOptions` configuration options are available only on Android and iOS:

* **sampleRate**: Probability from `0.0` to `1.0` that session replay starts when enabled. Defaults to `1.0`.
* **frameRate**: Target capture rate in frames per second. Defaults to `1.0`.
* **scale**: The resolution multiplier for captured frames, where `1.0` is 1x (160 DPI) and `2.0` is 2x. Higher values capture more detail but produce larger frames. A `null` value is treated as `1.0`. Defaults to `1.0`.
* **imageQuality**: The JPEG encoding quality of exported frames, from `0.0` (lowest quality, smallest payload) to `1.0` (highest quality, largest payload). Values outside that range are clamped. Defaults to `0.3`.

### Privacy options

Use `PrivacyOptions` to control which elements are masked in session replay recordings. By default, text inputs are masked to protect user data.

<CodeGroup>
  ```dart title="PrivacyOptions" lines wrap theme={null}
  const PrivacyOptions(
    maskTextInputs: true,  // default — masks all text inputs
    maskWebViews: false,   // when true, masks WebView content
    maskLabels: false,     // when true, masks all text labels
    maskImages: false,     // when true, masks all images
    minimumAlpha: 0.02,    // widgets below this opacity are not captured
    maskClickText: false,  // when true, drops the label from click events
  )
  ```
</CodeGroup>

The available privacy options are:

* **maskTextInputs**: Masks all text input fields. Defaults to `true`.
* **maskWebViews**: Masks the contents of web views. When enabled, web views appear as blank rectangles in recordings. Defaults to `false`.
* **maskLabels**: Masks all text labels. Defaults to `false`.
* **maskImages**: Masks all images. Defaults to `false`.
* **minimumAlpha**: The opacity threshold, from `0.0` to `1.0`, below which the plugin treats a widget as invisible and skips it during capture and masking. Raise it to ignore nearly transparent UI. Defaults to `0.02`.
* **maskClickText**: Drops the visible label from click events. Taps report the widget type and identifier but no text. Defaults to `false`. This option is independent of `maskLabels`, which controls whether the plugin paints over text in captured frames. To learn more, read [Record taps](#record-taps).

LaunchDarkly applies masks to every captured frame. Masks follow their widgets through scrolling, transforms, and animations. If a mask cannot be placed reliably in a frame, LaunchDarkly drops that frame rather than risk exposing unmasked content.

### Per-widget masking

In addition to screen-wide `PrivacyOptions`, you can redact individual widgets using `LDMask`, `LDIgnore`, and `LDUnmask`.

Use `LDMask` to redact a widget's subtree in all captured frames:

<CodeGroup>
  ```dart title="LDMask" lines wrap theme={null}
  LDMask(
    child: Text(creditCardNumber),
  )
  ```
</CodeGroup>

Use `LDIgnore` to exclude a subtree from session replay entirely. In Flutter, `LDIgnore` behaves like `LDMask` and paints over the region in every captured frame, so its contents never appear in a recording:

<CodeGroup>
  ```dart title="LDIgnore" lines wrap theme={null}
  LDIgnore(
    child: VideoPlayer(controller),
  )
  ```
</CodeGroup>

Use `LDUnmask` to exempt a subtree from global masking rules such as `maskTextInputs`. For example, to reveal one non-sensitive field on a page where every input is masked:

<CodeGroup>
  ```dart title="LDUnmask" lines wrap theme={null}
  // maskTextInputs masks every field; reveal just this one
  LDUnmask(
    child: TextField(controller: searchController),
  )
  ```
</CodeGroup>

`LDUnmask` only overrides global masking. It does not override an explicit `LDMask` or `LDIgnore`. An `LDUnmask` nested inside either one stays masked, because an explicit per-widget mask always takes precedence.

`LDMask`, `LDIgnore`, and `LDUnmask` are active on iOS and Android. On web, where session replay is not supported, they render their child unchanged.

### Mask by widget type or key

When wrapping widgets is not convenient, name them once in `PrivacyOptions` by their `Type` or `Key`. LaunchDarkly resolves these rules on the Flutter side and does not send them to the native SDKs:

<CodeGroup>
  ```dart title="Mask by widget type or key" lines wrap theme={null}
  PrivacyOptions(
    maskWidgetTypes: {CreditCardField},
    maskWidgetKeys: {const ValueKey('ssn-field')},
    unmaskWidgetTypes: {SearchBox},
    ignoreWidgetTypes: {LiveCameraPreview},
  )
  ```
</CodeGroup>

The available widget matching options are:

* **maskWidgetTypes**: A set of widget `Type`s to mask wherever they appear. Defaults to an empty set.
* **maskWidgetKeys**: A set of widget `Key`s to mask wherever they appear. Defaults to an empty set.
* **unmaskWidgetTypes**: A set of widget `Type`s to reveal from global masking. Defaults to an empty set.
* **unmaskWidgetKeys**: A set of widget `Key`s to reveal from global masking. Defaults to an empty set.
* **ignoreWidgetTypes**: A set of widget `Type`s to ignore. Defaults to an empty set.
* **ignoreWidgetKeys**: A set of widget `Key`s to ignore. Defaults to an empty set.

These rules follow the same precedence as the wrapper widgets. A mask or ignore match takes precedence over an unmask match.

To learn more about session replay configuration, read [Configuration for session replay](/docs/sdk/features/session-replay-config).

## Record screen views

Flutter renders your entire app into a single native view, so the native observability SDKs never detect your Flutter route changes. To record screen views, report route changes from your Dart code.

Each screen view emits a `screen_view` span. On Android and iOS, it also adds a **Navigate** event to the session replay timeline.

### Record screen views automatically

Add an `LDNavigatorObserver` to the `navigatorObservers` list on your top-level navigator. Every route change then becomes a screen view:

<CodeGroup>
  ```dart title="LDNavigatorObserver" lines wrap theme={null}
  MaterialApp(
    navigatorObservers: [LDNavigatorObserver()],
    // ...
  );
  ```
</CodeGroup>

The observer reads the screen name from `route.settings.name` and skips routes that do not have one. An app that pushes a bare `MaterialPageRoute` records nothing and reports no error. To avoid this, name your routes where you push them:

<CodeGroup>
  ```dart title="Name a route" lines wrap theme={null}
  Navigator.of(context).push(
    MaterialPageRoute<void>(
      settings: const RouteSettings(name: '/checkout'),
      builder: (_) => const CheckoutPage(),
    ),
  );
  ```
</CodeGroup>

Skipping unnamed routes is deliberate. It keeps the dialogs and bottom sheets that `showDialog` and `showModalBottomSheet` push without settings from appearing as screens.

To record those routes anyway, or to derive screen names another way, pass a `screenNameExtractor`. Returning `null` from the extractor skips the route, so the extractor also works as a filter for routes you do not want to record:

<CodeGroup>
  ```dart title="screenNameExtractor" lines wrap theme={null}
  MaterialApp(
    navigatorObservers: [
      LDNavigatorObserver(
        screenNameExtractor: (route) =>
            route.settings.name ?? route.settings.arguments?.toString(),
        category: 'navigation',
      ),
    ],
  );
  ```
</CodeGroup>

### Report route patterns instead of URLs

Routers that navigate by URL, including go\_router, GetX, Beamer, and auto\_route, put the concrete path in `route.settings.name`. Without route patterns, `'/orders/42'` becomes its own screen, one screen fragments into one screen per order, and an order identifier ends up in a name that appears in the LaunchDarkly UI.

The observer also drops the query string and fragment from every screen name. This means `'/reset?token=abc123'` and `'/search?q=running+shoes'` are reported as `/reset` and `/search`. A query string holds values rather than structure, and a screen name identifies a screen. Because names that are not paths keep their punctuation, a confirmation route named `'Delete this?'` is reported as written.

Path parameters are the one part of a route name that the plugin cannot resolve on its own. Only your app knows which segments are identifiers. List the patterns your app registers, and `LDRoutePatterns.extractor` collapses concrete paths back onto them:

<CodeGroup>
  ```dart title="LDRoutePatterns.extractor" lines wrap theme={null}
  LDNavigatorObserver(
    screenNameExtractor: LDRoutePatterns.extractor(const [
      '/orders',
      '/orders/new', // A literal listed first wins over '/orders/:id'.
      '/orders/:id',
      '/orders/:id/receipt',
    ]),
  );
  ```
</CodeGroup>

That flow then reports `/orders`, `/orders/:id`, and `/orders/:id/receipt`, no matter which order identifiers are involved.

Patterns use the same syntax those routers use:

* A `:name` segment matches exactly one path segment.
* A trailing `*` matches the rest of the path.

The extractor compares patterns in the order you list them, and the first match wins. Place a literal before any pattern that also matches it, such as `'/orders/new'` before `'/orders/:id'`.

Because a route that matches no pattern is reported by its path, any screen you forget to list still appears. To treat your pattern list as an allow list and record only those patterns you name, pass `skipUnmatched: true`.

### Navigation an observer cannot see

Each `LDNavigatorObserver` instance observes a single navigator, and Flutter asserts if you share one instance between navigators. The following setups need additional configuration:

* **Nested navigators**: A tab shell, or a `Navigator` inside a page, reports only its own routes. Create a separate observer instance for each navigator.
* **`MaterialApp.router`**: Routers such as go\_router, auto\_route, and Beamer do not accept `navigatorObservers`. Pass the observer to the router's own observer list instead, such as `GoRouter(observers: [LDNavigatorObserver()])`.
* **`GetMaterialApp`**: GetX merges the observers you pass with its own, so `navigatorObservers: [LDNavigatorObserver()]` works the same way it does on `MaterialApp`. `GetMaterialApp.router` is the exception. It accepts `navigatorObservers` and then builds its delegate without them, so pass them as `routerDelegate: GetDelegate(navigatorObservers: [LDNavigatorObserver()])` instead.
* **Navigation that leaves the route stack unchanged**: Switching tabs in an `IndexedStack`, or paging a `PageView`, is invisible to any observer. Record these screen views manually.

### Record screen views manually

To record a screen view yourself, or to add detail that an observer cannot supply, call `LDObserve.trackScreenView`:

<CodeGroup>
  ```dart title="trackScreenView" lines wrap theme={null}
  LDObserve.trackScreenView(
    'Checkout',
    screenClass: 'CheckoutPage',
    category: 'commerce',
    properties: <String, Object?>{'cart_size': 3},
  );
  ```
</CodeGroup>

Only the screen name is required. The plugin records `screenClass`, `screenId`, and `category` as the `event.screen_class`, `event.screen_id`, and `event.category` span attributes, and attaches any `properties` you pass as additional attributes.

## Record taps

The plugin captures taps automatically and records each one as a `click` span. Every tap that resolves to a recognized widget describes that widget by type, identifier, visible label, ancestry path, and coordinates. On Android and iOS, the same tap also adds a **Click** event to the session replay timeline. This enables you to jump to the moment a user pressed a widget.

Tap capture requires you to wrap your widget tree in `SessionReplayCapture`, which hosts the tap detector on every platform, including web. To learn more, read [SessionReplayCapture widget](#sessionreplaycapture-widget).

The plugin resolves the tapped widget in Dart, because Flutter renders its entire interface into one native view. A native hit test names that view, `FlutterSurfaceView`, for every tap in your app regardless of which widget the user pressed.

### Tap event attributes

Each `click` span carries the following attributes:

<table>
  <thead>
    <tr>
      <th>Attribute</th>
      <th>Description</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>`event.tag`</td>
      <td>The widget type, such as `ElevatedButton`.</td>
    </tr>

    <tr>
      <td>`event.id`</td>
      <td>A stable identifier for the widget. The plugin uses the first value it finds, in this order: the `id` of an enclosing `LDClick`, an `id` from your `customClickTargetResolver`, a `Semantics.identifier`, or a `ValueKey`. This attribute is optional. A widget with none of these is still reported, grouped by type and ancestry path.</td>
    </tr>

    <tr>
      <td>`event.text`</td>
      <td>The visible label: a button's own text, or otherwise a semantic label, icon label, or tooltip. The plugin does not harvest a container's inner text, so a tapped row reports `ListTile` rather than whichever word sat under the user's finger. A `Radio` with no label falls back to its `value`.</td>
    </tr>

    <tr>
      <td>`event.xpath`</td>
      <td>The widget ancestry, such as `Scaffold/Column/ProductRow/IconButton#cart.add`. The plugin leaves out framework plumbing, including theme and media query providers, builders, focus and semantics wrappers, and single-child layout and painting boxes, and keeps only the innermost ten segments.</td>
    </tr>

    <tr>
      <td>`event.x`, `event.y`</td>
      <td>The tap coordinates. Automatic capture reports the units native taps use on that platform, which are physical pixels on Android and logical pixels on iOS. This means a Flutter tap lands on the session replay timeline next to a native one. `LDObserve.trackClick` takes `x` and `y` as `double` logical pixels, the units of a gesture's `globalPosition`, and converts them to those same platform units. Manual and automatic taps therefore share one coordinate space.</td>
    </tr>
  </tbody>
</table>

The plugin recognizes the following widgets out of the box:

* Material and Cupertino buttons, including `IconButton` and `FloatingActionButton`
* Selection controls, including `Switch`, `Checkbox`, `Radio`, and `Slider`
* Chips, tabs, and menus, including `PopupMenuButton` and `DropdownButton`
* Navigation bars
* `ListTile`, `InkWell`, and `GestureDetector`

A tap on unrecognized empty space, or on a disabled control, records nothing.

`event.xpath` segments for widget types the plugin does not recognize come from the Dart runtime type, which release builds compiled with `--obfuscate` mangle. The `event.tag` and `event.id` attributes stay readable in obfuscated builds, so you can group your product analytics charts on those attributes.

### Name a widget with LDClick

To give a specific widget a stable identifier, wrap it in `LDClick`. Because the widget renders its child unchanged and emits nothing itself, wrapping a button cannot double count a tap:

<CodeGroup>
  ```dart title="LDClick" lines wrap theme={null}
  LDClick(
    id: 'checkout.pay',
    properties: <String, Object?>{'cart_size': 3},
    child: ElevatedButton(onPressed: _pay, child: const Text('Pay')),
  );
  ```
</CodeGroup>

A tap on any descendant resolves to the `id` of the nearest enclosing `LDClick`, so you can wrap a composite control to name the entire control. Use `LDClick` when a widget has no `Key` or `Semantics.identifier`, or when the automatic name is too generic to group on.

### Name your own widget types

A design system button often looks like an anonymous composition of Material widgets. To name every instance of one of your own widget types at once, register a `customClickTargetResolver` in `AnalyticsOptions` instead of tagging each call site:

<CodeGroup>
  ```dart title="customClickTargetResolver" lines wrap theme={null}
  ObservabilityOptions(
    analytics: AnalyticsOptions(
      customClickTargetResolver: (widget) => switch (widget) {
        PrimaryButton(:final label) =>
          LDClickTargetInfo(tag: 'PrimaryButton', text: label),
        _ => null,
      },
    ),
  );
  ```
</CodeGroup>

Return an `LDClickTargetInfo` for the types you want to name, and `null` for everything else so the built-in rules apply. `LDClickTargetInfo` accepts the following parameters:

* **tag**: The widget type name reported as `event.tag`. Required.
* **id**: A stable identifier for the instance, reported as `event.id`.
* **text**: The visible label, reported as `event.text`. If you omit it, the plugin falls back to its usual text extraction.
* **preferInnerTarget**: When `true`, a more specific target nested inside this widget is reported instead. Set this for containers that only make a region tappable, such as a card or a row, to ensure that the plugin reports a button inside the containers. Defaults to `false`.

Use a string literal for `tag` rather than `runtimeType.toString()`. Release builds compiled with `--obfuscate` mangle runtime type names, which would group the same widget differently in every build.

The resolver runs for every widget above the tap, so keep it inexpensive and free of side effects. The plugin catches and logs an exception from your resolver and then continues with the built-in rules.

### Record taps manually

For an interaction that automatic capture cannot observe, such as a shake, a hardware button, or a custom gesture recognizer, call `LDObserve.trackClick` yourself. Pass `x` and `y` as `double` logical pixels, the same units a Flutter `Offset` uses. The plugin converts them to the units automatic capture reports on the current platform:

<CodeGroup>
  ```dart title="trackClick" lines wrap theme={null}
  LDObserve.trackClick(
    id: 'onboarding.shake_to_skip',
    tag: 'ShakeGesture',
    x: 24,
    y: 80,
    properties: <String, Object?>{'step': 2},
  );
  ```
</CodeGroup>

Avoid calling `LDObserve.trackClick` from an `onPressed` handler that automatic capture already observes, because that counts the tap twice. To give an automatically captured widget a stable name, wrap it in `LDClick` instead.

### Tap capture limitations

Tap capture has the following limitations:

* **Only taps count as clicks.** The plugin does not report a press that moves further than `kTouchSlop`, such as a scroll or a drag, a press that outlasts the long press timeout, or any multi-touch gesture. The native SDKs apply the same rule, which keeps Flutter and native clicks comparable.
* **Embedded platform views resolve to their host widget.** A tap in a `WebView` or a native map reports the Flutter widget hosting it, such as `WebViewWidget`. The plugin does not describe what the user pressed inside the embedded view.
* **Pointer-blocking overlays are honored.** An `IgnorePointer` is transparent to the plugin, because the tap passed through it. Because an `AbsorbPointer`, such as a loading overlay laid over a `Stack`, consumes the tap, the plugin reports neither its children nor the controls painted behind it. This matches what Flutter delivered to your app.
* **Tap labels follow your masking rules.** The plugin never reports text inside an `LDMask` or `LDIgnore` subtree, and never reads the contents of an editable field. Setting `maskClickText` in `PrivacyOptions` turns off tap labels entirely while still recording the taps. To learn more, read [Privacy options](#privacy-options).

## Manual instrumentation

After initializing the observability plugin, use `LDObserve` to manually instrument your Flutter application with custom logs, errors, traces, and product analytics events.

### Recording custom logs

Use `LDObserve.recordLog` to emit a structured log. `severity` is a `LogSeverity` value: `trace`, `debug`, `info`, `warn`, `error`, or `fatal`. It defaults to `LogSeverity.info`. `properties` is a plain Dart map with no LaunchDarkly or OpenTelemetry types required:

<CodeGroup>
  ```dart title="Record logs" lines wrap theme={null}
  // Record a basic log message
  LDObserve.recordLog(
    'User login successful',
    severity: LogSeverity.info,
  );

  // Record a log with custom properties
  LDObserve.recordLog(
    'Authentication completed',
    severity: LogSeverity.info,
    properties: <String, Object?>{
      'user_id': '12345',
      'action': 'login',
    },
  );

  // Record a log with a stack trace
  LDObserve.recordLog(
    'Checkout failed',
    severity: LogSeverity.error,
    stackTrace: StackTrace.current,
  );
  ```
</CodeGroup>

Each `LogSeverity` value carries the OpenTelemetry severity number the plugin exports it with. `LogSeverity` is distinct from `ObservabilityLogLevel`, which sets the minimum severity the native SDKs export. To learn more, read [Configure plugin options](#configure-plugin-options).

If you record a log while a span is active, the plugin associates the log with that span through the OpenTelemetry context:

<CodeGroup>
  ```dart title="Record a log on the active span" lines wrap theme={null}
  final span = LDObserve.startSpan('checkout-flow');
  LDObserve.recordLog(
    'Processing on the same trace',
    severity: LogSeverity.warn,
    properties: <String, Object?>{'source': 'checkout'},
  );
  span.end();
  ```
</CodeGroup>

### Record errors

Use `LDObserve.recordException` to capture an error. Because the stack trace in Dart is independent of the exception, capture both together:

<CodeGroup>
  ```dart title="Record an error" lines wrap theme={null}
  try {
    await processPayment();
  } catch (e, stack) {
    LDObserve.recordException(e, stackTrace: stack);
  }
  ```
</CodeGroup>

To attach extra detail to the error, pass a `properties` map.

### Recording custom traces

Use `LDObserve.startSpan` to create a span for tracing an operation. Spans nest automatically under the currently active span. Always end spans when the operation completes:

<CodeGroup>
  ```dart title="Record traces" lines wrap theme={null}
  // Start a span with custom properties
  final span = LDObserve.startSpan(
    'database_query',
    properties: <String, Object?>{
      'table': 'users',
      'operation': 'select',
    },
  );

  // Perform your operation
  await performDatabaseQuery();

  // Always end the span
  span.end();
  ```
</CodeGroup>

To set the span's kind, pass the `kind` parameter to `startSpan`. `SpanKind` supports `internal`, which is the default, along with `client`, `server`, `producer`, and `consumer`.

Each span supports these methods:

* **setAttribute(name, value)**: Sets a single attribute on the span.
* **setAttributes(map)**: Sets multiple attributes on the span.
* **addEvent(name, {attributes})**: Records a named event on the span.
* **setStatus(SpanStatusCode)**: Sets the span status to `ok`, `error`, or `unset`.
* **recordException(exception, {stackTrace, attributes})**: Records an exception on the span.
* **end()**: Ends the span.

End nested spans in reverse order of starting them. Calling `end()` again on a span that already ended does nothing.

To record spans independently instead of nesting them, end each span before you start the next:

<CodeGroup>
  ```dart title="Sequential spans" lines wrap theme={null}
  final span1 = LDObserve.startSpan('SequentialOperation1');
  span1.setAttribute('sequence', '1');
  span1.end();

  final span2 = LDObserve.startSpan('SequentialOperation2');
  span2.setAttribute('sequence', '2');
  span2.end();
  ```
</CodeGroup>

#### Record scoped spans

Use `LDObserve.withSpan` to run a function inside a new span that the plugin ends for you, either when the function returns or when its `Future` completes. `withSpan` returns whatever the function returns:

<CodeGroup>
  ```dart title="withSpan" lines wrap theme={null}
  final orders = await LDObserve.withSpan('load-orders', (span) async {
    span.setAttribute('page', 1);
    return api.fetchOrders(page: 1);
  });
  ```
</CodeGroup>

Because the span stays current across `await` boundaries, any span you start inside the function is a child of it. If the function throws an error or its future fails, the plugin records the error on the span, sets the span status to `SpanStatusCode.error`, and rethrows the error unchanged.

We recommend `withSpan` for asynchronous work. A span from `startSpan` stays current for every piece of code in the zone until you end it, including unrelated work that runs during an `await`.

Because the plugin does not instrument HTTP requests automatically, wrap a request in `withSpan` to trace it:

<CodeGroup>
  ```dart title="Trace an HTTP request" lines wrap theme={null}
  final response = await LDObserve.withSpan('api_request', (span) async {
    span.setAttribute('endpoint', '/api/users');
    span.setAttribute('method', 'GET');
    return httpClient.get(Uri.parse('https://api.example.com/api/users'));
  });
  ```
</CodeGroup>

#### Attribute and property values

Attributes and properties are plain Dart values. No LaunchDarkly or OpenTelemetry types are required. A value can be a `String`, `int`, `double`, `bool`, or a homogeneous list of any of those types. The SDK ignores values it cannot represent as an attribute, such as nested maps or mixed-type lists.

The `recordLog`, `recordException`, `startSpan`, `withSpan`, `track`, `trackScreenView`, and `trackClick` methods all accept a `properties` map of these same plain values.

### Recording product analytics events

Use `LDObserve.track` to record a custom event as a product analytics span:

<CodeGroup>
  ```dart title="Track a custom event" lines wrap theme={null}
  // Track an event with properties and an optional metric value
  LDObserve.track(
    'purchase_completed',
    properties: <String, Object?>{
      'product_id': 'SKU-123',
      'price': 29.99,
    },
    metricValue: 29.99,
  );

  // Track an event with no properties
  LDObserve.track('button_tapped');
  ```
</CodeGroup>

`LDObserve` also records screen views and taps manually. To learn more, read [Record screen views manually](#record-screen-views-manually) and [Record taps manually](#record-taps-manually).

### Shut down observability

Call `LDObserve.shutdown()` to shut down observability and session replay for the rest of the process. The plugin flushes buffered spans and stops native session replay. It also removes the Dart instrumentation for lifecycle, `debugPrint`, and tap capture, and turns every recording method into a no-op.

<CodeGroup>
  ```dart title="Shut down observability" lines wrap theme={null}
  await LDObserve.shutdown();
  ```
</CodeGroup>

Shutting down is terminal. Calling `LDObserve.init` or `LDObserve.initStandalone` afterward does nothing.

`shutdown` returns a `Future<void>` that completes after native session replay stops. Awaiting it is optional. The future never completes with an error, and repeated calls return the same future.

Because the native observability SDK on Android and iOS has no teardown, its automatic instrumentation, including crash reporting, launch times, and native lifecycle spans, keeps running until the process exits.

## Identify contexts

To tie observability data to the correct context, use the LaunchDarkly client to identify or switch contexts:

<CodeGroup>
  ```dart title="Identify a context" lines wrap theme={null}
  final userContext = LDContextBuilder()
      .kind('user', 'user-key')
      .name('Bob Smith')
      .build();
  await client.identify(userContext);
  ```
</CodeGroup>

You do not need to call any `LDObserve` method. The observability plugin hooks into the LaunchDarkly client and, on Android and iOS, forwards each completed identify to the native observability SDK and to session replay. This attributes subsequent `LDObserve.track` events to the active context and records the context on the active session replay recording.

## Explore supported features

The observability plugin supports the following features. After the SDK and plugins are initialized, you can access these from within your application:

* [Configuration for client-side observability](/docs/sdk/features/observability-config-client-side)
* [Configuration for session replay](/docs/sdk/features/session-replay-config#flutter)
* [Errors](/docs/sdk/features/observability-errors#flutter)
* [Logs](/docs/sdk/features/observability-logs#flutter)
* [Symbolication](/docs/sdk/features/observability-symbolication#flutter)
* [Tracing](/docs/sdk/features/observability-traces#flutter)

## Review observability data in LaunchDarkly

After you initialize the SDK and observability plugin, your application automatically starts sending observability data back to LaunchDarkly, including errors and logs. You can review this information in the LaunchDarkly user interface. To learn how, read [Observability](/docs/home/observability).
