iOS & macOS

Native Swift SDK for iOS and macOS: signal and NSException crash capture with Mach-O image tracking for dSYM symbolication, app-hang detection, release-health sessions, breadcrumbs and logs.

Requirementslink

  • iOS 14+ (Swift PM or CocoaPods), macOS 11+ (Swift PM only: the podspec declares platform :ios), Swift 5.9 / Xcode 15+
  • System frameworks only (Foundation, Combine, Network). Swift PM resolves swift-crypto in the 2.0.0 to 4.0.0 range; the CocoaPods build uses system CryptoKit and needs no extra pod

1 - Installlink

Swift Package Manager:

.package(url: "https://github.com/New-Instance-Org/bug-watch-ios.git", from: "0.1.0")

CocoaPods:

pod 'BugWatch', '~> 0.1'

2 - Initialise (as early as possible)link

SwiftUI:

import BugWatch

@main
struct MyApp: App {
    init() {
        BugWatch.start(options: BugWatchOptions(
            projectId: Secrets.bugwatchProjectId,
            appSecret: Secrets.bugwatchAppSecret,
            environment: "production",
            release: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
        ))
    }
    var body: some Scene { WindowGroup { ContentView() } }
}

UIKit:

func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    BugWatch.start(options: BugWatchOptions(projectId: "...", appSecret: "...", release: "2.4.1"))
    return true
}

start is idempotent: a second call returns the existing instance without reconfiguring. Inject credentials via an xcconfig or build setting, never in source.

3 - Configurationlink

OptionDefaultWhat it does
enabledtrueMaster switch
endpointhttps://api.newinstance.cloudOverride for dev or self-hosted backends
environment / releaseproduction / -Labels; release must match your dSYM upload metadata
sampleRate1.0Sampling for logs and handled events. Crashes, hangs and sessions always bypass it
autoSessionTrackingtrueRelease-health sessions. Set false in background extensions where sessions are meaningless
enableAppHangTracking / appHangThresholdMstrue / 2000Main-thread hang detector
enableAutoBreadcrumbstrueLifecycle breadcrumbs (UIKit only; no-op on macOS)
enableNetworkBreadcrumbs + allow/deny hoststrueURLSession crumbs. Deny wins; lists are lowercased at construction; empty allow means all hosts
sensitiveFieldsbuilt-in listExtra redaction keys
batchSize / flushIntervalMs / maxQueueSize50 / 5000 / 1000Delivery and offline queue
requestTimeoutMs15000Per-request timeout
retry3 attempts, 200 ms to 5 sExponential backoff; the batch drops after the last attempt
debugfalseDiagnostics via a handler you install (see troubleshooting)

Network-breadcrumb coverage gotcha: the recorder installs a global URLProtocol, which covers URLSession.shared and sessions built from .default after start. A session created from a custom configuration before start is never covered.

4 - Capture APIlink

BugWatch.capture(error: error)
BugWatch.captureMessage("Sync finished", level: .info)
BugWatch.setUser(BugWatchUser(id: "u_123", email: "ada@example.com"))
BugWatch.setUser(nil)
BugWatch.setTag(key: "tenant", value: "acme")
BugWatch.setContext(key: "payment.provider", value: "paystack")
BugWatch.addBreadcrumb(Breadcrumb(category: "ui", message: "Tapped checkout"))
BugWatch.setRelease("2.4.2")
BugWatch.close()
  • Severity wire values: .trace 10, .debug 20, .info 30, .warn 40, .error 50, .fatal 60.
  • Breadcrumb(category:type:level:message:data:timestamp:) with type defaulting to default and level to .info.
  • flush() has an async form and a flush(completion:) fire-and-forget form.
  • setRelease also rewrites the crash sidecar, so a crash after an in-app update is attributed to the new release.
  • BugWatch.didCrashOnPreviousExecution is readable before start(); shared?.crashedLastRun is the post-start form.
  • captureWrapperException(type:value:frames:level:platform:rawStacktrace:) exists for React Native and Flutter hosts; platform overrides ios so the backend picks the right artifact.

Automatic capturelink

  • Crashes: POSIX signal handlers (SEGV, ABRT, BUS, ILL, FPE, TRAP, SYS) plus NSSetUncaughtExceptionHandler, recorded with the process's Mach-O binary images (UUID and load address per frame). The handler restores the previous handler and re-raises, so the OS still writes its own crash report, and any other reporter you run (Crashlytics, Bugsnag) still fires. The report ships on the next launch.
  • Swift runtime traps: fatalError, force-unwrapping nil, array index out of range and a failed precondition all trap through SIGTRAP, so they arrive as ordinary signal crashes.
  • Stack overflows: handlers run on a dedicated alternate signal stack (sigaltstack plus SA_ONSTACK). Without it a stack overflow leaves the crashing thread with no stack for the kernel to build the signal frame on, and the process dies before the handler's first instruction. sigaltstack is per-thread and is registered on the thread that calls BugWatch.start, so an overflow on a background thread is still missed.
  • App hangs: an off-main watchdog polls the main thread every 200 ms and latches, so one continuous stall produces exactly one event. Hang events are .error severity, never terminate the app, and deliberately attach no stack (the watchdog's own frames would mislead); they carry hang.threshold_ms and hang.duration_ms tags instead.
  • Lifecycle breadcrumbs (app.lifecycle): foreground, background, and device.memory.low on memory warnings.
  • Network crumbs record method, host, path, status_code, duration_ms with the query string stripped; the SDK re-issues observed requests through an internal tagged session so they are never intercepted twice.

Delivery and storagelink

  • A ring buffer of the most recent 100 breadcrumbs rides on every event and is mirrored into the crash sidecar so a crash carries the trail.
  • On-disk state lives in Application Support under cloud.newinstance.bugwatch/ (pending-events.ndjson, crash-context.json, crash-breadcrumbs.ndjson), with a temp-dir fallback; the crashed-last-run flag and install id live in UserDefaults(suiteName: "cloud.newinstance.bugwatch").
  • Queue eviction is both size (maxQueueSize) and age: nothing older than 7 days survives.
  • Delivery is network-aware (NWPathMonitor): offline events wait for connectivity.

5 - Symbolication (CI)link

npx @newinstance/bugwatch-cli symbols upload MyApp.xcarchive \
  --release "$MARKETING_VERSION" --build-number "$BUILD_NUMBER" \
  --distribution app-store --token "$BUGWATCH_AUTH_TOKEN"
  • Accepts .xcarchive, .dSYM bundles, zips of dSYMs, or raw Mach-O files; needs a macOS runner with zip; the key needs the symbols:upload scope.
  • In Xcode, add it as a Run Script phase placed after "Embed Frameworks", with BUGWATCH_AUTH_TOKEN supplied as keyId:secret.
  • Xcode Cloud: use ci_post_xcodebuild.sh with $CI_ARCHIVE_PATH and --upload-source xcode-cloud, the token as a Secret environment variable. --bundle-id and --upload-source local-xcode exist for manual archive uploads.
  • Bitcode archives: download the App Store dSYMs in Xcode Organizer first, or disable bitcode and upload directly.
  • Matching is by debug UUID, so any machine's upload resolves any device's crash of that binary. Poll Source Maps & Symbols until DONE, then reprocess early crashes.

What is never capturedlink

Platform limits, not SDK limits. No crash reporter on this platform captures them, so plan around them rather than discover them during an incident.

Not capturedWhy
Out-of-memory kills, jetsam, watchdog terminationsThe OS sends SIGKILL, which no handler can intercept. The prior session finalises as exited, so crash-free rate reads slightly optimistic. There is no abnormal session status
The user force-quitting the appIndistinguishable from a clean exit
Anything before BugWatch.start runsHandlers are armed by start. Call it first in your launch path
A crash inside the crash handler itselfThe signal is already being handled and the process dies
A stack overflow on a thread other than the one that started the SDKsigaltstack is registered per-thread

Delivery needs a relaunchlink

Nothing is uploaded from inside a crash handler: the process is dying and networking is not async-signal-safe there. The handler writes a small artifact to disk synchronously, then the next launch turns it into a fatal event and uploads it. iOS provides no way to run code after the process is gone, so a user who crashes and never returns is never counted. Queued events are delayed, never lost.

Device context on every eventlink

model, family, osName, osVersion, locale, timezone, simulator, appVersion, appBuild, bundleId. Alongside it every event carries an installId (stable per install), a sessionId, the release, the environment, your tags, contexts, user and the breadcrumb ring.

App Store privacylink

The SDK ships no PrivacyInfo.xcprivacy; declare your crash-data collection in your app's own privacy manifest and App Store privacy answers.

Confirm the connectionlink

The SDK performs a handshake with BugWatch on start and exposes the result:

BugWatch.shared?.onConnectionStateChange = { check in
    switch check.state {
    case .connected: print("BugWatch reachable, clock skew \(check.clockSkewMs ?? 0) ms")
    case .rejected: print("BugWatch refused: \(check.reason ?? "") \(check.hint ?? "")")
    case .offline, .disconnected: print("BugWatch unreachable: \(check.reason ?? "")")
    default: break
    }
}
let check = await BugWatch.shared?.testConnection()   // on demand, async
let state = BugWatch.shared?.connectionState          // .connected / .rejected / .offline / ...
let detail = BugWatch.shared?.lastConnectionCheck     // reason, hint, HTTP status, server time

.rejected carries the server's exact reason and a fix hint (signature_invalid, project_unavailable, mobile_ingest_disabled, token_expired, clock_ahead), and is also emitted through BugWatchDiagnosticLog even with debug: false. Every delivery attempt updates the same state. The dashboard's project setup page shows the matching Mobile SDK connection panel per platform. testConnection() ingests nothing and is not billed.

Troubleshootinglink

  • No diagnostics even with debug: true: install a handler first: BugWatchDiagnosticLog.setHandler { print($0) }.
  • No events at all: await BugWatch.shared?.testConnection() and read reason and hint; signature_invalid is a wrong app secret, token_expired or clock_ahead is the device clock, project_unavailable is a wrong or inactive projectId.
  • Crash reports missing: they upload on the next launch; a force-quit by swipe writes no artifact by design, so the crashed flag stays false.
  • Frames show addresses, not symbols: verify the build UUID matches an indexed upload: dwarfdump --uuid MyApp.dSYM against binaryImages in the raw payload.
  • TestFlight vs App Store confusion: pass --distribution so the dashboard separates them.