+1 (415) 843-4662

Crash reporting and observability for Ionic apps: source maps, native symbolication and release health

When an Ionic app crashes in the field you get one of two useless artefacts: a native stack trace full of WebView frames, or a JavaScript stack that reads t is not a function at main-4f8c2a.js:1:98421. Neither tells you which customer, which screen, or which release. This tutorial wires up an observability stack for an Ionic + Capacitor app that answers those questions: readable JavaScript stacks via uploaded source maps, symbolicated native crashes from iOS and Android, breadcrumbs that survive a WebView reload, and a release-health gate you can run in CI before a staged rollout goes wide.

Examples use Sentry because it covers both the web layer and the native layers in one project, but the shape of the work — build-time symbol upload, a consistent release identifier, native-plus-web correlation — is the same with Firebase Crashlytics, Bugsnag, Datadog or an OpenTelemetry collector you run yourself.

The three layers you have to instrument

An Ionic app is three runtimes stacked on top of each other, and each one fails differently:

LayerTypical failureWho catches it
Web (Angular/React/Vue in the WebView)unhandled promise rejection, render errorJS SDK
Native shell + plugins (Swift/Kotlin)plugin crash, EXC_BAD_ACCESS, ANRnative SDK
Backend / network5xx, timeout, expired tokenserver SDK + client spans

If you only install the JS SDK — the most common setup we find when we audit an existing app — every native plugin crash and every Android ANR is invisible to you. The app just "disappears" for the user and your dashboard stays green.

1. Install the SDK on all three layers

npm install @sentry/angular @sentry/capacitor
npx cap sync

@sentry/capacitor installs the native SDKs into the iOS and Android projects and bridges events from the JS layer through them, so a single init covers web and native. Swap @sentry/angular for @sentry/react or @sentry/vue as appropriate.

// src/main.ts
import * as Sentry from '@sentry/capacitor';
import * as SentrySibling from '@sentry/angular';

Sentry.init(
  {
    dsn: import.meta.env.VITE_SENTRY_DSN,
    // MUST match what CI uses when it uploads source maps and dSYMs
    release: import.meta.env.VITE_APP_RELEASE,   // e.g. com.example.app@4.12.0+318
    dist: import.meta.env.VITE_BUILD_NUMBER,     // e.g. 318
    environment: import.meta.env.VITE_ENV,       // production | staging
    tracesSampleRate: 0.2,
    replaysOnErrorSampleRate: 1.0,
    enableNativeCrashHandling: true,
    beforeSend: scrubPii,
  },
  SentrySibling.init,
);

The release/dist pair is the single most important thing on this page. If the value in the app does not byte-for-byte match the value CI used when it uploaded the source maps, your stack traces stay minified forever. Derive it once and inject it everywhere:

# scripts/release-id.sh — used by the app build AND by every upload step
VERSION=$(node -p "require('./package.json').version")
BUILD=${GITHUB_RUN_NUMBER:-0}
echo "com.example.app@${VERSION}+${BUILD}"

2. Make JavaScript stack traces readable

Build with hidden source maps so the maps exist on disk but are not referenced from the shipped bundle, upload them, then delete them from the www directory before cap sync copies it into the native projects. Shipping maps inside the app bundle hands your source to anyone who unzips the IPA.

// vite.config.ts (or angular.json: "sourceMap": { "scripts": true, "hidden": true })
build: { sourcemap: 'hidden' }
RELEASE=$(scripts/release-id.sh)

npm run build
npx sentry-cli sourcemaps inject ./www
npx sentry-cli sourcemaps upload ./www --release "$RELEASE" --dist "$BUILD"
find ./www -name '*.map' -delete     # do this BEFORE npx cap sync
npx cap sync

Verify before you ship, not after the first crash: npx sentry-cli sourcemaps explain <event-id> tells you exactly which artefact was missing or which release name did not line up.

3. Symbolicate native crashes

iOS crash reports are useless without dSYMs, and Bitcode-free builds mean the dSYMs come straight out of your archive:

npx sentry-cli debug-files upload --include-sources \
  ~/Library/Developer/Xcode/Archives/**/*.xcarchive/dSYMs

Android needs the ProGuard/R8 mapping file, plus native symbol tables if you ship any .so (SQLite, image codecs, some map SDKs):

npx sentry-cli debug-files upload --type proguard \
  android/app/build/outputs/mapping/release/mapping.txt
npx sentry-cli debug-files upload \
  android/app/build/intermediates/merged_native_libs/release/

Run both from the same CI job that builds the artefact, using the same $RELEASE. If a human ever uploads symbols from a laptop, you will eventually ship a build whose symbols were never uploaded at all.

4. Breadcrumbs that actually explain the crash

Default breadcrumbs give you clicks and XHRs. What you want, in an app whose UI lives in a WebView, is navigation plus plugin activity plus app lifecycle.

// Router navigation (Angular; the React/Vue routers expose equivalents)
router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(e =>
  Sentry.addBreadcrumb({ category: 'navigation', message: e.urlAfterRedirects }),
);

// Lifecycle — "crashed 200 ms after resume" is a whole class of bug
App.addListener('appStateChange', ({ isActive }) =>
  Sentry.addBreadcrumb({ category: 'lifecycle', message: isActive ? 'resume' : 'pause' }),
);

// Connectivity — most "random" failures are just a dead network
Network.addListener('networkStatusChange', s =>
  Sentry.addBreadcrumb({ category: 'network', message: `${s.connected}/${s.connectionType}` }),
);

Wrap plugin calls once, centrally, so every native round trip is timed and recorded rather than instrumented ad hoc at forty call sites:

export async function callPlugin<T>(name: string, fn: () => Promise<T>): Promise<T> {
  const started = performance.now();
  try {
    return await fn();
  } catch (err) {
    Sentry.captureException(err, { tags: { plugin: name } });
    throw err;
  } finally {
    Sentry.addBreadcrumb({
      category: 'plugin',
      message: name,
      data: { ms: Math.round(performance.now() - started) },
    });
  }
}

const photo = await callPlugin('Camera.getPhoto', () =>
  Camera.getPhoto({ resultType: CameraResultType.Uri, quality: 80 }),
);

5. Connect the app trace to your backend trace

Distributed tracing is what turns "the app is slow" into "the /orders endpoint takes 3 s at P90 for users in Australia". Allow trace headers to be attached to your own origins only:

Sentry.init({
  // ...
  integrations: [Sentry.browserTracingIntegration()],
  tracePropagationTargets: ['https://api.example.com'],
});

Then make sure your API accepts and forwards sentry-trace and baggage (or traceparent if you are on OpenTelemetry) in CORS, and that the server SDK continues the incoming trace rather than starting a new one. Without that last step you get two disconnected halves and neither one explains the other.

6. Scrub PII before it leaves the device

Mobile crash payloads leak more than people expect: URLs with tokens, form values in replay sessions, request bodies in breadcrumbs. This matters for the GDPR answer you owe your client and for the App Store privacy manifest and Play Data Safety form you already filled in.

function scrubPii(event: Sentry.Event): Sentry.Event | null {
  if (event.request?.url) {
    event.request.url = event.request.url.replace(/([?&](token|code|email)=)[^&]+/gi, '$1[redacted]');
  }
  event.breadcrumbs = event.breadcrumbs?.map(b => {
    if (b.data && 'Authorization' in b.data) b.data.Authorization = '[redacted]';
    return b;
  });
  delete event.user?.ip_address;
  return event;
}

Also enable session-replay masking (maskAllText, blockAllMedia) for any screen that shows account, health or payment data, and prefer a stable pseudonymous user.id over an email address.

7. Gate the rollout on release health

Once every build reports sessions tagged with a release, you can make the staged rollout a decision instead of a vibe. Sentry calls it crash-free session rate; Crashlytics calls it crash-free users. Pick a threshold with the client — 99.5% crash-free sessions is a reasonable starting bar for a consumer app — and enforce it in CI:

# .github/workflows/release.yml (excerpt)
- name: Hold rollout until release health clears
  run: |
    RELEASE=$(scripts/release-id.sh)
    RATE=$(npx sentry-cli releases info "$RELEASE" --json \
      | node -p "JSON.parse(require('fs').readFileSync(0)).crashFreeSessions ?? 0")
    echo "crash-free sessions: $RATE"
    awk -v r="$RATE" 'BEGIN { exit (r >= 99.5 ? 0 : 1) }'

Pair it with a phased release on the store side (iOS phased release, Play staged rollout at 5% → 20% → 50% → 100%) and, if you ship Live Updates over the air, with the ability to roll the web bundle back in minutes rather than waiting on review. A rollback path plus a health metric is the whole point of this exercise.

A one-hour audit for an app you inherited

Run these checks on any existing Ionic app before you promise anyone an uptime number:

  1. Force a native crash in a debug build (Sentry.nativeCrash()) and confirm it arrives symbolicated — not just a JS error.
  2. Throw inside a .then() with no .catch() and confirm the unhandled rejection is captured.
  3. Open the newest release in your dashboard and confirm the top frame shows a file and line number from your source, not a bundle chunk.
  4. Check that the release identifier in the dashboard matches the version string the app shows in its settings screen.
  5. Trigger a slow API call and confirm the trace spans the app and the server.
  6. Open a captured event and read it as if you were the data protection officer. Anything in there you would not want in a subpoena?

Most inherited apps fail three or four of these. Fixing them is a day of work and it changes every incident conversation afterwards from guesswork to evidence.

Where this fits with the rest of your pipeline

Symbol upload belongs in the same CI job that builds and signs the app, next to your fastlane lanes; the release identifier is the thread that ties the build, the source maps, the native symbols and any over-the-air web bundle together. If you are moving off Ionic Appflow, this is a good moment to add it, because you are already rewriting the build pipeline.

HybridMob's senior Ionic consultants do this as a fixed-scope engagement: instrument all three layers, wire symbol upload into CI, set a crash-free threshold with your team, and hand back a runbook. If you want a look at your current crash blind spots, get in touch and describe your stack.