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
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() } }
}#import <SankofaIOS/SankofaIOS-Swift.h>
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
SankofaConfig *config = [[SankofaConfig alloc]
initWithEndpoint:@"https://api.sankofa.dev"
debug:NO
trackLifecycleEvents:YES
flushIntervalSeconds:30
batchSize:50
recordSessions:YES
maskAllInputs:YES];
[[Sankofa shared] initializeWithApiKey:@"sk_live_..." config:config];
return YES;
}SankofaConfig properties
endpointStringdefault https://api.sankofa.devdebugBooldefault falsetrackLifecycleEventsBooldefault trueflushIntervalSecondsTimeIntervaldefault 30batchSizeIntdefault 50recordSessionsBooldefault truemaskAllInputsBooldefault truecaptureScaleCGFloatdefault 0.35enableCatchBooldefault truecatchEnvironmentStringdefault livereleaseString?appVersionString?beforeSendBeforeSendFn?catchStallThresholdSecondsDoubledefault 2.0Analytics — events, identify, setPerson
// 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()[[Sankofa shared] track:@"checkout_started"
properties:@{@"cart_value": @49.99, @"item_count": @3}];
[[Sankofa shared] identifyWithUserId:@"user_123"];
[[Sankofa shared] reset];
[[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.
// 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 handlers —
SIGSEGV,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), ananrevent is emitted. Set the threshold to0to disable. - Auto-discovered flag/config snapshots — every event carries the active
flag_snapshot+config_snapshotif Switch/RemoteConfig are linked.
withScope — Sentry-style temporary scope
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
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:
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.
_ = 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:
Task {
let registered = await SankofaPulse.shared.register()
if !registered {
print("Sankofa Core must be initialized before Pulse.")
}
}Show a survey:
SankofaPulse.shared.show(surveyId: "srv_post_purchase_feedback")Listen for events:
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:
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:
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
| Method | Description |
|---|---|
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 / addBreadcrumb | Ambient 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. |