SDK reference · iOS

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.

The documentation is in English only. Ravenkeep em português

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

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

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.swiftSwift
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:

OptionDefaultMeaning
publishableKeynoneThe project publishable key, rk_pub_.... A secret key is refused and never sent.
hosthttps://api.ravenkeep.ioAPI origin. Change it only for a self-hosted Ravenkeep.
environmentproductionThe crashes' environment: crash groups are kept per environment.
releasethe app's CFBundleShortVersionStringThe crashes' release, so a resolved crash regresses only in a newer one.
captureCrashestrueReport uncaught NSExceptions and crash signals, sent on the next launch.
startOptedOutfalseStart opted out until optIn(), on a device where the SDK never ran (consent).
debugfalseLog what the SDK does to the unified log (subsystem io.ravenkeep); never a key, a token or a user hash.
flushAt20Send as soon as this many events are queued (1 to 100).
flushInterval5Send 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.swiftSwift
// 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

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.swiftSwift
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.swiftSwift
// 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

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.
Player.swiftSwift
// 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

Crashes

With captureCrashes on, the SDK reports uncaught NSExceptions 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.swiftSwift
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

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

  • startVoid

    Starts the SDK with RavenkeepOptions; later calls are ignored.

  • identifyVoid

    Opens the user's widget session (userId, userHash, email, name, account); the completion gets its own call's IdentifyResult, or superseded.

  • trackVoid

    Queues a custom event with optional properties; the publishable key accepts public event names only.

  • screenVoid

    A screen view ($screen), ending the previous view's engaged time.

  • captureExceptionVoid

    Reports a handled Error, or a caught NSException, (level error) with optional tags, at most 10 a minute.

  • reportInteractionVoid

    The person is there although the SDK saw no input: a player with sound, a pointer, a hardware keyboard.

  • handleDeepLinkVoid

    A deep link opened the app or a screen: its utm_* parameters name the current view again, or the next one.

  • flushVoid

    Sends what is queued now; the completion gets true when nothing is left queued.

  • resetVoid

    Sign-out: ends the current view, then forgets the identity, its token, the anonymous id and the visit.

  • optOutVoid

    Stops everything and deletes what the SDK kept; stored across launches.

  • optInVoid

    Turns the SDK back on after optOut or startOptedOut, with a new anonymous id, after the calls made before it.

  • isOptedOutBool

    Whether the SDK is opted out; right after start, or while an optIn is on its way, it may wait up to 1 second.

  • getSessionIdString?

    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.

MethodRequest to https://api.ravenkeep.io
identifyPOST /v1/widget/session
track, screen, engaged timePOST /v1/events (batched)
captureException, captured crashesPOST /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.

CodeStatusMeaning
not_initialized0identify before Ravenkeep.start.
invalid_input0The user id is empty, over 255 characters or holds control characters.
opted_out0The SDK is opted out: nothing is sent.
not_identified0Another user was identified, or reset() was called, before this session opened.
superseded0A 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_error0The request never reached Ravenkeep.
timeout010 seconds without data, or no answer within 60 seconds in all (the request is then cancelled).
invalid_response2xxThe answer had no session token (a proxy or captive portal page in between).
invalid_api_key401The key is unknown or was rotated.
identity_verification_failed401The userHash is missing or does not match the user id.
app_not_allowed403The bundle ID is not listed in the project's Apps.
rate_limited429More than 20 requests per second from one address, or 200 for the whole project, in a route family.
http_<status>the statusAnother 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