> 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/multi-provider-analytics-flutter.md).

# Multi-Provider Analytics in Flutter

How I put Firebase Analytics, Snowplow, and Adjust behind a typed gateway to control events, consent, identity, and delivery evidence in Flutter

## Result

In my app, Firebase Analytics, Snowplow, and Adjust once sat behind the same `TrackingSystem` interface. Feature code did not have to import all three SDKs directly, but that abstraction still fanned out strings and maps, propagated an overly broad domain user, and dropped many `Future`s. It hid SDK names without owning the data contract.

I changed the boundary to this structure:

```
Feature / Redux / use case
          │ typed AppAnalyticsEvent
          ▼
   Reviewed event catalog
          │ destination + minimal projection + purpose
          ▼
 Consent / identity / readiness gate
          │
     ┌────┼──────────────┐
     ▼    ▼              ▼
 Firebase Snowplow      Adjust event
 adapter  adapter       adapter
     │       │              │
     └───────┼──────────────┘
             ▼
   per-destination DispatchResult
             │
             ▼
 redacted operational telemetry
```

The application event is the source of truth. The catalog selects providers and creates reviewed minimal projections; the same `Map<String, dynamic>` is not broadcast by default.

With this structure, I can review each decision independently:

* Which events may reach Firebase, Snowplow, or Adjust.
* Which fields enter each provider projection.
* Which purpose and consent govern product measurement and attribution.
* Which identity belongs to the login session and which belongs to advertising measurement.
* Whether a provider accepted a client call, dropped it because of policy, or failed.
* Which navigation coordinator receives an Adjust deep link.

I also separate four evidence levels that are often grouped under “analytics works”:

| Evidence level       | What it can prove                                                         |
| -------------------- | ------------------------------------------------------------------------- |
| Source/config        | SDKs, options, callbacks, and adapters are declared                       |
| Callsite             | The application actually calls the gateway or provider API                |
| Client/provider test | The SDK accepts the call, or a queue or collector observes the test event |
| Dashboard/consumer   | The event reaches the correct project, schema, and downstream report      |

`acceptedByClient` only belongs to the third level, and even that depends on the SDK contract. It is not a dashboard receipt.

The reference source locks `firebase_analytics 12.2.0`, `snowplow_tracker 0.8.0`, and `adjust_sdk 5.5.1`. The code in this article is a proposed hardening based on source evidence, not a claim that every change already runs in production.

## Problem

### An interface is not necessarily an analytics boundary

`Tracking` in the source holds three singleton adapters and two parallel event paths:

* `logEvent()` accepts a legacy name and map, then calls Firebase, Snowplow, and Adjust.
* `log()` accepts a typed event and selects providers through marker mixins.

A scoped scan found `trackingEvent(...)` in 220 files and `Tracking.log(...)` in 43 files. These are files with callsites, not unique event counts and certainly not counts of events that reached a dashboard.

Typed events are better than strings and maps, but provider selection is still spread across markers, conditional chains, and adapters. Among 80 typed event files in the scan, Firebase markers appeared in 40, Snowplow markers in 44, and both in 6. No typed event implemented the Adjust marker in that scan; Adjust mostly used the legacy token map and purchase path.

When destinations do not live beside the event contract, reviewers cannot easily tell how many records an interaction creates or which fields leave the app.

### A lazy iterable leaves the screen context stale

`Tracking.setCurrentScreen()` calls `tracking.map(...)` without consuming the iterable. Dart's [`Iterable.map()`](https://api.dart.dev/dart-core/Iterable/map.html) is lazy: its callback runs only when the iterable is iterated.

Therefore, on the call path I inspected, the adapter callback inside `setCurrentScreen()` does not run. The route observer still emits a separate screen event, but that event does not prove that the Snowplow mobile-screen context was updated.

The Snowplow adapter also emits both a `ScreenView` and a self-describing screen record for the same transition. Source proves that both calls exist; there is no consumer or dashboard evidence yet to decide whether they are intentionally distinct semantic records or duplicates.

### Fire-and-forget hides delivery and errors

Async boundaries in the source are inconsistent:

* `Tracking.logEvent()` is `async void` and does not await adapters.
* `Tracking.log()` creates `Future.wait(...)` and drops the `Future`.
* Purchase tracking drops adapter futures.
* Set user, add property, and clear user return `Future`s, but the auth/UI callsites I read do not await them.
* `Tracking.init()` is awaited, but Firebase and Adjust adapter init methods are no-ops; only Snowplow init is actually awaited at that boundary.
* The Adjust manager initializes separately in the root widget, and its caller does not await it.

The caller therefore cannot tell whether an operation was accepted by the client, dropped by policy, or failed in an adapter. Adding `await` to every `onTap` is not the answer; ownership of the `Future` and the reporting contract are what must change.

### A domain user is not an analytics identity

The Firebase adapter currently sets a stable domain identifier as the user ID, then iterates through the complete `user.toJson()` output as user properties. The domain model contains account identifiers, contact data, and many other fields.

This broad propagation is confirmed by source. I have not runtime-verified which properties actually appear in a dashboard or assessed a specific compliance impact. Even so, the architectural risk is clear: adding a field to the domain model can silently change the analytics schema without analytics review.

The Firebase adapter awaits the user ID but calls `setUserProperty()` inside a synchronous `forEach`, so property futures are dropped. Firebase and Adjust implement `clearUserData()` as no-ops; only Snowplow clears its custom context and user ID. Logout also does not await the clear call.

The source therefore does not prove that old provider identity is cleared consistently before a new session starts. [Firebase says not to use PII as a user ID](https://firebase.google.com/docs/analytics/userid), and [Google Analytics prohibits sending PII](https://support.google.com/analytics/answer/6366371) in fields it can receive.

### ATT is not unified consent

The Adjust manager has a helper that requests App Tracking Transparency on iOS, but the helper uses `.then(...)` without awaiting the callback. `init()` continues before the ATT status completes, and the caller of `init()` is also fire-and-forget.

ATT is authorization for advertising tracking on iOS. It does not grant application attribution consent, enable Firebase or Snowplow collection, or replace a privacy notice. Conversely, ATT denied or restricted does not automatically prohibit all product analytics or attribution modes that avoid IDFA; purpose and legal policy still decide those separately.

The scoped source scan did not find a unified application policy that calls Firebase collection/consent APIs, Adjust disable/privacy APIs, or Snowplow consent/anonymisation controls. The accurate conclusion is “no policy appeared at the scanned callsites,” not “the app definitely has no consent.”

### Attribution, backend payloads, and deep links mix owners

The Adjust manager waits one second and then calls async getters without awaiting the nested futures. `getOrFetch()` also calls getters without awaiting them, and two page callsites I inspected do not await `getOrFetch()`. The attribution/device snapshot may therefore be empty or stale depending on timing; the research has no device trace that quantifies this race.

Raw attribution JSON, deep links, and advertising/device identifiers are also written to the application logger. An account-registration flow uses the same `AdjustData` snapshot for a backend business request and an analytics event. What a backend needs does not determine what analytics may receive; the two payloads need separate purposes and allowlists.

The attribution callback and deferred callback can both call the same navigation ingress. I found no deduplication in the manager I inspected, so duplicate navigation is an inferred risk rather than a runtime-verified incident.

Exact `adjust_sdk 5.5.1` also marks `Adjust.onPause()` and `Adjust.onResume()` as testing-only. The source app calls both APIs from production lifecycle code and suppresses the lint. This article does not preserve that call pattern.

### Static configuration does not prove runtime behavior

The Android source declares native Firebase Analytics and Adjust dependencies directly beside the Flutter plugins. Adjust Flutter lock `5.5.1` maps to native Android `5.5.1`, while the app also directly pins `5.4.2`. I did not run the resolved Gradle graph, so I only conclude that the declarations overlap.

iOS contains an ATT usage description, the AdServices framework, and Adjust/Firebase pods. These declarations prove that capabilities and dependencies exist in source; they do not prove that a user saw the prompt, granted ATT, or that attribution worked.

Seven tests under `test/analytics/` validate typed payloads and schemas. A scoped scan did not find direct tests for `Tracking`, individual adapters, or `AdjustManager`. Focused tests also did not run during this research because dependency resolution stopped at private Git SSH host verification; there are no passed or failed tests to report.

## Solution

### Put typed events and the catalog at the application boundary

Feature code creates only application events. It does not know Firebase event names, Snowplow schema URIs, or Adjust event tokens:

```dart
sealed class AppAnalyticsEvent {
  const AppAnalyticsEvent();
}

final class ArticleShared extends AppAnalyticsEvent {
  const ArticleShared({required this.surface});

  final AnalyticsSurface surface;
}

final class SubscriptionStarted extends AppAnalyticsEvent {
  const SubscriptionStarted({required this.planTier});

  final PlanTier planTier;
}

enum AnalyticsSurface { articleDetail, searchResults }

enum PlanTier { standard, premium }

enum AnalyticsDestination { firebase, snowplow, adjust }
```

Each provider receives its own typed command. Adjust uses a public alias; only the adapter resolves that alias into environment-specific client configuration:

```dart
sealed class ProviderCommand {
  const ProviderCommand(this.destination);

  final AnalyticsDestination destination;
}

final class FirebaseCommand extends ProviderCommand {
  const FirebaseCommand({required this.name, required this.parameters})
    : super(AnalyticsDestination.firebase);

  final String name;
  final Map<String, Object> parameters;
}

final class SnowplowCommand extends ProviderCommand {
  const SnowplowCommand({required this.schema, required this.data})
    : super(AnalyticsDestination.snowplow);

  final Uri schema;
  final Map<String, Object> data;
}

enum AdjustEventAlias { subscriptionStarted }

final class AdjustCommand extends ProviderCommand {
  const AdjustCommand({required this.alias})
    : super(AnalyticsDestination.adjust);

  final AdjustEventAlias alias;
}
```

The catalog is the only place that selects destinations and projections:

```dart
abstract interface class AnalyticsCatalog {
  List<ProviderCommand> project(AppAnalyticsEvent event);
}

final class ReviewedAnalyticsCatalog implements AnalyticsCatalog {
  @override
  List<ProviderCommand> project(AppAnalyticsEvent event) {
    return switch (event) {
      ArticleShared(:final surface) => [
        FirebaseCommand(
          name: 'article_shared',
          parameters: {'surface': surface.name},
        ),
        SnowplowCommand(
          schema: Uri.parse('iglu:com.example/article_shared/jsonschema/1-0-0'),
          data: {'surface': surface.name},
        ),
      ],
      SubscriptionStarted(:final planTier) => [
        FirebaseCommand(
          name: 'subscription_started',
          parameters: {'plan_tier': planTier.name},
        ),
        AdjustCommand(alias: AdjustEventAlias.subscriptionStarted),
      ],
    };
  }
}
```

`Map<String, Object>` appears only after the typed event and contains only the catalog-reviewed projection. The gateway never accepts a free-form map from a feature or domain model.

### Separate purpose consent from ATT

I use default-deny while application consent is `unknown`. Events are not buffered as raw data for later replay; the gateway returns a dropped result with an allowlisted reason.

```dart
enum AnalyticsPurpose { productMeasurement, attributionMeasurement }

enum ConsentState { unknown, granted, denied }

enum AttAuthorization {
  notApplicable,
  notDetermined,
  restricted,
  denied,
  authorized,
}

final class AnalyticsPolicy {
  const AnalyticsPolicy({
    required this.measurement,
    required this.attribution,
    required this.attAuthorization,
  });

  final ConsentState measurement;
  final ConsentState attribution;
  final AttAuthorization attAuthorization;

  bool allows(AnalyticsPurpose purpose) {
    return switch (purpose) {
      AnalyticsPurpose.productMeasurement =>
        measurement == ConsentState.granted,
      AnalyticsPurpose.attributionMeasurement =>
        attribution == ConsentState.granted,
    };
  }

  bool get allowsIdfa {
    return attribution == ConsentState.granted &&
        attAuthorization == AttAuthorization.authorized;
  }
}
```

`allowsIdfa` is only an iOS IDFA gate: attribution purpose must be granted and ATT must be `authorized`. Android advertising ID needs a separate platform and policy gate; it does not reuse `AttAuthorization.notApplicable`.

If the product has another legal basis or a provider offers a cookieless mode, the legal or product owner must configure each purpose explicitly. An adapter must not silently change the default.

Firebase provides native default-off and runtime collection controls for [Android](https://firebase.google.com/docs/analytics/android/configure-data-collection) and [iOS](https://firebase.google.com/docs/analytics/ios/configure-data-collection). Adjust separates [ATT](https://dev.adjust.com/en/sdk/flutter/features/att/) from [privacy controls](https://dev.adjust.com/en/sdk/flutter/features/privacy/). These APIs are mechanisms; application policy still owns the decision.

### Dispatch commands to typed adapters

Each adapter accepts only its own command. An exhaustive switch preserves the specific type; the gateway does not broadcast and then cast blindly:

```dart
import 'dart:async';

enum DispatchOutcome { acceptedByClient, dropped, failed }

enum DispatchReason {
  consentUnknown,
  consentDenied,
  providerNotReady,
  invalidProjection,
  providerTimeout,
  adapterFailure,
}

final class DispatchResult {
  const DispatchResult({
    required this.destination,
    required this.outcome,
    this.reason,
  });

  final AnalyticsDestination destination;
  final DispatchOutcome outcome;
  final DispatchReason? reason;
}

abstract interface class AnalyticsAdapter<C extends ProviderCommand> {
  Future<void> send(C command);
}

final class AnalyticsDispatcher {
  const AnalyticsDispatcher({
    required this.firebase,
    required this.snowplow,
    required this.adjust,
  });

  final AnalyticsAdapter<FirebaseCommand> firebase;
  final AnalyticsAdapter<SnowplowCommand> snowplow;
  final AnalyticsAdapter<AdjustCommand> adjust;

  Future<DispatchResult> dispatch(ProviderCommand command) {
    return switch (command) {
      final FirebaseCommand typed => _sendSafely(typed, firebase),
      final SnowplowCommand typed => _sendSafely(typed, snowplow),
      final AdjustCommand typed => _sendSafely(typed, adjust),
    };
  }

  Future<DispatchResult> _sendSafely<C extends ProviderCommand>(
    C command,
    AnalyticsAdapter<C> adapter,
  ) async {
    try {
      await adapter.send(command).timeout(const Duration(seconds: 2));
      return DispatchResult(
        destination: command.destination,
        outcome: DispatchOutcome.acceptedByClient,
      );
    } on TimeoutException {
      return DispatchResult(
        destination: command.destination,
        outcome: DispatchOutcome.failed,
        reason: DispatchReason.providerTimeout,
      );
    } catch (_) {
      return DispatchResult(
        destination: command.destination,
        outcome: DispatchOutcome.failed,
        reason: DispatchReason.adapterFailure,
      );
    }
  }
}
```

The raw exception never leaves the dispatcher. Operational telemetry receives only `DispatchReason`; it does not receive an exception message, stack, event payload, or provider context.

`Future.timeout()` does not cancel the source provider operation. The timeout here limits how long the caller waits for a report; it does not prove the SDK stopped queuing or sending. If cancellation is required, I inspect the exact provider API or use a generation guard instead of inferring cancellation from `TimeoutException`.

### Choose best-effort and awaited boundaries

A tap event should not normally block the UI. I keep fire-and-forget in one wrapper, while the dispatcher must convert every expected provider failure into a typed result:

```dart
void trackBestEffort(
  Future<List<DispatchResult>> Function() operation,
  void Function(List<DispatchResult>) record,
  void Function(DispatchReason) recordFailure,
) {
  unawaited(
    Future.sync(operation).then<void>(
      record,
      onError: (_, _) {
        recordFailure(DispatchReason.adapterFailure);
      },
    ),
  );
}
```

The operational callback receives only allowlisted results or categories. If crash diagnostics need the raw exception, it must pass through the observability boundary's sanitizer instead of going through the analytics logger.

These boundaries should be awaited deliberately:

* Initialization before a destination becomes `ready`.
* Set or clear identity before session handoff.
* Apply a consent transition before accepting a new event.
* Fetch attribution when a backend operation truly depends on the snapshot, with a clear timeout and fallback.
* Flush at an explicit test/export boundary when the exact SDK supports it.

The Sentry article owns sanitization and the distinction between configured, captured, transport, and dashboard evidence:

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

### Manage readiness per destination

One provider init failure does not necessarily block the app or the other two providers:

```
disabled ──enable──► initializing ──success──► ready
   ▲                     │                       │
   └────consent off──────┘                       │
                         └────failure────────► failed
                                                  │
                                             explicit retry
```

The public sample chooses drop-with-reason while a provider is not ready. It does not retain raw pre-consent events in memory. If a product truly needs a bounded buffer, events must be sanitized first, have TTL and size bounds, and be deleted on logout or consent withdrawal.

Remote Config also needs an adapter/coordinator, typed policy, and redacted telemetry. The article below owns that boundary; the analytics gateway only consumes a validated policy snapshot and never reads raw remote values itself.

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

### Use minimal identity and typed allowlists

The analytics layer does not accept a domain `UserModel` or a generic traits map:

```dart
final class AnalyticsIdentity {
  const AnalyticsIdentity({
    required this.opaqueUserKey,
    required this.planTier,
  });

  final String opaqueUserKey;
  final PlanTier planTier;
}

const identity = AnalyticsIdentity(
  opaqueUserKey: 'user_7f2c',
  planTier: PlanTier.standard,
);
```

`opaqueUserKey` contains no email address, mobile number, account number, or value meaningful outside the analytics identity system. Hashing stable PII still produces a pseudonymous identifier and does not remove privacy obligations by itself.

Only the provider projection turns `PlanTier` into an allowlisted key and value:

* Firebase sets the opaque user ID, sets only registered properties, awaits every `Future`, and sets properties to `null` when clearing them.
* Snowplow sets the opaque user ID, attaches a minimal self-describing context, removes the old context before replacement, and clears context and user on logout.
* Adjust does not imitate login identity when the use case only needs device attribution. Campaign or advertising/device IDs never enter shared `AnalyticsIdentity`.

The OneSignal auth lifecycle uses the same principle: identify and clear belong at the central auth boundary, not only on a particular logout button.

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

### Separate logout, consent withdrawal, and erasure

These three transitions do not replace one another:

```
logout
  └─ clear session-scoped analytics identity

consent withdrawn
  ├─ disable collection by destination/purpose
  ├─ clear the pending sanitized buffer
  └─ remove optional identity/context

data erasure request
  └─ call the provider/server erasure workflow under legal policy
```

I do not call Adjust `gdprForgetMe()` on every logout; it is not session cleanup. Conversely, clearing the app session while collection stays enabled does not complete consent withdrawal.

Firebase `resetAnalyticsData()` has broader semantics than clearing a user ID. I do not call it by default on logout; the legal or product owner must decide when to reset the app instance or trigger an erasure workflow.

### Separate Adjust attribution from navigation

Adjust needs two adapters with different owners:

```
Auth lifecycle ─────► AnalyticsIdentity ─────► Firebase / Snowplow

Consent lifecycle ──► collection policy ─────► permitted destinations

iOS ATT ────────────► IDFA gate ─────────────► Adjust measurement mode

Adjust attribution ─► redacted snapshot ─────► approved backend/event use

Adjust deep link ───► LinkCandidate ─────────► NET-05 coordinator ─► Navigator
```

Identity, consent, attribution, and deep links can relate to each other, but they do not replace one another. Only the deep-link coordinator owns the navigation effect.

With exact `adjust_sdk 5.5.1`:

* Await `requestAppTrackingAuthorization()` when initialization order depends on the result.
* Use `getAdidWithTimeout()` or `getAttributionWithTimeout()` when the use case truly needs a snapshot; do not rely on a fixed one-second delay.
* Do not call testing-only `onPause()` or `onResume()` from production lifecycle code.
* Do not copy the direct deep-link callback added in `5.6.0` into a `5.5.1` sample.
* `processAndResolveDeeplink()` can support reattribution or short-link flows, but the coordinator still owns navigation.

Normalization, exact-origin validation, readiness, auth gating, and multi-provider deduplication already have one owner in the deep-link article. Adjust only submits a redacted candidate:

{% content-ref url="/pages/XuzrnRXJWWpiiB1XhH1m" %}
[Multi-Source Deep Links in Flutter](/flutter/my-flutter/systems-realtime/deep-link-app-links-adjust-onesignal.md)
{% endcontent-ref %}

### Record only neutral operational telemetry

The analytics gateway records only technical metadata:

```
analytics_dispatch
  event_alias
  destination
  outcome
  reason_category
  consent_state
  provider_ready
  latency_bucket
  sdk_version
```

It does not record event property maps, self-describing contexts, domain user JSON, raw exceptions, attribution JSON, deep links or full queries, advertising/device IDs, provider tokens, or collector URLs.

For correlation, I use a random operation ID that cannot be reversed into a user or event payload. `event_alias` is a reviewed neutral name, not a raw provider schema or token.

### Choose one owner for native dependencies

The Flutter plugin is the default owner of native Firebase and Adjust SDK versions. I add a direct Gradle or Pod dependency only when vendor integration requires it and a comment or test explains why.

On Android, inspect the resolved graph instead of only reading `pubspec.lock`:

```bash
./gradlew :app:dependencyInsight \
  --dependency com.adjust.sdk:adjust-android \
  --configuration debugRuntimeClasspath

./gradlew :app:dependencyInsight \
  --dependency com.google.firebase:firebase-analytics \
  --configuration debugRuntimeClasspath
```

On iOS, inspect the plugin and native pods that actually resolve:

```bash
grep -A 6 '^  Adjust' ios/Podfile.lock
grep -A 6 '^  firebase_analytics' ios/Podfile.lock
```

This article does not claim a resolved native Android version for the source snapshot because the Gradle graph did not run during research.

### Keep platform policy explicit

#### Android

* Inspect the merged manifest for `AD_ID` and Firebase collection metadata, not only the source manifest.
* If advertising ID is not used, remove the permission according to vendor and platform guidance, then test the remaining attribution mode.
* Install Referrer is an attribution signal, not a user identity.
* Do not invent an “analytics permission”; application consent combined with provider controls decides collection.

#### iOS

* `NSUserTrackingUsageDescription` is needed only when the app requests ATT; its copy must reflect the actual purpose and pass localization review.
* ATT denied does not mean all product analytics must stop; collection follows a separate policy.
* Do not read or log IDFA when the purpose does not allow it.
* AdServices/SKAdNetwork attribution is not login identity.

### Test the catalog, policy, and partial failures

The first unit test locks destinations and projections. An event without an Adjust mapping cannot accidentally obtain a token from a string name:

```dart
void main() {
  test('article share only targets reviewed destinations', () {
    final catalog = ReviewedAnalyticsCatalog();

    final commands = catalog.project(
      const ArticleShared(surface: AnalyticsSurface.articleDetail),
    );

    expect(commands.map((command) => command.destination), [
      AnalyticsDestination.firebase,
      AnalyticsDestination.snowplow,
    ]);
    expect(commands.whereType<AdjustCommand>(), isEmpty);
  });
}
```

Consent and ATT must be tested as two independent axes:

```dart
void main() {
  test('ATT authorization does not grant attribution consent', () {
    const policy = AnalyticsPolicy(
      measurement: ConsentState.granted,
      attribution: ConsentState.denied,
      attAuthorization: AttAuthorization.authorized,
    );

    expect(policy.allows(AnalyticsPurpose.attributionMeasurement), isFalse);
    expect(policy.allowsIdfa, isFalse);
  });
}
```

For the dispatcher, I test that one failed adapter does not hide the other two results; the result contains only `DispatchReason` and no raw error. I also test that each command reaches only its typed adapter through the exhaustive switch, with no `dynamic` or unsafe cast.

Identity lifecycle needs separate cases for account switching, logout, consent withdrawal, and erasure. Account switching must await clearing the old user before setting the new one. Normal logout never calls a forget-me API.

### Verify providers on dedicated devices

Unit tests prove only the application contract. To raise the evidence level, I use fake accounts, devices, and campaigns in isolated environments:

| Provider | Verification tool    | Expected result                                                                                          |
| -------- | -------------------- | -------------------------------------------------------------------------------------------------------- |
| Firebase | DebugView/dev device | Event, parameters, and user properties match the allowlist; events after clear do not carry the old user |
| Snowplow | Micro/dev collector  | Schema/context is correct; events after logout do not carry the old context                              |
| Adjust   | Sandbox/test device  | Session/event callbacks follow the test plan; attribution snapshots are not logged raw                   |

[`FirebaseAnalytics.logEvent()`](https://firebase.google.com/docs/analytics/flutter/events), [`SnowplowTracker.track()`](https://pub.dev/documentation/snowplow_tracker/latest/tracker/SnowplowTracker-class.html), and Adjust client calls still have queues, native layers, and networks behind them. Method completion is not enough to say a dashboard received the event.

The minimum manual matrix is:

| Case                           | Android                            | iOS                                                         | Evidence to retain                     |
| ------------------------------ | ---------------------------------- | ----------------------------------------------------------- | -------------------------------------- |
| Fresh install, consent unknown | Optional event is dropped          | Optional event is dropped; ATT does not start automatically | Redacted decision log                  |
| Consent granted                | Enable the correct purpose         | Enable the correct purpose; handle ATT separately           | Provider debug/test view               |
| Consent withdrawn              | Collection stops and buffer clears | Collection stops; ATT state remains separate                | State transition and no new test event |
| Login                          | Opaque key and typed traits        | Opaque key and typed traits                                 | Fake account only                      |
| Account switch/logout          | Clear completes before new user    | Clear completes before new user                             | Timestamped redacted trace             |
| One adapter fails              | App flow does not roll back        | App flow does not roll back                                 | Partial dispatch report                |
| Direct/deferred campaign       | One typed intent                   | One typed intent                                            | NET-05 deduplication trace             |

I do not use production dashboard screenshots because they can expose projects, events, campaigns, user properties, collectors, or device identifiers.

### Common mistakes and trade-offs

#### The same map still goes to every provider

The interface only renames SDKs and does not own a catalog. Move destinations and minimal projections into one reviewed switch; features emit only typed events.

#### A screen event exists but the Snowplow context is stale

Check for a lazy `map()` that is never consumed. Replace it with an owned loop or `Future.wait`, then test adapter calls; do not infer from a route event that the context update ran.

#### The old user remains after logout

The clear implementation is empty or its caller does not await it. The central auth boundary must await each provider clear before account switching and test an event immediately after the transition.

#### ATT is authorized but attribution is still dropped

ATT does not grant application consent. Check attribution purpose first; then check `allowsIdfa` and the provider mode.

#### ATT denied disables all product analytics

The policy has conflated ATT with measurement consent. Separate the states and disable only the destination or purpose required by policy.

#### A fixed delay returns an empty attribution snapshot

The getter future is not awaited or the timeout has no fallback. Use the exact async API with a timeout; backend requests and analytics projections must handle a missing snapshot independently.

#### One campaign opens the same route twice

The Adjust attribution callback and deferred callback both own navigation. The adapter should only submit typed candidates; the `NET-05` coordinator normalizes, deduplicates, and authorizes once.

#### The operational log becomes another data sink

The logger receives complete parameters, contexts, attribution, or URLs. Replace them with enums or categories, destinations, and latency buckets; route raw crash diagnostics through a separate sanitized error boundary only when they are truly needed.

#### A typed catalog adds work for every new event

Each new event needs an enum or model, provider projections, and tests. This costs more than calling `logEvent(name, map)` directly, but it turns destinations, privacy fields, and schema migrations into reviewed changes.

### Verified versions and evidence

* Flutter: `3.41.2`.
* Dart: `3.11.0`, constrained below `4.0`.
* `firebase_analytics`: exact `12.2.0`.
* `snowplow_tracker`: exact `0.8.0`.
* `adjust_sdk`: exact `5.5.1`.
* Platform: Android and iOS.

Available evidence:

* Source/configuration and call graph: verified.
* Exact Adjust `5.5.1` API and lifecycle behavior: checked against its tag and release.
* Direct source tests for the gateway, adapters, or Adjust manager: none found in the scoped scan.
* Focused `test/analytics`: did not run because of private dependency SSH verification; `0` tests executed.
* Resolved Android native dependency graph: not run.
* Android/iOS build, device callback ordering, and provider test views: not run.
* Dashboard or collector delivery: not verified.

### References

* [Dart API — `Iterable.map()`](https://api.dart.dev/dart-core/Iterable/map.html)
* [Firebase — Log Events for Flutter](https://firebase.google.com/docs/analytics/flutter/events)
* [Firebase — Set a user ID](https://firebase.google.com/docs/analytics/userid)
* [Firebase — Set user properties for Flutter](https://firebase.google.com/docs/analytics/flutter/user-properties)
* [Google Analytics — Avoid sending PII](https://support.google.com/analytics/answer/6366371)
* [Firebase — Configure Analytics data collection on Android](https://firebase.google.com/docs/analytics/android/configure-data-collection)
* [Firebase — Configure Analytics data collection on iOS](https://firebase.google.com/docs/analytics/ios/configure-data-collection)
* [Snowplow — Get started with the Flutter tracker](https://docs.snowplow.io/docs/sources/flutter-tracker/getting-started/)
* [Snowplow — Add entities and subject data](https://docs.snowplow.io/docs/sources/flutter-tracker/adding-data/)
* [Snowplow — Initialization and configuration](https://docs.snowplow.io/docs/sources/flutter-tracker/initialization-and-configuration/)
* [Adjust — Flutter SDK integration guide](https://dev.adjust.com/en/sdk/flutter/)
* [Adjust — App Tracking Transparency](https://dev.adjust.com/en/sdk/flutter/features/att/)
* [Adjust — Deferred deep linking](https://dev.adjust.com/en/sdk/flutter/features/deep-links/deferred/)
* [Adjust — Privacy features](https://dev.adjust.com/en/sdk/flutter/features/privacy/)
* [Adjust Flutter SDK `v5.5.1` — `adjust.dart`](https://github.com/adjust/flutter_sdk/blob/v5.5.1/lib/adjust.dart)
* [Adjust Flutter SDK `v5.5.1` — `adjust_config.dart`](https://github.com/adjust/flutter_sdk/blob/v5.5.1/lib/adjust_config.dart)
* [Adjust Flutter SDK release `5.5.1`](https://github.com/adjust/flutter_sdk/releases/tag/v5.5.1)
* [Firebase Analytics Dart API](https://pub.dev/documentation/firebase_analytics/latest/firebase_analytics/FirebaseAnalytics-class.html)
* [Snowplow Tracker Dart API](https://pub.dev/documentation/snowplow_tracker/latest/tracker/SnowplowTracker-class.html)

## Conclusion

Putting three SDKs behind one interface is only a first step. An analytics boundary becomes useful only when the application owns the typed event catalog, provider projections, consent, identity lifecycle, and delivery reports.

The new structure does not call client acceptance dashboard delivery. It does not treat ATT as application consent, use a domain user as analytics identity, or let an attribution callback become a second router.

Static source exposes lazy screen updates, dropped futures, broad identity propagation, raw logging, and Adjust timing and lifecycle as areas to harden. These findings do not prove production incidents. I only raise the conclusion to runtime evidence after provider test views or collectors, the device matrix, and resolved native dependency checks pass for the exact build.

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