Crash reports and browser errors

Play this lesson

Who wrote a.b.a?

A crash report tells the studio which device crashed, on which version, with a stack trace: the list of functions that were running, down to the line of code.

Shard Garden 1.4.2 on Android starts crashing when players open the level editor. The stack trace says:

at a.b.a(Unknown Source:12)

That is real data, but nobody on the team wrote a function called a.

Why release builds lose their names

Turning the result back into names and line numbers is called symbolication, or deobfuscation on Android.

Quick check

A release build keeps every function name inside the app, so its crash reports can be read as they are.

Show the answer

False

Release builds shorten names (R8 on Android, minifiers on the web) or move symbols into separate files (dSYM on iPhone). The team must keep those files to read crashes.

iPhone: dSYM files

Apple's guide shows an unsymbolicated crash report: only hexadecimal addresses, like 0x1022c0000 + 49064. A fully symbolicated one shows function names on every frame.

An app and its dSYM share a build UUID, an ID they only share if they came from the same build. Building the same code with another Xcode version or other settings gives a different UUID. So keep the dSYM of every build you ship, and include symbols when uploading to App Store Connect.

Bug hunt

Kai, #crashes
We lost the 1.4.2 dSYM, so I rebuilt 1.4.2 from the same commit on the new Xcode. Symbolication still fails.

Why?

  1. The new build has a different build UUID, so its dSYM doesn't match the shipped app
  2. dSYM files only work on Android
  3. The crash happened in the level editor
Show the answer

The new build has a different build UUID, so its dSYM doesn't match the shipped app

An app and a dSYM only match with identical build UUIDs. A different Xcode version or different build settings make a different UUID, even from the same code.

Android and the web

On Android, R8 writes a mapping.txt file listing every old name next to its new one. It is overwritten on every build, so save a copy for each release. An Android App Bundle carries it to Google Play automatically, and the retrace tool turns a stack trace back into real names.

On the web, a source map maps minified code back to its original form. Shard Garden's build uploads its source maps to the studio's error tool instead of publishing them.

Release check

For an APK release, Play Console needs the mapping file uploaded by hand. Remy uploads it a day after 1.4.2 goes live. What happens?

  1. Only crashes from now on are deobfuscated in Play Console. Yesterday's stay unreadable there
  2. All of yesterday's crashes become readable too
  3. Play Console refuses the file because it is late
Show the answer

Only crashes from now on are deobfuscated in Play Console. Yesterday's stay unreadable there

Google's help page says only future crashes and ANRs for that version get deobfuscated. The team can still run retrace on yesterday's traces with the saved mapping.txt.

Errors that never crash

A player writes: "In the level editor, Publish does nothing." No crash, no server error. Priya needs real user monitoring (RUM): the game reports errors from each player's browser.

The browser fires error when a script has an error nothing catches, and unhandledrejection when a promise is rejected and nothing handles it. An error thrown inside an async function ends up as one. Shard Garden listens for both and sends each home with the build version.

Bug hunt

editor.js
window.addEventListener(  'error', report);async function publish(l) {  const res = await fetch(    '/api/levels',    { method: 'POST',      body: l });  if (!res.ok) throw    new Error('failed');}btn.onclick =  () => publish(current);

Failed publishes never reach Priya's reports. What is missing?

  1. A listener for unhandledrejection, because publish() is async and its error rejects a promise
  2. A second listener for error
  3. A shorter error message
Show the answer

A listener for unhandledrejection, because publish() is async and its error rejects a promise

An error thrown inside an async function rejects its promise. Nothing handles it, so the browser fires unhandledrejection, not error. Listen for both.

One visit, in order

Every event and error also carries a session ID, a random ID for one visit, so Priya can line up what happened: opened the editor, placed 40 shards, tapped Publish, error.

RUM runs on players' own devices, many of them children's. It never sends names, emails, chat or what players type. Events go in batches, with sendBeacon when the page is hidden.

And Remy's pipeline saves the symbol files of every release, so a crash from any build players still have can be read.

Bolt says: Next: exporting telemetry, and how long to keep it.

Fact-checked 12 October 2026