Symbolicate .NET MAUI Crashes: dSYMs, R8 Mapping, and Sentry Upload (2026)

Map stripped .NET MAUI 10 crashes back to source: where dSYMs and R8 mapping.txt land, how to upload to Sentry and Crashlytics, and how to automate it in CI without shipping unsymbolicated builds.

Symbolicate .NET MAUI Crashes (2026)

Updated: June 29, 2026

Symbolicating .NET MAUI crashes means mapping the stripped, obfuscated frames in your release binary back to the source file, line number, and method that produced them, using dSYM bundles on iOS and the R8/ProGuard mapping.txt on Android. Most teams skip this part, and then they stare at 0x00000001045d6e3c in a Sentry issue trying to guess what broke. Honestly, I've been there. In this guide I'll walk through where .NET MAUI 10 puts symbol files, how to upload them to Sentry and Crashlytics, and how to wire the whole thing into your CI pipeline so nothing ships unsymbolicated.

  • iOS Release builds of .NET MAUI 10 produce a .app.dSYM bundle next to the .ipa. You must upload it to your crash reporter before the build's traces become readable.
  • Android Release builds with R8 enabled (the default for .NET 10) emit mapping.txt under obj/Release/<tfm>/android/proguard/. Upload it per build, not per app version.
  • Sentry uses sentry-cli debug-files upload for both platforms. Crashlytics uses the Firebase Gradle plugin or fastlane upload_symbols_to_crashlytics.
  • NativeAOT and full trimming change the stack-trace shape. You'll see compiled method tokens instead of managed names if symbols aren't uploaded.
  • Android ANRs need a separate path. Native ANRs come from libc frames that require NDK symbols, not your mapping.txt.
  • Automate symbol upload in CI on every release. Manual uploads drift, and the version that crashed in production is never the one you have locally.

Why .NET MAUI crashes need symbolication

When you ship a Release build, the compiler strips debug information so the binary is smaller and harder to reverse-engineer. On iOS, the linker pulls debug data out of the Mach-O binary into a separate .dSYM bundle. On Android with .NET 10, R8 obfuscates class and method names (so UserService.LoginAsync becomes a.b.c) and writes the reverse map to mapping.txt. AOT compilation on iOS adds another wrinkle: managed methods turn into native frames that look nothing like your C#.

Without these symbol files, a crash report looks like a stack of memory addresses. With them, your crash reporter can resolve every frame back to MyApp.ViewModels.LoginViewModel.OnLoginClicked(LoginPage.xaml.cs:42). The difference is "I know exactly what broke" versus "we can repro it sometimes if the user can remember what they tapped."

Symbol files are build-unique. A dSYM from build 142 won't symbolicate a crash from build 143, even if the source code is identical, because LLVM embeds a UUID at link time. This is why every CI run must upload symbols. You cannot regenerate them after the fact unless you've kept the entire build artifact, and even then a single tool-version drift will make UUIDs mismatch. Treat symbol files the same way you treat the binary itself: build once, archive forever.

What is a dSYM and where does .NET MAUI put it?

A dSYM ("debug symbols") is a bundle Apple's toolchain creates alongside any compiled iOS, macOS, or Mac Catalyst binary. It's technically a directory, not a file. Finder shows it as a single icon, but on disk it's MyApp.app.dSYM/Contents/Resources/DWARF/MyApp. The inner DWARF file holds the address-to-source mapping. Each dSYM is tagged with the same UUID as the Mach-O binary it pairs with; run dwarfdump --uuid MyApp.app.dSYM to see it.

For a .NET MAUI 10 iOS Release build, dSYMs land in your output directory next to the .ipa. The default path for a single-architecture build is:

bin/Release/net10.0-ios/ios-arm64/MyApp.app.dSYM
bin/Release/net10.0-ios/ios-arm64/publish/MyApp.ipa

If you publish with dotnet publish -f net10.0-ios -c Release, the dSYM is preserved. If you build through Visual Studio's Archive workflow, you'll find it inside the Xcode Archives folder at ~/Library/Developer/Xcode/Archives/<date>/<archive>.xcarchive/dSYMs/. NativeAOT builds produce an additional dSYM for the AOT-compiled native code, and both must be uploaded together or AOT frames stay unresolved.

Zip the whole .dSYM bundle, not just the inner DWARF file, before uploading. Every tool I've used (Sentry CLI, Firebase, App Store Connect API) expects the bundle structure intact. cd bin/Release/net10.0-ios/ios-arm64 && zip -r MyApp.app.dSYM.zip MyApp.app.dSYM is the canonical pattern. If you upload only the DWARF file, the tools sometimes accept it silently and then fail to resolve frames in production. That's exactly the failure mode you can't catch without testing a real crash.

R8, ProGuard, and the Android mapping.txt file

R8 is Google's replacement for ProGuard. It does shrinking, obfuscation, and optimization in one pass and ships in the Android Gradle Plugin. In .NET MAUI 10, R8 is enabled by default for Release configurations and produces a mapping.txt file at:

obj/Release/net10.0-android/android/proguard/mapping.txt
obj/Release/net10.0-android/android/proguard/seeds.txt
obj/Release/net10.0-android/android/proguard/usage.txt

Each line of mapping.txt reverses one obfuscation: com.example.UserService -> a.b.c:. Without this file, every Android crash that touched obfuscated bytecode (which is most of them) shows up as alphabet soup. R8 also rewrites line numbers, so even unobfuscated methods need the mapping to recover accurate source positions.

You can verify R8 ran by checking your .csproj for <AndroidLinkTool>r8</AndroidLinkTool> and <AndroidEnableProguard>true</AndroidEnableProguard>. If you turned R8 off to dodge a crash on launch, your obfuscation is also off, and that's a different problem worth fixing properly. The .NET MAUI app size optimization guide covers R8 keep-rules for reflection-heavy code without disabling it wholesale.

Unlike dSYMs, the Android mapping file is identified by the app's versionCode and versionName rather than a UUID. This means re-running the build for the same version produces a different mapping (because R8 is non-deterministic by default), and uploads will get rejected as duplicates or silently overwritten. Pin versions to your CI build number and treat mapping.txt as a build artifact, not a developer convenience. I learned this the hard way when a hotfix rebuild silently invalidated a week of crash data for the affected version.

Uploading dSYMs and mapping files to Sentry

Sentry's CLI handles both platforms with one binary. Install it once per CI runner and authenticate with SENTRY_AUTH_TOKEN in your environment. The Sentry debug files documentation covers the upload subcommand in detail, but the .NET MAUI workflow boils down to three steps.

For iOS, after dotnet publish:

sentry-cli debug-files upload \
  --org my-org --project mobile-app \
  --include-sources \
  bin/Release/net10.0-ios/ios-arm64/MyApp.app.dSYM

The --include-sources flag bundles your C# source into the upload so Sentry can show source context next to each frame. Incredibly useful for triage, and the storage cost is negligible for most projects. For Android:

sentry-cli debug-files upload \
  --org my-org --project mobile-app \
  --type proguard \
  obj/Release/net10.0-android/android/proguard/mapping.txt

The --type proguard tells Sentry to register the file as a ProGuard/R8 map and link it to the io.sentry.ProguardUuids AndroidManifest meta-data. Critical: your app's Sentry SDK init must include the same UUID, otherwise the server has the mapping but doesn't know which app version it belongs to. The Sentry .NET SDK handles this automatically if you use the MSBuild integration, but verify with sentry-cli info after upload. Confirm the UUID printed in the upload output matches the meta-data baked into the AAB you submitted to Play Console.

Uploading symbols to Firebase Crashlytics

Crashlytics splits the workflow by platform. On iOS, the Firebase iOS SDK auto-uploads dSYMs during the build if you've included its run-script phase, but .NET MAUI doesn't use Xcode's build phases, so you upload manually using the Firebase upload-symbols tool:

./upload-symbols \
  -gsp GoogleService-Info.plist \
  -p ios \
  bin/Release/net10.0-ios/ios-arm64/MyApp.app.dSYM

The upload-symbols binary ships inside the FirebaseCrashlytics CocoaPod. If you don't use CocoaPods (most MAUI teams don't), download it from the Firebase iOS SDK GitHub releases and check it into your repo. On Android, the Firebase Crashlytics Gradle plugin handles mapping uploads, but again, MAUI doesn't run Gradle the same way. The cleanest workaround is Fastlane:

# Fastfile
lane :upload_android_mapping do
  upload_symbols_to_crashlytics(
    binary_path: "./tools/upload-symbols",
    gsp_path: "./google-services.json",
    mapping_path: "obj/Release/net10.0-android/android/proguard/mapping.txt",
    app_id: "1:1234567890:android:abcdef"
  )
end

The app_id must match the Firebase app's App ID (the mobilesdk_app_id in google-services.json), not your Android applicationId. Mixing those up is the most common reason uploads silently no-op. If you're evaluating Crashlytics against alternatives, the crash reporting comparison guide walks through Sentry, Crashlytics, and Raygun side-by-side post App Center shutdown.

ANRs, native frames, and the NDK gap

An ANR (Application Not Responding) fires on Android when the UI thread is blocked for over 5 seconds. ANRs are not crashes (the app keeps running), but they show up in Play Console under the same Vitals dashboard, and Google ranks your app on the combined ANR + crash rate. ANR stack traces dump the state of every thread and frequently bottom out in native frames inside libart.so, libc.so, or your AOT-compiled MAUI runtime.

R8's mapping.txt does nothing for these native frames. To symbolicate native code on Android you need the original .so files with debug symbols intact, which .NET MAUI typically strips during AAB packaging. The fix is to publish your AAB with <AndroidPackageNativeLibraries>true</AndroidPackageNativeLibraries> and upload the unstripped libraries separately to Play Console's Native Debug Symbols section.

On iOS, the equivalent issue is "main thread hangs". Apple's MetricKit reports them but their stack frames are inside system libraries (UIKit, Foundation, your AOT-compiled MAUI managed code). Apple's symbol servers cover the system frames automatically; your dSYMs cover the MAUI ones. If you only upload the main app's dSYM and skip the AOT dSYM, hangs will look like they all bottom out in raw native code. The general rule: whenever your crash report shows addresses inside an executable you compiled, you owe the symbol file for that executable.

Automating symbol upload in GitHub Actions

Symbol uploads must happen on the same CI run that built the binary you shipped to users. Doing it later from a developer machine almost always fails (different toolchain version, different commit, missing intermediate files). Here's a top-down workflow that builds, uploads symbols, then ships. Adapt the action versions to whatever's current when you read this.

name: release-mobile
on:
  push:
    tags: ['v*']

jobs:
  ios:
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '10.0.x' }

      - name: Install workloads
        run: dotnet workload install maui-ios

      - name: Publish iOS Release
        run: |
          dotnet publish -f net10.0-ios -c Release \
            -p:ArchiveOnBuild=true \
            -p:RuntimeIdentifier=ios-arm64

      - name: Upload dSYMs to Sentry
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
        run: |
          npx @sentry/cli debug-files upload \
            --org ${{ vars.SENTRY_ORG }} \
            --project mobile-app \
            --include-sources \
            bin/Release/net10.0-ios/ios-arm64/MyApp.app.dSYM

      - name: Upload to TestFlight
        run: # your altool / app-store-connect-api step

  android:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '10.0.x' }
      - run: dotnet workload install maui-android

      - name: Publish Android Release (AAB)
        run: |
          dotnet publish -f net10.0-android -c Release \
            -p:AndroidPackageFormat=aab

      - name: Upload mapping.txt to Sentry
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
        run: |
          npx @sentry/cli debug-files upload \
            --org ${{ vars.SENTRY_ORG }} \
            --project mobile-app \
            --type proguard \
            obj/Release/net10.0-android/android/proguard/mapping.txt

      - name: Upload native symbols to Play
        run: # bundletool + Play Developer API

A few things I've learned the hard way running this in production. The Sentry CLI exits 0 even if no debug files were found, so you can ship a build with zero symbol coverage and your CI will still look green. Add --no-zips --wait and pipe the output through a check that asserts at least one file uploaded. If you're on Azure DevOps instead, the .NET MAUI CI/CD setup guide covers the equivalent pipeline tasks. Pair this with the signing workflow in the code signing for CI/CD article and you have a complete release pipeline.

Verification checklist and common gotchas

I keep this checklist pinned in our team's runbook. Run through it after any toolchain bump, any change to AndroidLinkTool, and any time you flip on NativeAOT.

  1. dSYM UUID matches binary UUID. Run dwarfdump --uuid MyApp.app.dSYM and dwarfdump --uuid Payload/MyApp.app/MyApp; they must agree. Mismatch means the dSYM was generated by a different build.
  2. mapping.txt is non-empty and post-R8. A zero-byte mapping means R8 didn't actually run. Open the file; it should be tens of thousands of lines for any real app.
  3. versionCode and versionName are pinned in CI. Auto-incrementing on local builds but not CI causes mismatched mappings on the server.
  4. AOT dSYM is uploaded too. Search the bin output for two .dSYM bundles when AOT is on. Both must be zipped and uploaded.
  5. Symbol upload runs before store upload. If your TestFlight push happens before Sentry has the dSYM, the first batch of crashes from beta testers will be unresolved forever.
  6. Test a real crash. Add a button that throws and verify the resulting Sentry issue shows method names and line numbers, not addresses. Don't trust "the upload returned success."

Frequently Asked Questions

Where does .NET MAUI 10 generate the dSYM file?

Release iOS builds emit MyApp.app.dSYM next to the .app bundle under bin/Release/net10.0-ios/ios-arm64/. If you build via Archive, the dSYM is inside the .xcarchive under dSYMs/. NativeAOT produces an extra dSYM for the AOT-compiled code, and both must be uploaded.

Do I need ProGuard for a .NET MAUI Android app?

You don't need legacy ProGuard. .NET 10 uses R8, Google's faster, more capable successor, by default. R8 produces a mapping.txt in the same format ProGuard did, so existing tooling and upload workflows still work.

Why do my Sentry stack traces still show memory addresses after uploading dSYMs?

The most common cause is a UUID mismatch: the dSYM you uploaded was built from a different commit than the binary that crashed. Verify with dwarfdump --uuid on both. The second-most-common cause is missing the AOT dSYM when NativeAOT or full trimming is enabled.

Can I symbolicate Android ANRs with mapping.txt?

Only the managed Java/Kotlin frames. ANRs frequently bottom out in native libraries (libart.so, your AOT runtime), and those need the unstripped .so files uploaded to Play Console's Native Debug Symbols section. mapping.txt alone won't resolve them.

How long should I keep dSYMs and mapping files?

At least 90 days, ideally as long as the app version is installed on any real user's device. Crash reports can arrive months after release when users update an old version, and without the original symbols those crashes will never be diagnosable.

Sofia Rodriguez
About the Author Sofia Rodriguez

Mobile DevOps engineer focused on the unglamorous stuff: build pipelines, signing, store releases, and the tooling that keeps teams shipping.