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:
- As the
proguard_uuidfield when you upload the mapping. - 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:
| Path | Where the UUID goes |
|---|---|
OTLP (/api/otel/v1/traces, /v1/logs) | the app.debug.proguard_uuid resource attribute |
/api/report | the 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-datacurl -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| Field | Required | Notes |
|---|---|---|
files | yes | The mapping.txt file. 200 MB per file. |
proguard_uuid | yes | The 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}.txtThe 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:
| Status | When |
|---|---|
401 | Missing Authorization header, or a token that is not an upload token for a live project. |
400 | No files field, a single file over 200 MB, or a missing proguard_uuid. |
413 | The whole request exceeds 250 MB. Split the batch. |
422 | The 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). |
503 | Too 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:
- Point the OpenTelemetry Android SDK's OTLP exporter at
https://ingest.tracepath.dev/api/otel, withAuthorization: Bearer <project token>. - Keep whatever already sets
app.debug.proguard_uuidon the resource. - Upload each build's mapping to
/api/symbols/uploadwith that UUID as theproguard_uuidfield.
Symbolication then happens at TracePath's ingest, with no collector in the path.
Related pages
- Architecture: when symbolication runs, the cache tiers, and the
.twformat - iOS and Dart: the flat-DWARF approach for native symbols
- Performance: what happens when an artifact is missing, and how to tell