# Ravenkeep iOS SDK reference

**The iOS SDK.** The iOS SDK runs in your app with the project publishable key. It reports crashes on the next launch, the screens your app names and the time people spend on them, their visits, the events you made public and the signed-in user, from a queue on disk that survives being offline. Its first version is not published yet, and it is built and tested on Linux; the UIKit/SwiftUI layer is pending validation on Apple platforms.

- **iOS 13**: iOS 13 and newer, from Swift (Xcode 15 or newer)
- **rk_pub_**: Publishable key, accepted only from the apps you list
- **Never throws**: Stores and sends on its own queue, never the caller's

## Installation

> **Not published yet**
>
> The first version has not been released, so there is no package line to copy yet. The SDK will be published as a Swift package in a public repository, with plain version tags (`0.1.0`), and this section will show the address and the version to add once it is.

- One Swift package product, `Ravenkeep`, with no dependencies; the SwiftUI modifier is inside it. Swift tools version 5.9.
- It needs no entry in your `Info.plist` and no entitlement: it sends HTTPS requests to the API. It calls none of the APIs Apple asks a privacy manifest to give a reason for (file timestamps, system boot time, disk space, active keyboards, user defaults).

> **Validation status**
>
> The SDK is built and tested on Linux; the UIKit/SwiftUI layer is pending validation on Apple platforms. The platform-free core (visits, screens, engaged time, identity, the queue and delivery) and the crash signal handler run their tests on Linux; the UIKit adapter, the SwiftUI modifier, the `NSException` handler and the list of loaded images compile only for Apple platforms and have not been built yet.

## Quickstart

- **List the app.** In the console, Settings, then Projects, then your project's **Apps** tab, choose iOS and add your app's bundle ID (`com.acme.shop`). Each ID is its own entry, so a debug build with a `.debug` suffix needs one too. A request from an app that is not listed is refused (`app_not_allowed`) and stays queued: listing the app later delivers it.
- **Start the SDK** once, as early as you can: in your `App`'s initializer, or in `application(_:didFinishLaunchingWithOptions:)`, with the project publishable key from the Install tab.

**ShopApp.swift**

```swift
import Ravenkeep
import SwiftUI

@main
struct ShopApp: App {
    init() {
        // The publishable key is safe in an app: it only works from the apps listed in the project.
        Ravenkeep.start(RavenkeepOptions(publishableKey: "rk_pub_lumen_xxxxxxxxxxxx"))
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in Ravenkeep.handleDeepLink(url) }
        }
    }
}
```

From then on the SDK keeps the visit, measures the time spent on the screens your app names (below) and reports crashes. `start` refuses a secret key (`rk_secret_`), which would ship inside the app, and trims white space around the key as JavaScript's `trim()` trims it (a byte order mark from a paste included), as the Android and browser SDKs do. It is ignored in an app extension (not supported yet). If another crash reporter runs in the app, start Ravenkeep after it: each hands a crash over to the one installed before it.

## Configuration

`RavenkeepOptions` takes the key and, as arguments with defaults, these settings:

| Option | Default | Meaning |
| --- | --- | --- |
| `publishableKey` | none | The project publishable key, `rk_pub_...`. A secret key is refused and never sent. |
| `host` | `https://api.ravenkeep.io` | API origin. Change it only for a self-hosted Ravenkeep. |
| `environment` | `production` | The crashes' environment: crash groups are kept per environment. |
| `release` | the app's `CFBundleShortVersionString` | The crashes' release, so a resolved crash regresses only in a newer one. |
| `captureCrashes` | `true` | Report uncaught `NSException`s and crash signals, sent on the next launch. |
| `startOptedOut` | `false` | Start opted out until `optIn()`, on a device where the SDK never ran (consent). |
| `debug` | `false` | Log what the SDK does to the unified log (subsystem `io.ravenkeep`); never a key, a token or a user hash. |
| `flushAt` | `20` | Send as soon as this many events are queued (1 to 100). |
| `flushInterval` | `5` | Send this many seconds after the first unsent event at the latest (at least 1). |

Ravenkeep times the events of a publishable key when they arrive, so `flushInterval` is how far a visit's times can be off in the reports (the browser SDK waits 1 second); a longer wait sends fewer requests. The events of one request keep their order. The Android SDK's `flushIntervalMs` is `flushInterval` here, in seconds, and its `trackActivityScreens` has no counterpart: iOS screens are the ones your app names.

## Identify users

`identify` opens the user's widget session, as the browser SDK does: later events and crashes carry the user, and Ravenkeep attributes them to the contact once the session vouches for them. The user hash is `hex(HMAC-SHA256(identitySecret, userId))`, computed by your backend (`widgetUserHash` in `@ravenkeep/node`); never put the identity secret in the app. With identity verification on (the default), a missing or wrong hash fails with `identity_verification_failed`.

**SignIn.swift**

```swift
// After sign-in, and on every launch of a signed-in app: the hash stays in memory only.
Ravenkeep.identify(
    userId: me.id,
    userHash: me.ravenkeepUserHash, // from your backend
    email: me.email,
    account: RavenkeepAccount(id: me.teamId, name: me.teamName)
) { result in
    if let error = result.error { print("Ravenkeep identify: \(error.code)") }
}

// On sign-out
Ravenkeep.reset()
```

- The completion receives an `IdentifyResult`: `data` (an `IdentifySession`: the contact id, the user id, whether it is verified, when the session expires) or `error`, never both, on the SDK's own queue: dispatch to the main queue for UI work. Before `start` it gets `not_initialized`, at once, on the caller's thread.
- The SDK keeps the user and the session's token on disk and renews the token while the app runs, 5 minutes before it expires: the expiry is kept on the device's clock from the API's answer, so a device clock hours off still renews on time (at most once a minute), and a renewal that came due while the device slept runs when the app comes back to the foreground or sends its next batch. A renewal that gets no answer, a `408`, a `429` or a `5xx` is retried every 30 seconds, or after the API's `Retry-After` up to 5 minutes, while the token lasts; a refused one drops the token until the next `identify`. The hash is never written to disk, so the SDK cannot renew a session across launches: call `identify` on every launch of a signed-in app. Until it does, events carry the stored user and token while that token is valid (at most 12 hours after it was issued), then go without it, anonymous.
- Each call's completion gets its own call's answer. Calls with the same input while one is in flight share its request; a call with another input for the same user supersedes the one in flight, or the one waiting with it for the SDK's files before the first unlock, whose completion gets `superseded` at once (its request is left to finish: `URLSession` runs requests side by side, so the later call does not wait behind it). The identity kept is the last call's once it succeeds; when it gets no answer, a `408`, a `429` or a `5xx`, the earlier session stays until it expires and the retry sends the last call's input; when it is refused, the session is dropped. `identify` of another user, or `reset()`, answers the call in flight `not_identified`.
- Only the fields you pass are sent; an account with a blank id is left out. As in the browser, a name or an email set by your backend is never overwritten from the app.
- `reset()` on sign-out ends the current view, its engaged time so far sent as the signed-in user's in its visit (dropped when that visit is over: sign-out never opens a visit), then forgets the identity, its token, the anonymous id and the visit; the next screen starts a new view. `identify` of another user sends the view's engaged time so far as the earlier user's, the same way, and starts a new visit. The user id is trimmed as the key is.

[How identity verification works](https://ravenkeep.io/docs#identity)

## Screens and events

A screen view is a `$screen` event whose `screen` property names it; Metrics, then Pages & screens ranks it as you named it. iOS has no automatic screens: a screen is what your app names. In SwiftUI, put the modifier on each destination; in UIKit, call `screen` from the view controller's `viewDidAppear(_:)`, which runs again when the person comes back to it:

**CartView.swift**

```swift
struct CartView: View {
    let items: Int

    var body: some View {
        List { /* ... */ }
            .ravenkeepScreen("Cart", properties: ["items": items])
    }
}

final class SettingsViewController: UIViewController {
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        Ravenkeep.screen("Settings")
    }
}
```

- `.ravenkeepScreen(_:properties:)` names the screen when its view appears and, on iOS 14 and newer, again when its name or properties change while the view is shown (a view covered by a pushed one is not named by a change). Put it on destinations (a navigation destination, a tab's root), not on a sheet: SwiftUI does not make the view under a sheet disappear or appear again, so it counts as shown under the sheet and is not named again when the sheet closes. Give its properties strings, numbers and booleans. A screen named while the app is in the background (a launch by a push) is sent when the app comes to the foreground, as the user identified then (after `identify` of another user, theirs), unless it is the current view, which is not sent again.
- The same name and properties as the current view is the same view: nothing is sent, so a re-render, a rotation or a view appearing again with nothing named in between is not a new view. It is sent again only in a visit an event opened without it (an event tracked in the background, 30 minutes after the last); after its visit ended, the next event names it again in the new visit. Name screens the way your routes are named (`Lesson`, `Settings`), and send the id of what they show as a property.
- The SDK does not swizzle your app's delegates, so it sees a deep link only when you pass it: give every URL your app receives (`onOpenURL`, `scene(_:openURLContexts:)`, `scene(_:continue:)` or `application(_:open:options:)`) to `Ravenkeep.handleDeepLink(_:)`. Its `utm_*` parameters name the current view again with them, so a link into a screen already shown keeps its campaign, or join the next screen named when none is shown yet (dropped if the app goes to the background first). A link without `utm_*` changes nothing. `utm_source`, `utm_medium` and `utm_campaign` in a screen's properties name its campaign, and a view with another campaign starts a new visit.

**Checkout.swift**

```swift
// With the publishable key, only the project's public event names
Ravenkeep.track("order.placed", properties: ["items": 3, "plan": "pro"])
```

`track` queues a custom event. With the publishable key, Ravenkeep accepts only the names listed in the project's Install tab, under Public events; anything else is refused and dropped, so send it from your backend with the Node SDK. Names are 1 to 100 characters and never start with `$`. Properties are copied before the call returns, so your app may change its dictionaries afterwards: strings, booleans, numbers (NaN and infinities as `null`), `Date` (ISO 8601), `URL`, `UUID`, `NSNull`, dictionaries, arrays and sets, anything else as its description; a container inside itself reads `null`, nesting stops at 16 levels, and properties over 32 KiB or 16,384 values are dropped with a debug line.

[Naming events and what they power](https://ravenkeep.io/docs#events)

## Visits and engaged time

The SDK follows the same session and engaged-time rules as the browser and Android SDKs, so web and app visits compare in Metrics, then Sessions, Pages & screens and Features:

- **Visits.** A visit continues while events come within 30 minutes of each other (by the device's clock) and lasts less than 24 hours. It ends on `reset()`, on `identify()` of another user and on a view with another campaign. It is stored, so a relaunch within 30 minutes continues it; going to the background ends nothing. A visit that starts while a view is on screen (at the first event in the foreground after 30 minutes without one, the app just back or not; an event in the background opens the visit without it, and the next one in the foreground names it) opens on that view: its `$screen` is sent again first, without `utm_*`. When that first event is a view (a deep link into the screen shown, a new screen), it opens the visit itself with its campaign, so a link that brings the app back makes one visit. Every event carries its `sessionId`; `getSessionId()` returns it, to place a server event in the visit. Ravenkeep times the events when they arrive, so events held back over 30 minutes (a phone offline) split their visit in the reports, as on the web.
- **Engaged time.** A view's time counts while the app is in the foreground (active or inactive: a scene on screen, Control Center or the app switcher over it), for at most 60 seconds after the view started, the app came back or the person last touched the screen or typed. It is sent as `$screenleave` with `screen` and `engaged_ms` when the view ends, when the app goes to the background and every 15 minutes; later segments of the same view carry `resumed: true`.
- **Input.** A touch beginning or moving in any of your app's windows (a passive gesture recognizer the SDK adds to each window: it never recognizes, delays or cancels a touch) and a text change in a `UITextField` or `UITextView`.
- **Background.** When the app leaves the foreground, the view's segment and the queue are sent inside a background task, ended when the request answers or iOS expires it.
- **iPad with several windows.** The app is in the foreground while any of its scenes is; the SDK keeps one current view for the app, the last screen named in any window.

> **What the SDK cannot see**
>
> A pointer hovering or a trackpad's scroll on iPad, a hardware key that changes no text, another process's windows (system alerts), and whether your own player is audible; the keyboard counts as your app's (a tap on it is the person typing). Call `reportInteraction()` from those places, and while a player plays with sound, so their time counts.

**Player.swift**

```swift
// Every few seconds while your player plays unmuted
if player.timeControlStatus == .playing && !player.isMuted && player.volume > 0 {
    Ravenkeep.reportInteraction()
}
```

[Sessions, time on screens and features](https://ravenkeep.io/docs#events)

## Crashes

With `captureCrashes` on, the SDK reports uncaught `NSException`s and crash signals (`SIGSEGV`, `SIGBUS`, `SIGILL`, `SIGTRAP`, `SIGABRT`, `SIGFPE`; Swift's runtime traps, such as a force unwrap of `nil`, an index out of range or `fatalError`, are `SIGTRAP` or `SIGILL`). The report is written on the crashing thread, then the handler installed before the SDK's runs (another crash reporter's, or the system's), so the process still ends by its own signal. It leaves on the next launch, before the events, with the crashing thread's stack, the Swift runtime's message, the device and the user identified at the time.

- The signal handler calls only async-signal-safe functions and reads what was prepared before the crash: the list of loaded images (their UUIDs and load addresses), the paths and the scope. A stack overflow on the main thread, or on the thread that called `start`, is reported too.
- On the next launch each frame is named where the same binary is loaded again (the function names a debug build or an unstripped binary keeps); when the app was updated between the crash and the relaunch, its own frames stay addresses. dSYMs are not uploaded: a stripped release build's own frames stay addresses, with their image's UUID and offset.
- Ravenkeep groups Swift and Objective-C stacks by their in-app functions, ignoring closure numbers, specializations, thunks and addresses, so the same bug groups alike across releases. A stripped release build groups by the crash's type, its system frames and its message.
- Not captured: Mach exceptions, other threads' stacks, a stack overflow on another thread, terminations by `SIGKILL` (the watchdog, memory pressure, a force quit), app hangs, a crash before `start` or in the few milliseconds after it, before the SDK has read its files and installed its handlers, and a crash before the device's first unlock after a reboot (iOS's data protection refuses the report's file then).
- The store keeps at most 10 reports, 2.5 MiB and 3 of the same crash, each for 5 days: a crash loop never fills the device. A crash in the first instant of every launch can end the process before its report leaves; it is sent on the first launch that lives long enough.
- A debugger takes a crash's signal first: test crashes from a launch without Xcode attached.

**Sync.swift**

```swift
do {
    try sync.run()
} catch {
    Ravenkeep.captureException(error, tags: ["job": "sync"])
}
```

`captureException(_:tags:)` reports a handled Swift `Error` (level `error`) with the stack of the call: its type and description, an `NSError` by its domain and code. It also takes a caught `NSException`, with its own stack. At most 10 a minute, with up to 50 tags.

[How crashes are grouped](https://ravenkeep.io/docs#crashes)

## Delivery

- **On disk.** Events wait in a queue on disk, at most 1,000 events, 2 MiB and 5 days (the oldest go first), and leave in batches of up to 100 (512 KiB): when `flushAt` is reached, `flushInterval` after the first one, when the app goes to the background, at launch and on `flush()`.
- **Exactly once.** Every event and crash gets its `messageId` when queued, and its file is deleted only once Ravenkeep answered. A retry after a lost answer resends the same id, and Ravenkeep stores it once.
- **Retries.** Network errors, timeouts, `408`, `429` and `5xx` are retried with exponential backoff and jitter from 1 second up to 5 minutes, honouring `Retry-After`. A refused key or app (`401`, `403`) and a redirect (a proxy, an `http://` host: never followed) keep the queue and retry every 5 minutes. Items Ravenkeep refuses (a name that is not public, invalid properties) are dropped, not retried.
- **Timeouts.** Requests go through an ephemeral `URLSession` (no cookies, no cache) and give up after 10 seconds without data and 60 seconds in all; past 60 seconds the SDK cancels the request itself and retries it later, whatever the network stack does.
- **Threads.** Disk and network never run on the caller's thread, and no method throws or traps: the calls hand their work to the SDK's queue. On the caller's thread run only `track`, `screen` and `captureException` copying their arguments (`captureException` also reads the call's return addresses), `reportInteraction()` updating a few fields, `handleDeepLink` reading the URL's query, `start` reading the main bundle's identifier and version and installing the crash handlers' alternate stack, and `getSessionId()`, which may wait up to 1 second for the SDK's queue (call it off the main thread), as `isOptedOut()` may right after `start` or while an `optIn()` is on its way through that queue (right after `optOut()`, it answers `true` at once). The crash handlers alone write on their (crashing) thread.

## Privacy and consent

`optOut()` stops everything and deletes the queue, the stored crashes, the visit, the identity and the anonymous id; the choice is stored (a marker file synced to the disk, so a damaged state file never turns the SDK back on), and the SDK stays off on later launches until `optIn()`. Opted out, the crash handlers only hand over to the previous ones. To ask for consent first, start opted out:

**ShopApp.swift**

```swift
Ravenkeep.start(RavenkeepOptions(publishableKey: "rk_pub_lumen_xxxxxxxxxxxx", startOptedOut: true))

// When the person consents, and if they withdraw
Ravenkeep.optIn()
Ravenkeep.optOut()
```

`optOut()` applies at once: no call made after it sends anything, and `isOptedOut()` answers `true` at once. `optIn()` takes effect after the calls made before it, so an event tracked while opted out and still on its way is dropped, and an `optOut()` made after it before it took effect leaves the SDK off. The SDK keeps its files in `Application Support/io.ravenkeep`, excluded from backups: a restored device starts with a new anonymous id. It reads no advertising identifier. Before the first unlock after a reboot (a launch in the background), iOS's data protection keeps the SDK's files unreadable: the SDK deletes and rewrites nothing, and the calls made meanwhile wait in memory, each with the time it was made (at most 500 events), until it can read them, or until the device is unlocked (the app in the foreground, or iOS saying its protected data is available): a state file still unreadable then is a damaged one, and the SDK starts as on a first run. `flush()` answers `false` and `getSessionId()` `nil` meanwhile. `optOut()` applies at once even then: the crash handlers stop writing reports, the waiting events are dropped, and the stored choice is written as soon as it can be.

## API reference

Every method is a static method of `Ravenkeep`, may be called from any thread and never throws or traps. Before `start` the calls do nothing and write a line to the log (`debug` or not: there are no options yet); `identify`'s completion gets `not_initialized` and `flush`'s `false`, at once, on the caller's thread. Otherwise completions run on the SDK's own queue. The SDK's types (`RavenkeepOptions`, `RavenkeepError`, `RavenkeepAccount`, `IdentifyResult`, `IdentifySession`) are `Sendable` and the completions `@Sendable`, so an app in the Swift 6 language mode uses them as they are.

| Method | Returns | Description |
| --- | --- | --- |
| `start` | `Void` | Starts the SDK with RavenkeepOptions; later calls are ignored. |
| `identify` | `Void` | Opens the user's widget session (userId, userHash, email, name, account); the completion gets its own call's IdentifyResult, or superseded. |
| `track` | `Void` | Queues a custom event with optional properties; the publishable key accepts public event names only. |
| `screen` | `Void` | A screen view ($screen), ending the previous view's engaged time. |
| `captureException` | `Void` | Reports a handled Error, or a caught NSException, (level error) with optional tags, at most 10 a minute. |
| `reportInteraction` | `Void` | The person is there although the SDK saw no input: a player with sound, a pointer, a hardware keyboard. |
| `handleDeepLink` | `Void` | A deep link opened the app or a screen: its utm_* parameters name the current view again, or the next one. |
| `flush` | `Void` | Sends what is queued now; the completion gets true when nothing is left queued. |
| `reset` | `Void` | Sign-out: ends the current view, then forgets the identity, its token, the anonymous id and the visit. |
| `optOut` | `Void` | Stops everything and deletes what the SDK kept; stored across launches. |
| `optIn` | `Void` | Turns the SDK back on after optOut or startOptedOut, with a new anonymous id, after the calls made before it. |
| `isOptedOut` | `Bool` | Whether the SDK is opted out; right after start, or while an optIn is on its way, it may wait up to 1 second. |
| `getSessionId` | `String?` | The current visit's id, never extending it; may wait up to 1 second, so call it off the main thread. |

SwiftUI views gain `.ravenkeepScreen(_:properties:)`. Every request carries the publishable key in the `apikey` header, `X-Ravenkeep-SDK: ios/<version>` and `X-Ravenkeep-App: ios:<bundle id>`, never an `Origin`; batches of an identified user add the session token in `X-Ravenkeep-Widget-Token`.

| Method | Request to https://api.ravenkeep.io |
| --- | --- |
| `identify` | `POST /v1/widget/session` |
| `track`, `screen`, engaged time | `POST /v1/events` (batched) |
| `captureException`, captured crashes | `POST /v1/crashes` (batched) |

## Errors and troubleshooting

Errors reach your code through `identify`'s completion only: `result.error` is a `RavenkeepError` with a stable `code` and the HTTP `status` (0 when no answer came). Delivery handles its own failures (above); with `debug: true`, the unified log says what happened.

| Code | Status | Meaning |
| --- | --- | --- |
| `not_initialized` | 0 | `identify` before `Ravenkeep.start`. |
| `invalid_input` | 0 | The user id is empty, over 255 characters or holds control characters. |
| `opted_out` | 0 | The SDK is opted out: nothing is sent. |
| `not_identified` | 0 | Another user was identified, or `reset()` was called, before this session opened. |
| `superseded` | 0 | A later `identify` of the same user with other input was made while this one was in flight, or waiting for the SDK's files before the first unlock; the later call goes on. |
| `network_error` | 0 | The request never reached Ravenkeep. |
| `timeout` | 0 | 10 seconds without data, or no answer within 60 seconds in all (the request is then cancelled). |
| `invalid_response` | 2xx | The answer had no session token (a proxy or captive portal page in between). |
| `invalid_api_key` | 401 | The key is unknown or was rotated. |
| `identity_verification_failed` | 401 | The `userHash` is missing or does not match the user id. |
| `app_not_allowed` | 403 | The bundle ID is not listed in the project's Apps. |
| `rate_limited` | 429 | More than 20 requests per second from one address, or 200 for the whole project, in a route family. |
| `http_<status>` | the status | Another answer without an error code, a redirect included. |

- **Nothing arrives.** Check that the bundle ID, exactly as built, is in the project's Apps, and turn on `debug` to see the answers in the unified log (Console.app, subsystem `io.ravenkeep`).
- **No crash reports.** A debugger was attached: it takes the signal before the SDK. Launch the app from the home screen, crash it, then launch it again.
- **Crashes and events show as anonymous.** They were sent before the first `identify` succeeded, after `reset()`, or on a launch that has not called `identify` yet once the stored token expired (12 hours at most).
- **Custom events are dropped.** The name is not one of the project's public event names: send it from your backend with the Node SDK.

## Compatibility

- iOS 13 and newer, from Swift (tools version 5.9: Xcode 15 or newer). The SwiftUI modifier names a screen again when its properties change on iOS 14 and newer. App extensions and Objective-C callers are not supported yet.
- Status: built and tested on Linux; the UIKit/SwiftUI layer is pending validation on Apple platforms.
- Semantic versioning: patches fix, minors add, majors change a contract. Releases are tagged `sdk-ios-vX.Y.Z` in Ravenkeep's repository and `X.Y.Z` in the package's; before 1.0 a minor release may change the API.

[Continue with the Android SDK](https://ravenkeep.io/docs/android)

---

Markdown rendering of https://ravenkeep.io/docs/ios. The other docs pages in Markdown: https://ravenkeep.io/docs/index.md, https://ravenkeep.io/docs/node.md, https://ravenkeep.io/docs/browser.md, https://ravenkeep.io/docs/android.md, https://ravenkeep.io/docs/import.md.
