@cross-deck/swift — Swift SDK reference
@cross-deck/swift is one native SDK that handles all three Crossdeck pillars across Apple platforms: verified subscriptions and entitlements, behavioural analytics, and error capture. Start one Crossdeck client at app launch and every screen tap, paywall gate, and uncaught exception flows through the same durable queue, the same idempotent network path, and the same dashboard your web users do. Zero runtime dependencies — built on Foundation / URLSession / os.Logger only.
TL;DR
- One Swift Package, three pillars. One dependency —
@cross-deck/swift— covers analytics, subscription/entitlement gating, and error capture across iOS / iPadOS / macOS / tvOS / watchOS. No separate Sentry + Mixpanel + RevenueCat install. - Bank-grade durability.
track(...)returns sync; the event sits in an actor-isolated buffer, persists toUserDefaultson every enqueue, and ships in batches with a stable per-batchIdempotency-Keyreused across retries. A crash mid-flush leaves the pending batch on disk; the next launch rehydrates and re-sends with the same key. - Microsecond entitlement gates.
cd.isEntitled("pro")is a synchronous in-memory cache read scoped to the currently-identifieddeveloperUserId— safe to call from a paywall tap handler. The cache never serves another customer's entitlements; switching users viaidentify(...)unconditionally clears it. - Built for Apple lifecycle. Background & foreground transitions auto-trigger a queue persist + flush so a force-quit while suspended can't lose buffered events.
NSException+ manualcaptureError(...)route through one path with breadcrumbs attached. - Strict concurrency by construction. Every piece of mutable state lives behind a Swift actor (
EventQueue,Identity,EntitlementCache,SuperProperties,Breadcrumbs). All cross-actor types areSendable. The SDK compiles clean with-Xfrontend -enable-experimental-feature StrictConcurrency. - PII scrubbing on by default. Emails are replaced with
<email>, payment-card numbers with<card>, recursively across nested dictionaries and arrays — same vocabulary as the Web, Node, and React Native SDKs. The persisted queue never holds raw PII, so a crash dump of disk state is safe to share with support. - Zero runtime dependencies. Implemented against
Foundation,URLSession, andos.Loggeronly. No OkHttp, no Alamofire, no swift-log — your build never inherits a third-party version conflict from us.
Install
One Swift Package covers every Apple platform we support. The library target ships as a single module — import Crossdeck brings the entire public API into scope. Tested on iOS 13+, iPadOS 13+, macOS 11+, tvOS 13+, and watchOS 7+.
| Platform | Minimum target | Notes |
|---|---|---|
| iOS / iPadOS | 13.0+ | StoreKit purchase rail features require iOS 15+ at runtime; the SDK still compiles + runs on 13/14 for non-purchase consumers (analytics, errors). |
| macOS | 11.0 Big Sur+ | Mac Catalyst apps report platform: "macos". |
| tvOS | 13.0+ | Same SDK; no UIDevice-specific code paths required. |
| watchOS | 7.0+ | Same SDK; lightweight enough to run inside a complication update. |
Swift Package Manager — Xcode UI
- File → Add Package Dependencies…
- Paste the URL into the search field (top-right of the dialog):
https://github.com/VistaApps-za/crossdeck-swift.git - In the Dependency Rule dropdown on the right, select "Up to Next Major Version" and enter
1.5.0. Do not leave it set to "Branch: main" — branch tracking auto-pulls every commit including breaking changes when v2.0.0 lands. "Up to Next Major Version" gives you patch + minor updates automatically and lets you choose when to take breaking changes. - Click Add Package. Xcode resolves the package and offers to add the
Crossdecklibrary product to your app target — accept.
Branch: main, switch it to a tag.
If you added the Crossdeck package before stable tags existed, Xcode auto-anchored your project to Branch: main. That works but pulls every new commit silently. Do not try to fix this via File → Add Package Dependencies… — that dialog is hard-blocked from changing the rule on an existing dependency (the Dependency Rule dropdown greys out with "already depends on … with rule main" at the bottom). Don't waste time fighting it.
Path that works — change the rule in place via the project's Package Dependencies tab:
- In Xcode's file navigator (left sidebar), click your project's top-level entry — the one with the blue Xcode icon (the
.xcodeprojfile). - The main editor now shows project settings. On the LEFT side of that editor are two columns: PROJECT and TARGETS. Click your project (e.g. Clippi) under PROJECT — not under TARGETS. (Easy to mix up; the rule editor only lives on the project, not the target.)
- Along the top tab bar of the editor, click Package Dependencies (next to Info, Build Settings, Build Phases).
- You'll see a table of every package your project depends on. Find
crossdeck-swift. - Double-click the
crossdeck-swiftrow. A small sheet pops up with the Dependency Rule editor — this is the ONLY UI in Xcode that can change a rule on an already-added package. - In the sheet, change the rule from Branch to Up to Next Major Version, set the version to
1.5.0. - Click Done. Xcode re-resolves and rewrites
Package.resolved.
If double-click doesn't open the sheet: select the row, then look for a small edit icon (pencil or chevron) on the row — or right-click → Modify Package Settings (exact label varies by Xcode version).
Bulletproof fallback (no Xcode UI): Quit Xcode. Open YourProject.xcodeproj/project.pbxproj in a text editor. Find the XCRemoteSwiftPackageReference "crossdeck-swift" block. Change the requirement entry from branch = main; to kind = upToNextMajorVersion; minimumVersion = 1.0.0;. Save. Re-open Xcode — it picks up the change and re-resolves.
Swift Package Manager — Package.swift
dependencies: [
.package(url: "https://github.com/VistaApps-za/crossdeck-swift.git", from: "1.0.0"),
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "Crossdeck", package: "crossdeck-swift"),
]
),
]
from: "1.5.0" is the SwiftPM shorthand for "Up to Next Major Version" — same rule as the Xcode picker. SwiftPM will resolve to the highest 1.x.x available, so future patches/minors land automatically; major bumps require an explicit rule change.
Crossdeck (no scope).
The repository is crossdeck-swift, the package name (in Package.swift) is crossdeck-swift, and the library product you import is Crossdeck. SwiftPM uses the product name for the import statement.
Quickstart — five lines, full mobile pillar
The fastest path from zero to "events flowing, errors captured, entitlements gated" is one start call in your app delegate or App's init — wrapped in do/catch so a misconfigured key never crashes a customer's launch:
import Crossdeck
let cd: Crossdeck?
do {
cd = try Crossdeck.start(options: CrossdeckOptions(
appId: "app_ios_xxx",
publicKey: "cd_pub_live_…", // or cd_pub_test_… for sandbox
environment: .production // MUST match the key prefix — see Initialise
))
} catch {
cd = nil // log + degrade, never crash the host app
}
// Anywhere in your view code (cd? propagates the Optional):
cd?.track("paywall_seen", properties: ["variant": "annual"])
if cd?.isEntitled("pro") == true {
// gate logic — sync, cache-read fast
}
That single setup gives you, automatically and without writing any more code:
- Durable batched event delivery —
track()returns sync; the SDK flushes atbatchSize(20) orflushIntervalMs(5 s), whichever fires first. - App-background & app-resume
UIApplicationnotification observers wired automatically — when the user backgrounds the app, the buffer persists toUserDefaultsand a best-effort flush runs against the iOS background-execution budget. - An
anonymousIdminted on first launch, persisted across launches, used as the identity envelope on every event untilidentify(...)is called. - PII scrubbing on by default — emails and card numbers replaced with
<email>/<card>tokens before the event ever leaves the device. - Per-batch
Idempotency-Keyguarantees the server dedups retries — the same payload never lands twice. - The entitlement cache is empty until you call
identify(...)and then fetch — but once warmed,isEntitled(...)is a synchronous read suitable for a paywall gate.
SwiftUI surfaces need two one-line bolt-ons
The auto-tracking above covers sessions, errors, queue durability, and identity with zero per-surface code. Two dashboard surfaces specifically — Pages and Top Actions / CTA labels — need one modifier per screen and one per important button on iOS 16+ SwiftUI. This isn't a CrossDeck regression; Apple's Metal text-rendering pipeline and SwiftUI's accessibility-merge model genuinely hide screen names and button labels from any UIView-level runtime introspection (the same wall every analytics SDK hits — Mixpanel, Amplitude, and PostHog all ship the same per-surface modifier pattern).
- Per destination View →
.crossdeckScreen("Name"). Populates the Pages dashboard ("Create Image · 63 views · 14 visitors"). Without it, the SDK's UIKit auto-track has nothing to fire on for pure-SwiftUI screens (UIHostingController is denylisted because tagging it would replay on every navigation push) — Pages shows zero rows. - Per important CTA →
.crossdeckTap("Name"). Populates Top Actions on the page detail and renders "Clicked 'Name'" on the per-person journey. Without it, modern SwiftUI Button taps still fireelement.clickedevents but with an empty label — the journey reads the useless "Clicked an element."
Two lines per screen, two lines per CTA — every other surface on the dashboard works the moment Crossdeck.start(...) succeeds. See the dedicated Screen views and Tap labels sections for the pattern matrix (NavigationStack, NavigationLink, .sheet, .fullScreenCover, TabView; Button, Image with .onTapGesture, custom layouts).
What you'll see on the dashboard
Once the bolt-ons are wired, the per-person journey on /dashboard/people/<id> reads like a real human flow — a diary, not a vanity-metric row count. For a user moving through your app, the timeline will read something like:
08:32:51 AM Opened the app
08:32:52 AM Viewed "Library" 5s
08:32:57 AM Tapped "Create Image"
08:32:57 AM Viewed "Create Image" 32s
08:33:29 AM Tapped "Generate"
08:33:29 AM Viewed "Generated Image" 2m 14s
08:35:43 AM Tapped "Subscribe"
08:35:44 AM Viewed "Paywall" 8s
08:35:52 AM Backgrounded the app 3m 8s
Each Viewed line carries a duration pill — that's time-on-screen, computed from the gap to the next event. The Tapped lines render the CTA label resolved from your .crossdeckTap("Name"). The Opened the app / Backgrounded the app prose is mobile-native (web stays on "Arrived from X" / "Session ended") — driven by the event's platform property.
The Pages tab rolls these up: one row per screen name with views, distinct visitors, total events. Click any row to drill into a single screen's traffic with referrer host, country breakdown, and Top Actions (the .crossdeckTap labels grouped by frequency — your conversion signal for "Generate" vs "Subscribe" vs "Dismiss").
End-to-end latency from cd.track(...) to dashboard render is typically 3–8 seconds (SDK batches at 20 events or 5s flush interval → projector resolves identity → Firestore write → dashboard onSnapshot listener fans the update out). Fast enough to feel real-time during a dogfood walkthrough; not synchronous.
Initialise the SDK
Every Apple app constructs Crossdeck exactly once at process boot. The canonical place is the App's init() (SwiftUI) or the application(_:didFinishLaunchingWithOptions:) (UIKit). The shipping pattern wraps start in do/catch and stores the client as Optional — a typo'd key should log + degrade telemetry, never crash the host app.
// SwiftUI — production-grade boot
@main
struct MyApp: App {
let cd: Crossdeck?
init() {
cd = Self.startCrossdeck()
}
var body: some Scene {
WindowGroup { ContentView().environment(\.crossdeck, cd) }
}
private static func startCrossdeck() -> Crossdeck? {
// Build-configuration switch — Debug builds NEVER route into a
// live dashboard, Release builds NEVER route into sandbox.
#if DEBUG
let publicKey = "cd_pub_test_…"
let environment: Environment = .sandbox
#else
let publicKey = "cd_pub_live_…"
let environment: Environment = .production
#endif
do {
return try Crossdeck.start(options: CrossdeckOptions(
appId: "app_ios_xxx",
publicKey: publicKey,
environment: environment
))
} catch let error as CrossdeckError {
// invalid_secret_key / env_mismatch / missing_app_id —
// recoverable misconfig. Log + carry on without telemetry.
assertionFailure("[Crossdeck] start failed (\(error.code)): \(error.message)")
return nil
} catch {
assertionFailure("[Crossdeck] start failed: \(error)")
return nil
}
}
}
Crossdeck is both the namespace and the instance type.
The factory call let cd: Crossdeck = try Crossdeck.start(...) looks duplicate-typed at first glance. It isn't. Crossdeck the type holds your client instance; Crossdeck.start(...) the static factory returns one. Mirrors Apple's own URLSession / URLSession.shared shape — single short symbol for both surfaces.
Read the injected client in any child view with @Environment(\.crossdeck):
struct PaywallView: View {
@Environment(\.crossdeck) var cd: Crossdeck?
var body: some View {
if cd?.isEntitled("pro") == true {
ProContent()
} else {
Button("Upgrade") {
cd?.track("upgrade_tapped")
}
}
}
}
// UIKit — same pattern, AppDelegate edition
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var cd: Crossdeck?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
cd = do {
try Crossdeck.start(options: CrossdeckOptions(
appId: "app_ios_xxx",
publicKey: publicKey, environment: environment
))
} catch { return nil }()
return true
}
}
How keys and environments fit together
The publishable-key prefix is authoritative. The environment: argument is a redundancy check, not the routing switch:
cd_pub_live_…→ routes to.production.environment: .sandboxwith this prefix throwsenv_mismatch.cd_pub_test_…→ routes to.sandbox.environment: .productionwith this prefix throwsenv_mismatch.publicKeynot starting withcd_pub_→ throwsinvalid_secret_key(never embed acd_sk_…on the device).appIdempty → throwsmissing_app_id.
Drive BOTH from your build configuration (xcconfig, #if DEBUG, environment-specific schemes) so a Debug build can never accidentally embed a live key and Release can never embed a test key. The mismatch check is the safety net, not the source of truth.
Accessing the client outside SwiftUI
@Environment(\.crossdeck) only reaches Views. For services, view models, singletons, UIKit call sites, background workers, and Combine pipelines, use the Crossdeck.current static accessor — a process-singleton that returns the most-recently-started client. Thread-safe, Optional-typed so the call-site idiom is the same cd?. propagation used everywhere else. Shipped in v1.0.2.
// Anywhere in your codebase — services, view models, UIKit, Combine:
final class AuthService {
static let shared = AuthService()
func didSignIn(user: User) {
try? Crossdeck.current?.identify(userId: user.id, email: user.email)
}
}
// Read a paywall gate from a ViewModel:
final class CheckoutViewModel: ObservableObject {
var shouldShowProUI: Bool { Crossdeck.current?.isEntitled("pro") == true }
}
// Track an event from an AppDelegate method:
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {
try? Crossdeck.current?.track("push_received", properties: ["campaign": userInfo["campaign"] as? String ?? ""])
}
Crossdeck.current returns nil before Crossdeck.start(...) has succeeded in this process, or after the current client's stop() is called. The slot only clears when the stopped instance is the one currently advertised, so concurrent start+stop sequences on a second client never clobber the first.
Inside SwiftUI views, prefer @Environment(\.crossdeck) — it participates in SwiftUI's dependency tracking. The static accessor is for the 50% of the codebase that isn't a View.
DI-container alternative. If you use Swinject, Factory, or your own DI graph, register the instance once at boot and inject it through your container. Same pattern, different plumbing:
// In your App.init() right after startCrossdeck() resolves:
init() {
let cd = Self.startCrossdeck()
self.cd = cd
Container.shared.register(Crossdeck?.self) { cd }
}
Keep one client per process; never call Crossdeck.start a second time. The validation that runs inside start is cheap, but the second instance would install a second pair of UIApplication lifecycle observers and the global error-capture singleton would route through the most-recent install — surprising side effects you don't want.
cd_pub_) are safe in the binary — secret keys (cd_sk_) are not.
The publishable key (cd_pub_live_… / cd_pub_test_…) is the same shape Stripe uses for its client keys — safe to ship in your .ipa. Never embed a cd_sk_… secret key on the device; secret keys belong in your backend's secret store only.
Lifecycle & idempotency
The Crossdeck.start(options:) call is synchronous. By the time it returns:
anonymousIdread or freshly generated and persisted toUserDefaults.- Any buffered or pending events from the prior launch are rehydrated and a flush is scheduled.
- Super-properties + entitlement cache are loaded from disk.
UIApplication.willResignActive/didEnterBackgroundobservers are installed (iOS / iPadOS / Mac Catalyst).- If
captureUncaughtExceptions: true, the globalNSExceptionhandler is installed and chained to any prior handler (Crashlytics, Sentry, etc. continue to receive the crash).
The SDK does not auto-initialise via a hidden background mechanism — you control the start moment. If you want deterministic teardown for tests, call cd.stop() to persist all in-memory state and reject subsequent track / identify calls with CrossdeckError(code: "not_initialized").
Identity & users
Three identity primitives — exactly the same vocabulary as the Web, Node, and React Native SDKs:
anonymousId— device handle minted on first launch, persisted inUserDefaults, never regenerated except byreset(). Sent on every event so the server can roll pre-identify analytics into the same person via merge.developerUserId— your auth provider's stable user ID (Firebase Auth'suid, Auth0'ssub, Supabase'sid, Sign In with Apple'suserIdentifier, etc.). Passed in as theuserId:argument toidentify(...).crossdeckCustomerId— Crossdeck's canonicalcdcust_…record handle, returned by the server-side alias call (planned for Swift v1.1). NEVER confuse this withdeveloperUserId— they live in different concept spaces.
cdcust_….
The userId: argument to identify(...) is the same string you'd put on your backend's user-record primary key. cdcust_… handles are Crossdeck-side; they live on the server and are not exposed to the client SDK in v1.x.
cd.identify(userId:email:traits:)
Links the device to a stable user identity. email is a first-class top-level option — the platform-wide universal anchor for identity-merge. Pass it whenever you have it; the backend uses it to coalesce the same person across devices and auth-provider migrations.
cd.identify(
userId: "user_847",
email: "[email protected]",
traits: [
"plan": "pro",
"signedUpAt": "2026-05-15",
]
)
Throws CrossdeckError(code: "missing_user_id") if userId is empty. Trait values are sanitised in-place (NaN / Infinity / functions / cyclic references replaced or dropped) with a debug warning — identify never throws on a bad trait.
Calling identify(...) sets developerUserId synchronously, clears the local entitlement cache, and fires /identity/alias in the background. The backend returns the canonical crossdeckCustomerId (cdcust_…) which the SDK persists for the lifetime of the install and stamps on every subsequent event.
Calling identify(userId:) twice with the same id is well-defined and won't break anything, but it isn't a no-op: each call unconditionally clears the entitlement cache (the bank-grade leak guard documented below) and re-fires /identity/alias. Most apps gate it with a lastIdentifiedUserId check so a screen that mounts ten times doesn't fire ten alias POSTs:
if let userId = AuthService.shared.currentUser?.id,
userId != lastIdentifiedUserId {
cd?.identify(userId: userId, email: AuthService.shared.currentUser?.email)
lastIdentifiedUserId = userId
}
Unconditional entitlement cache clear. Every identify(...) call clears the entitlement cache — even a same-id re-identify. This is the defence that prevents a freshly-identified user from briefly seeing the prior user's entitlements through any sync read path. A tiny redundant cache rebuild is cheaper than a leaked entitlement across an account switch.
cd.identifyAndWait(...) — when you need the cdcust_
The sync identify fires the alias POST in the background. identifyAndWait is the same call but awaits the result and returns the canonical AliasResult with the crossdeckCustomerId. Use it when the next step in your sign-in flow needs that handle:
- Cross-referencing the
cdcust_…on your backend right after sign-in (e.g. attaching it to your user row). - Showing the customer a Crossdeck-side support reference before they navigate away.
- Server-side analytics that join your data warehouse against the
cdcust_…at sign-in time.
If you're just identifying the user for telemetry purposes, the sync identify is the right call — it sets the local identity instantly and your next track already carries the new developerUserId; the cdcust_ lands a few hundred ms later without anyone waiting.
let result = try await cd?.identifyAndWait(userId: user.id, email: user.email)
// result.crossdeckCustomerId is the cdcust_ — POST it to your backend now
if let cdcust = result?.crossdeckCustomerId {
try await myBackend.attachCustomerHandle(userId: user.id, crossdeckCustomerId: cdcust)
}
cd.reset() / cd.resetSync() — call on sign-out
Critical for shared-device privacy. Sign-out without reset leaves the prior user's developerUserId, entitlement cache, super-properties, and breadcrumbs on disk — the next sign-in would coalesce events from two different humans into the same Crossdeck record. reset wipes all five.
reset() is async because the SDK durably clears every per-user entitlement slot before it returns. Three safe call shapes — pick the one that fits your call site:
// 1. From a sync call site (button handler, AuthService method,
// non-async closure) — the escape hatch. resetSync() flips the
// entitlement tombstone immediately so isEntitled() returns false
// across the wipe window, then drains the rest on a detached Task.
cd?.resetSync()
// 2. From a sync call site when you specifically want to wait for
// the full drain — wrap in a Task.
Task { await cd?.reset() }
// 3. From an async context (an async func, .task modifier on a
// SwiftUI View, an async closure) — call it directly.
await cd?.reset()
reset regenerates the anonymousId so the next anonymous session is fully unlinked from the prior identified user. It does not server-side erase the prior user's data — that's the separate cd.forget() async call (GDPR right-to-be-forgotten, POSTs /identity/forget). Both run safely without a current identity; forget calls reset internally on success or failure.
iOS-specific auth provider examples
The shape is identical regardless of auth provider — pass your provider's stable user ID as userId and the email if you have it:
// Sign In with Apple
import AuthenticationServices
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization auth: ASAuthorization) {
guard let credential = auth.credential as? ASAuthorizationAppleIDCredential else { return }
cd?.identify(
userId: credential.user,
email: credential.email,
traits: [
"name": [credential.fullName?.givenName, credential.fullName?.familyName].compactMap { $0 }.joined(separator: " ")
]
)
}
// Firebase Auth iOS
import FirebaseAuth
Auth.auth().addStateDidChangeListener { _, user in
guard let user else { cd?.resetSync(); return }
cd?.identify(
userId: user.uid,
email: user.email,
traits: ["displayName": user.displayName ?? ""]
)
}
// Auth0 iOS
import Auth0
Auth0.authentication().userInfo(withAccessToken: token).start { result in
if case .success(let profile) = result {
cd?.identify(
userId: profile.sub,
email: profile.email,
traits: ["name": profile.name ?? ""]
)
}
}
Super properties — per-install enrichment
Register key/value pairs once; every subsequent track() auto-includes them on the event's properties bag. Persisted across launches in UserDefaults.
cd.registerSuperProperty("theme", "dark")
cd.registerSuperPropertyOnce("first_install_version", "2.3.1")
cd.unregisterSuperProperty("theme")
registerSuperPropertyOnce only writes if the key isn't already present — use it for values that should reflect first-install state and never get overwritten (e.g. install version, install date).
Events & analytics
cd.track(_:properties:)
Synchronous from the caller's perspective — the event is validated, scrubbed, enriched, and enqueued on a background actor; the network round-trip happens in batches. Throws CrossdeckError for malformed input (missing event name, invalid property shape) before the event ever touches the queue.
cd.track("checkout_completed", properties: [
"amount_usd": 29,
"plan": "pro",
])
Properties are validated at the SDK boundary before the event enters the queue. One bad property can never crash the serialiser at flush time:
Double.nanandDouble.infinityare rejected (no JSON representation).- Reference-type cycles (an
NSMutableDictionaryreferencing itself) are rejected withevent_properties_cyclic. - Nesting deeper than 32 levels is rejected with
event_properties_too_deep. - Non-encodable instances (a custom class with no Codable conformance) are rejected with
event_property_not_encodable. - Empty event names are rejected with
missing_event_name.
Enrichment order: super-properties → device info (platform, os_version, locale, timezone, app_bundle_id, app_version, sdk_name, sdk_version) → caller-supplied properties. The caller's bag wins on key collision.
The durable queue
The event queue is the analytics workhorse — used by track() and by every error report:
- Buffer cap: 1000 events. Above that, the OLDEST events are evicted (the newest signal is most likely the most diagnostically useful) and an overflow signal is emitted to the debug logger.
- Batch size: default 20 events per HTTP POST. Configurable via
queueConfig.batchSize. - Flush interval: default 5 s between time-triggered flushes. Configurable via
queueConfig.flushIntervalMs. - Per-batch
Idempotency-Key: minted once on first send, reused across every retry of the same batch. The server short-circuits duplicates by key. - Pending-batch slot: when a flush starts, the head-of-queue is MOVED into a dedicated
pendingBatchslot. Newenqueuecalls go into the fresh buffer behind it. The pending slot is only cleared when the server confirms success — a crash mid-HTTP-request leaves the pending batch persisted toUserDefaultsfor the next launch to re-send with the same idempotency key. - 4xx hard stop: permanent client errors (
invalid_request_error,authentication_error,permission_error) drain through theonPermanentFailurecallback and are NOT retried. Retrying a payload the server has definitively rejected wastes battery and (worse) blocks newer events behind a dead batch. - Retry policy: exponential backoff with full jitter on
408/429/5xx. HonoursRetry-Afterheaders even above the localmaxMscap (server is authoritative on its rate budget), clamped at 24 h as a sanity ceiling.
cd.flush() — async drain
Returns when the in-flight batch resolves — success or failure. On failure, events stay queued for the next retry; the awaited call does not throw. Use before app-shutdown to drain explicitly.
await cd.flush()
Permanent failure observability — onPermanentFailure
Closure invoked when a batch fails permanently (4xx that won't change on retry, or retry budget exhausted). Your last chance to observe events that will never deliver — typical use is logging them to your own observability stack and surfacing a diagnostic banner for invalid_request_error cases.
let options = CrossdeckOptions(
appId: "app_ios_xxx",
publicKey: "cd_pub_live_…",
environment: .production,
onPermanentFailure: { events, error in
Logger().error("Crossdeck batch dropped: \(error.code) — \(events.count) events")
}
)
Screen views
Screen views are how the dashboard's Pages tab tells you which screens your users actually use — the iOS equivalent of "/dashboard · 63 views" on a web app's Pages tab. The SDK fires page.viewed on every screen-enter; the dashboard groups by the screen name so you can see in one glance which features get traffic and which sit unused.
UIKit auto-tracks. SwiftUI needs one line per screen.
UIKit hosts — anywhere your app's screens are UIViewController subclasses — fire page.viewed automatically via a viewDidAppear swizzle. Class name becomes the screen property; a non-empty title on the controller becomes the title. Nothing to wire.
Pure-SwiftUI hosts are the tricky case. SwiftUI deliberately hides destination names from the runtime — every screen is backed by a generic UIHostingController<Content>, which is on the swizzle's denylist (otherwise every push/pop fires the same useless host name). The result: a pure-SwiftUI app emits zero page.viewed events without help.
Industry-standard fix — and the same shape Mixpanel, Amplitude, and PostHog ship: one .crossdeckScreen("Name") modifier per user-visible screen. It fires page.viewed with { screen, title } on .onAppear; the Pages backend groups on screen when no url/path is present, so your iOS app populates Pages identically to a web app.
Where to tag — five patterns cover every SwiftUI app
Tag the destination view, not the container that navigates to it. Tagging a container fires on every redraw or push-pop.
1. NavigationStack with typed destinations:
struct ContentView: View {
var body: some View {
NavigationStack {
HomeView().crossdeckScreen("Home")
.navigationDestination(for: ImagePrompt.self) { prompt in
CreateImageView(prompt: prompt)
.crossdeckScreen("Create Image")
}
}
}
}
2. NavigationLink(destination:):
NavigationLink(destination: SettingsView().crossdeckScreen("Settings")) {
Label("Settings", systemImage: "gear")
}
3. .sheet and .fullScreenCover content:
.sheet(isPresented: $showPaywall) {
PaywallView().crossdeckScreen("Paywall")
}
.fullScreenCover(item: $editingAsset) { asset in
AssetEditor(asset: asset).crossdeckScreen("Asset Editor")
}
4. TabView — tag each tab's root view, not the TabView itself:
TabView {
LibraryView().crossdeckScreen("Library")
.tabItem { Label("Library", systemImage: "photo.stack") }
CreateView().crossdeckScreen("Create")
.tabItem { Label("Create", systemImage: "sparkles") }
AccountView().crossdeckScreen("Account")
.tabItem { Label("Account", systemImage: "person") }
}
5. With extra properties — when you want the screen view to carry context the dashboard can break down by (e.g. plan tier, A/B variant). Properties flow through the same cd.track validation as any other event:
PaywallView()
.crossdeckScreen("Paywall", properties: ["variant": experiment])
Don't tag these
Tagging the wrong layer either double-fires (visible to you as inflated views on Pages) or fires the wrong name. The four common mistakes:
NavigationStack { … }.crossdeckScreen("…")— only a problem once the stack actually pushes. A single-screen "chrome" NavigationStack with no.navigationDestination(for:)modifiers and noNavigationLinks firesonAppearexactly once and is fine to tag. The risk is silent regression: adding a destination later quietly introduces double-fires because every push/pop replaysonAppearon the stack root. Future-proof rule: if there's any chance the stack will gain destinations, tag the destination View instead. If you accept the risk for a navigation-less chrome stack, leave it tagged.TabView { … }.crossdeckScreen("…")— same problem; fires on every tab switch with the same name.List { ForEach(items) { … .crossdeckScreen(…) } }— fires per-row; turns one screen view into N.- Layout wrappers (
VStack,HStack,ZStack,Group) — they're not the screen. Tag the screen-level View that's inside the stack.
Naming conventions
The screen name becomes the grouping key on the dashboard. Two rules:
- Human-readable and stable. "Create Image" (good); "CreateImageViewV3" (leaks SwiftUI type names; breaks the moment you rename); "create_image_screen" (works but reads worse on the dashboard).
- No high-cardinality data, no PII. "Create – \(promptId)" generates a new Pages row per prompt and aggregates to nothing useful;
"Profile – \(user.email)"publishes a user's email to the grouping axis. If you want per-prompt or per-user breakdowns, pass them asproperties:— they ride alongside the screen name without polluting the grouping key.
Opt-out
The modifier is a thin wrapper around cd.track("page.viewed", …) — when autoTrack: .off is set in CrossdeckOptions, the underlying call still fires (your code asked for it explicitly; the SDK respects the contract). For strict-consent flows that want zero page.viewed events, remove the modifier from the view tree or guard it on a consent flag your app already owns.
Tap labels
Tap labels are how the dashboard's per-person journey and Pages → Top Actions tell you which CTA the user pressed — "Clicked 'Create Image'" instead of the useless "Clicked an element."
UIKit auto-tracks. SwiftUI buttons on iOS 16+ need one line per CTA.
UIKit controls (UIButton, UIControl subclasses) fire element.clicked automatically with the button's title / accessibility label resolved via the UIControl.sendAction swizzle. Nothing to wire.
SwiftUI buttons on iOS 13–15 are also caught — the SDK's UIWindow.sendEvent hit-test walks up 16 ancestors and down 6 descendants looking for an accessibilityLabel or UILabel.text. Most older-pipeline SwiftUI buttons render through UILabel primitives the descendant search finds.
SwiftUI buttons on iOS 16+ genuinely don't expose the label. Apple changed the SwiftUI rendering pipeline — Button("Create Image")'s text is drawn straight to a CALayer via Metal, and never lives on any UILabel.text or UIView.accessibilityLabel the runtime can read. The accessibility merge happens at the SwiftUI virtual-view layer above UIKit; UIView.accessibilityLabel stays nil on the rendered hierarchy. This is an Apple-imposed limit, not a CrossDeck regression — every analytics SDK hits the same wall.
Industry-standard fix — what Mixpanel / Amplitude / PostHog ship: one .crossdeckTap("Name") modifier per important CTA. Same shape as .crossdeckScreen. The wrapped view's own action still runs (.simultaneousGesture attaches the tap-capture gesture alongside the button's own).
Where to use it
// Button — wrapped action still fires.
Button { generateImage() } label: { Text("Create Image") }
.crossdeckTap("Create Image")
// Image / Text / custom card — anywhere a tappable surface lives.
Image(systemName: "sparkles")
.onTapGesture { generateImage() }
.crossdeckTap("Generate sparkle")
// With extra properties — A/B variant, plan tier, prompt class.
Button { showPaywall() } label: { Text("Subscribe") }
.crossdeckTap("Subscribe", properties: ["plan": "pro_monthly"])
What to tag, what to skip
- Tag the funnel CTAs — Generate, Subscribe, Continue, Sign Out, Save, Submit. The buttons whose press count answers a product question.
- Skip incidental interactions — sheet dismissals, back navigation, scroll gestures, decorative tap targets. Untagged taps still flow through auto-track and show up as "tapped something" — useful as a denominator, not as a CTA.
- One name per CTA, stable across releases — "Create Image" (good); "create_image_btn_v3" (leaks internals; breaks on rename); "Subscribe –
$plan" (high-cardinality, breaks Top Actions aggregation — use thepropertiesarg for the plan instead).
Opt-out
The modifier is a thin wrapper around cd.track("element.clicked", …) — autoTrack settings don't gate it (your code asked for it explicitly). For strict-consent flows that want zero element.clicked events, remove the modifier from the view tree or guard it on a consent flag your app already owns.
Entitlements
Entitlements are how Crossdeck answers "does this customer get this feature right now?" — without exposing your subscription rail's internal product IDs to the client. The Swift SDK gives you two ways to consult the cache:
cd.isEntitled(_:)
Synchronous, cache-only. Returns true if the cached set for the currently-identified developerUserId contains the entitlement key; false otherwise. Safe to call from a SwiftUI view body or a UIKit tap handler — it never blocks on network.
if cd.isEntitled("pro") {
showProFeature()
} else {
showUpgradeSheet()
}
Customer-scoped reads. The cache is keyed on (developerUserId, entitlements). If the cached snapshot was for a different customer (e.g. after a sign-out / sign-in flow that the entitlement fetch hasn't yet refreshed), isEntitled(...) returns false — never silently leaks the prior user's entitlements.
cd.entitlementsForCurrentCustomer()
Returns the full Set<String> of entitlement keys for the currently-identified customer, or nil if no customer is identified or the cache has nothing for them yet. Use when you need to render an entitlement list (e.g. a settings screen showing the user's plan features).
guard let ents = cd.entitlementsForCurrentCustomer() else {
// Not identified, or cache cold
return
}
List(ents.sorted(), id: \.self) { Text($0) }
v1.0.1 ships the full server-side surface: getEntitlements() async / GET /entitlements hydrates the cache; syncPurchases(rail: .apple, signedTransactionInfo:...) async forwards StoreKit 2 JWS evidence to /purchases/sync for verification + entitlement projection; onEntitlementsChange(...) subscribes to cache mutations. On 5xx / network failure the cache stays authoritative (markRefreshFailed records the failure timestamp without invalidating the last-known-good state — a paying customer never falls back to free during a Crossdeck outage). Google Play / cross-rail sync ships in v1.1 alongside the React Native + Android Play Billing surface; v1.0.1 throws rail_not_supported for any non-Apple rail.
Verify your install
The contract: after try Crossdeck.start(options: ...) returns, the dashboard's iOS / macOS · Swift SDK row flips LIVE within ~30 seconds. The signal is the boot heartbeat — a GET on /sdk/heartbeat that fires automatically from the synchronous start path. The backend reads appId + environment from the API key, plus the Crossdeck-Sdk-Version header the SDK ships on every request, and writes a record to projects/{p}.sdkHeartbeats.swift.{env} that the dashboard polls every few seconds.
What you should see, in order:
- Within 200ms of start — the heartbeat request lands.
projects/{p}.appHeartbeats.{appId}+projects/{p}.sdkHeartbeats.swift.{env}both write. - Within ~5s of dashboard refresh — the "Swift SDK" row on Dashboard → SDKs flips green.
- Within 5s of your first
cd.track(...)call — the event appears in Dashboard → Activity → Realtime.
If the row stays dark for more than 60 seconds, check the troubleshooting section below for publicKey + environment alignment first — a misconfigured prefix is the most common cause.
Error capture
Two roads into the SDK's error pipeline:
- Uncaught
NSExceptioncapture (opt-in viacaptureUncaughtExceptions: true). Installs a global handler that snapshots the exception, attaches breadcrumbs, runs yourbeforeSendErrorhook, and ships the event before the process dies. - Manual
captureError(_:handled:)— call from ado/catchblock to ship a handled error. Same enrichment, same pipeline, same idempotency.
Uncaught exception capture
let cd = try! Crossdeck.start(options: CrossdeckOptions(
appId: "app_ios_xxx",
publicKey: "cd_pub_live_…",
environment: .production,
captureUncaughtExceptions: true
))
NSSetUncaughtExceptionHandler is a process-wide C hook. Crashlytics, Sentry, Bugsnag, and Firebase Crashlytics all want it. The Swift SDK chains: on install we capture the prior handler (whoever registered last) and invoke them after our own snapshot. Crash reporters that came before Crossdeck still receive the crash; Crashlytics dashboards stay populated. Apple does not provide a clean uninstall API for NSSetUncaughtExceptionHandler — if you stop capture via cd.stop(), our chain wrapper stays in place but the per-Crossdeck routing is removed.
Manual capture
do {
try performRiskyOperation()
} catch {
cd.captureError(error, handled: true)
}
Handled errors flow through the same path as uncaught ones — same fingerprinting (top-5-frame hash), same breadcrumb attachment, same beforeSend hook. The handled flag is preserved on the wire so the dashboard distinguishes them.
Breadcrumbs — context before the crash
50-entry ring buffer of timestamped events leading up to an error. Capacity configurable via breadcrumbCapacity. Every track() call adds a breadcrumb automatically; you can add custom ones from your UI handlers:
cd.addBreadcrumb(Breadcrumb(
category: .ui,
level: .info,
message: "tapped subscribe button",
data: ["plan": "annual"]
))
When an error fires, the current breadcrumb buffer is attached as error.breadcrumbs on the resulting event. The buffer is process-scoped — survives across screens, dropped on reset().
Fingerprinting — group identical errors
Errors are auto-fingerprinted from the top 5 stack frames (module:symbol joined). Two crashes from the same code path collapse to one issue in the dashboard. Stack symbols are parsed from Thread.callStackSymbols — addresses (ASLR-randomised per launch) are stripped so the fingerprint is stable across runs.
The SDK does NOT call swift_demangle at capture time (it's not on the public platform surface and pulling it via dlsym is brittle). Mangled symbols round-trip fine; the dashboard demangles server-side where the Swift toolchain is available.
beforeSendError — the redaction last line
Final filter before an error event leaves the device. Return nil to drop the error entirely (e.g. a known-noisy framework exception your team has decided to ignore). Return a mutated CapturedError to scrub PII the auto-scrubber missed (rare — the recursive PII scrubber catches emails and cards in every field by default).
let options = CrossdeckOptions(
appId: "app_ios_xxx",
publicKey: "cd_pub_live_…",
environment: .production,
captureUncaughtExceptions: true,
beforeSendError: { event in
// Drop a known-noisy framework exception
if event.message.contains("NSNotificationCenter is deprecated") {
return nil
}
return event
}
)
Self-request skip — no feedback loops
The error-capture pipeline does NOT report HTTP failures against the Crossdeck ingest endpoint itself. Without this guard, a transient ingest failure would generate an $error event, which would itself fail to send, which would generate another error, ad infinitum. The check is a case-insensitive hostname compare against the configured endpoint's host.
App lifecycle
On iOS / iPadOS / Mac Catalyst, the SDK observes UIApplication notifications automatically. You do not need to wire anything yourself.
| Notification | SDK behaviour |
|---|---|
willResignActive | Persist entire buffer + pending batch to UserDefaults immediately. Cheap; runs on the main thread but only writes <50 KB typical. |
didEnterBackground | Best-effort flush() using iOS's background-execution budget (typically a few seconds). Whatever fits ships; the rest survives in the durable buffer for next launch. |
| Force-quit while suspended | Nothing happens at runtime (the app is gone) — but the durable buffer persisted at willResignActive rehydrates on next launch and re-sends with the original idempotency keys. |
On macOS / tvOS / watchOS the equivalent observers are not installed (no UIApplication). The durable buffer still persists on every enqueue, and you can call cd.flush() from any NSApplicationWillTerminate observer if you want explicit drain.
Privacy & consent
Two channels of consent, tracked independently:
- Analytics consent — gates
track(). With analytics off, the SDK silently drops the event after the consent check (the debug logger firesconsent.deniedso you can verify the gate is wired). - Errors consent — separately gated from analytics. With
consent.errorsoff the error pipeline drops$errorevents silently (debug logger firessdk.consent_deniedwithchannel: errorsso you can verify the gate is wired). Both channels default GRANT.
Both channels default GRANT — no explicit setup required. Wire setConsent(...) for an opt-out flow (EU cookie banner, COPPA age gate):
// User declines in your consent UI:
cd.setConsent(ConsentState(analytics: false, errors: false))
// User accepts — restore full access:
cd.setConsent(ConsentState(analytics: true, errors: true))
PII scrubber
On by default. Two patterns are scrubbed before any event leaves the device, recursively across nested dictionaries and arrays:
- Email addresses →
<email> - Payment card numbers (13–19 digits, optional space/hyphen separators) →
<card>
The tokens (<email> / <card>) are the platform-wide vocabulary — identical to the Web, Node, React Native, and backend allow-list. The scrubber walks reference-type containers (NSDictionary / NSArray) FIRST so object-identity-based cycle detection catches loops before falling back to depth-cap.
The scrub runs BEFORE the event is enqueued, so the persisted queue never holds raw PII. A crash dump of disk state is safe to share with support.
cd.setScrubPII(false) // disables — use only when consumer has explicit consent to ship raw values
App Store privacy manifest (PrivacyInfo.xcprivacy)
iOS 17+ App Store review rejects any binary that calls a required-reason API without a privacy manifest. The Swift SDK uses UserDefaults for queue persistence and anonymousId storage — a required-reason API since Xcode 15. @cross-deck/swift does not yet bundle its own PrivacyInfo.xcprivacy. Until it does, add the following declaration to your app target's PrivacyInfo.xcprivacy (create one via Xcode → File → New → App Privacy if your project doesn't have one):
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
Reason CA92.1 covers same-app UserDefaults access. If your target already declares NSPrivacyAccessedAPICategoryUserDefaults, add CA92.1 to the existing reasons array — one entry per category, multiple reasons. Crossdeck will ship a bundled manifest in a future minor release; once it does, this manual declaration is only needed for your own UserDefaults use. Full reason-code catalogue: developer.apple.com → Describing use of required reason API.
Configuration reference
Every field on CrossdeckOptions:
| Option | Type | Default | Notes |
|---|---|---|---|
appId | String | — | Required. Crossdeck App ID from the dashboard (e.g. app_ios_xxx). |
publicKey | String | — | Required. Publishable key starting with cd_pub_ (cd_pub_live_… or cd_pub_test_…). |
environment | Environment | — | Required. .production or .sandbox. Must match the publicKey prefix; mismatch throws env_mismatch at start. |
baseUrl | URL? | https://api.cross-deck.com/v1 | Override for self-hosted setups or the local emulator. |
urlSession | URLSession? | nil (SDK builds one) | Pass a custom session for proxy / App Group transport / test mocking. |
storage | Storage? | UserDefaultsStorage() | Pass MemoryStorage() for ephemeral or test use. |
initialConsent | ConsentState | (analytics: true, errors: true) | Default-GRANT both channels (matches Web/Node/RN). Wire setConsent(...) for an opt-out flow (cookie banner / EU age gate). |
scrubPII | Bool | true | Recursive PII scrub on nested properties. |
queueConfig.batchSize | Int | 20 | Events per HTTP POST. |
queueConfig.flushIntervalMs | Int | 5000 | Time-triggered flush cadence. |
queueConfig.maxBufferSize | Int | 1000 | Hard cap. Overflow drops OLDEST. |
queueConfig.retry.baseMs | Int | 1000 | Initial backoff for retryable failures. |
queueConfig.retry.maxMs | Int | 30000 | Backoff cap (overridden by Retry-After if larger). |
queueConfig.retry.maxAttempts | Int | 5 | After exhaustion, batch routes to onPermanentFailure. |
breadcrumbCapacity | Int | 50 | Ring-buffer depth. |
captureUncaughtExceptions | Bool | false | Opt in to chain into NSSetUncaughtExceptionHandler. |
beforeSendError | (CapturedError) -> CapturedError? | nil | Filter / mutate / drop before ship. |
onPermanentFailure | (events, error) -> Void | nil | Observe never-delivered batches. |
debugLogger | DebugLogger | noopDebugLogger | Pass defaultDebugLogger() for os.Logger routing during development. |
API reference
Lifecycle
Crossdeck.start(options:) throws -> Crossdeck— designated start path. Throws on invalid_secret_key, env_mismatch, or missing_app_id.Crossdeck.current -> Crossdeck?— static process-singleton accessor (v1.0.2+). Returns the most-recently-started client, ornilbeforestarthas succeeded / after the current client'sstopis called. Thread-safe. Use from services, view models, AppDelegate methods, background workers — anywhere outside a SwiftUI view body where@Environment(\.crossdeck)isn't an option.cd.stop()— persist + reject subsequent calls. Clears theCrossdeck.currentslot iff this instance is the one advertised. Idempotent.cd.flush() async— drain. Returns when the in-flight batch resolves.cd.heartbeat() async -> HeartbeatResponse?— GET/sdk/heartbeat. Auto-fires once fromstart(...)unlessautoHeartbeat: false. Best-effort; returnsnilon network failure.
Identity
cd.identify(userId:email:traits:)— set user id + optional traits. Non-throwing (v1.1.0+); empty userId is dropped with a debug log + assertionFailure in Debug.cd.identifyAndWait(userId:email:traits:) async throws -> AliasResult— identify and await the canonicalcrossdeckCustomerId.cd.forget() async throws— GDPR right-to-be-forgotten. POSTs/identity/forget; local state (identity + entitlements + super-properties + breadcrumbs) is always wiped, even if the server call fails.cd.reset()— sign-out path; regenerate anonymousId + wipe identity, entitlement cache, super-properties, breadcrumbs.async, non-throwing (v1.1.0+).cd.resetSync()— sync escape hatch with identical wipe semantics. Flips the entitlement tombstone immediately soisEntitled()returns false across the wipe window, then drains the rest on a detached Task. Use from sync call sites (button handlers, AuthService methods, non-async closures) whereawaitisn't an option.
Events
cd.track(_:properties:) throws— enqueue an event.cd.registerSuperProperty(_:_:)— set persistent enrichment key.cd.registerSuperPropertyOnce(_:_:)— set if absent.cd.unregisterSuperProperty(_:)— remove key.cd.stats() async -> QueueStats— buffered / pending / next-retry-at snapshot.
Entitlements
cd.isEntitled(_:) -> Bool— sync cache check.cd.entitlementsForCurrentCustomer() -> Set<String>?— sync full set.cd.getEntitlements() async throws -> [PublicEntitlement]— GET/entitlements; hydrates the cache. On 5xx/network failure, preserves the existing cache (markRefreshFailed) so a Crossdeck outage never downgrades a paying customer to free.cd.syncPurchases(rail:signedTransactionInfo:signedRenewalInfo:) async throws -> PurchaseResult— POST/purchases/syncwith StoreKit 2 JWS evidence; hydrates the cache. Apple rail only in v1.0.1; non-Apple rails throwrail_not_supported.Crossdeck.appAccountTokenForCurrentIdentity() -> UUID— Apple rail attribution token (v1.5.0+). Pass toProduct.PurchaseOption.appAccountToken(_:)at every StoreKit purchase site so the chain stays bound to the correct app user acrossidentify(), merges, and SSO upgrades. Lazy-mints on first call, persists, wipes onreset(). See Apple in-app purchase: bank-grade attribution for the full contract.cd.onEntitlementsChange(_:) -> () -> Void— subscribe to cache mutations; returns an unsubscribe handle.
Apple in-app purchase: bank-grade attribution
If your iOS / macOS / tvOS app makes StoreKit purchases, every purchase site needs one extra line on the purchase options:
let token: UUID = Crossdeck.appAccountTokenForCurrentIdentity()
let result = try await product.purchase(options: [
.appAccountToken(token)
])
That line closes Shape 2 (identity-key mismatch) on the Apple rail — the silent-subscription-orphan bug that grows linearly with auto-tracked purchases when the developer's identify() value changes mid-stream (anonymous → logged in, account merge, SSO upgrade). Apple's transaction records are permanent, so a token that goes stale never recovers. The helper guarantees the token is one stable random UUID per install, decoupled from every other identifier in your app.
What the helper does
- First call lazy-mints a fresh
UUID(), persists it under the storage keycrossdeck.apple_app_account_token, and returns it. Returns a non-optionalUUID— the exact typeProduct.PurchaseOption.appAccountToken(_:)wants. - Every subsequent call returns the same value forever, within the install/sign-in session. Identity mutations (anonymous → identified, traits updated,
crossdeckCustomerIdresolved from the server) do not change the token. cd.reset()(sign-out) wipes the token. The next user on the same device mints a fresh one — uniqueness-per-purchasing-entity is the property that makes the server-side attribution join correct.
Anonymous-user / pre-identify purchases
The helper is safe to call from an anonymous session — common when an app shows a paywall before sign-up. On that first anonymous purchase:
- The token is lazy-minted on the first call, before any
identify()has happened. The purchase still gets a stable, Apple-immutable token stamped on it. - The token is not derived from the SDK's anonymous identifier. It's a fresh random UUID, independent of every other Crossdeck identifier. Rotating the anonymous ID does not affect it.
- When the user later signs up and you call
cd.identify("user_123"), the SDK forwards the persisted token alongside the alias request. The server records the bindingappAccountToken → developerUserIdat that moment, and every past purchase in that chain attaches touser_123. No re-purchase, no manual reconciliation.
Server-side resolution
When Apple's ASSN V2 webhook arrives later carrying the token, the backend looks the binding up first and resolves the customer via the recorded mapping — not via the older implicit assumption that appAccountToken == developerUserId. Customers who slipped through with a mismatched token (older SDK install, manual syncPurchases without the helper, etc.) surface in Settings → Identity → Conflicts as an Apple unbound token row for operator review.
Apple's transaction record is permanent. A token that goes stale never recovers — every renewal in that chain carries the wrong token forever, and the subscription orphans silently. The v1.4.x line shipped a derivation-from-developerUserId path that caused exactly this trap; v1.5.0 replaces it with the helper. The deprecated AppAccountTokenDerivation.derive(developerUserId:) symbol is still in the module to preserve the cross-SDK test oracle, but the auto-track listener and cd.syncPurchases(...) no longer call it.
Errors
cd.captureError(_:handled:)— manual capture.cd.addBreadcrumb(_:)— append to the ring buffer.
Consent
cd.setConsent(_:)— toggle analytics + errors channels.cd.setScrubPII(_:)— toggle PII scrubber.
Diagnostics & debugging
Pass defaultDebugLogger() for human-readable signal routing to Apple's unified logging system at info level under the com.crossdeck.sdk subsystem:
let options = CrossdeckOptions(
appId: "app_ios_xxx",
publicKey: "cd_pub_test_…",
environment: .sandbox,
debugLogger: defaultDebugLogger()
)
View in Console.app or via log stream:
log stream --predicate 'subsystem == "com.crossdeck.sdk"' --level info
Signal vocabulary
Every internal SDK log goes through a structured DebugSignal enum so a consumer can grep for specific events:
| Signal | Fires when |
|---|---|
sdk.configured | Client successfully started + ready to accept track/identify. Carries appId, environment, sdkVersion. |
sdk.first_event_sent | First event of this process landed at the ingest endpoint. One-shot per process lifetime — dashboard onboarding checklist keys off this signal. |
sdk.invalid_key | publicKey doesn't start with cd_pub_ (or matches no known prefix). Always loud — also surfaced as an error. |
sdk.no_identity | track() fired without a known developerUserId AND no stored anonymousId (degenerate — usually storage failed). |
sdk.entitlement_cache_used | isEntitled(...) answered from the local cache without a network round-trip. |
sdk.purchase_evidence_sent | Successful /purchases/sync POST. |
sdk.environment_mismatch | Configured environment doesn't match the publicKey prefix. Surfaced at start; SDK refuses to construct. |
sdk.sensitive_property_warning | A property key looks like PII (email, password, token, secret, card, phone). Warning-level — event ships, consumer reviews. |
sdk.property_coerced | A property value was coerced during sanitisation (Date → ISO, NaN → null, circular → "[circular]", truncated, etc.). |
sdk.queue_persisted | Queue state successfully written to UserDefaults. Emitted on every enqueue, every successful flush, and every app-background transition. |
sdk.queue_restored | Queue state successfully rehydrated from UserDefaults on start. |
sdk.flush_retry_scheduled | Flush hit a retryable failure (5xx / 408 / 429 / network) and is scheduled for retry. Payload: attempt, delay_ms. |
sdk.flush_permanent_failure | Queue dropped a batch — permanent 4xx OR retry budget exhausted. Always loud; routed to onPermanentFailure when set. |
sdk.consent_changed | Consent state changed via setConsent(...). |
sdk.consent_denied | track() or $error dropped because the relevant consent channel is off. Payload includes channel. |
sdk.consent_dnt_applied | Do-Not-Track detected (web parity — N/A on iOS but emitted for cross-SDK consistency). |
sdk.pii_scrubbed | PII scrubber replaced an email or card on the wire. |
Queue stats
let stats = await cd.stats()
print("buffered=\(stats.buffered) pending=\(stats.pending) attempts=\(stats.attemptsForPending)")
Use for in-app diagnostics screens or to gate a "Send feedback" flow on "queue is fully drained."
Troubleshooting
Xcode shows "Status: v1.0.0" in the package picker (or an old README)
- Xcode caches the README aggressively. File → Packages → Reset Package Caches forces a fresh fetch from GitHub. After that, File → Packages → Update To Latest Package Versions re-resolves and pulls the newest tag matching your Dependency Rule.
- If you're tracking
Branch: main, you may also need to verify the rule is what you actually want — see the next item.
My package is set to Branch: main and I can't change it
- Why this happens: Xcode's Add Package Dependencies dialog falls back to
Branch: mainwhen no semver tags exist on the repository at the time you added it. If you added Crossdeck beforev1.0.0was tagged, your project anchored tomain. - Why the obvious fix fails: reopening File → Add Package Dependencies… shows the Dependency Rule dropdown greyed out, with "The project already depends on: crossdeck-swift with rule main" at the bottom. That dialog is hard-blocked from changing rules on existing dependencies — don't waste time fighting it.
- Removing + re-adding usually loops, too: Xcode's "Recently Used" suggestions auto-reseed the package as soon as you type the URL, with the dropdown still greyed. The only way to change the rule is from the project's Package Dependencies tab.
- The path that works:
- Click your project file (blue Xcode icon, top of the file navigator).
- In the editor pane, select your project under the PROJECT column — not under TARGETS. The rule editor only lives on the project.
- Click the Package Dependencies tab at the top.
- Double-click the
crossdeck-swiftrow. A small sheet opens with the Dependency Rule editor — this is the only UI in Xcode that can change a rule on an already-added package. - Change Branch to Up to Next Major Version, set the version to
1.5.0, click Done.
- Bulletproof fallback (no Xcode UI): Quit Xcode. Open
YourProject.xcodeproj/project.pbxprojin a text editor. Find theXCRemoteSwiftPackageReference "crossdeck-swift"block. Changerequirement = { branch = main; … };torequirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; };. Save. Re-open Xcode — it picks up the change and re-resolves.
Build fails with "no such module 'Crossdeck'"
- The Crossdeck library product wasn't added to your app target. Project settings → your app target → General → Frameworks, Libraries, and Embedded Content → click
+→ selectCrossdeckunder thecrossdeck-swiftpackage. - If the package itself is missing from Package Dependencies, you skipped the Add Package step. Repeat the Install flow.
Events not appearing in the dashboard
- Consent defaults to GRANT for both analytics + errors — you should NOT need to call
setConsent(...)to start seeing events. If you've explicitly setinitialConsent: .denyor calledcd.setConsent(...analytics: false), that's the culprit. Re-grant withcd.setConsent(.init(analytics: true, errors: true)). - Check that the
publicKeymatches the project you're inspecting in the dashboard. Test keys (cd_pub_test_…) and live keys (cd_pub_live_…) route to separate event streams. - Enable
debugLogger: defaultDebugLogger()inCrossdeckOptionsand watch forsdk.queue_persistedentries in Console.app (or the one-shotsdk.first_event_senton the first successful batch of the process). - If your launch crashed before the queue flushed, durability kicks in — pending events persist to
UserDefaultsand replay on next launch. Restart the app and check the dashboard again.
Stale simulator install — ⌘B vs ⌘R
Xcode's ⌘B (Product → Build) compiles into DerivedData but does not install the new binary onto the simulator. ⌘R (Product → Run) compiles, installs, and launches. If you bumped @cross-deck/swift, added a .crossdeckScreen or .crossdeckTap modifier, and only hit ⌘B to confirm the compile, the simulator is still running the old binary — your new modifiers aren't in the running process and the dashboard sees no page.viewed events.
Symptoms: Pages tab shows 0 pages even after adding modifiers; per-person journey shows session events but no Viewed "..." lines. Auto-tracked sessions still fire (they use the SDK's process singleton, no SwiftUI environment plumbing) which is why the journey shows some activity but not the screens you decorated.
Fix: force-quit Clippi in the simulator (swipe up + flick), then ⌘R from Xcode. Watch for the fresh launch sequence (icon fade-in, splash) rather than a process resume. If you're chasing a stubborn build cache, also run Product → Clean Build Folder (⇧⌘K) and quit Xcode entirely.
"Clicked an element" rows in the per-person journey
Expected — these are taps the auto-track captured but couldn't resolve a label for. Two causes:
- The CTA hasn't been decorated with
.crossdeckTap("Name")yet. Modern SwiftUI hides Button text from UIView introspection (see Tap labels for the Apple-side mechanics). For funnel-critical buttons, add the modifier and the journey will render "Clicked 'Generate'" on the next launch. - The tap was on an incidental surface — a sheet's dismiss handle, a scroll thumb, a decorative chevron, a list-row tap that doesn't drive a funnel step. These genuinely don't need a label; their value is as the engagement denominator, not as a CTA. Leave them undecorated.
The presence of Clicked an element rows alongside named ones is the correct state — it preserves the engagement signal (someone tapped something, the session is alive) without forcing you to label every tappable pixel.
How long until I see events on the dashboard?
Typical end-to-end is 3–8 seconds from cd.track(...) returning to the event surfacing on Activity / People journey / Pages. The path:
- SDK batches —
track()appends to the in-memory queue; a batch flushes atbatchSize(default 20) or everyflushIntervalMs(default 5 000ms), whichever fires first. - HTTP POST — one batch goes over the wire with a per-batch
Idempotency-Key. Typically <500ms on Wi-Fi. - Backend projector — resolves identity (anonymousId → cdcust if known), normalises the event, writes to Firestore.
- Dashboard
onSnapshotlistener — fans the new Firestore docs out to the open browser tab in real time.
Force a quicker round-trip during testing with await cd.flush() — drains the current batch immediately rather than waiting for the timer. Useful in dogfood walkthroughs where you want to refresh the dashboard the instant you finish a session.
queue.flush_permanent_failure in logs
401 unauthorized— publishable key wrong or revoked, or theenvironmentinCrossdeckOptionsdoesn't match the key prefix (cd_pub_live_requires.production;cd_pub_test_requires.sandbox). Rotate via the dashboard's API Keys screen.422 unprocessable_entity— payload shape rejected by server validation. Themessageon theCrossdeckErrorhanded toonPermanentFailuretells you which field.
isEntitled() returns false after a successful purchase
- Call
cd.syncPurchases(rail: .apple, signedTransactionInfo: ...)(StoreKit 2 JWS) to POST evidence directly — the server verifies and projects entitlements. Follow it withawait cd.refreshEntitlements()to hydrate the local cache immediately. Both shipped in v1.0.1; call them in your StoreKitTransaction.updateshandler right after a successful purchase.
Strict-concurrency warnings in my app
- The SDK compiles clean under strict concurrency. If your app sees warnings about
Crossdeck, check that you're holding the instance from a Sendable context (e.g. a SwiftUI@Stateor an actor-isolated property). The client itself is@unchecked Sendable— safe to share across actors.
Versioning & changelog
The Swift SDK follows SemVer. Breaking changes only ship in a major-version bump. Full release notes live in the public repo's CHANGELOG.md; every released version is tagged on the public repo so SwiftPM consumers can pin to a specific tag or use .upToNextMajor resolution. Current tags: v1.0.0, v1.0.1, v1.0.2, v1.0.3, v1.1.0, v1.2.0, v1.4.4, v1.4.5, v1.4.6, v1.4.7, v1.4.8, v1.4.9, v1.5.0 (latest).
Latest: v1.4.9 — 2026-05-27
View.crossdeckTap("Name") — explicit tap-label bolt-on for SwiftUI buttons whose label the auto-track can't reach on iOS 16+. Apple's Metal text rendering pipeline draws Button text straight to a CALayer with no UILabel intermediary, so the walk-up + descendant search added in v1.4.7 finds nothing on modern SwiftUI. Same shape as .crossdeckScreen — one line per important CTA, the button's own action still runs via .simultaneousGesture. See the new Tap labels section for the pattern matrix.
v1.4.8 — 2026-05-27
Rename enum Environment → enum CrossdeckEnvironment. Structural fix for the SwiftUI symbol collision that v1.4.4's typealias only papered over for consumer code — the SDK author's own @Environment(\.crossdeck) inside CrossdeckScreenModifier still resolved to the module-scoped enum and failed with 'Environment' cannot be used as an attribute. Renaming the source enum eliminates the trap structurally: there is no short form in scope, so @Environment(...) resolves to SwiftUI's wrapper unambiguously inside the SDK module AND for every consumer. Breaking — code using Environment.production / .sandbox must use CrossdeckEnvironment.production / .sandbox. Pre-launch, all shipping install snippets already use the qualified form.
v1.4.7 — 2026-05-26
SwiftUI tap-label auto-capture. Closes the "Clicked an element" gap that buried real CTAs on the dashboard. The UIWindow.sendEvent walk-up extended from 4 ancestors to 16 — SwiftUI's button-hosting tree puts the merged accessibility label 8–12 hops above the touched Text/Image. Plus a descendant-search fallback that descends up to 6 levels into the touched view's subtree for a UILabel or accessibility-labeled view (SwiftUI's accessibility-merge model commonly puts the label on a sibling or descendant). Same PII substring filter applied to descendant-found text so a password field's visible text never lands on the wire.
v1.4.6 — 2026-05-26
SwiftUI hosting-controller denylist. Fixes a leak where the auto-track viewDidAppear swizzle was emitting framework class names (PresentationHostingController, NavigationStackHostingController, UIKitNavigationController) as page.viewed events — those buried the developer's real screens with meaningless internal names. The substring denylist now catches *HostingController across all SwiftUI host variants. Pairs with backend filtering (isFrameworkHostName) so existing already-shipped events stop polluting the dashboard without waiting for the consumer to rebuild.
v1.4.5 — 2026-05-26
SwiftUI screen tracking. Pure-SwiftUI apps now populate the Pages dashboard with one line per screen — .crossdeckScreen("Name") on each user-visible destination. See Screen views for the five-pattern matrix (NavigationStack, NavigationLink, .sheet, .fullScreenCover, TabView).
View.crossdeckScreen("Name", properties:)— new SwiftUI modifier, public. Firespage.viewedwith{ screen, title, …properties }on.onAppear. Name truncated to 128 chars (matches the host-side title cap). Properties flow through the samecd.trackvalidation as any other event.- Existing UIKit auto-track is unchanged —
UIViewController.viewDidAppearstill firespage.viewedautomatically with the class name.
v1.2.0 — 2026-05-25
Full bank-grade parity with the Web/Node/RN SDKs. v1.1.0 closed the ergonomics gap (non-throwing API); v1.2.0 closes every remaining gap a serious customer would notice — auto-tracking, App Store privacy manifest, performance vitals, mobile lifecycle, and ambient signal modules.
- Auto-tracking (default-on). The SDK fires
session.started/session.ended/element.clickedautomatically across UIKit and SwiftUI, pluspage.viewedon every UIViewController appearance. SwiftUI screens deliberately hide their destination names from the runtime, so they need one.crossdeckScreen("Name")modifier per screen — see Screen views. Same event vocabulary as Web/Android so a single dashboard query returns rows uniformly. UIControl actions and SwiftUI button taps are both captured (the tap routes throughUIWindow.sendEventregardless of UIKit vs SwiftUI). Privacy guardrails baked in: secure text fields, password / card / SSN accessibility labels skipped silently;cd-noTrackidentifier opt-out. 30-minute idle threshold matches GA4 / Mixpanel convention. Configure viaCrossdeckOptions(autoTrack: .off)for strict-consent flows. - PrivacyInfo.xcprivacy bundled in the SDK. Apple began enforcing the required-reason API manifest at App Store Connect submit in May 2024. Without one, every embedding app is rejected. Crossdeck now ships
PrivacyInfo.xcprivacydeclaringNSPrivacyAccessedAPICategoryUserDefaults(reason CA92.1) andNSPrivacyAccessedAPICategorySystemBootTime(reason 35F9.1). Consumer apps inherit the manifest automatically via SPM resource copy. - MetricKit performance vitals (opt-in).
CrossdeckOptions(enablePerformanceMonitoring: true)subscribes the SDK to MetricKit and firesperf.metrics(daily aggregates),perf.hang,perf.cpu_exception,perf.disk_write_exception, andperf.crash_diagnosticevents. Mirrors the Web SDK'sweb-vitalscontract. iOS 14+ / macOS 12+. - Proactive network-edge flush.
NWPathMonitorwatches reachability. On offline → online transitions, the event queue flushes immediately instead of waiting for the next 5-second timer. Closes the latency gap on intermittent connections. ON by default; iOS 12+. - Automatic StoreKit 2 purchase tracking (opt-in).
CrossdeckOptions(automaticPurchaseTracking: true)installs aTransaction.updatesconsumer. Every signed transaction flows to/purchases/syncAND firespurchase.completed/purchase.refunded/purchase.unverifiedevents. Off by default; iOS 15+. - Deep-link + push tracking helpers.
cd.trackDeepLink(url:source:)extracts UTM + click-id query params (gclid, fbclid, msclkid, ttclid, li_fat_id, twclid) as top-level properties — same acquisition-attribution shape the Web SDK captures.cd.trackPushReceived(...)/cd.trackPushInteraction(...)for FCM / APNs payloads, never logging the alert body. - Lifecycle parity fixes. Force-quit on iOS now persists pending events (was losing up to one batch). Mac Catalyst Cmd+Q + pure-AppKit terminate now wire to
queue.persistAll(). watchOS extension lifecycle wired. - Strictly additive. Zero v1.1.0 call-sites change. All new modules are default-OFF where they could be surprising. Update by bumping the SPM rule to
1.2.0and re-resolving.
v1.1.0 — 2026-05-25
Fire-and-forget API ergonomics — matches Mixpanel / Amplitude / Sentry / Firebase Analytics iOS conventions. Dogfood feedback flagged that requiring try? at every analytics call site was hostile in Swift even though Web/Node/RN's track() throws.
- Non-throwing:
track,identify,reset. No moretry?at every call site. Empty event name / userId / called-after-stop now log a debug warning +assertionFailure(loud in Debug, silent in Release) and the call becomes a no-op. - Still throwing (legitimate runtime failure modes):
Crossdeck.start(options:),identifyAndWait,forget,getEntitlements,syncPurchases,flush,heartbeat. - Migration: soft break.
try? cd.track(...)still compiles with a "no calls to throwing functions" warning — drop thetry?to clean up.try cd.track(...)inside ado/catchcompiles but the catch becomes unreachable — drop both. Plaincd.track(...)(the v1.1.0 idiom) compiles clean. - Cross-SDK consistency: Web/Node/RN keep throwing signatures because JS uncaught throws propagate to the global error handler without requiring
tryeverywhere. The platform contract is "trackvalidates input and signals failure for empty name." Swift's signalling is now language-idiomatic (assertionFailure+ debug log) instead ofthrows.
v1.0.3 — 2026-05-25
Critical compile-fix release. v1.0.0–v1.0.2 declared iOS(.v13) in Package.swift but defaultDebugLogger() used Apple's modern Logger API (iOS 14+). Apps with deployment target below iOS 14 failed to compile.
- Fixed.
defaultDebugLogger()now branches on availability. iOS 14+ usesLogger; older OS versions fall back toos_log(iOS 10+). Same Console.app filtering surface, identical signal vocabulary. - Package compiles cleanly against any deployment target ≥ iOS 13 — same floor
Package.swifthas always claimed.
v1.0.2 — 2026-05-25
Dogfood pass on the v1.0.1 surface. One additive API change closes the biggest first-time-Swift-dev friction point; everything else is documentation polish that ships on cross-deck.com.
Crossdeck.current— process-singleton accessor. Returns the most-recently-started client, ornilbeforestarthas succeeded in this process / after the current client'sstopis called. Thread-safe viaNSLock; safe to read from any actor or queue. Use it from services, view models, AppDelegate methods, Combine pipelines, and background workers — anywhere outside a SwiftUI view body where@Environment(\.crossdeck)isn't an option. Strictly additive: every v1.0.1 caller continues to compile and behave identically.- Bank-grade slot discipline.
stop()clears theCrossdeck.currentslot iff the stopped instance is the one currently advertised. Concurrent start+stop sequences on a second client never clobber the first client's slot — important for tests that spin clients up and down in arbitrary order.
v1.0.1 — 2026-05-25
KPMG-grade audit pass. Closes every contract gap and privacy concern an external bank-grade reviewer would flag against v1.0.0. Public API is additive only — every v1.0.0 caller still compiles.
- Public sync paywall reads.
cd.isEntitled(_:)andcd.entitlementsForCurrentCustomer()are now first-class methods on theCrossdeckclient, backed by NSLock-protected sync mirrors on the underlying actors. Safe to call from a SwiftUI view body or a UIKit tap handler. - NSException handler now chains into Crashlytics / Sentry / Bugsnag instead of overwriting them. v1.0.0 silently broke every co-installed crash reporter; v1.0.1 captures the prior handler at install time and invokes it after our own snapshot.
- PII scrubber runs on
$errorevents. Every scrubbable field on the wire payload (message, stack symbols, breadcrumb messages + data) is now scrubbed when consent is on. - Breadcrumbs attached to
$errorevents aserror.breadcrumbs: [{timestamp_ms, category, level, message, data}]. Previously collected but dropped before enqueue. identify(...)unconditionally clears the entitlement cache, matching the documented contract. Even identifying with the same id wipes — tiny redundant rebuild is cheaper than a leaked entitlement across an account switch.- Errors-consent channel gate. The error pipeline now honours
consent.errorsindependently fromconsent.analytics. - Self-request skip on
captureError— drops errors whose URL host matches the configured ingest endpoint, closing the feedback loop where a wrapped ingest failure generated another ingest failure. - Identify+track race fixed. Identity is now read synchronously on the caller's thread before the enqueue task spawns, so a track immediately following an identify always observes the new developerUserId.
- Empty-key validation on super-properties.
- +19 new tests (53 → 72 total). Coverage added for: sync paywall reads from any thread, identify cache clearing under idempotent same-id calls, identify cache clearing across customer switches,
stop()rejecting subsequent calls, URL-stub HTTP tests for 2xx/400/401/422/500/408/429 classification,Retry-Afterhonouring,Idempotency-Key+User-Agentheaders shipped verbatim.
v1.0.0 — 2026-05-24
Initial release. Bank-grade event ingestion (pending-batch slot, 4xx hard stop, Retry-After honoured, durable rehydration). Identity actor with anonymousId persistence + reset regeneration. Customer-scoped entitlement reads. PII scrubber with <email> / <card> tokens, recursive walk, NSDictionary cycle detection via object identity. NSException + manual captureError. Strict concurrency enabled; every shared-mutable type behind a Swift actor. Zero runtime dependencies.