> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/security-observability/sentry-flutter-crash-http-navigation-tracing.md).

# Sentry for Crashes and Tracing

How I assign ownership for Sentry crashes, HTTP, navigation, and transaction tracing while controlling sampling and data in Flutter

## Result

In my app, Sentry is initialized near the composition root, wraps the HTTP client, observes navigation, and receives handled exceptions or custom transactions from the application layer. Each call site looks reasonable in isolation. The gaps only become visible when I draw the entire pipeline:

```
Flutter error ───────┐
Caught exception ────┤
HTTP failure ────────┤
Navigation ──────────┤──► Observability gateway
Use-case latency ────┘          │
                                ├── Select one event owner
                                ├── Sanitize data
                                ├── Make a sampling decision
                                ├── Attach parent context
                                ▼
                           Sentry SDK
                                │
                   ┌────────────┼────────────┐
                   ▼            ▼            ▼
              Local queue    Transport    Dashboard
```

This flow separates four questions that are often collapsed into one:

| Question                                      | Required evidence                                                 |
| --------------------------------------------- | ----------------------------------------------------------------- |
| Is Sentry configured?                         | Options, integrations, and observers exist in source              |
| Does the app call capture?                    | Call graph or a local envelope in a test                          |
| Does the transport enqueue or send the event? | Fake transport, SDK diagnostics, or a release-device test         |
| Did the dashboard receive and symbolicate it? | The event or trace is confirmed in Sentry for the correct release |

A `SentryFlutter.init()` call answers only the first question. It does not prove the other three.

The reference source resolves `sentry`, `sentry_flutter`, and `sentry_logging` to exact version `9.16.0`. It already has automatic Flutter error integration, a handled-exception helper, `SentryHttpClient`, two navigator observers, and manual transactions. The source review also exposed gaps that need hardening:

* An HTTP `5xx` can pass through both the SDK's automatic failed-request capture and a service-level custom event.
* A service-level skip flag suppresses only the custom event; the request still passes through the Sentry HTTP wrapper.
* Both navigator observers can add breadcrumbs, while automatic navigation transactions are disabled.
* Manual transactions are standalone and are not bound to scope, so they do not automatically parent HTTP spans.
* Trace sampling is set to `1.0` for every non-debug build; a local gate described as 5% actually selects 6 out of 100 values.
* Custom URLs, route arguments, contexts, and user data do not pass through a central redactor.
* A user is set after login, but I did not find matching Sentry cleanup in the central logout path.
* A logging integration is added, but the main logger uses a different package from the one supported by that integration.
* The source has no direct Sentry tests, and this research did not verify dashboard delivery or symbolication.

This article applies to Android and iOS. Ownership, HTTP, navigation, and tracing live in Dart and Flutter; native crashes and symbolication must be verified separately on each platform.

The code below is a hardened design derived from that evidence. I do not claim that the reference source already implements or runtime-verifies every change.

## Problem

### Initialization is not delivery

The reference source skips Sentry initialization in debug and calls `SentryFlutter.init()` in non-debug builds. Its configuration includes a DSN from runtime configuration, an environment, `beforeSend`, a logging integration, and a trace sample rate.

The exact SDK installs integrations for Flutter errors and current-isolate errors when it is initialized correctly. The app does not need to reassign `FlutterError.onError` or `PlatformDispatcher.instance.onError` merely to duplicate SDK behavior.

Handled exceptions still have several paths:

* A central helper awaits capture.
* A startup catch invokes capture without awaiting it.
* A custom-event wrapper invokes capture without returning the SDK Future.

If the wrapper's `Future` has completed, the capture Future inside it may still be running. Even when `captureException()` returns an event ID, that ID is not a dashboard receipt.

### HTTP failures have two owners

In exact SDK `9.16.0`, `SentryHttpClient` layers three concerns around `http.Client`:

```
App request
    │
    ▼
Breadcrumb client
    │
    ▼
Tracing client
    │
    ▼
Failed-request client
    │
    ▼
HTTP transport
```

For this version, the defaults are:

* Failed-request capture is enabled.
* HTTP breadcrumbs are enabled.
* `500–599` is the failed status range.
* The default failed-request target is broad.
* The default trace propagation target is `.*`.
* `sendDefaultPii` defaults to `false`.

The service layer in the source also creates a custom event for most responses at or above `400`, except one authentication status. A `500` response can therefore create two events:

```
HTTP 500
  ├── SentryHttpClient automatic failed-request event
  └── Service custom event
        └── One failure, two owners
```

The service's skip flag sits only in front of the custom event. It does not disable breadcrumbs, child spans, trace headers, or failed-request behavior inside `SentryHttpClient`.

### A breadcrumb is not a transaction

The source installs both:

* A custom route observer that updates application state, records analytics, and adds a Sentry breadcrumb.
* `SentryNavigatorObserver(enableAutoTransactions: false)`.

The exact SDK still records navigation breadcrumbs when automatic transactions are disabled. A push, pop, or replace can therefore appear twice in the breadcrumb timeline.

The custom observer also passes a fixed placeholder instead of the actual navigation type. This is a small line-level bug, but it reveals a larger problem: two places own the same Sentry signal.

Conversely, `enableAutoTransactions: false` means the source does not create automatic route transactions. Seeing navigation breadcrumbs does not prove route performance tracing is active.

### HTTP spans need an active parent

The tracing client creates a meaningful child span only when the scope has an active span. The source creates manual transactions from start and end timestamps, but exact SDK `9.16.0` defaults `bindToScope` to `false`.

The current flow is closer to:

```
Use case and HTTP request have completed
                  │
                  ▼
Create a standalone transaction from two timestamps
                  │
                  └── It cannot parent the earlier HTTP span
```

The transaction may still be sent if it is sampled, but it does not create an end-to-end waterfall for the completed operation.

### `sendDefaultPii = false` does not scrub custom data

This SDK default is an important safeguard, but the source also attaches application data explicitly:

* Full request URLs and serialized errors in custom HTTP events.
* Route settings and arguments in breadcrumbs.
* Domain payloads in breadcrumb or transaction data.
* Persistent user identifiers, email, and account context in Sentry scope.

The SDK cannot know which custom fields are sensitive to the application's business. When the application adds a field, the application must also own its allowlist and redaction policy.

### Sampling and user scope are lifecycle policies

A trace sample rate of `1.0` requests sampling for every root transaction in non-debug builds. It can be useful during a time-boxed investigation, but it is too broad as a long-lived policy for high-frequency operations.

Another collector applies its own random gate before starting a transaction. The condition `nextInt(100) <= 5` selects `0, 1, 2, 3, 4, 5`, which is 6 out of 100 rather than 5 out of 100.

User scope has a similar lifecycle problem. If the app sets a user during login but does not clear that user on every logout path, an event after logout can retain the old attribution until the next user is set or the process ends.

## Solution

### Keep the exact dependency as the version boundary

The source declares caret constraints, while the lockfile resolves:

```yaml
dependencies:
  sentry: 9.16.0
  sentry_flutter: 9.16.0
  sentry_logging: 9.16.0
```

The examples in this article target `9.16.0`. When upgrading, I recheck the HTTP client constructor, navigator observer, sampling callback, native SDK mapping, and symbol upload flow instead of changing only the version constraint.

### Put a gateway in front of the Sentry SDK

I do not let services, middleware, and widgets accept arbitrary `Map<String, dynamic>` values and call Sentry directly. The application sends typed signals to a gateway:

```dart
enum ErrorCategory { unexpected, upstreamUnavailable, invalidState }

final class SafeContext {
  const SafeContext({this.operation, this.statusCode});

  final String? operation;
  final int? statusCode;
}

abstract interface class ObservabilityGateway {
  Future<String?> captureException(
    Object error,
    StackTrace stackTrace, {
    required ErrorCategory category,
    SafeContext context = const SafeContext(),
  });

  Future<void> captureHttpFailure(HttpFailureSignal signal);

  void addBreadcrumb(SafeBreadcrumb breadcrumb);

  Future<T> trace<T>(TraceOperation operation, Future<T> Function() body);

  Future<void> setSessionUser(String opaqueUserKey);
  Future<void> clearSessionUser();
}
```

`SafeContext` contains only reviewed fields. The gateway does not accept raw requests, responses, route arguments, domain objects, or authentication state.

I use this taxonomy to avoid renaming a signal and sending it as the wrong type:

| Signal             | Use it for                                          | Do not use it instead of       |
| ------------------ | --------------------------------------------------- | ------------------------------ |
| Exception or crash | A failure that needs a stack trace and grouping     | An expected business rejection |
| Custom event       | An abnormal failure without a suitable exception    | Every HTTP `4xx`               |
| Breadcrumb         | Short context before another event                  | Analytics or an audit log      |
| Span               | One child step inside an active transaction         | A standalone latency point     |
| Transaction        | An end-to-end operation with start, end, and status | A dashboard counter            |

If a caller needs an event ID, the gateway awaits capture and returns the ID. The method remains `captureException`, not `deliverException`.

### Initialize with an explicit build policy

The composition root chooses a remote reporter or a no-op reporter before building the app:

```dart
Future<void> bootstrap() async {
  WidgetsFlutterBinding.ensureInitialized();

  final policy = TelemetryPolicy.forEnvironment(currentEnvironment);

  if (!policy.remoteCaptureEnabled) {
    await startApplication(const NoOpObservabilityGateway());
    return;
  }

  await SentryFlutter.init((options) {
    options.dsn = runtimeSentryClientDsn;
    options.environment = currentEnvironment.publicName;
    options.sendDefaultPii = false;

    options.beforeSend = policy.sanitizeEvent;
    options.beforeBreadcrumb = policy.sanitizeBreadcrumb;
    options.beforeSendTransaction = policy.sanitizeTransaction;

    options.tracesSampler = (samplingContext) {
      final operation = samplingContext.transactionContext.operation;
      return policy.traceRateFor(operation);
    };

    options.tracePropagationTargets
      ..clear()
      ..add(r'^https://api\.example\.com/');
  }, appRunner: () => startApplication(SentryObservabilityGateway(policy)));
}
```

`runtimeSentryClientDsn` is client configuration that routes events to the correct Sentry project. A mobile app necessarily carries the public DSN or client key in its artifact, so I do not call it a secret. I still load it from neutral configuration and do not publish the real DSN in this article to avoid exposing project-routing metadata.

Authentication tokens and symbol-upload credentials are actual secrets. They belong in a backend or CI secret store and do not ship in the Flutter artifact with the client DSN.

The three sanitizing callbacks are the final defense. Producers must still create minimal data in the first place; `beforeSend` is not the only layer that understands business policy.

If the app must capture a startup failure before `runApp`, the startup catch should await capture or flush with a bounded timeout. A telemetry failure must not keep the app stuck on its splash screen.

### Sanitize before calling the SDK

My data pipeline has three layers:

```
Raw application state
        │
        ▼
Typed allowlist at the producer
        │
        ▼
Gateway sanitizer
  ├── URL: remove query/fragment, replace dynamic IDs with a template
  ├── HTTP: method + status bucket + operation
  ├── Route: public screen key, no arguments
  ├── User: opaque key, no email or account fields
  └── Error: reviewed category/code, no full domain object
        │
        ▼
beforeSend / beforeBreadcrumb / beforeSendTransaction
```

For example, this URL sanitizer returns only a public summary:

```dart
final class SafeUri {
  const SafeUri({required this.origin, required this.pathTemplate});

  final String origin;
  final String pathTemplate;
}

SafeUri sanitizeUri(Uri uri) {
  if (uri.host != 'api.example.com') {
    return const SafeUri(origin: 'external', pathTemplate: '/unknown');
  }

  return SafeUri(
    origin: 'first-party-api',
    pathTemplate: toPublicPathTemplate(uri.pathSegments),
  );
}
```

The function does not return a query, fragment, username, or password. `toPublicPathTemplate()` must replace dynamic segments such as UUIDs, order IDs, or account IDs with `{id}`.

I also avoid printing raw events in a debug toast or console. Debug logging is another data sink and needs the same redaction policy.

### Choose one owner for HTTP failed-request events

I prefer `SentryHttpClient` to own technical exceptions and `5xx` failures. The service layer maps a typed error for the application but does not send another custom Sentry event for the same response.

```dart
import 'package:sentry/sentry.dart';

final httpClient = SentryHttpClient(
  failedRequestStatusCodes: const [SentryStatusCode.defaultRange()],
  failedRequestTargets: const [r'^https://api\.example\.com/'],
  captureFailedRequests: true,
);
```

The resulting policy is:

* Connection exceptions and `5xx` responses from a first-party API are technical failures.
* Expected `4xx` responses become domain results rather than Sentry issues by default.
* An unexpected `4xx` becomes a custom event only when it has semantics that a standard HTTP event cannot represent.
* A URL in custom context is always a sanitized path template.
* Retries do not create another custom event at every layer.

If the application must own every HTTP event, I set `captureFailedRequests: false` on that wrapper and send a typed `HttpFailureSignal`. Breadcrumbs and tracing remain separate concerns.

If a request must not touch Sentry at all, I use a policy-approved raw-client path. A boolean in the service cannot disable behavior inside the wrapper.

The HTTP Client article below owns timeout, retry, typed failures, and redacted logging. This article adds Sentry ownership above that client.

{% content-ref url="/pages/r1BG7cXgqoay4tvlyEeO" %}
[Production HTTP Client for Flutter](/flutter/my-flutter/systems-realtime/production-http-client.md)
{% endcontent-ref %}

### Separate capture, breadcrumbs, spans, and propagation

A `skipObservability` boolean is too vague because four behaviors can need different policies:

```dart
enum CaptureMode { automatic, applicationOwned, none }

final class HttpObservabilityPolicy {
  const HttpObservabilityPolicy({
    required this.captureMode,
    required this.recordBreadcrumb,
    required this.createSpan,
    required this.propagateTrace,
  });

  final CaptureMode captureMode;
  final bool recordBreadcrumb;
  final bool createSpan;
  final bool propagateTrace;
}
```

For example:

| Request type                      | Event            | Breadcrumb     | Span                 | Trace header        |
| --------------------------------- | ---------------- | -------------- | -------------------- | ------------------- |
| First-party API                   | Automatic        | Yes, sanitized | With a parent        | Allowlist           |
| Public CDN                        | No               | Optional       | Depends on operation | No                  |
| Authentication or secret exchange | Dedicated policy | Minimal        | Depends on use case  | Trusted origin only |
| Dynamic third-party URL           | No               | No raw URL     | No                   | No                  |

`tracePropagationTargets` checks the URL when the SDK decides whether to attach trace headers to the initial request. It does not prove that the HTTP stack will strip those headers while following a cross-origin redirect.

For a request that can redirect, I disable automatic redirects or handle redirects manually. Each hop parses `Location`, rechecks scheme, host, and port, and creates the next request with trace headers only when the new origin remains on the allowlist. If the HTTP client cannot control headers per hop, I do not propagate a trace on a request that can redirect outside the trusted origin. An integration test must still use a fake cross-origin redirect to prove the header was removed.

### Start an active transaction before the operation

To make an HTTP request appear as a child span, the transaction must start before the use case. However, exact Hub `9.16.0` assigns a bound transaction directly to `scope.span`; that scope is not automatically isolated per `Future`. Two overlapping operations can overwrite each other's parent.

This simple design uses one `SingleFlightTraceRunner` for each Hub or scope and rejects a second operation while a bound transaction is active:

```dart
final class SingleFlightTraceRunner {
  bool _hasActiveBoundTransaction = false;

  Future<T> run<T>({
    required String name,
    required String operation,
    required Future<T> Function() body,
  }) async {
    if (_hasActiveBoundTransaction) {
      throw StateError('A bound transaction is already active');
    }

    _hasActiveBoundTransaction = true;
    final transaction = Sentry.startTransaction(
      name,
      operation,
      bindToScope: true,
    );

    var status = const SpanStatus.ok();

    try {
      return await body();
    } on ExpectedCancellation {
      status = const SpanStatus.cancelled();
      rethrow;
    } catch (_) {
      status = const SpanStatus.internalError();
      rethrow;
    } finally {
      try {
        await Sentry.configureScope((scope) {
          if (identical(scope.span, transaction)) {
            scope.span = null;
          }
        });
      } finally {
        try {
          await transaction.finish(status: status);
        } finally {
          _hasActiveBoundTransaction = false;
        }
      }
    }
  }
}
```

After correct binding, the flow becomes:

```
User intent
    │
    ▼
Use-case transaction
    ├── Validation span
    ├── HTTP child span from SentryHttpClient
    └── State update span when it is truly useful
    │
    ▼
Finish with a status that matches the outcome
```

The runner must be registered as an app-level singleton for that Hub or scope; creating multiple runners removes the single-flight guarantee. I bind only for the lifetime of one operation and do not run two bound root transactions concurrently.

If the product needs concurrent operations, I do not use shared `scope.span` as an implicit parent. Each operation instead owns an explicit transaction or span reference and passes it to client instrumentation, or uses a Hub or scope with a controlled lifecycle. That design needs an integration-tested exact API and concurrency model before it replaces the single-flight runner.

A manual transaction built from two historical timestamps can still represent a separate measurement, but I do not call it a waterfall. When the start timestamp comes from a server and the end timestamp comes from a device, clock skew must be handled first.

### Use a monotonic clock for in-process duration

I separate four kinds of time:

| Measurement          | Suitable clock                             |
| -------------------- | ------------------------------------------ |
| In-process duration  | `Stopwatch`                                |
| Network round trip   | `Stopwatch` around the request             |
| Server-to-device age | Remote timestamp plus a clock-offset model |
| Historical interval  | Two timestamps from the same clock domain  |

For example, I measure a local operation with `Stopwatch`:

```dart
Future<T> measure<T>(
  Future<T> Function() body,
  void Function(Duration elapsed) onMeasured,
) async {
  final stopwatch = Stopwatch()..start();

  try {
    return await body();
  } finally {
    stopwatch.stop();
    onMeasured(stopwatch.elapsed);
  }
}
```

A negative duration should not merely be dropped. It is a signal for `clock_skew`, `invalid_timestamp`, or a timezone error. I record a sanitized category instead of sending raw server or device timestamps when they are unnecessary.

### Put one traces sampler at the composition root

I do not keep `tracesSampleRate = 1.0` for every operation, and I do not add another random gate at each producer. One central `tracesSampler` makes the decision at the trace root:

```dart
double traceRateFor(String operation) {
  return switch (operation) {
    'app.start' => 0.20,
    'ui.checkout' => 0.10,
    'background.refresh' => 0.01,
    _ => 0.0,
  };
}
```

The rates in this snippet are fake values that demonstrate the API. Production rates must reflect application volume, cost, release risk, and privacy policy.

I keep these rules:

* One sampler owns the root transaction decision.
* Child spans inherit the decision instead of sampling again.
* High-frequency messages use a low rate or an aggregate metric.
* High debug sampling is time-boxed and reversible.
* Transaction sampling does not prove anything about crash or error sampling.
* Stable sampling does not use a raw user ID as its key.

If Remote Config rolls out a rate, the local default must be safe and typed configuration must not become a security boundary.

{% content-ref url="/pages/VlHUWEnrJDCc9sRZw2rf" %}
[Firebase Remote Config and feature flags](/flutter/my-flutter/security-observability/firebase-remote-config-feature-flags.md)
{% endcontent-ref %}

### Choose one navigation breadcrumb owner

The custom observer in the source still has application-state and analytics responsibilities. I remove only its Sentry call and let `SentryNavigatorObserver` own Sentry navigation breadcrumbs:

```
CustomRouteObserver
  ├── Application state
  ├── Analytics screen mapping
  └── No Sentry.addBreadcrumb call

SentryNavigatorObserver
  ├── One navigation breadcrumb owner
  ├── Public route name
  ├── No route arguments
  └── Explicit transaction policy
```

When navigation transactions are not used:

```dart
RouteSettings sanitizeRoute(RouteSettings? settings) {
  final safeName = publicRouteName(settings?.name) ?? '/unknown';
  return RouteSettings(name: safeName);
}

final sentryNavigatorObserver = SentryNavigatorObserver(
  enableAutoTransactions: false,
  enableNewTraceOnNavigation: false,
  routeNameExtractor: sanitizeRoute,
  ignoreRoutes: const ['/dialog', '/overlay'],
);
```

`sanitizeRoute()` always returns a new `RouteSettings` without arguments, even when the input is null or the route name is not on the allowlist. This matters because the exact SDK uses the extractor result as `extractor(...) ?? settings`; returning null falls back to the original `RouteSettings` and can serialize raw arguments.

`/unknown`, `/dialog`, and `/overlay` are placeholders. The safe fallback name must be stable and must not contain the raw route name or a dynamic ID.

This test uses a fake secret to lock down the null and unknown-route fallback:

```dart
void main() {
  test('null and unknown routes do not retain raw arguments', () {
    const fakeSecret = 'token=fake-secret';
    final inputs = <RouteSettings?>[
      null,
      const RouteSettings(arguments: fakeSecret),
      const RouteSettings(name: '/private/42', arguments: fakeSecret),
    ];

    for (final input in inputs) {
      final sanitized = sanitizeRoute(input);

      expect(sanitized.name, '/unknown');
      expect(sanitized.arguments, isNull);
    }
  });
}
```

The exact SDK defaults `enableNewTraceOnNavigation` to `true` even when automatic transactions can be disabled. If the app does not need to reset trace context on navigation, I disable both options as shown and enable them deliberately only after sampling and runtime tests exist.

Before enabling automatic navigation transactions, I also verify:

* Route names are stable and contain no dynamic IDs.
* `autoFinishAfter` matches the UI lifecycle.
* Child spans finish before the timeout.
* Dialogs and overlays do not pollute the transaction list.
* Route arguments appear in neither breadcrumb nor transaction data.
* Slow and frozen frame behavior is tested on a real device.

### Clear the Sentry user at the central authentication boundary

The Sentry user contains only an opaque pseudonymous key:

```dart
Future<void> setSessionUser(String opaqueUserKey) async {
  await Sentry.configureScope((scope) async {
    await scope.setUser(SentryUser(id: opaqueUserKey));
  });
}

Future<void> clearSessionUser() async {
  await Sentry.configureScope((scope) async {
    await scope.setUser(null);
  });
}
```

In exact API `9.16.0`, `Sentry.configureScope` returns `FutureOr<void>`, and `scope.setUser()` also has asynchronous work. A helper declared as `Future<void>` therefore awaits both the configure callback and `setUser()` instead of discarding a Future inside the closure.

I do not set an email, username, or account fields merely to make an issue easier to search. When an opaque key needs to be resolved, the mapping belongs in a system with its own access and audit policy.

Cleanup does not belong to one logout button:

```
Manual logout ───────┐
Session expired ─────┤
Account switch ──────┤──► Central auth cleanup
Account unavailable ─┘          │
                                ├── Clear auth state
                                ├── Clear Sentry user
                                ├── Clear analytics session
                                └── Clear connection/subscription state
```

Telemetry cleanup is best effort and has a timeout; it must not prevent local authentication state from becoming logged out. An event after cleanup must have a null user.

The OneSignal article uses the same rule: SDK identity follows the authentication lifecycle, not a widget or button lifetime.

{% content-ref url="/pages/8aTmf7QiEUqTDX5DsiMb" %}
[OneSignal Push and iOS NSE](/flutter/my-flutter/security-observability/onesignal-push-ios-notification-service-extension.md)
{% endcontent-ref %}

### Use a logging integration only with a compatible pipeline

`sentry_logging 9.16.0` integrates the Dart `logging` package. The main logger in the reference source uses the `logger` package.

There are three valid options:

1. Standardize application logging on `package:logging` and keep `LoggingIntegration`.
2. Write an output or hook adapter for the existing logger, with level mapping and sanitization.
3. Remove the integration when no compatible producer exists.

I do not call `Sentry.captureMessage()` for every logger line. Debug logs remain local; warnings and errors become breadcrumbs or events only after passing the data policy.

The accurate documentation claim is **the integration is configured**, not **application logs are captured**.

### Treat isolates and native crashes as separate boundaries

The exact Sentry Flutter SDK covers the current isolate when it is initialized correctly. A custom isolate owned by the app needs an error listener according to the SDK contract:

```
Main isolate has initialized Sentry
        │
        ├── Flutter/current-isolate error
        │       └── SDK integration
        │
        ├── App-owned custom isolate
        │       ├── Add a Sentry error listener
        │       └── Remove/close it with the correct lifecycle
        │
        └── Plugin background callback
                └── Verify the provider's process/isolate contract
```

I do not add a listener to an isolate the application does not own. A plugin background callback can run in another engine, isolate, or process; its setup must follow the plugin documentation and a real test.

Native crashes and symbolication also have separate gates:

* The Android or iOS SDK is present in the release artifact.
* Release, distribution, and build IDs match the event.
* iOS dSYMs and Android mappings or native symbols are uploaded for the correct build.
* When Flutter uses obfuscation or `--split-debug-info`, Dart symbol files enter the correct pipeline.
* A crash is triggered on a test device and the app is relaunched when a cached envelope needs to be sent.
* The dashboard shows a symbolicated stack for the correct release.

The reference source locks the native iOS Sentry SDK through the Flutter plugin, but I did not find a Sentry symbol-upload step in the CI and Fastlane scope I reviewed. A symbol step for another vendor does not prove that Sentry has the symbols.

### Verify with fakes before using the dashboard

The source currently has no direct unit, widget, or integration tests for Sentry. I begin with a fake gateway, hub, or transport to verify the data contract without sending an event externally.

The important unit tests are:

* Debug and test policies do not create a remote transport.
* The environment drop rule works, while the sanitizer still runs in other environments.
* URLs lose query, fragment, and dynamic IDs.
* Headers, bodies, tokens, route arguments, email, and full domain objects do not appear in the envelope.
* One caught exception creates one event.
* An HTTP `500` creates one failed-request event according to the selected owner.
* A request in `none` mode creates no event, breadcrumb, span, or trace header.
* Navigation push, pop, and replace create one breadcrumb with the correct type.
* Disabling automatic navigation transactions creates no route transaction.
* A bound parent creates an HTTP child span; a standalone transaction is not called a waterfall.
* The single-flight runner rejects overlapping operations rather than overwriting `scope.span`.
* A deterministic sampler selects the correct denominator.
* Logout clears the user before the next event.
* The logging integration test uses the compatible logger pipeline.
* Null and unknown route names always become the safe fallback and retain no fake secret in arguments.

For example, this test verifies HTTP ownership at the application boundary:

```dart
void main() {
  test('one HTTP 500 emits one failure signal', () async {
    final observability = FakeObservabilityGateway();
    final transport = FakeHttpTransport(statusCode: 500);
    final service = ApiService(transport, observability);

    await expectLater(service.loadProfile(), throwsA(isA<UpstreamFailure>()));

    expect(observability.httpFailures, hasLength(1));
  });
}
```

The overlap test keeps the first operation active and verifies that the second is rejected:

```dart
void main() {
  test('two bound transactions cannot overlap', () async {
    final firstBody = Completer<void>();
    final runner = SingleFlightTraceRunner();

    final first = runner.run<void>(
      name: 'first',
      operation: 'test',
      body: () => firstBody.future,
    );

    await expectLater(
      runner.run<void>(name: 'second', operation: 'test', body: () async {}),
      throwsA(isA<StateError>()),
    );

    firstBody.complete();
    await first;
  });
}
```

The test uses a fake endpoint and payload. It needs neither a DSN nor a dashboard.

For HTTP integration tests, I cover `400`, `401`, `404`, `500`, timeout, socket exceptions, cross-origin redirects, and retries. The redirect test disables automatic following or inspects every hop, then asserts that the request to the new origin has no trace header. Mock Web Server verifies request and response behavior without calling a production endpoint.

{% content-ref url="/pages/iwqvNcdIuoIMnTEG7T2p" %}
[Mock Web Server for Service/API Tests](/flutter/my-flutter/quality-delivery/mock-web-server-service-api-test.md)
{% endcontent-ref %}

### Release-device verification matrix

A local fake proves only that the application created the intended envelope. Before claiming delivery works, I verify each case separately:

| Platform | Case                               | Expected result                                            |
| -------- | ---------------------------------- | ---------------------------------------------------------- |
| Android  | Unhandled Dart or Flutter error    | One event with the correct release and environment         |
| iOS      | Unhandled Dart or Flutter error    | One event with the correct release and environment         |
| Android  | Native crash                       | Event is sent after relaunch and symbolicated              |
| iOS      | Native crash                       | Event is sent after relaunch and symbolicated              |
| Both     | Offline event followed by relaunch | Cached envelope is processed according to the SDK contract |
| Both     | HTTP `500`                         | One failed-request event and no duplicate custom event     |
| Both     | Bound use-case transaction         | HTTP span has the correct parent                           |
| Both     | Third-party URL                    | No trace header outside the allowlist                      |
| Both     | Login followed by logout           | The post-logout event has no old user                      |
| Both     | Navigation                         | One breadcrumb; a transaction only when enabled            |

Dashboard verification should record at least the build and release, platform and device, test case, event or trace ID, capture and receive timestamps, symbolication result, and PII inspection result.

I do not include a real dashboard screenshot because it can contain a project, route, endpoint, user context, or internal payload.

### Common mistakes and trade-offs

#### One `5xx` creates two issues

The symptom is an automatic HTTP event and a custom event with the same timestamp. Check the `SentryHttpClient` defaults and service-level capture. Select one failed-event owner and test the event count.

#### A skip flag does not actually skip Sentry

The custom event disappears, but a breadcrumb, failed-request event, or trace header remains. The flag is in the wrong layer. Separate `captureMode`, `recordBreadcrumb`, `createSpan`, and `propagateTrace`, or use a raw client for the `none` case.

#### Navigation breadcrumbs exist but route transactions do not

Check `enableAutoTransactions`. Breadcrumb observation is independent from automatic performance transactions.

#### An HTTP span has no parent

The manual transaction starts after the operation or is not bound to scope. Start the transaction before the use case, serialize bound ownership through the single-flight runner, and clear the scope when it finishes.

#### Route arguments appear in an event

The exact navigator observer can format route arguments into a breadcrumb. Use a `routeNameExtractor` that always returns new `RouteSettings` without arguments, including for null and unknown names, and add an envelope test with a fake secret.

#### A high sample rate still produces an incomplete trace

`1.0` controls only sampling. A trace is still incomplete when no transaction starts, no parent is bound, a span finishes incorrectly, or the transport does not send.

#### The old user appears after logout

Central authentication cleanup did not call `scope.setUser(null)`, or account switching set the new user too late. Test an event immediately after logout instead of testing only the login path.

#### The logging integration has no logs

The integration supports `logging`, while the app uses a different logger. Standardize the pipeline, add a tested bridge, or remove the integration.

#### The gateway adds more code

For a small app that needs only automatic crash reporting, a complete gateway can be too much structure. When the app has a custom HTTP wrapper, several middleware layers, authentication sessions, and performance tracing, the gateway provides one place to control sampling, PII, and event ownership.

### Verified versions and evidence

* Flutter: `3.41.2`.
* Dart: `3.11.0`, constrained below `4.0`.
* `sentry`: exact `9.16.0`.
* `sentry_flutter`: exact `9.16.0`.
* `sentry_logging`: exact `9.16.0`.
* iOS `Sentry/HybridSDK`: resolved `8.58.0` through the Flutter plugin.
* Platforms: Android and iOS.

Current evidence:

* Source and configuration: verified.
* Exact SDK behavior: checked against tag `9.16.0`.
* Sentry-specific automated tests in the source: none found.
* Real-device crash, HTTP, or navigation trace: not run during this research.
* Dashboard delivery and symbolication: not confirmed.

### References

* [Sentry Flutter SDK 9.16.0](https://github.com/getsentry/sentry-dart/tree/9.16.0/packages/flutter)
* [Sentry Dart SDK 9.16.0](https://github.com/getsentry/sentry-dart/tree/9.16.0/packages/dart)
* [Sentry HTTP client source 9.16.0](https://github.com/getsentry/sentry-dart/blob/9.16.0/packages/dart/lib/src/http_client/sentry_http_client.dart)
* [Sentry failed-request client source 9.16.0](https://github.com/getsentry/sentry-dart/blob/9.16.0/packages/dart/lib/src/http_client/failed_request_client.dart)
* [Sentry tracing client source 9.16.0](https://github.com/getsentry/sentry-dart/blob/9.16.0/packages/dart/lib/src/http_client/tracing_client.dart)
* [Sentry options source 9.16.0](https://github.com/getsentry/sentry-dart/blob/9.16.0/packages/dart/lib/src/sentry_options.dart)
* [Sentry hub transaction source 9.16.0](https://github.com/getsentry/sentry-dart/blob/9.16.0/packages/dart/lib/src/hub.dart)
* [Sentry navigator observer source 9.16.0](https://github.com/getsentry/sentry-dart/blob/9.16.0/packages/flutter/lib/src/navigation/sentry_navigator_observer.dart)
* [Sentry Logging SDK 9.16.0](https://github.com/getsentry/sentry-dart/tree/9.16.0/packages/logging)
* [Sentry Flutter configuration options](https://docs.sentry.io/platforms/dart/guides/flutter/configuration/options/)
* [Sentry automatic instrumentation](https://docs.sentry.io/platforms/dart/guides/flutter/tracing/instrumentation/automatic-instrumentation/)
* [Sentry custom instrumentation](https://docs.sentry.io/platforms/dart/guides/flutter/tracing/instrumentation/custom-instrumentation/)
* [Sentry sensitive data](https://docs.sentry.io/platforms/dart/guides/flutter/data-management/sensitive-data/)

## Conclusion

The source already places Sentry at several important boundaries: the composition root, handled exceptions, the HTTP client, navigation, and manual transactions. Hardening does not mean adding more `capture...()` calls. It means reducing the number of owners and making the data contract explicit.

After introducing the gateway, each HTTP failure has one event owner, breadcrumbs are not mistaken for transactions, and an HTTP span is expected only when an active parent exists. Sampling, route data, and user scope pass through central policy instead of being scattered across middleware.

Static source and the exact SDK let me predict behavior, but they do not replace a release-device test. I claim delivery and symbolication only after the correct event or trace appears on the dashboard for the correct build and the envelope contains no data outside the allowlist.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
