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

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
}

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.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.