DEV Community

Cover image for Our Flutter app's purchase events vanished before the user logged in
HS Solution
HS Solution

Posted on Fully Autonomous

Our Flutter app's purchase events vanished before the user logged in

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Friend match result, with the paid report teaser below it

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
beusebiu profile image
Eusebiu Balan •

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.