Note
Most crash reports are noise.
Kickgeist filed 1,450 crash reports on its own during a tournament we couldn't pause. Keeping that stream readable took more work than collecting it.
Wiring up crash reporting takes an afternoon. Keeping the stream readable is the actual work, and it decides whether anybody still opens it in week three.
Kickgeist ran live through the 2026 World Cup, 11 June to 19 July. The calendar was fixed and there was no maintenance window during a match. Over the life of the project the crash pipeline auto-filed 1,450 reports, out of 1,598 issues in total. Both figures come from the GitHub API on 5 August 2026. One pull request during the knockout rounds closed 185 of them.
We built our own reporter, and paid for it
The reporter is a service inside the app rather than a vendor SDK. It hooks the framework’s global error handler, the platform dispatcher and a zone-level hook for asynchronous failures. Reports go through an edge function into the project’s own issue tracker, labelled by crash, by auto-report and by platform.
The upside: a crash lands where the work already happens, so triage and planning are the same activity. Nobody exports anything and nobody reconciles two backlogs. The downside: everything a vendor would have done for you is now yours to do. Most of that list has nothing to do with capturing errors.
Rate limiting comes first
Volume breaks first. A bug on the launch path doesn’t produce one report. It produces one per affected session, and launch day produces a lot of sessions. Without a limiter the tracker fills up with a single crash and is useless for everything else.
So the pipeline has a rate limit and a session guard, so one run can’t file repeatedly. It has an attempt cap, and exponential backoff for reports queued while a device was offline. None of that improves error capture. All of it keeps the output readable on the one day that matters.
Deduplicate on structure, never on text
Reports are grouped by a fingerprint computed from the stack frames, not from the message text. Message text carries device names, locale strings and numbers, so grouping on it gives you a hundred issues for one bug.
The 185-report fix shows what the fingerprint buys. On the surface those reports looked like several unrelated failures. Grouped, they collapsed into a handful of buckets, and underneath was one notification and deep-link routing crash with four smaller faults behind it. One change, one release, 185 reports closed together.
The unglamorous half: reclassification
This is the part we didn’t expect to spend weeks on. A lot of what an error pipeline captures on mobile is not a defect in your product.
- Transient image-cache database hiccups on iOS.
- “Device full” errors.
- Type mismatches raised by the messaging library’s background isolate.
- Sliver errors from a layout that collapsed to zero height mid-transition.
Every one of those is a real exception. None of them is actionable. Each had to be identified, understood well enough to be sure it was benign, then reclassified as non-fatal, one at a time. That campaign ran for months alongside feature work, and it is the reason the stream stayed worth opening. A tracker that cries wolf gets muted. A muted tracker is worse than none, because it still looks like coverage.
Everything has an off switch
The pipeline sits behind a database-backed feature flag, one of ten in the product. Flipping a flag is a single database call, and the app picks it up within five minutes without a release.
That mattered more than it sounds. The tournament’s operational calendar was a flag schedule, not a deployment schedule.
- Friendly fixtures on in March, off at kickoff.
- The tournament flag on for 11 June.
- Each sync job independently killable, and advertising killable without an app update.
An instrument you can’t switch off will eventually take the product down with it. The same instinct produced a forced-update gate that shipped months early and blocked nobody. It exists because it’s the one control you can’t add during the incident that needs it.
Fast triage needs fast shipping
Triaging quickly is pointless if shipping is slow. Kickgeist shipped 92 releases between 30 December 2025 and the end of the tournament, 18 of them in January 2026 alone. Those releases carry 417 merged pull requests, gated by 2,032 automated tests and 231 golden images.
CI runs 14 jobs, path-filtered so a landing-page typo doesn’t start a macOS runner. Versions are dates. A CalVer tag tells you when a build shipped without looking anything up, which is the question you’re actually asking late on a match night. Store delivery is scripted end to end, 15 lanes for iOS and 17 for Android, so a fix is a tag rather than an afternoon.
What to copy
Build the limiter before the reporter. Fingerprint on structure, never on text. Put a kill switch on your own instrument. And budget real time for reclassification, because the distance between 1,450 reports and one signal isn’t a feature. It’s months of somebody deciding, one exception at a time, what is actually broken.

