Skip to content

[FR]: Analytics: hold events logged while collection is disabled and upload them on enable (ATT-deferred consent) #16509

Description

@adamma2048

Description

1. Use case

Our app must not let Firebase Analytics collect anything until the user has answered the App Tracking Transparency prompt, so we start with collection disabled and call Analytics.setAnalyticsCollectionEnabled(true) only once ATT resolves.

Every event our app logs in that window is dropped. That window covers the entire opening of a new user's first session — splash, onboarding, login, the first screens of our activation funnel — and it is not short: iOS only presents the ATT prompt while the app is active, we let the UI settle before requesting it, and the user then has to read and answer it.

To avoid losing that data we had to build and maintain our own event queue in front of logEvent. Ours is 123 lines: a lock, a bounded backlog, overflow accounting, a deliberate choice of CLOCK_MONOTONIC_RAW over systemUptime (the holds we care about happen while the device is asleep, so uptime under-reports them), a gate_held_ms marker written onto every replayed event, and a downstream convention in BigQuery that reconstructs real_event_time = event_time - gate_held_ms. Every app that gates collection on ATT has to write some version of this.

Four problems remain that no app-side queue can solve:

  • It cannot preserve event time. logEvent(_:parameters:) has no timestamp parameter and the firebase_, google_, and ga_ parameter prefixes are reserved (FIRAnalytics.h:68-73). Every replayed event is stamped at flush time. We can recover ordering in our own BigQuery queries via the marker above, but GA4's event_timestamp, sessionization, and the built-in funnel, path, and retention reports all use the flush instant, so the opening minutes of every new install collapse into a single point.
  • It cannot survive process death. Our backlog is in memory. The SDK's own event store is on disk. A process that is killed during the ATT window — a background launch that never reaches active and so never prompts, an OOM, a user swipe-kill — loses everything we were holding. We cannot fix this without shipping a second on-disk event store next to the one the SDK already has.
  • It has to guess a bound. We chose 512 events, dropping oldest on overflow and reporting the count. The SDK does not have to guess; it already manages its own store size and upload scheduling.
  • It cannot see SDK-generated events at all. Automatic events during the window — screen_view, session_start, user_engagement — never pass through app code, so no app-side queue can hold them. For the ATT window they are simply absent, and that part is not recoverable by any workaround.

2. How the Firebase SDK currently handles it

Events logged via Analytics.logEvent(_:parameters:) while collection is disabled are not delivered after collection is later enabled (FirebaseAnalytics 12.17.0, SPM). This is not documented on the data-collection page, and FIRAnalytics.h:100-104 documents only that the setting itself persists.

We are not calling this a bug — collection disabled meaning not collected is a coherent contract. We are asking for a way to opt out of the discarding part while keeping the not uploading part, because those are two different guarantees and only the second one is what consent actually requires.

Alternatives we considered

  • Consent Mode. We know Analytics.setConsent plus the GOOGLE_ANALYTICS_DEFAULT_ALLOW_* Info.plist defaults are the documented way to start denied and grant later. Neither the app consent docs nor FIRAnalytics+Consent.h state what happens to events logged while analytics_storage is denied. If those events are retained and sent with their original timestamps on grant, that solves our problem and this request reduces to documenting it — we would take that outcome gladly.
  • Our own queue. Described above; it is what we ship today, and the four gaps above are why it is not sufficient.
  • Logging nothing before consent. A total loss of first-session data.

3. How you propose the Firebase SDK should handle it

An opt-in, default-off mode in which events passed to logEvent(_:parameters:) while collection is disabled are written to the local event store with their original timestamps and uploaded once collection is enabled. Gate the upload stage rather than the logging stage.

Constraints we explicitly accept, since this is a privacy question and not only an engineering one:

  • Opt-in, default off. No change to what setAnalyticsCollectionEnabled(false) means for any existing caller. With the flag off, behavior is exactly what it is today.
  • FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED keeps drop-everything semantics. It is a kill switch, not deferred consent, and nothing here should touch it.
  • No identifiers at hold time. Held records carry the event name, developer-supplied parameters, and the original timestamp. No IDFA, no app instance ID; identity is applied at flush from the state in effect then.
  • Bounded and discardable. A cap and a TTL are fine. Discard without uploading on a second setAnalyticsCollectionEnabled(false), on resetAnalyticsData(), and if consent never arrives. Nothing leaves the device until collection is enabled.
  • Automatic events during the disabled window can stay out of scope. Our own logEvent calls are the part we can currently lose and the part we most need.

There is precedent for exactly this inside Firebase: Crashlytics holds unsent reports on device across launches when automatic collection is disabled, specifically so an app can obtain one-time consent, then sendUnsentReports() or deleteUnsentReports().

We would file the mirror request against firebase-android-sdk and link it — we are not asking for iOS-only semantics.

API Proposal

Just keep logEvent can work even as setAnalyticsCollectionEnable(true)
Or, separate ads features to another SDK.

Firebase Product(s)

Analytics

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions