Swift SDK
Report errors and crashes, send analytics events, identify users, track revenue and run experiments from native iOS, iPadOS, macOS, tvOS, visionOS and watchOS apps.
The Swift SDK reports what happens inside your native Apple app. It captures errors you catch, uncaught Objective-C exceptions and fatal signals, keeps everything in an on-disk queue until the device is online, records app start, resume and screen spans, and sends analytics events, user profiles, revenue and experiment exposures. It posts OTLP/HTTP JSON to the ingest endpoint in your DSN, the same data @unhandled/react-native sends, so a native app and an Expo app look the same in Unhandled.
It is a Swift package with no third-party dependencies and no device identifiers. It needs Xcode 16 or later and runs on iOS 13, macOS 10.15, tvOS 13, watchOS 6 and visionOS 1.
Add the package
In Xcode, File > Add Package Dependencies, or in
Package.swift:.package(url: "https://github.com/treboryx/unhandled-swift", from: "0.1.0")Add the
Unhandledproduct to your app target.Start it at launch
import SwiftUI import Unhandled @main struct ShopApp: App { init() { Unhandled.start(Options( dsn: "https://uh_live_…@ingest.unhandled.net/<service id>", environment: .production )) } var body: some Scene { WindowGroup { ContentView() } } }In a UIKit app, call
Unhandled.startfirst thing inapplication(_:didFinishLaunchingWithOptions:).
start never throws. It returns the client, and Unhandled.shared returns it anywhere else. An empty dsn gives an inactive client whose isActive is false, which is how a debug build stays silent. A malformed DSN logs a warning and returns the same inactive client. A second start keeps the first client.
Options
dsnStringrequiredIngest endpoint, API key and service id. Empty turns the SDK off.
environment.production | .staging | .developmentrequiredSent as
x-unhandled-env.releaseStringdefaultbundleId@version+buildSent as
service.version. The default readsCFBundleIdentifier,CFBundleShortVersionStringandCFBundleVersionfrom the main bundle.sampleRateDoubledefault1Share of errors to send.
tracesSampleRateDoubledefault1Share of spans to send.
ignoreErrors[IgnoreRule]default[]A string drops errors whose message contains it.
.matching { $0.type == "…" }drops by predicate.queueLimitsQueueLimitsBounds of the on-disk queue:
maxBytes(default4000000) andmaxEnvelopes(default200).captureCrashesBooldefaulttrueCatch uncaught Objective-C exceptions and fatal signals.
storageDirectoryURLdefaultCaches/unhandled/<service id>Where the queue lives.
enabledBooldefaulttruefalsereturns the inactive client.
Errors
do {
try checkout.submit()
} catch {
Unhandled.shared.captureError(error, context: CaptureContext(tags: ["flow": "checkout"]))
}
Unhandled.shared.captureMessage("Payment slow", level: .warning)
Unhandled.shared.setUser(CaptureUser(id: "42", email: "[email protected]"))| Source | mechanism | handled | Severity |
|---|---|---|---|
captureError, captureException | manual | true | ERROR |
An uncaught NSException | nsexception | false | FATAL |
| A fatal signal | signal | false | FATAL |
An NSError reports its domain and code as the type and its NSUnderlyingErrorKey error as the cause. Every error carries the current screen as app.screen.name, and the tags os.name and device.class.
Crashes
An uncaught NSException is written to disk before the previous handler runs. SIGABRT, SIGBUS, SIGFPE, SIGILL, SIGSEGV and SIGTRAP go to a signal handler that writes the signal, the faulting address and the stack addresses to a file opened at start, then hands the signal to the handler that was there before. The next launch sends the crash with the binary images of the run that crashed.
A Swift runtime trap, such as a force unwrap of nil, arrives as SIGTRAP or SIGILL without its message. Watchdog terminations and out-of-memory kills end the process with SIGKILL, which no in-process handler can catch, so they are not reported. The handler runs on an alternate stack only on the thread that called start, so a stack overflow on another thread is not reported either.
Symbolication
Native frames carry instruction_addr, image_addr and image_uuid, and the symbol name when the binary has one. Unhandled does not symbolicate native frames on the server yet (#336), so keep the dSYMs of every release: the dSYMs folder of the Xcode archive. To resolve a frame:
dwarfdump --uuid MyApp.app.dSYM
atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l <image_addr> <instruction_addr>The UUID that dwarfdump prints must match the frame's image_uuid.
Analytics
Analytics are anonymous until you call identify. There is no device id, install id or cookie, so an event sent before identify counts toward totals and belongs to no one.
Unhandled.shared.track("checkout_started", properties: ["items": 3, "gift": false])
Unhandled.shared.identify(account.id, properties: ["plan": "pro"])
Unhandled.shared.setUserProperties(["plan": "team"])
Unhandled.shared.reset()identify(_:properties:) sends a profile update, makes later events carry the user id and sets the error user to that id. The id lives in memory only, so call it again after each launch while the user is signed in. setUserProperties does nothing before identify, and reset() forgets the user on sign-out. Properties follow the analytics rules; a call that breaks one is dropped.
Events from the app have no site or path, so they count as events and users, never as visitors or visits.
Revenue
Unhandled.shared.trackRevenue(Revenue(
amount: 1999,
currency: "USD",
id: transaction.id,
product: "pro",
subscription: .init(id: "sub_123", interval: .month)
))amount is a whole number in the currency's smallest unit, negative for a refund. Pass the store's transaction id as id so a retried call counts once. See Revenue.
Experiments
let variant = Unhandled.shared.getVariant("paywall", variants: ["control", "annual-first"])
let weighted = Unhandled.shared.getVariant("pricing-page", variants: [Variant("control", weight: 3), "green"])
Unhandled.shared.trackExposure("onboarding", variant: myOwnAssignment)getVariant returns the identified user's variant, the same one every Unhandled SDK picks, and records an exposure once per variant per app run. Before identify it returns the first variant and records nothing. trackExposure records a variant you assigned yourself and does nothing before identify. See Experiments.
Spans
| Span | When |
|---|---|
| The screen name | Each trackScreen(_:) call that changes the screen, to the next frame. app.screen.previous names where it came from. |
app start | Once per launch, from process start to the first screen or appReady(). app.start.type is cold. |
app resume | Back from the background to the next frame. app.start.type is resume. |
Unhandled.shared.trackScreen("Checkout")Offline queue
Errors are written to a file under the caches directory before the capture call returns. Analytics and spans are batched for a second, or 50 records, and written off the calling thread. Files go out oldest first, one at a time, and a file is deleted only after the ingest answers. A 408, 429, 5xx or network error backs off exponentially up to five minutes; coming back to the foreground retries at once. Any other 4xx drops the file. Past maxEnvelopes or maxBytes, the oldest analytics and spans go first and errors last.
await Unhandled.shared.flush() sends what is queued, and await Unhandled.shared.shutdown() removes the crash handlers, flushes and stops.
Privacy
The resource carries os.name, os.version and device.class (phone, tablet, tv, desktop, vision). The SDK never reads the IDFA or identifierForVendor, sends no device model, install id, locale or IP address, and does not link AdSupport or AppTrackingTransparency. A user is attached only when you call identify or setUser.