Our billing code logged store_ready on every cold start.
On some starts, that event never reached the server.
The billing layer was fine. Our own analytics wrapper was throwing the events away. This is a short write-up of that bug from our Flutter fortune app, and the small fix.
The setup
The app has a small analytics wrapper, Backend.event(name, props). It only sends when the backend session is live:
bool get _live => ready && _api.usable;
Future<void> event(String name, [Map<String, dynamic> props = const {}]) async {
if (!_live) return; // no session yet -> drop
// ...send
}
For screen events that is fine. A user who has not signed in yet has not seen a screen worth measuring.
The billing layer is different. On a cold start it queries the store for products right away, with a retry loop. When that query finishes it logs store_ready (how many products, which attempt) or store_missing (which product IDs, which error code).
On a fast phone that query can finish before sign-in does. So the most important events in the payment funnel hit if (!_live) return; and disappeared. Nothing crashed and nothing warned us. The payment funnel was simply missing its first step for those users.
The fix: hold, then flush once
We kept event() as it was and added a second entry point for code that runs before login:
final _pending = <(String, Map<String, dynamic>)>[];
Future<void> eventWhenLive(String name, [Map<String, dynamic> props = const {}]) async {
if (_live) return event(name, props);
if (_pending.length < 20) _pending.add((name, props));
}
void flushPending() {
if (!_live || _pending.isEmpty) return;
final list = List.of(_pending);
_pending.clear();
for (final e in list) {
unawaited(event(e.$1, e.$2));
}
}
flushPending() is called once, right after sign-in succeeds. The billing code switched from event('store_ready', ...) to eventWhenLive('store_ready', ...). Same for store_missing.
A few choices on purpose:
- Cap at 20. If sign-in never happens (offline, server down), we don't grow a list forever.
- Copy, then clear, then send. Clearing first means a second flush can't send the same event twice.
- Only the billing calls use it. Screen events still drop before login. Queuing everything would hide other ordering bugs.
Testing the "before login" case
Our test hook used to sit before the live check, so tests saw events that production never sent. That is how the bug passed tests. We moved the tap after the check and added a switch to force the login state:
@visibleForTesting
static bool? debugLive;
bool get _live => debugLive ?? (ready && _api.usable);
The new test sets debugLive = false, triggers a store load, checks nothing was sent, flips to true, calls flushPending() twice, and checks store_ready arrived exactly once.
What I'd check in your app
If your analytics wrapper has an early return when there is no user or session, list every event that can fire during startup: store/product queries, remote config, push token, deep links. Any of them can finish before auth. A test hook placed before that return will hide it.
The app is Starlit Alley: Tarot & Saju on Android (free, optional paid reports): Google Play
Written with AI assistance from our own commit and code; the code above is from the app.

Top comments (1)
Had the mail version of that early return. A filter in front of my outgoing email drops everything for an address flagged as bounced, except two subjects it lets through, matched by text. The verification email's subject wasn't on the list, so for an account flagged by mistake every "resend" was dropped without a word. For weeks.