Symbolicator
iOS

iOS

For iOS, the debug info is the build's .dSYM (a Mach-O wrapper around DWARF). A stripped Swift or Objective-C release build keeps no function names or line tables in the shipped binary, so a crash reports against raw machine code. Each frame carries the image's Mach-O UUID, an offset into that image, and the image name:

Fatal Signal SIGTRAP (5)
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
os: ios arch: arm64
#00 8a5f0c2e1b7d4f93a6e0c8b1d2f3a4b5 0x00000000000a1340 MyApp
#01 8a5f0c2e1b7d4f93a6e0c8b1d2f3a4b5 0x00000000000a2d10 MyApp

Everything above the *** … separator is the error preamble, preserved verbatim in the symbolicated output. The os: … arch: … line picks the dSYM architecture slice to resolve against (arm64 when absent). The offset is image-relative (the frame address minus the image's load address), so it is independent of where the dynamic linker slid the image at runtime. With the build's .dSYM uploaded, that resolves into named source frames, inline calls expanded:

#0  ProfileViewModel.load() (ProfileViewModel.swift:88:14)
#1  ProfileView.body.getter (ProfileView.swift:142:20)
#2  closure #1 in AppDelegate.application(_:didFinishLaunchingWithOptions:) (AppDelegate.swift:24:9)

Symbolication runs server-side from the .dSYM you upload; the client never parses anything. This page covers the iOS-specific mechanics in the order you meet them: what a .dSYM is, how to upload one, how a non-symbolic trace is recognized, and how each frame resolves into source. For when symbolication runs, the cache, and the .tw format, see Architecture.

The .dSYM and why it ships separately

A Release build strips names and line tables out of the binary to shrink the app and protect your source. Xcode writes that stripped debug information into a separate .dSYM bundle per build, which ships to you instead of inside the app. Inside the bundle, the Mach-O binary lives at *.dSYM/Contents/Resources/DWARF/<ProductName> and carries the DWARF debug info plus the LC_UUID load command that anchors it to the build.

Mach-O builds carry no GNU build-id note, so the debug ID is the Mach-O UUID. A fat (universal) .dSYM holds one Mach-O slice per architecture you ship, each with its own UUID; the backend stores one entry per slice. Symbols are unique to each build, so upload on every release: a crash can only be symbolicated against the exact build it came from.

Uploading a .dSYM

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.

POST /api/symbols/upload
Authorization: Bearer <upload token>
Content-Type: multipart/form-data

Post the Mach-O binary from inside the .dSYM bundle, not the bundle directory:

curl -H "Authorization: Bearer $TRACEPATH_UPLOAD_TOKEN" \
  -F "files=@build/MyApp.xcarchive/dSYMs/MyApp.app.dSYM/Contents/Resources/DWARF/MyApp" \
  https://app.tracepath.dev/api/symbols/upload
FieldRequiredNotes
filesyesOne or more Mach-O dSYM binaries. Repeatable to upload several at once. 200 MB per file.

Unlike the Dart upload, iOS needs no arch or debug_id form fields. The backend auto-detects the Mach-O, reads the LC_UUID straight from the binary, and for a fat dSYM stores one entry per architecture slice. The stored key is content-addressed by UUID:

iossymbols/{projectId}/{uuid}.dsym

Because the key is content-addressed, releases never collide and re-uploading the same build is idempotent. The response reports how many uploaded files were accepted. A fat dSYM counts once, even though it stores one entry per slice:

{ "uploaded": 1 }

A file that is not a recognized symbol format is skipped without an error, so check that count against what you sent — a 0 from a successful-looking request means nothing matched, most often because something other than the Mach-O binary inside the bundle was posted.

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 at all, or a single file over 200 MB.
413The whole request exceeds 250 MB. Split the batch.
422A file is detected as Mach-O but is not a readable dSYM, 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.

This is the same /api/symbols/upload endpoint Dart and Android use. Dart uploads .symbols files and Android a mapping.txt; iOS just uploads the Mach-O dSYM, and the backend routes on the file's format.

An Xcode Run Script build phase that finds the dSYMs and posts them automatically on every Release build ships with the TracePath iOS SDK, which is not published yet. Until it is, the curl above is the whole contract — run it from your release job against the archive's dSYMs directory.

How an iOS trace is recognized

iOS symbolication runs on the /api/report ingest path, for projects created with the iOS framework. Every stack trace in such a report is scanned for non-symbolic iOS frames; a trace with at least one is resolved, and anything else passes through untouched. Crashes sent over OTLP are not routed here — that path covers JavaScript and Android.

A non-symbolic iOS frame has this form:

#00 8a5f0c2e1b7d4f93a6e0c8b1d2f3a4b5 0x00000000000a1340 MyApp

The parser pulls three things from each frame:

FieldSourceUsed for
debug IDthe 32-hex Mach-O UUIDmatching the trace to an uploaded .dSYM
offset0x<image-relative-offset>the address to resolve, relative to the image base
imagethe trailing image namegrouping frames by image

When crafting traces by hand (a custom sender, CI tooling), include the *** … separator line between the error message and the frames. The symbolicated output preserves everything above the first *** line as the error preamble. Without a separator, the entire raw trace (frame lines included) is treated as preamble and carried into the resolved output verbatim, duplicating the frames.

How an offset resolves

The matching key is the Mach-O UUID (the debug ID). Because the frame offset is image-relative rather than the absolute runtime address, no runtime slide needs to be sent: the original VM address is reconstructed from the dSYM itself.

  1. Reconstruct the program counter. The frame's offset is added to the dSYM's __TEXT segment VM address, pc = dSYM __TEXT VMAddr + offset. That VM address is what the lookup keys on, and it is the same regardless of where the image was slid at runtime.
  2. Look up the PC. A binary search over the resolver's range table finds the PC's entry and its span of frames. DWARF inline information means one machine address can stand for several source frames, so a single #N offset can expand into a chain of inlined calls, innermost first.
  3. Render. Resolved frames print as #N functionName (file:line:col). A frame with no match (a PC outside any known range, or a dSYM that was not uploaded) keeps its raw form, so the trace is never worse than it arrived.

If no dSYM is found for the build's UUID, every frame falls back to its raw offset form.

The flat format

The .dSYM is decoded into TracePath's compact .tw flat artifact and cached, the same family of encoding used for the JavaScript source maps and the Dart .symbols. As with Dart, the iOS .tw maps PC ranges to inline frame spans: building it flattens the DWARF debug info into a range table sorted by PC, where each entry points at a run of frames (more than one when calls were inlined), plus a frame table of (file, line, column, function) records with names interned.

The general properties (memory-mapped, zero-copy on little-endian hosts, a versioned cache artifact rebuilt from the original debug info) are the same as every .tw; see Architecture.

Related pages

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