Stacks and Craft mobile apps

On this page 10

A Stacks app that ships to phones through Craft reports to BugHQ from the native shell itself. Nothing in your screens has to change to catch a crash.

SourceiOSAndroid
Crash at the moment it happensSignal handlers (a Swift trap is SIGTRAP, a bad pointer SIGSEGV) and the uncaught NSException handlerThe uncaught exception handler on every thread
Delivered by the OS on a later launchMetricKit crash and hang diagnosticsApplicationExitInfo: ANRs and crashes in native code (Android 11 and later)
JavaScriptUncaught errors in native screens and the app script, and web view errors through @bughq/sdkThe same

Every event carries the app version and build (1.0.0+41), the OS version, the device model, the environment, the session and the trail of screens and failed requests that led to it. Events wait in an outbox on the device and are sent when it is online, so a crash on the underground arrives once the phone is back above ground.

Create the projects

Create one project per platform in the dashboard, choosing iOS or Android as the platform, and copy each ingest key.

Configure the app

Add the keys to .env the same way as BUGHQ_KEY:

BUGHQ_IOS_KEY=bughq_your_ios_project_key
BUGHQ_ANDROID_KEY=bughq_your_android_project_key

Then turn reporting on in config/mobile.ts. A top-level crashReporting applies to both platforms, and each platform can override it:

export default {
  crashReporting: {
    host: 'https://bughq.org',
    environment: env.APP_ENV,
  },
  ios: {
    // ...
    crashReporting: { key: env.BUGHQ_IOS_KEY },
  },
  android: {
    // ...
    crashReporting: { key: env.BUGHQ_ANDROID_KEY },
  },
} satisfies MobileConfig

Without a key the reporter installs nothing. buddy build:ios and buddy build:android write the settings into the app's craft.config.json.

OptionDefaultNotes
keynoneThe project's public ingest key. It ships in the app, which is safe.
hosthttps://bughq.orgA self-hosted BugHQ.
environmentproductionTags every event.
release<version>+<build>Every event and session names it.
sessionstrueOne session per launch, for crash-free sessions per release.
uploadSymbolstrueUpload symbols from release builds when BUGHQ_UPLOAD_TOKEN is set.

Name the signed-in user

Only an id you choose identifies a person. Never send an email address, a name or health data.

import { crashReporting } from '@stacksjs/mobile'

crashReporting.setUser(user.id) // null on sign-out

From a native screen or the app script, craft.crashReporter.setUser(id) does the same.

JavaScript in web views

crashReporting.start() initialises @bughq/sdk with a transport that hands every event to the native reporter, so web view errors share its outbox, session and device context. Call it once in the layout of the pages the app shows. Outside the app it does nothing unless you pass a key.

import { crashReporting } from '@stacksjs/mobile'

crashReporting.start()

Screen changes and failed requests become breadcrumbs. Successful requests are left out.

Symbols

Release builds do not carry readable names. An iOS frame is an address in a binary, and an Android release build is renamed by R8. Upload each build's symbols and BugHQ restores functions, files and lines, and groups a crash by the functions it happened in rather than by addresses that change with every build.

Create an upload token in the project's settings, under Symbols. It is a secret: keep it in CI and never put it in an app. The ingest key cannot upload symbols.

iOS: dSYMs from Xcode Cloud

buddy build:ios writes ci_scripts/ci_post_xcodebuild.sh into the iOS project when crash reporting is on. After an archive it runs symbols on every dSYM and uploads the result. In App Store Connect, open the Xcode Cloud workflow, then Environment, and add a secret variable named BUGHQ_UPLOAD_TOKEN. Without it, the script skips the upload and says so in the build log.

BugHQ runs on Linux, which can neither read a dSYM's DWARF in Apple's layout nor demangle Swift. The Mac that built the app can do both, so the build reduces each dSYM to a symbol map of function and line ranges and uploads that. To upload from a local archive instead:

symbols -arch arm64 MyApp.app.dSYM | gzip -c | curl -X POST \
  -H "Authorization: Bearer $BUGHQ_UPLOAD_TOKEN" --data-binary @- \
  "https://bughq.org/api/debug-files?kind=apple"

Android: the R8 mapping

buddy build:android --bundle uploads app/build/outputs/mapping/release/mapping.txt after the build when BUGHQ_UPLOAD_TOKEN is set. The mapping is stored for the release the app reports, so a crash in 1.0.0+41 is retraced with the mapping of build 41. To upload by hand:

gzip -c app/build/outputs/mapping/release/mapping.txt | curl -X POST \
  -H "Authorization: Bearer $BUGHQ_UPLOAD_TOKEN" --data-binary @- \
  "https://bughq.org/api/debug-files?kind=proguard&release=1.0.0%2B41"

Symbols uploaded after a crash arrived apply to the next events. The issue page names any binary or release whose symbols were missing.

Grouping

A native crash groups by its type and its top three in-app frames, never by its message, which for a native crash is usually an address. A wrapping JVM exception groups by the cause that holds your code. See issues and triage.

Release health

Each launch reports a session. A crash ends its session as crashed, an ANR the person had to close ends it as abnormal, and any other error marks it errored. The dashboard shows crash-free sessions for the last 30 days, per release. See releases and alerts.

Test the pipeline

Debug builds accept a test crash, which Release builds do not contain:

# iOS simulator: swift, signal, exception or js
xcrun simctl launch booted your.bundle.id -CraftCrashTest swift

# Android emulator or device: native or js
adb shell am start -n your.package/.MainActivity --es craftCrashTest native

The crash is sent on the next launch.

Open source error tracking, released under the MIT License.