# iOS & macOS

The native Swift SDK for iOS and macOS captures crashes, app hangs, release-health sessions, breadcrumbs and logs. Crash capture covers signals and NSException, with Mach-O image tracking for dSYM symbolication.

## Requirements

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

Swift Package Manager:

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

CocoaPods:

```ruby
pod 'BugWatch', '~> 0.1'
```

## 2. Initialise (as early as possible)

SwiftUI:

```swift
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:

```swift
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. Configuration

| Option | Default | What it does |
|--------|---------|--------------|
| `enabled` | true | Master switch |
| `endpoint` | `https://api.newinstance.cloud` | Override for dev or self-hosted backends |
| `environment` / `release` | production / none | 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 API

```swift
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 capture

- **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 storage

- 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)

```bash
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](/source-maps-symbols) until `DONE`, then reprocess early crashes.


## What is never captured

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 relaunch

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 event

`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 privacy

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 connection

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

```swift
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.

## Troubleshooting

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