docs

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.

  1. Add the package

    In Xcode, File > Add Package Dependencies, or in Package.swift:

    Package.swift
    .package(url: "https://github.com/treboryx/unhandled-swift", from: "0.1.0")

    Add the Unhandled product to your app target.

  2. Start it at launch

    ShopApp.swift
    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.start first thing in application(_: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

dsnStringrequired

Ingest endpoint, API key and service id. Empty turns the SDK off.

environment.production | .staging | .developmentrequired

Sent as x-unhandled-env.

releaseStringdefault bundleId@version+build

Sent as service.version. The default reads CFBundleIdentifier, CFBundleShortVersionString and CFBundleVersion from the main bundle.

sampleRateDoubledefault 1

Share of errors to send.

tracesSampleRateDoubledefault 1

Share of spans to send.

ignoreErrors[IgnoreRule]default []

A string drops errors whose message contains it. .matching { $0.type == "…" } drops by predicate.

queueLimitsQueueLimits

Bounds of the on-disk queue: maxBytes (default 4000000) and maxEnvelopes (default 200).

captureCrashesBooldefault true

Catch uncaught Objective-C exceptions and fatal signals.

storageDirectoryURLdefault Caches/unhandled/<service id>

Where the queue lives.

enabledBooldefault true

false returns the inactive client.

Errors

swift
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]"))
SourcemechanismhandledSeverity
captureError, captureExceptionmanualtrueERROR
An uncaught NSExceptionnsexceptionfalseFATAL
A fatal signalsignalfalseFATAL

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:

Terminal
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.

swift
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

swift
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

swift
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

SpanWhen
The screen nameEach trackScreen(_:) call that changes the screen, to the next frame. app.screen.previous names where it came from.
app startOnce per launch, from process start to the first screen or appReady(). app.start.type is cold.
app resumeBack from the background to the next frame. app.start.type is resume.
swift
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.