Most crash reports are noise.

Kickgeist auto-filed 1,450 crash reports during a tournament it could not pause. Keeping that stream readable took more work than collecting it.

1,450reportsauto-filed by the crash pipeline
out of 1,598 issues over the life of the projectGitHub API, 5 August 2026
185reportsclosed by one pull request
one routing crash, four smaller faults behind itGitHub API, 5 August 2026

Wiring up crash reporting takes an afternoon. Keeping the stream readable is the 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 no maintenance window existed during a match. Its crash pipeline auto-filed 1,450 reports over the life of the project, 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, and that was a choice with a bill

The reporter is a service in the app rather than a vendor SDK. It captures through the framework’s global error handler, the platform dispatcher and a zone-level hook for asynchronous failures. Reports then route through an edge function into the project’s own issue tracker, labelled by crash, by auto-report and by platform.

The upside is that a crash arrives where the work already happens, so triage and planning are the same activity. Nobody exports anything and nobody reconciles two backlogs. The downside is that everything a vendor would have done for you is now yours. Most of that list has nothing to do with capturing errors.

Rate limiting is not optional

The first thing that breaks is volume. A bug on the launch path does not produce one report. It produces one per affected session, and a launch day produces a great many sessions. Without a limiter the tracker fills with a single crash, and it is now useless for every other purpose.

So the pipeline carries a rate limit and a session guard, so one run cannot file repeatedly. It carries 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.

Deduplication has to be structural

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 produces a hundred issues for one bug.

The 185-report fix shows what the fingerprint buys. The reports looked like several unrelated failures. Once grouped, they collapsed into a small number of buckets, and underneath was a notification and deep-link routing crash with four smaller faults behind it. One change, one release, 185 reports closed together.

The unglamorous half: reclassification

Here is the part we did not expect to spend weeks on. Much 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. Not one of them is actionable. Each had to be identified, understood well enough to be sure it was benign, and then reclassified as non-fatal, individually. That campaign ran for months alongside feature work, and it is the reason the stream stayed worth reading. A tracker that cries wolf gets muted, and a muted tracker is worse than none, because it still looks like coverage.

The whole thing has an off switch

The pipeline is gated by a database-backed feature flag, one of ten the product carries. Flipping a flag is a single database call, and the app picks it up within five minutes without a release.

That matters more than it sounds. The tournament’s operational calendar was a flag schedule rather than a deployment schedule.

  • Friendly fixtures on in March and 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 cannot 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 is the one control you cannot add during the incident that needs it.

What the release pipeline had to look like

Triaging fast 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 does not start a macOS runner. Versions are dates. A CalVer tag tells you when a build shipped, without looking anything up. That is the question you are 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 we would tell you 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 is not a feature. It is months of somebody deciding, one exception at a time, what is actually broken.

Tell us what is in the way.

Say what you're building and where it stopped.

hello@beevelop.com

Copy it into your mail client if that is easier.

The draft opens with

  1. What you are building
  2. What is in the way
  3. The stack
  4. When you want to start

The contact page says who answers, and what the reply contains.