Mobile

iOS SDK

Native Swift framework with Objective-C interop. Six products bundled — Analytics, Catch, Switch, Config, Pulse, Replay — distributed via Swift Package Manager.

The Sankofa iOS SDK is a native Swift framework with first-class Objective-C interop. The product set on iOS is six: Analytics, Catch, Switch, Config, Pulse, and Replay. Deploy / OTA is not available on iOS; the engine recognises the module on the handshake but the iOS SDK doesn't ship an implementation. For OTA on iOS, use the React Native SDK in a hybrid app.

Distribution: Swift Package Manager. Apple Privacy Manifest support is bundled — the SDK ships its own PrivacyInfo.xcprivacy.

For installation and project setup, see Install on iOS. This page is the all-products reference.

Requirements

  • Xcode 15+ (Swift 5.9+)
  • iOS 13+ deployment target
  • Swift Package Manager

Initialize

SwiftApp.swift
import SwiftUI
import SankofaIOS

@main
struct YourApp: App {
  init() {
      // One-line init. enableCatch=true (default) auto-installs the
      // NSException + POSIX-signal handlers + main-queue stall detector.
      // No separate SankofaCatch.shared.start(...) call needed.
      Sankofa.shared.initialize(
          apiKey: "sk_live_...",
          config: SankofaConfig(
              endpoint: "https://api.sankofa.dev",
              recordSessions: true,
              maskAllInputs: true,
              catchEnvironment: "production",
              release: "[email protected]",
              appVersion: "2.4.1"
          )
      )
  }

  var body: some Scene { WindowGroup { ContentView() } }
}

SankofaConfig properties

endpointStringdefault https://api.sankofa.dev
Server base URL.
debugBooldefault false
Verbose console logging during development.
trackLifecycleEventsBooldefault true
Auto-track app open / foreground / background.
flushIntervalSecondsTimeIntervaldefault 30
Foreground flush cadence.
batchSizeIntdefault 50
Maximum events per batch upload.
recordSessionsBooldefault true
Enable Replay capture (screenshot mode).
maskAllInputsBooldefault true
Auto-mask all UITextField/UITextView views in replays.
captureScaleCGFloatdefault 0.35
Replay screenshot resolution. Lower = smaller files, less detail.
enableCatchBooldefault true
Auto-install Catch + NSException + POSIX signal handlers + main-queue stall detector. Set false to defer Catch boot.
catchEnvironmentStringdefault live
Environment tag stamped on every captured event.
releaseString?
Release identifier (e.g. '[email protected]') sent on every Catch event.
appVersionString?
App version override sent in the Catch device context.
beforeSendBeforeSendFn?
Synchronous hook fired AFTER event composition but BEFORE enqueue. Return modified event or nil to drop.
catchStallThresholdSecondsDoubledefault 2.0
Main-queue stall threshold. When the main queue wedges longer than this, an 'anr' event is emitted. Set to 0 to disable.

Analytics — events, identify, setPerson

Swift
// Track an event
Sankofa.shared.track("checkout_started", properties: [
  "cart_value": 49.99,
  "item_count": 3
])

// Tag the current screen
Sankofa.shared.screen("Checkout")

// Identify after sign-in
Sankofa.shared.identify(userId: "user_123")

// Update profile traits
Sankofa.shared.setPerson(
  name: "Ada Lovelace",
  email: "[email protected]",
  avatar: nil,
  properties: ["company": "Sankofa Ltd"]
)

// Logout — rotates session, clears identity
Sankofa.shared.reset()

// Force-flush
Sankofa.shared.flush()

The SQLite-backed queue is thread-safe (GRDB takes care of write serialization). Events fired offline persist across app suspends and force-quits and replay on next launch.

Catch — error capture

Catch auto-boots inside Sankofa.shared.initialize. Once init resolves, every Sankofa.captureException / Sankofa.log call routes to the singleton — no separate SankofaCatch.shared.start(...) call needed. Crashes serialize synchronously and upload on the next launch.

Swift
// Capture a handled error from anywhere — Sentry-style.
do {
  try chargeCard(amount)
} catch {
  Sankofa.captureException(error)
}

// Capture a non-error event (warning-level).
Sankofa.captureMessage("Payment retry attempted")

// Crashlytics-style breadcrumb log — rides on the next captured
// event. Doesn't bill on its own.
Sankofa.log("checkout: applying coupon SUMMER25")

// Attach ambient context. Sticky — every subsequent event carries it.
Sankofa.setUser(CatchUserContext(id: "user_123", email: "[email protected]"))
Sankofa.setTag("flow", "checkout")
Sankofa.setExtra("cart_id", AnyCodable(cart.id))

// Structured breadcrumb.
Sankofa.addBreadcrumb(CatchBreadcrumb(
  type: "user-action",
  category: "ui",
  message: "Tapped checkout button"
))

Automatic coverage

Sankofa.shared.initialize installs:

  • NSSetUncaughtExceptionHandler — chained so the previous handler (Crashlytics, etc.) still fires after Sankofa records the event.
  • POSIX signal handlersSIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE, SIGTRAP, SIGSYS. Dumps written async-signal-safely and drained on the next launch.
  • Main-queue stall detector — background timer pings a sentinel block onto the main queue; if it doesn't fire within catchStallThresholdSeconds (2.0s by default, matching Sentry), an anr event is emitted. Set the threshold to 0 to disable.
  • Auto-discovered flag/config snapshots — every event carries the active flag_snapshot + config_snapshot if Switch/RemoteConfig are linked.

withScope — Sentry-style temporary scope

Swift
Sankofa.withScope { scope in
  scope.setTag("checkout_step", "payment")
  scope.setExtra("cart_id", AnyCodable(cart.id))
  scope.setLevel(.warning)
  Sankofa.captureException(err)
}
// Outside the closure, those tags / extras are gone.

Scopes are stack-scoped to the closure — async captures deferred past the closure's return won't see the scope.

beforeSend — scrub PII / drop noise

Swift
Sankofa.shared.initialize(
  apiKey: "sk_live_...",
  config: SankofaConfig(
      endpoint: "https://api.sankofa.dev",
      beforeSend: { event in
          // Drop framework noise.
          if event.message?.contains("setNeedsLayout from a non-main thread") == true {
              return nil
          }
          // PII scrubbing — strip email.
          if event.user?.email != nil {
              var scrubbed = event
              scrubbed.user?.email = nil
              return scrubbed
          }
          return event
      }
  )
)

Throws inside beforeSend are swallowed — the original event ships unchanged.

Switch — feature flags

SankofaSwitch.shared is a singleton that self-registers with the Module Registry on first access. Seed bundled defaults via withDefaults:

Swift
import SankofaIOS

Sankofa.shared.initialize(
  apiKey: "sk_live_...",
  config: SankofaConfig(endpoint: "https://api.sankofa.dev")
)

_ = SankofaSwitch.shared.withDefaults([
  "new_checkout": false,
  "dark_mode_default": false
])

// Boolean flag
if SankofaSwitch.shared.getFlag("new_checkout") {
  showNewCheckout()
}

// Variant flag
let variant = SankofaSwitch.shared.getVariant("checkout_redesign", default: "control")

// Full decision envelope
if let decision = SankofaSwitch.shared.getDecision("new_checkout") {
  print(decision.value, decision.reason)
}

// Subscribe to changes (e.g. halt-webhook fire)
let token = SankofaSwitch.shared.onChange("new_checkout") { decision in
  DispatchQueue.main.async { /* update UI */ }
}

// Cancel later
token.cancel()

// Inspect available keys
let keys = SankofaSwitch.shared.getAllKeys()

The SDK forwards anon_id automatically on the next handshake after Sankofa.shared.identify(...) runs, so cohort-targeted decisions stay continuous across the login boundary.

Config — remote config

SankofaRemoteConfig.shared is named distinctly from SankofaConfig (the init-options struct). Different responsibility, different type.

Swift
_ = SankofaRemoteConfig.shared.withDefaults([
  "max_upload_mb": 25,
  "support_email": "[email protected]"
])

// Generic typed read
let maxMB: Int = SankofaRemoteConfig.shared.get("max_upload_mb", default: 25)
let email: String = SankofaRemoteConfig.shared.get("support_email", default: "[email protected]")

// Decision envelope
let decision = SankofaRemoteConfig.shared.getDecision("max_upload_mb")

// Inspect everything
let allValues = SankofaRemoteConfig.shared.getAll()
let keys = SankofaRemoteConfig.shared.getAllKeys()

// Subscribe to changes
let token = SankofaRemoteConfig.shared.onChange("max_upload_mb") { decision in
  print("max_upload_mb is now \(decision.value)")
}

A single generic get<T>(_:default:) infers the return type from the default. The SDK validates the engine's payload against the declared type at decode time.

Pulse — surveys

Register Pulse after Sankofa.shared.initialize:

Swift
Task {
  let registered = await SankofaPulse.shared.register()
  if !registered {
      print("Sankofa Core must be initialized before Pulse.")
  }
}

Show a survey:

Swift
SankofaPulse.shared.show(surveyId: "srv_post_purchase_feedback")

Listen for events:

Swift
let subscription = SankofaPulse.shared.on(.completed) { event in
  print("Survey \(event.surveyId) completed")
}
// later: subscription.cancel()

Inspect which surveys are eligible for the current user:

Swift
SankofaPulse.shared.activeMatchingSurveys { surveys in
  print("Eligible:", surveys.map(\.id))
}

Session replay

The replay engine renders a copy of your view hierarchy to an off-screen UIGraphicsImageRenderer buffer and applies masks before serialization. The live UI is never touched. Mode is screenshot only on iOS today — there's no wireframe / screenshot mode toggle.

Masking

Masking is automatic when config.maskAllInputs is true (the default) — every UITextField and UITextView is blacked out in the buffer.

For custom masking, tag a view by setting view.tag to a sentinel value the SDK looks for. The exact tag-name convention and any SwiftUI view-modifier helpers are documented in the SDK's replay/SankofaMask.swift source.

SwiftUI scroll-offset tagging

UIKit UIScrollView (and subclasses: UITableView, UICollectionView) are walked from the key window's view tree by SankofaTouchInterceptor automatically — heatmaps and replay touch attribution see the right Y offset out of the box on iOS 16+ where SwiftUI's ScrollView bridges to a UIScrollView.

For custom scroll containers, LazyVGrid inside opaque hosting views, or pre-iOS 16 SwiftUI hosts where the walk returns zero, register an explicit provider:

Swift
struct ProductList: View {
  @State private var scrollOffset: CGFloat = 0
  @State private var sankofaHandle: SankofaScrollContainerHandle?

  var body: some View {
      ScrollView {
          LazyVStack { /* ... */ }
              .background(GeometryReader { geo in
                  Color.clear.preference(
                      key: ScrollOffsetKey.self,
                      value: -geo.frame(in: .named("scroll")).minY
                  )
              })
      }
      .coordinateSpace(name: "scroll")
      .onPreferenceChange(ScrollOffsetKey.self) { scrollOffset = $0 }
      .onAppear {
          sankofaHandle = Sankofa.shared.tagScrollContainer { scrollOffset }
          Sankofa.shared.screen("ProductList")
      }
      .onDisappear {
          sankofaHandle?.remove()
          sankofaHandle = nil
      }
  }
}

Sankofa.shared.tagScrollContainer { ... } returns a SankofaScrollContainerHandle — call .remove() when the scroll container leaves scope. Handles are idempotent: removing twice is a no-op. Multiple registrations are allowed (nested scrollables); providers iterate in registration order and the first non-zero result wins, matching the "first scrollable wins" semantics of the UIKit walker.

A host callback that throws will surface clearly during development.

Privacy Manifest

The SDK bundles PrivacyInfo.xcprivacy declaring:

  • NSPrivacyAccessedAPICategoryUserDefaults (CA92.1)
  • NSPrivacyAccessedAPICategoryFileTimestamp (C617.1)
  • Device ID + product interaction tracking categories
  • No method swizzling

App Store Connect picks up these declarations alongside your own.

API summary

MethodDescription
Sankofa.shared.initialize(apiKey:config:)Initialize the SDK.
Sankofa.shared.track(_:properties:)Record an event.
Sankofa.shared.screen(_:properties:)Tag the current screen.
Sankofa.shared.identify(userId:)Stitch anonymous → known.
Sankofa.shared.setPerson(name:email:avatar:properties:)Update profile traits.
Sankofa.shared.reset()Rotate session + clear identity.
Sankofa.shared.flush()Force-drain the queue.
Sankofa.captureException(_:)Capture an error (auto-routes to Catch singleton).
Sankofa.captureMessage(_:)Capture a non-error event.
Sankofa.log(_:[category:])Crashlytics-style breadcrumb.
Sankofa.setUser / setTag / setTags / setExtra / addBreadcrumbAmbient context.
Sankofa.withScope(_:)Temporary scope overlay.
Sankofa.flushCatch()Force-flush Catch events.
Sankofa.shared.tagScrollContainer { offset }Register a SwiftUI / custom scroll-offset provider. Returns a SankofaScrollContainerHandle whose .remove() unregisters (idempotent).
SankofaSwitch.shared.getFlag(_:default:)Boolean feature flag.
SankofaSwitch.shared.getVariant(_:default:)Variant feature flag.
SankofaSwitch.shared.getDecision(_:)Full decision envelope.
SankofaRemoteConfig.shared.get(_:default:) (generic over T)Typed remote config.
SankofaPulse.shared.register()Initialize Pulse (async).
SankofaPulse.shared.show(surveyId:)Manually show a survey.

What's next

Edit this page on GitHub