Symbolicator
Android

Android

A release build of an Android app runs R8, which minifies and obfuscates the bytecode. When a Kotlin or Java exception is thrown out of that code, every class and method has been renamed and the line tables rewritten, so the trace arrives obfuscated:

a.b: card declined for $30.59
	at a.a.a(SourceFile:2)
	at com.example.app.MainActivity.a(SourceFile:3)

The trace is reversed with the build's mapping.txt. With it uploaded, the trace above resolves to source:

com.example.app.PaymentDeclinedException: card declined for $30.59
	at com.example.app.Checkout.chargeCard(Checkout.kt:5)
	at com.example.app.Checkout.applyTax(Checkout.kt:10)
	at com.example.app.Checkout.checkout(Checkout.kt:18)
	at com.example.app.MainActivity.crashKotlin(MainActivity.kt:33)

This page covers what mapping.txt says, how to upload it, how the build UUID ties a crash to its mapping, how a trace is recognized and resolved, and the compact .tw artifact each mapping compiles into. For the shared cache model and when symbolication runs, see Architecture.

R8: what mapping.txt says

A release build with minifyEnabled true runs R8, which shrinks, optimizes, and obfuscates the bytecode. It renames classes and methods to short tokens, inlines small methods into their callers, and rewrites the line-number table. The reverse map is the mapping.txt written to app/build/outputs/mapping/<variant>/mapping.txt.

Retracing reads three things from it: a class line maps the original fully qualified name to the obfuscated one; a sourceFile comment records the original file; and member lines map each method with line ranges on both sides. Because R8 inlines, one obfuscated frame can expand to several source frames, listed innermost first. The grouping hash is therefore computed after retrace, so inline expansion does not split one error into many issues.

The build UUID ties a crash to its mapping

A mapping.txt is only valid for the build that produced it, and nothing inside an obfuscated trace says which build it came from. That link is a UUID you choose per release and use in two places:

  1. As the proguard_uuid field when you upload the mapping.
  2. As the identifier your app reports with every crash.

Anything stable and unique per release works — a generated UUID stored with the release, or one derived from the version name and code. What matters is that the two sides match byte for byte: only hex digits and dashes are kept from the value, and the comparison is otherwise exact, so A1B2… and a1b2… are two different mappings.

Where the app reports it depends on the ingest path:

PathWhere the UUID goes
OTLP (/api/otel/v1/traces, /v1/logs)the app.debug.proguard_uuid resource attribute
/api/reportthe top-level proguardUuid field of the report body

Bump the UUID each release. The mapping is stored under the UUID, so reusing one overwrites the previous release's mapping and its crashes stop resolving.

Uploading mapping.txt

Uploads go to the API host, authenticated with your project's Upload Token. Open the project's Connection page in the dashboard to copy it (generate one if the project has none). It is a separate credential from the ingest token your app runs with, and it is the only credential this endpoint accepts — using the ingest token here is rejected with a 401.

POST /api/symbols/upload
Authorization: Bearer <upload token>
Content-Type: multipart/form-data
curl -H "Authorization: Bearer $TRACEPATH_UPLOAD_TOKEN" \
  -F "files=@app/build/outputs/mapping/release/mapping.txt" \
  -F "proguard_uuid=$TRACEPATH_PROGUARD_UUID" \
  https://app.tracepath.dev/api/symbols/upload
FieldRequiredNotes
filesyesThe mapping.txt file. 200 MB per file.
proguard_uuidyesThe build UUID. Applies to every file in the request, so upload one build per request.

The backend recognizes an R8 mapping by its header and class lines. The stored key is:

androidmappings/{projectId}/{proguardUuid}.txt

The response reports how many files were stored:

{ "uploaded": 1 }

An unrecognized file is skipped without an error, so check that count against what you sent. Failure modes worth handling in CI:

StatusWhen
401Missing Authorization header, or a token that is not an upload token for a live project.
400No files field, a single file over 200 MB, or a missing proguard_uuid.
413The whole request exceeds 250 MB. Split the batch.
422The proguard_uuid contains no usable characters, the file is not a valid R8/ProGuard mapping, or the request carries too many parts (the limit is around a thousand).
503Too many uploads in flight at once. The request waited for a slot and gave up; retry it.

Wire this into the release job right after assembleRelease, with the token from a secret and the same UUID the app was built with. A Gradle plugin that injects the UUID and runs the upload for you ships with the TracePath Android SDK, which is not published yet; until then the call above is the whole contract.

How a trace is recognized and resolved

Android symbolication runs on both ingest paths: over OTLP when the resource attribute telemetry.sdk.language is android, java, or kotlin, and on /api/report for projects created with the Android framework. In both cases the mapping is selected by the build UUID, and a report without one is stored as it arrived.

  • A at pkg.Class.method(File:line) frame is retraced through the build's mapping, expanding inlined frames.
  • Any other line passes through; obfuscated class tokens in the exception header and Caused by: lines are deobfuscated too.

A maximum of 50 frames are emitted, inline expansions included. A frame with no match keeps its raw form, so a trace is never worse than it arrived.

The flat format

R8 mapping.txt is plain text and cheap to parse, but the engine never retraces against the text twice. On the first crash for a build the mapping is compiled once and reused: in memory mode the parsed mapping is held on the heap, and where the on-disk tier is enabled it is compiled into the same compact .tw flat artifact used for JavaScript source maps and native dSYM/DWARF symbols, and retraced straight off the memory-mapped file.

Where the other languages' .tw files map an address (a bundle position, a program counter) to a span of frames, the R8 .tw maps an obfuscated (class, method, line) to its original frames. It holds two sorted tables, both binary-searchable, with every class, method, and file name interned once:

  • A class table, sorted by obfuscated class name, mapping each token to its original fully qualified name and source file.
  • A member table, sorted within each class by obfuscated method name. Each record carries the original class, method, and file, plus the obfuscated and original line ranges. One obfuscated method can map to several records, because R8 inlines small methods into their callers, so a single frame resolves to a chain of source frames innermost first, with the obfuscated line selecting which records apply. Synthesized members are flagged and skipped.

A lookup binary-searches the class table, then the member table, then filters by line range, so resolution stays a handful of comparisons no matter how large the mapping is. The compiled form rides the same cache tiers as every other language and rebuilds automatically when the format version is bumped.

Migrating from Honeycomb

Honeycomb symbolicates Android in its OpenTelemetry Collector, keying ProGuard mappings by the app.debug.proguard_uuid resource attribute and storing them as <uuid>.txt. TracePath reads that same attribute and the same <uuid>.txt naming on the OTLP path, so a migrating app does not change its instrumentation.

To move an app across:

  1. Point the OpenTelemetry Android SDK's OTLP exporter at https://ingest.tracepath.dev/api/otel, with Authorization: Bearer <project token>.
  2. Keep whatever already sets app.debug.proguard_uuid on the resource.
  3. Upload each build's mapping to /api/symbols/upload with that UUID as the proguard_uuid field.

Symbolication then happens at TracePath's ingest, with no collector in the path.

Related pages

  • Architecture: when symbolication runs, the cache tiers, and the .tw format
  • iOS and Dart: the flat-DWARF approach for native symbols
  • Performance: what happens when an artifact is missing, and how to tell