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 (6)
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.
The 'weeks' part is what stings: nothing errors, the count is just quietly lower than reality. A suppression list with a short allow-list is the same shape as our early return, since whatever nobody thought to list gets dropped without a trace. Did you end up adding a counter for suppressed mails, or just inverting the default?
Neither, the fix was smaller. The verification mail went on the allow-list, and its subject now sits in one constant that both the mail and the filter read, so the two can't drift apart again.
The flag also clears itself now. A verification link only works for the current address, so a click on it means mail arrives.
No counter. There was a log line for every blocked mail the whole time, at info level... it didn't save me 😅
One shared constant is the nicer fix: it removes the drift instead of hoping someone notices it. And a log line that sat there at info level the whole time is painfully relatable.
Moving the test hook after the live check is the real lesson here. A hook placed before the early return proves the code runs, not that the event is sent. Two small things we'd consider: if the app is killed before sign-in, the in-memory queue is lost, so saving the pending list to disk might be worth it for billing events. And when the cap of 20 is hit, counting the dropped events somewhere would stop that limit from becoming a new silent drop. Clear write-up, thanks for sharing.
Agreed on the hook placement; a tap before the early return only proves the call site runs, not that anything was sent. Both of your suggestions are fair and neither is in our code: the queue is in-memory only, so a kill before sign-in loses it, and past 20 we drop silently, which is the same kind of silent drop the fix was meant to remove. The queue only holds a few startup events, so persisting it would be the bigger change of the two.