> 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/firebase-performance-custom-trace.md).

# Firebase Performance and Custom Traces

How I design Firebase Performance custom traces per operation while separating readiness, collection, sampling, and runtime evidence in Flutter

## Result

In my app, Firebase Performance used to sit behind two functions: `startTrace(name)` and `stopTrace(name)`. This wrapper kept direct SDK imports out of feature code, but it turned the trace name into global mutable state. Two operations with the same name could stop each other; an early screen could call the wrapper before initialization completed; and the post-frame callback was registered only after a method-channel Future.

I changed the boundary to this structure:

```
TraceSpec catalog
      │
      ├─ readiness / policy / sampling ─────► NoopTraceSession
      │
      ▼
Firebase projection
      │
      ▼
provider.start() ─────► TraceSession(operation A)
      │                TraceSession(operation B)
      │                     [same aggregate name]
      ▼
operation owner
      ├─ success
      ├─ failure
      ├─ measurement timeout
      └─ cancelled
             │
             ▼
         finish once
```

The trace name is only for aggregation. Each operation keeps its own session, the session owns the correct provider handle, and it finishes only once. Disabled, not-ready, and sampled-out paths also return typed no-op sessions instead of `null`.

After separating the boundary, I can independently check these questions:

* Was the gateway ready before `runApp`?
* Which policy controls native automatic collection?
* Is the application creating new custom traces?
* What rate samples this specific trace?
* Which operation owns start, success, failure, timeout, and cancellation?
* Which attributes and metrics may leave the app?
* Do a Firebase automatic trace, Firebase custom trace, and Sentry span measure the same boundary?
* Does the current evidence stop at source, device observation, or Firebase Console?

I also separate configuration from delivery evidence:

| Level              | What I can conclude                                                     |
| ------------------ | ----------------------------------------------------------------------- |
| Source/config      | The dependency, plugin, option, and call site are declared              |
| Unit/widget test   | Readiness, overlap, exactly-once, and projection follow the contract    |
| Device local       | The SDK accepts a trace or emits diagnostics on the intended test build |
| Upload/provider    | A batch or upload is observed without exposing sensitive data           |
| Firebase Console   | The correct project, app, build, trace name, and time window appear     |
| Release monitoring | Volume, sampling, cardinality, overhead, and trends have an owner       |

A Gradle plugin line or `setPerformanceCollectionEnabled(true)` belongs only to the early levels. It is not a dashboard receipt.

The reference source resolves exact `firebase_performance 0.11.2`, `firebase_performance_platform_interface 0.1.6+6`, and `firebase_core 4.6.0`; the iOS Pod lock resolves `Firebase/Performance 12.9.0`. The code below is an improved design derived from source evidence, not a claim that the reference source has implemented or runtime-verified every change.

## Problem

### `runApp` executes before performance initialization

The composition root initializes Firebase, builds several services, and then calls `runApp`. Only afterward does it await `AppPerformance.init()`.

Before `_options` is assigned, `AppPerformance.startTrace()` returns `null`. After the option and service list are assigned but before provider initialization completes, a call site can still race SDK readiness.

The source scan found exactly seven `State` classes using the performance mixin. The mixin starts the benchmark from `initState`, so some very early pages have a call path through this gap. Static source permits that race; I have not run a scheduling or device test that proves which page actually loses a trace or how often.

The nullable `Future?` also collapses several different states into one result:

* The wrapper is disabled.
* The gateway has not been initialized.
* The trace was sampled out.
* Provider start failed.
* No service is registered for the method to call.

The caller cannot distinguish or test those cases.

### Post-frame is not fully ready

The current utility records `DateTime.now()` in `initState`, calls `startTrace(name)?.then(...)`, and registers `addPostFrameCallback` only after the start Future succeeds. The callback reads the wall clock again, records a `rendered_time` metric, and calls stop without awaiting it.

According to [`addPostFrameCallback`](https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addPostFrameCallback.html), the callback runs at the end of a frame, does not request a new frame, cannot be unregistered, and runs exactly once. If the method-channel start finishes after the first frame, the callback can wait for a later frame that may not arrive promptly.

The source boundary measures “the first post-frame callback registered after provider start completes.” It is not:

* Cold app startup.
* Screen data loaded.
* Screen interactive ready.
* Flutter build or raster duration.
* The moment pixels are presented on the display.

`DateTime.now()` is also a wall clock. Elapsed duration should use `Stopwatch`; build, raster, and total span require [`SchedulerBinding.addTimingsCallback`](https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addTimingsCallback.html) and `FrameTiming`.

### A global name loses trace ownership

The Firebase service keeps a `Map<String, TraceWrapperModel>`. Starting an existing trace name stops the old trace and replaces it. The Firebase SDK allows multiple custom traces to run concurrently, but the wrapper cannot safely overlap two instances with the same name.

This interleaving is possible:

1. Operation A starts `search_load`; the map stores handle A.
2. Operation B starts the same name; the wrapper stops A and stores handle B.
3. Operation A calls stop; a name lookup stops handle B.
4. Operation B calls stop; the map is empty, so it becomes a no-op.

The source proves this call path is possible. It does not prove a specific production incident because no concurrency test or dashboard evidence is available.

A name is a stable aggregation dimension, not the identity of a running operation. An internal operation ID should not automatically become a Firebase attribute either, because each ID would create high cardinality.

### Start, stop, and dispose have no failure owner

The provider writes a new trace into the map before awaiting `start()`. If start throws, a stale entry can remain. Stop removes the entry only after awaiting the SDK; if stop throws, that entry can also remain.

The page callback stops the trace in a fire-and-forget path. The root widget calls `AppPerformance.dispose()` without awaiting it. Inside the provider, `Map.forEach` receives an async callback and the map is cleared immediately, so those stop Futures are not awaited.

`AppPerformance.dispose()` also leaves the service list and `_options` intact. Only an `assert` protects single initialization, so a release build has no idempotency guard and reinitialization can add the same service again.

Wrapping only `stop()` in `try/catch` is still insufficient. Projection, `putAttribute()`, and `setMetric()` can all throw synchronously during validation or inside the provider. If that error escapes a business wrapper's `finally`, telemetry can override the business result or exception it was meant only to observe.

### An app gate is not native collection state

When the application option is true, the provider calls `setPerformanceCollectionEnabled(true)`. When the option is false, the wrapper merely skips service registration; it does not call `setPerformanceCollectionEnabled(false)`.

Exact `0.11.2` states that this runtime setting persists across future app invocations and does not reflect build instrumentation. The native performance plugin or framework is applied or linked independently from the Dart wrapper. Therefore, “app gate false” does not prove automatic Firebase collection is off.

I separate six layers:

| Layer                 | Owner                         | What it does not imply                           |
| --------------------- | ----------------------------- | ------------------------------------------------ |
| Build instrumentation | Gradle/Xcode build            | Runtime collection is on                         |
| Native default        | Manifest/plist/startup config | A custom trace is sampled                        |
| Runtime SDK state     | Firebase adapter              | A trace was uploaded or aggregated               |
| Application gate      | Validated local/remote policy | Automatic app-start or network collection is off |
| Per-trace sampling    | Reviewed trace catalog        | The provider does not sample or drop again       |
| Evidence gate         | QA/runbook                    | The next release will behave identically         |

Android and Apple platforms use different native default-off, deactivation, and instrumentation options. Policy must be set early enough for each platform; a Dart gateway cannot retroactively control native data collected before Firebase configuration completes.

### Generic maps open privacy and cardinality gaps

The wrapper accepts `Map<String, String>` attributes and `Map<String, int>` metrics, then forwards them directly to the SDK. It has no typed allowlist, PII policy, cardinality budget, count or length validation, or release-mode trace-name validation.

The call sites I read send only one metric with a static key; I found no attribute call site. The correct conclusion is that the current boundary permits arbitrary maps, not that production has definitely sent PII.

Current Firebase documentation limits custom trace and metric names to 100 characters, allows at most 32 metrics including the default Duration, allows at most 5 custom attributes, limits an attribute name to 32 characters, and prohibits personally identifying data. The exact Dart platform interface validates some paths differently from the current documentation, so the public design uses the more conservative limits.

### Network metrics and metadata are only shapes

`metricCustomNetwork()` has an empty body. `NetworkMetricModel` exists, but the public gateway exposes no corresponding call. `PerformanceOptions.metaData` is also not consumed by the package.

The exact Firebase SDK offers `newHttpMetric`, and Firebase documentation describes automatic HTTP/S collection on supported platforms. Static source still does not prove that the app's Dart HTTP stack is captured automatically by Firebase. I do not call custom network metrics or metadata enrichment active merely because a DTO and interface exist.

### Native declarations do not reveal resolved Android behavior

The root lock resolves Dart plugin `0.11.2`; the iOS Pod lock resolves native Performance `12.9.0`. The Android source contains these declarations:

* Firebase Performance Gradle plugin version `2.0.0` in the plugins DSL.
* Legacy classpath declaration for perf plugin `1.4.1`.
* The app applies the same plugin in both the plugins block and legacy `apply plugin` syntax.
* The app also requests a separate Firebase BOM.

This is a confirmed configured graph. I could not run `dependencyInsight` because the repository has no `gradlew` script and the research machine has no system Gradle. I therefore do not claim which version resolves, whether the plugin is effectively applied twice, or whether runtime instrumentation is duplicated.

### Sentry overlap needs governance, not guesses

The source also has Sentry tracing, an HTTP client integration, and manual latency transactions. Firebase automatic traces, Firebase custom traces, and Sentry spans can coexist, but the source does not prove that a specific operation is measured twice.

The first task is to assign ownership and sampling budgets:

* Cold start belongs to the automatic or native startup owner.
* Flutter first post-frame belongs to a custom trace owner.
* Dart HTTP belongs to the HTTP client or Sentry boundary, followed by a device check for Firebase automatic network capture.
* Business latency chooses one primary duration owner.
* Crashes and raw exceptions belong to the Sentry sanitizer; Firebase Performance receives only an outcome enum.

I do not share provider trace IDs or invent a Firebase-to-Sentry parent-child relationship when the source and APIs do not prove compatible propagation.

### Static source is not runtime proof

The research found no `packages/performance/test` directory and no direct test for the wrapper, provider, or mixin under `test/` or `integration_test/`. I also did not run an Android or iOS build, emulator or device session, Firebase verbose logging, upload inspection, or Console verification.

The unverified items include:

* Whether an early page trace actually drops or races on a device.
* Native collection state after clean install, relaunch, or policy change.
* Which Dart HTTP requests Firebase automatic instrumentation captures.
* Which custom traces, metrics, and attributes are accepted, uploaded, or aggregated.
* Dashboard delay, trace volume, sample or drop rate, and overhead on a release device.
* Same-operation duplication between Firebase and Sentry.
* Behavior when the app pauses or terminates with an active trace.

The findings above are qualified source facts or inferred risks, not an incident report.

## Solution

### Put the measurement boundary in a typed catalog

Feature code selects a typed key. The wire name, maximum trace duration, and sampling policy live in a reviewable catalog; the caller does not invent a string:

```dart
enum PerformanceTraceKey {
  homeFirstPostFrame('home_first_post_frame'),
  searchLoad('search_load');

  const PerformanceTraceKey(this.wireName);

  final String wireName;
}

enum TraceOutcome { success, failure, timeout, cancelled }

enum TraceStartStatus {
  pending,
  started,
  disabled,
  notReady,
  sampledOut,
  providerFailure,
}

enum TraceFinishStatus { stopped, skipped, providerFailure }

final class TraceSpec {
  const TraceSpec({
    required this.key,
    required this.maxTraceDuration,
    required this.sampleRate,
  }) : assert(sampleRate >= 0 && sampleRate <= 1);

  final PerformanceTraceKey key;
  final Duration maxTraceDuration;
  final double sampleRate;
}
```

Each catalog entry answers these questions:

| Field              | Review question                                                         |
| ------------------ | ----------------------------------------------------------------------- |
| Boundary           | Where exactly does measurement start and finish?                        |
| Meaning            | Is it duration, first post-frame, build or raster, or data-ready?       |
| Owner              | Firebase automatic, Firebase custom, or Sentry?                         |
| Sampling           | Which rate, build policy, and kill switch apply?                        |
| Measurement budget | When does the session finish itself without changing business behavior? |
| Lifecycle          | What happens on pause, route pop, dispose, and cancellation?            |
| Dimensions         | Which enums are allowed, and what is the maximum cardinality?           |
| Evidence           | Which device and console checks confirm the trace?                      |

`maxTraceDuration` is only a measurement safety budget. Business timeout and cancellation remain with the HTTP client, repository, or feature owner.

### Lock the key, dimensions, and measurements together

The application boundary does not accept an arbitrary map. A feature passes types with finite value domains; only the Firebase projection creates maps:

```dart
enum CacheState { hit, miss, unavailable }

sealed class TraceDimensions {
  const TraceDimensions();
}

final class SearchLoadDimensions extends TraceDimensions {
  const SearchLoadDimensions({required this.cacheState});

  final CacheState cacheState;
}

sealed class TraceMeasurements {
  const TraceMeasurements();
}

final class FirstPostFrameMeasurements extends TraceMeasurements {
  const FirstPostFrameMeasurements({required this.elapsedMilliseconds});

  final int elapsedMilliseconds;
}

final class FirebaseTraceProjection {
  const FirebaseTraceProjection({
    required this.name,
    required this.attributes,
    required this.metrics,
  });

  final String name;
  final Map<String, String> attributes;
  final Map<String, int> metrics;
}

final class TraceContractViolation implements Exception {
  const TraceContractViolation();
}

FirebaseTraceProjection projectForFirebase(
  TraceSpec spec, {
  TraceDimensions? dimensions,
  TraceMeasurements? measurements,
}) {
  return switch ((spec.key, dimensions, measurements)) {
    (PerformanceTraceKey.homeFirstPostFrame, null, null) =>
      FirebaseTraceProjection(
        name: spec.key.wireName,
        attributes: const {},
        metrics: const {},
      ),
    (
      PerformanceTraceKey.homeFirstPostFrame,
      null,
      FirstPostFrameMeasurements(:final elapsedMilliseconds),
    ) =>
      FirebaseTraceProjection(
        name: spec.key.wireName,
        attributes: const {},
        metrics: {'elapsed_ms': elapsedMilliseconds.clamp(0, 120000).toInt()},
      ),
    (
      PerformanceTraceKey.searchLoad,
      SearchLoadDimensions(:final cacheState),
      null,
    ) =>
      FirebaseTraceProjection(
        name: spec.key.wireName,
        attributes: {'cache_state': cacheState.name},
        metrics: const {},
      ),
    _ => throw const TraceContractViolation(),
  };
}
```

The tuple switch prevents `searchLoad` from receiving a first-post-frame measurement or a home trace from receiving search dimensions. A generic typed spec can move more errors to compile time, but the adapter still needs a runtime guard because input can cross a dynamic or configuration boundary.

The production projection enforces these rules before calling the SDK:

* Trace and metric names have no leading or trailing whitespace, do not start with `_`, and are at most 100 characters.
* Attribute keys are at most 32 characters and values are at most 100 characters.
* At most 5 custom attributes are allowed; if the gateway adds `outcome`, the projection reserves one slot.
* At most 32 metrics are allowed, including the default Duration.
* No PII, raw URL or query, account, device, or session ID, exception message, or payload.
* An unrestricted server string is mapped to a reviewed enum or bucket instead of becoming a dimension directly.

The multi-provider analytics article applies the same principle to typed projection: the application owns the contract, and only the adapter translates it to a provider shape.

{% content-ref url="/pages/7FxGn5quNwHeyTJDP6IJ" %}
[Multi-Provider Analytics in Flutter](/flutter/my-flutter/security-observability/multi-provider-analytics-flutter.md)
{% endcontent-ref %}

### Return a synchronous session for each operation

A business operation should not wait for method-channel start. The gateway returns a session synchronously: disabled, not-ready, and sampled-out paths receive no-op sessions, while the enabled path receives a pending session that internally owns the provider-start Future.

```dart
abstract interface class ProviderTrace {
  void putAttribute(String key, String value);

  void setMetric(String key, int value);

  Future<void> stop();
}

abstract interface class FirebasePerformanceAdapter {
  Future<ProviderTrace> start(FirebaseTraceProjection projection);

  Future<void> setCollectionEnabled(bool enabled);

  Future<bool> isCollectionEnabled();
}

abstract interface class TraceSession {
  TraceStartStatus get startStatus;

  Future<TraceFinishStatus> finish({
    required TraceOutcome outcome,
    TraceMeasurements? measurements,
  });
}

final class NoopTraceSession implements TraceSession {
  const NoopTraceSession(this.startStatus);

  @override
  final TraceStartStatus startStatus;

  @override
  Future<TraceFinishStatus> finish({
    required TraceOutcome outcome,
    TraceMeasurements? measurements,
  }) async {
    return TraceFinishStatus.skipped;
  }
}
```

The pending session arms the overall measurement timer before provider start. If the owner finishes or the budget expires before the provider Future resolves, the late provider handle is stopped immediately and discarded. Wrapping only the provider Future in `Future.timeout()` and dropping the late result would leave an orphaned native trace.

The active session retains the correct provider handle and one single-flight finish Future:

```dart
import 'dart:async';

final class ActiveTraceSession implements TraceSession {
  ActiveTraceSession({
    required ProviderTrace providerTrace,
    required FirebaseTraceProjection Function(TraceMeasurements?) project,
    required Duration maxTraceDuration,
  }) : assert(maxTraceDuration > Duration.zero),
       _providerTrace = providerTrace,
       _project = project {
    _timeoutTimer = Timer(maxTraceDuration, () {
      unawaited(finish(outcome: TraceOutcome.timeout));
    });
  }

  final ProviderTrace _providerTrace;
  final FirebaseTraceProjection Function(TraceMeasurements?) _project;
  late final Timer _timeoutTimer;
  Future<TraceFinishStatus>? _finishFuture;

  @override
  TraceStartStatus get startStatus => TraceStartStatus.started;

  @override
  Future<TraceFinishStatus> finish({
    required TraceOutcome outcome,
    TraceMeasurements? measurements,
  }) {
    return _finishFuture ??= _finishOnce(outcome, measurements);
  }

  Future<TraceFinishStatus> _finishOnce(
    TraceOutcome outcome,
    TraceMeasurements? measurements,
  ) async {
    _timeoutTimer.cancel();
    var failed = false;

    try {
      final projection = _project(measurements);
      for (final attribute in projection.attributes.entries) {
        _providerTrace.putAttribute(attribute.key, attribute.value);
      }
      _providerTrace.putAttribute('outcome', outcome.name);
      for (final metric in projection.metrics.entries) {
        _providerTrace.setMetric(metric.key, metric.value);
      }
    } catch (_) {
      failed = true;
    }

    try {
      await _providerTrace.stop();
    } catch (_) {
      failed = true;
    }

    return failed
        ? TraceFinishStatus.providerFailure
        : TraceFinishStatus.stopped;
  }
}
```

The failure boundary covers projection, attributes, metrics, and stop. A projection or SDK setter failure still leads to one stop attempt; the result is only the allowlisted `providerFailure`. A raw exception does not enter a performance attribute or operational log.

If crash diagnostics genuinely need the exception, that path belongs to the observability boundary's sanitizer:

{% content-ref url="/pages/ATOHUDA9JCzn2wmCbZD9" %}
[Sentry for Crashes and Tracing](/flutter/my-flutter/security-observability/sentry-flutter-crash-http-navigation-tracing.md)
{% endcontent-ref %}

### Initialize idempotently before `runApp`

The gateway uses `uninitialized`, `initializing`, `ready`, `disabled`, `failed`, and `disposed` states. `initialize()` is single-flight; `dispose()` blocks new starts, snapshots active sessions, awaits cleanup, and then clears state.

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

  await Firebase.initializeApp();
  await performanceGateway.initialize(
    const PerformancePolicy(
      customTracesEnabled: true,
      nativeCollectionDesired: true,
    ),
  );

  runApp(const App());
}

final class PerformancePolicy {
  const PerformancePolicy({
    required this.customTracesEnabled,
    required this.nativeCollectionDesired,
  });

  final bool customTracesEnabled;
  final bool nativeCollectionDesired;
}
```

The acceptance contract is:

* Two initialization callers receive the same Future; the adapter is not added or initialized twice.
* Start before ready returns `NoopTraceSession(notReady)` and does not buffer raw dimensions.
* Provider initialization failure does not block `runApp`; the gateway fails open for UX but reports a typed category.
* Dispose blocks new starts, awaits active sessions, and resets state deterministically.
* Reinitialization after dispose is an explicit policy in every build, not a debug `assert`.
* If a first-screen trace is required, the gateway is ready before `runApp`.
* A native cold-start trace needs a native startup owner; a page trace is not renamed as app startup.

`nativeCollectionDesired` is policy input, not observed state. After the SDK setter, the adapter can read `isPerformanceCollectionEnabled()` and report a typed observation; even `true` does not prove upload or console aggregation.

If policy comes from Remote Config, the performance gateway receives only a validated typed snapshot. Fetch, defaults, activation, and kill-switch lifecycle remain with the Remote Config coordinator:

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

Remote policy is not the only emergency off path. Startup needs a safe local and native default when fetch fails or has not activated.

### Observe business timeout without owning it

The helper does not call `.timeout(spec.maxTraceDuration)`. It observes an operation whose timeout and cancellation policy already belong to the feature or service owner:

```dart
import 'dart:async';

Future<T> traceOperation<T>({
  required PerformanceGateway gateway,
  required TraceSpec spec,
  required Future<T> Function() operation,
}) async {
  final session = gateway.start(spec);
  var outcome = TraceOutcome.success;

  try {
    return await operation();
  } on TimeoutException {
    outcome = TraceOutcome.timeout;
    rethrow;
  } catch (_) {
    outcome = TraceOutcome.failure;
    rethrow;
  } finally {
    await session.finish(outcome: outcome);
  }
}
```

If a repository or HTTP owner raises `TimeoutException`, the wrapper maps the outcome and rethrows. The measurement timer may finish the session first, but it does not raise TimeoutException, change the return value, or cancel the original Future.

Dart `Future.timeout()` does not cancel the underlying operation. An HTTP request, database query, or isolate task needs a cancellation primitive at the resource owner. Performance instrumentation must not change business behavior merely because the team edits the sampling catalog.

The HTTP client article owns transport, retry, redirect, and cancellation boundaries; the performance wrapper does not create a second transport owner:

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

### Measure first post-frame with a monotonic clock

The probe creates a session and registers the callback in the same synchronous turn. `dispose()` can claim cancellation before the callback; session finish remains idempotent:

```dart
import 'dart:async';

import 'package:flutter/widgets.dart';

final class FirstPostFrameProbe {
  FirstPostFrameProbe({
    required PerformanceGateway gateway,
    required TraceSpec spec,
  }) : _gateway = gateway,
       _spec = spec;

  final PerformanceGateway _gateway;
  final TraceSpec _spec;
  final Stopwatch _elapsed = Stopwatch();
  late final TraceSession _session;
  bool _begun = false;
  TraceOutcome? _claimedOutcome;

  bool begin() {
    if (_begun) return false;
    _begun = true;
    _elapsed.start();
    _session = _gateway.start(_spec);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      unawaited(_claim(TraceOutcome.success));
    });
    return true;
  }

  void dispose() {
    if (!_begun) return;
    unawaited(_claim(TraceOutcome.cancelled));
  }

  Future<void> _claim(TraceOutcome outcome) async {
    if (_claimedOutcome != null) return;
    _claimedOutcome = outcome;
    _elapsed.stop();

    await _session.finish(
      outcome: outcome,
      measurements: FirstPostFrameMeasurements(
        elapsedMilliseconds: _elapsed.elapsedMilliseconds,
      ),
    );
  }
}
```

`begin()` returns `false` when called again, and dispose before begin is a no-op, so production code does not read an uninitialized `late` field.

Because provider start is asynchronous, `elapsed_ms` begins synchronously and can exceed the default trace duration. If post-frame occurs before provider start completes, the metric still preserves the local milestone while provider duration can be nearly zero. The two values are not expected to match.

When the required boundary is different:

* Build, raster, or total frame span: use `addTimingsCallback`, read `FrameTiming`, and remove the callback.
* Data ready: the feature emits a typed milestone after state and data commit.
* Interactive ready: define a testable predicate instead of inferring it from one post-frame callback.
* App startup: use an automatic or native startup trace, or a dedicated startup boundary.

### Assign ownership across Firebase automatic, custom, and Sentry

```
build plugin ─► native default ─► runtime SDK state ─► custom gate / sampling
                                                              │
                                                              ▼
source config ─► unit test ─► device log ─► upload ─► console ─► release trend
```

These two axes are independent. Enabling one configuration layer does not elevate evidence to the dashboard.

I use this ownership matrix:

| Operation                | Primary owner                     | Secondary signal                                   | Rule                                        |
| ------------------------ | --------------------------------- | -------------------------------------------------- | ------------------------------------------- |
| Cold app start           | Firebase automatic/native         | Sentry startup when enabled separately             | Do not substitute a page trace for startup  |
| Flutter first post-frame | Firebase custom                   | Flutter `FrameTiming` locally or in profile        | Name the milestone honestly; sample lightly |
| Screen data ready        | Firebase custom or product metric | Sentry transaction when causal spans are needed    | Choose one duration owner                   |
| Dart HTTP request        | HTTP client/Sentry                | Firebase automatic network only after device proof | Do not add custom HTTP by default           |
| Business latency         | Sentry span or Firebase custom    | The other provider only for a different question   | Do not share a handle or trace ID           |
| Crash/error              | Sentry                            | Performance outcome enum                           | Do not send a raw exception as an attribute |

Sampling budgets are independent. I do not call two records duplicates until device or console evidence shows that they measure the same operation and semantic boundary.

### Test overlap, failure, and lifecycle

The most important test verifies that two operations with the same key retain different provider handles:

```dart
void main() {
  test('same-name operations finish their own provider handles', () async {
    final adapter = FakeFirebasePerformanceAdapter();
    final gateway = PerformanceGateway(adapter: adapter);
    await gateway.initialize(enabledPolicy);

    final first = gateway.start(searchLoadSpec);
    final second = gateway.start(searchLoadSpec);

    await first.finish(outcome: TraceOutcome.success);
    await second.finish(outcome: TraceOutcome.cancelled);

    expect(adapter.startedNames, ['search_load', 'search_load']);
    expect(adapter.handles[0].stopCount, 1);
    expect(adapter.handles[1].stopCount, 1);
    expect(adapter.handles[0].attributes['outcome'], 'success');
    expect(adapter.handles[1].attributes['outcome'], 'cancelled');
  });

  test('first-post-frame metric stays an int inside its bounds', () {
    final negative = projectForFirebase(
      homeFirstPostFrameSpec,
      measurements: const FirstPostFrameMeasurements(elapsedMilliseconds: -1),
    );
    final capped = projectForFirebase(
      homeFirstPostFrameSpec,
      measurements: const FirstPostFrameMeasurements(
        elapsedMilliseconds: 120001,
      ),
    );

    expect(negative.metrics['elapsed_ms'], 0);
    expect(negative.metrics['elapsed_ms'], isA<int>());
    expect(capped.metrics['elapsed_ms'], 120000);
  });

  test('projection rejects a key and payload mismatch', () {
    expect(
      () => projectForFirebase(
        searchLoadSpec,
        measurements: const FirstPostFrameMeasurements(elapsedMilliseconds: 16),
      ),
      throwsA(isA<TraceContractViolation>()),
    );
  });

  test('every finish failure is contained and single-flight', () async {
    for (final failurePoint in ProviderFailurePoint.values) {
      final session = buildFailingSession(failurePoint);

      final first = session.finish(outcome: TraceOutcome.success);
      final second = session.finish(outcome: TraceOutcome.success);

      expect(identical(first, second), isTrue);
      expect(await first, TraceFinishStatus.providerFailure);
      expect(await second, TraceFinishStatus.providerFailure);
      expect(session.providerStopAttempts, 1);
    }
  });
}
```

The minimum test matrix is:

| Test                                 | Expected result                                                  |
| ------------------------------------ | ---------------------------------------------------------------- |
| Start before init                    | No-op `notReady`; the business operation still runs              |
| Concurrent initialization            | Provider initializes exactly once                                |
| Disabled or sampling miss            | No provider trace; typed status is correct                       |
| Same-name overlap                    | Independent handles with no cross-stop                           |
| Finish twice                         | The same Future and result; provider stops once                  |
| Late provider start                  | A completed measurement timeout or dispose stops the late handle |
| Projection, setter, or stop throws   | `providerFailure`; business result or error is not overridden    |
| Wrong key and payload                | Contract violation before provider projection                    |
| Negative or oversized metric         | Clamped to `int` 0 or 120000                                     |
| Dispose before begin or double begin | No late-field read; provider starts at most once                 |
| App pause and resume                 | Behavior matches catalog policy                                  |
| Clean install and relaunch           | App gate and persisted SDK state are asserted separately         |

### Verify native configuration and runtime evidence

Android checklist:

1. Select one owner for the Firebase Performance Gradle plugin and version.
2. Remove duplicate declarations and application only after a resolved graph and build test.
3. Inspect the actual Firebase BOM and native artifacts with a dependency report.
4. Check the build-time instrumentation flag separately from runtime collection.
5. Run clean-install and relaunch tests for default-off and runtime toggles.

iOS checklist:

1. Confirm that the Pod lock matches the Flutter plugin release being shipped.
2. Set native collection and instrumentation policy before Firebase configuration when earliest opt-in is required.
3. Test clean install, persisted state, and relaunch.
4. Do not infer console delivery from a linked framework.

Runtime evidence ladder:

| Level            | QA action                                                                           |
| ---------------- | ----------------------------------------------------------------------------------- |
| 0 — Configured   | Review dependency, plugin, initialization, and native policy                        |
| 1 — Tested       | Unit and widget tests for readiness, overlap, failure, and projection               |
| 2 — Device local | Enable provider diagnostics on a test build and confirm the trace name              |
| 3 — Upload       | Observe safe batching or upload without logging raw payloads                        |
| 4 — Console      | Check the correct project, app, build, time window, metric, and attribute allowlist |
| 5 — Release      | Monitor volume, sampling, cardinality, overhead, and regressions                    |

This article's research has reached only Level 0. The source scan found no direct performance tests; device and console checks were not run. Console validation must account for batching and delay instead of declaring a single test trace lost immediately.

### Versions and references checked

* Flutter `3.41.2`, Dart `3.11.0`.
* Exact `firebase_performance 0.11.2`.
* Exact `firebase_performance_platform_interface 0.1.6+6`.
* Exact `firebase_performance_web 0.1.8+4`.
* Exact `firebase_core 4.6.0`.
* iOS Firebase Performance and Core pod `12.9.0`.
* Platforms: Android and iOS.

Primary references:

* [Firebase — Get started with Performance Monitoring for Flutter](https://firebase.google.com/docs/perf-mon/flutter/get-started)
* [Firebase — Add custom monitoring for specific app code](https://firebase.google.com/docs/perf-mon/custom-code-traces?platform=flutter)
* [Firebase — Network request performance data](https://firebase.google.com/docs/perf-mon/network-traces)
* [Firebase — Disable Performance Monitoring on Android](https://firebase.google.com/docs/perf-mon/disable-sdk?platform=android)
* [Firebase — Disable Performance Monitoring on Apple platforms](https://firebase.google.com/docs/perf-mon/disable-sdk?platform=ios)
* [Firebase — Troubleshoot Performance Monitoring](https://firebase.google.com/docs/perf-mon/troubleshooting)
* [Flutter — `addPostFrameCallback`](https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addPostFrameCallback.html)
* [Flutter — `addTimingsCallback`](https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addTimingsCallback.html)
* [Dart — `Stopwatch`](https://api.dart.dev/dart-core/Stopwatch-class.html)
* [FlutterFire `firebase_performance-v0.11.2` — FirebasePerformance](https://github.com/firebase/flutterfire/blob/firebase_performance-v0.11.2/packages/firebase_performance/firebase_performance/lib/src/firebase_performance.dart)
* [FlutterFire `firebase_performance-v0.11.2` — Trace](https://github.com/firebase/flutterfire/blob/firebase_performance-v0.11.2/packages/firebase_performance/firebase_performance/lib/src/trace.dart)
* [FlutterFire `firebase_performance-v0.11.2` — HttpMetric](https://github.com/firebase/flutterfire/blob/firebase_performance-v0.11.2/packages/firebase_performance/firebase_performance/lib/src/http_metric.dart)
* [Firebase Performance platform interface at the exact tag](https://github.com/firebase/flutterfire/tree/firebase_performance-v0.11.2/packages/firebase_performance/firebase_performance_platform_interface)
* [FlutterFire version matrix](https://github.com/firebase/flutterfire/blob/main/VERSIONS.md)

## Conclusion

A custom trace is useful only when its name, boundary, and lifecycle describe the same thing. A global map keyed by name cannot preserve that contract: it confuses an aggregate name with operation identity, lets overlapping operations stop the wrong handle, and hides disabled, not-ready, and provider-failure states behind a nullable Future.

The new boundary returns a distinct session for each operation, finishes exactly once, and contains every projection, setter, and stop failure. `maxTraceDuration` cleans up only the measurement; it does not change timeout or cancellation for the business operation. First post-frame uses a monotonic clock and an honest name instead of being promoted to fully rendered or data-ready.

Native collection, the application gate, and per-trace sampling are different policies; source configuration, device observation, and Firebase Console are also different evidence layers. Static source has exposed a readiness gap, same-name collision, async cleanup gaps, and generic-map risk, but it has not proven dashboard delivery, automatic Dart HTTP capture, or duplication with Sentry.

I call the pipeline operational only after typed tests pass, the native graph resolves for the intended build, a device shows that the provider safely accepts the trace, and Firebase Console displays the expected trace, metric, and attribute inside the defined time window.

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