Guide

Upload dSYMs

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.

Worth knowing
The app’s client key is refused
The key in your app build (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"
fi

fastlane

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
end

Xcode 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
Worth knowing
A lost dSYM cannot be rebuilt
Only the dSYM of the exact build that shipped matches. Rebuilding the same commit produces new UUIDs, so a dSYM that is gone cannot be recreated — crashes in that build keep their addresses.

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
OptionWhat it does
--uuidUpload only the dSYMs holding this binary UUID (repeatable). Without a path, find them on this Mac.
--no-rawUpload the function tables only, not the dSYM itself. File and line will not be available for these builds later.
--strictExit non-zero when an upload fails. Without it, a failure is a warning and never fails your build.
--dry-runExtract 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.