Crash stacks arrive as addresses. Upload each build’s dSYMs from CI and Union names every frame of your own code — in the stack, in the issue title, in alerts.
What an upload does
The Union SDK sends each frame as a binary UUID and an offset. With the dSYM for that UUID, Union shows the function instead: CheckoutViewModel.submit() + 44 rather than MyApp +0x223f4. Issues that already exist are renamed when their dSYM arrives, so an upload that lands a day after the crash still helps.
An upload does not merge issues across builds. Union groups a crash by where it happened in one build, and that stays true with names — so an issue still covers one build, and a rebuilt app with the same bug opens a new one. Function names today; file and line come later from the same upload, without asking you to upload again.
Get a server key
In your project, open Settings → Server keys and create a key (sk_…). Store it in your CI’s secret store as UNION_SERVER_KEY.
av_…) ships inside every copy of the app. Accepting it here would let anyone who unpacked your IPA rename your stack traces, so uploads need a server key.Xcode Cloud
Add ci_scripts/ci_post_xcodebuild.sh next to your project and make it executable (chmod +x). Add UNION_SERVER_KEY to the workflow as a secret environment variable. Runs after every archive action.
#!/bin/sh
set -e
if [ "$CI_XCODEBUILD_ACTION" = "archive" ]; then
curl -fsSL https://useunion.dev/dsyms/v2.sh -o "/tmp/union-dsyms.sh" \
&& echo "90401dd15c53b179127a03eb1ffb47c6dd24d3937a755fd9816712ee6434e521 /tmp/union-dsyms.sh" | shasum -a 256 -c - \
&& bash "/tmp/union-dsyms.sh" "$CI_ARCHIVE_PATH"
fifastlane
After build_app (gym), which leaves the zipped dSYMs at DSYM_OUTPUT_PATH. Set UNION_SERVER_KEY in your CI secrets.
lane :release do
build_app(scheme: "MyApp")
sh(<<~SH)
curl -fsSL https://useunion.dev/dsyms/v2.sh -o "/tmp/union-dsyms.sh" \
&& echo "90401dd15c53b179127a03eb1ffb47c6dd24d3937a755fd9816712ee6434e521 /tmp/union-dsyms.sh" | shasum -a 256 -c - \
&& bash "/tmp/union-dsyms.sh" "#{lane_context[SharedValues::DSYM_OUTPUT_PATH]}"
SH
upload_to_testflight
endXcode build phase
Add a Run Script phase as the last phase of the app target, with ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME} as an input file. Release builds only. Turn off User Script Sandboxing for the target (the script reads the dSYM and uses the network), and provide UNION_SERVER_KEY from the environment — never paste it into the project.
[ "$CONFIGURATION" = "Release" ] || exit 0 curl -fsSL https://useunion.dev/dsyms/v2.sh -o "$DERIVED_FILE_DIR/union-dsyms.sh" \ && echo "90401dd15c53b179127a03eb1ffb47c6dd24d3937a755fd9816712ee6434e521 $DERIVED_FILE_DIR/union-dsyms.sh" | shasum -a 256 -c - \ && bash "$DERIVED_FILE_DIR/union-dsyms.sh" "$DWARF_DSYM_FOLDER_PATH"
By hand
For a build that shipped before you set this up, run the script on the archive yourself. Xcode keeps archives in Window → Organizer; right-click one and choose Show in Finder.
UNION_SERVER_KEY=sk_your_server_key \ bash v2.sh ~/Library/Developer/Xcode/Archives/2026-09-28/MyApp.xcarchive
Or let the script find it. Settings → Debug symbols → Missing dSYMs lists the binary UUIDs your crashes are in that no upload names yet, and gives one command carrying all of them. With --uuid and no path, the script looks each one up through Spotlight, then in Xcode’s Archives and DerivedData, uploads what it finds and names what it could not. With a path, it uploads only the dSYMs in it that hold those UUIDs.
UNION_SERVER_KEY=sk_your_server_key \ bash v2.sh --uuid 6474C08F92D03410BFAA668957E3F700
The script
https://useunion.dev/dsyms/v2.sh runs on the Mac that built your app. For each binary in each dSYM it reads the function table with Apple’s own tools (dwarfdump, nm, swift-demangle), uploads it, and archives the dSYM itself. Binaries Union already has are skipped, so running it on every build costs one request per binary.
The file is versioned: v2.sh never changes behaviour, so what runs in your pipeline is what you reviewed (v1.sh stays published for pipelines that pinned it; v2 adds --uuid and is otherwise the same). Every snippet above checks it against its SHA-256 before running it:
90401dd15c53b179127a03eb1ffb47c6dd24d3937a755fd9816712ee6434e521
| Option | What it does |
|---|---|
--uuid | Upload only the dSYMs holding this binary UUID (repeatable). Without a path, find them on this Mac. |
--no-raw | Upload the function tables only, not the dSYM itself. File and line will not be available for these builds later. |
--strict | Exit non-zero when an upload fails. Without it, a failure is a warning and never fails your build. |
--dry-run | Extract and write the tables to the current folder without uploading. Needs no key. |
Check it worked
Settings → Debug symbols lists every binary Union has, with its UUID. A crash is named only when its binary carries exactly that UUID — a dSYM from a different build of the same version will not match, and the crash page says which UUID it is waiting for.