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-cryptoin 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
| Option | Default | What it does |
|---|---|---|
enabled | true | Master switch |
endpoint | https://api.newinstance.cloud | Override for dev or self-hosted backends |
environment / release | production / - | Labels; release must match your dSYM upload metadata |
sampleRate | 1.0 | Sampling for logs and handled events. Crashes, hangs and sessions always bypass it |
autoSessionTracking | true | Release-health sessions. Set false in background extensions where sessions are meaningless |
enableAppHangTracking / appHangThresholdMs | true / 2000 | Main-thread hang detector |
enableAutoBreadcrumbs | true | Lifecycle breadcrumbs (UIKit only; no-op on macOS) |
enableNetworkBreadcrumbs + allow/deny hosts | true | URLSession crumbs. Deny wins; lists are lowercased at construction; empty allow means all hosts |
sensitiveFields | built-in list | Extra redaction keys |
batchSize / flushIntervalMs / maxQueueSize | 50 / 5000 / 1000 | Delivery and offline queue |
requestTimeoutMs | 15000 | Per-request timeout |
retry | 3 attempts, 200 ms to 5 s | Exponential backoff; the batch drops after the last attempt |
debug | false | Diagnostics 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:
.trace10,.debug20,.info30,.warn40,.error50,.fatal60. Breadcrumb(category:type:level:message:data:timestamp:)withtypedefaulting todefaultandlevelto.info.flush()has an async form and aflush(completion:)fire-and-forget form.setReleasealso rewrites the crash sidecar, so a crash after an in-app update is attributed to the new release.BugWatch.didCrashOnPreviousExecutionis readable beforestart();shared?.crashedLastRunis the post-start form.captureWrapperException(type:value:frames:level:platform:rawStacktrace:)exists for React Native and Flutter hosts;platformoverridesiosso 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-unwrappingnil, array index out of range and a failedpreconditionall trap through SIGTRAP, so they arrive as ordinary signal crashes. - Stack overflows: handlers run on a dedicated alternate signal stack (
sigaltstackplusSA_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.sigaltstackis per-thread and is registered on the thread that callsBugWatch.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
.errorseverity, never terminate the app, and deliberately attach no stack (the watchdog's own frames would mislead); they carryhang.threshold_msandhang.duration_mstags instead. - Lifecycle breadcrumbs (
app.lifecycle): foreground, background, anddevice.memory.lowon memory warnings. - Network crumbs record
method,host,path,status_code,duration_mswith 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 inUserDefaults(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,.dSYMbundles, zips of dSYMs, or raw Mach-O files; needs a macOS runner withzip; the key needs thesymbols:uploadscope. - In Xcode, add it as a Run Script phase placed after "Embed Frameworks", with
BUGWATCH_AUTH_TOKENsupplied askeyId:secret. - Xcode Cloud: use
ci_post_xcodebuild.shwith$CI_ARCHIVE_PATHand--upload-source xcode-cloud, the token as a Secret environment variable.--bundle-idand--upload-source local-xcodeexist 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 captured | Why |
|---|---|
| Out-of-memory kills, jetsam, watchdog terminations | The 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 app | Indistinguishable from a clean exit |
Anything before BugWatch.start runs | Handlers are armed by start. Call it first in your launch path |
| A crash inside the crash handler itself | The signal is already being handled and the process dies |
| A stack overflow on a thread other than the one that started the SDK | sigaltstack 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 readreasonandhint;signature_invalidis a wrong app secret,token_expiredorclock_aheadis the device clock,project_unavailableis 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.dSYMagainstbinaryImagesin the raw payload. - TestFlight vs App Store confusion: pass
--distributionso the dashboard separates them.