"Status: Ok" Was a Lie: Debugging Analytics Events That Vanish After You Send Them

"Status: Ok" Was a Lie: Debugging Analytics Events That Vanish After You Send Them

The Tap That Went Nowhere

The button worked. The SDK log said the event was queued, then flushed. The server came back with {"status":"Ok"}. The dashboard was empty. I was adding analytics to a journal screen in a Flutter app - nothing exotic, just a handful of events for the interactions that actually mattered, like opening a day's conversation. Then came the report every instrumentation task eventually produces: "I tapped it, and I don't see anything." My first assumption was the obvious one, that the code never fired, so I turned on verbose SDK logging and tapped again while watching the device log. Event queued. Batch flushed. Two hundred. And still nothing on the other end. That gap - between an SDK swearing it delivered and a dashboard that has never heard of you - is where a surprising amount of analytics debugging lives, and closing it taught me something uncomfortable about what an acknowledgement actually acknowledges.

What a 200 Actually Promises

Analytics ingest endpoints are built for volume, and that shapes what their response can honestly promise. They accept a payload, check it is well-formed, return immediately, and process it afterwards. So a 200 tells you the request left your device and arrived somewhere structurally valid. It tells you nothing about which project the events were filed under, because that is decided by the write key inside the payload, not by the URL you posted to. Our build was carrying a key from a local environment file that had quietly drifted out of date - it belonged to an older project under the same organisation. Same SDK, same host, same account, same reassuring 200. Different destination. Every tap had been recorded faithfully into a place nobody was watching. What made it worse is that the log was not empty: the SDK emits internal events of its own, and those flow regardless of how badly the app's own analytics layer is wired. Seeing traffic in the log felt like proof. It was proof of the transport, not of the thing I actually cared about.

The Key Was in the Binary. The App Still Ignored It.

Fixing that should have been one line - point the build at the right key. It was not. I rebuilt, relaunched, and the app sent the old key again. So I unzipped the APK and searched the compiled Dart kernel for the new value. It was in there. Shipped, present, and completely unused. The cause turned out to sit in the configuration layer rather than anywhere near analytics. Settings resolved in the order you would expect - a remote override first, then a locally cached value, then the default compiled into the binary - but the local cache had been seeded on the app's very first launch with a copy of every compiled default. Because the cache is consulted before the default, whatever the binary happened to hold on day one was frozen into storage and outranked every build afterwards. The analytics key was only the symptom. The real defect was far bigger: every compiled default arrives through a build-time define, which meant rotating a credential or changing an API base URL could ship perfectly and do absolutely nothing on any device that had already been opened once. The fix was to stop caching values the binary already carries, since that cache exists to survive being offline with remote config - not to preserve the first build a user ever installed.

Four Green Signals, None of Them the Right One

What makes this worth writing down is the order in which the checks failed. The unit tests passed, because they assert that a call happens, not where it arrives. The device log said flushed. The server said Ok. The key was verifiably inside the binary. Four signals, all green, every one of them true, and not one of them answering the actual question. Only a single check could have failed honestly: tapping through the app on a real device and then querying the analytics backend for those specific events. Instrumentation is unusual in this respect. For most features the evidence lives inside your process, so a test can hold it. For analytics the evidence lives in someone else's database, which means verification that stops at your process boundary stops one step short of the only thing you care about. One small change made that loop bearable - configuring non-release builds to flush a single event at a time instead of batching, so "I tapped and nothing arrived" can never be confused with "the batch isn't full yet."

Instrument for the Proof, Not Just the Event

The habit I walked away with is to design for the verification, not just for the emission. Give events stable, searchable names and keep that vocabulary next to the code that fires it, so a name cannot quietly drift away from the thing it describes. Stamp every build with something you can filter on - the environment, the build number - so you can find your own taps inside a stream of real traffic. Then press the button on a real device and go look at the destination. The few minutes that costs is far cheaper than the alternative, which is a chart your team trusts for a month before anyone notices the line has been flat since release. An acknowledgement is a statement about a request. Visibility is a statement about a system. They are not the same claim - and only one of them is the claim you are making when you tell your team a feature is instrumented.