Palbase
Sign inGet started

iOS SDK

Analytics

pb.analytics records behavioral events — what users do, which screens they see — and ships them to your project's analytics in efficient batches. Every entry point except flush() is synchronous, void, and fire-and-forget: capturing an event never makes a view body try or await, and failures are logged, never thrown. Identity is bound to Auth automatically, and the same identity rides your backend calls, so client events and server traces stitch into one journey.

Quick example

import Palbe

// Anywhere — a button action, a view body, a store. No await, no try.
pb.analytics.capture("todo_created", properties: [
    "list": .string("inbox"),
    "has_due_date": .bool(true),
])

pb.analytics.screen("TodoList")

Warning: capture is not async — don't await it. Only flush() is async. If you write await pb.analytics.capture(...), Swift warns that no async operations occur in the expression — that warning is your cue, not a bug in the SDK. The design is that capturing must be free at the call site.

API

MethodSignatureWhat it does
capturefunc capture(_ event: String, properties: [String: AnyCodableValue]? = nil)Record a behavioral event.
screenfunc screen(_ name: String, properties: [String: AnyCodableValue]? = nil)Record a screen view.
identifyfunc identify(userId: String, traits: [String: AnyCodableValue]? = nil)Bind the anonymous identity to a known user. Called automatically on sign-in.
aliasfunc alias(from: String, to: String)Merge two distinct IDs (advanced).
resetfunc reset()Rotate the anonymous ID and start a new session. Called automatically on user-initiated sign-out.
setOptOutfunc setOptOut(_ on: Bool)GDPR opt-out; persists across reinstalls.
flushfunc flush() asyncSend buffered events now — the only async entry point.

Capturing events

pb.analytics.capture("todo_completed", properties: [
    "todo_id": .string(todo.id),
    "open_days": .int(daysOpen),
])
  • Event names must match ^[a-zA-Z][a-zA-Z0-9_.:-]{0,64}$ — start with a letter; then letters, digits, _, ., :, -; at most 65 characters. Invalid names are dropped with a console warning, not an error.
  • Property values use AnyCodableValue.string, .int, .double, .bool, .array, .object, .null — so any JSON shape works.

Note: Property values under privacy-sensitive keys — email, phone, password, token, secret, credit_card, ssn — are automatically replaced with "[REDACTED]" before the event is buffered. Matching is case-insensitive and recurses into nested objects and arrays. Don't put PII in event properties; if you do anyway under one of these keys, it never leaves the device. This list is compiled in and there is no server-side flag that extends it — the only two Palbase-managed flags are palbase.debug_console and palbase.force_update, and neither touches analytics redaction.

Screen views

.onAppear { pb.analytics.screen("TodoDetail", properties: ["source": .string("list")]) }

screen(_:properties:) is not a separate event type. It captures the canonical event $screen, with the name you passed in properties.$screen_name alongside anything else you supplied. Build a screen funnel on the event $screen filtered by $screen_name — searching for an event named after the screen will find nothing.

Warning: Screen names follow the same naming rule as event names — "Todo List" (with a space) is silently dropped with a console warning. Use "TodoDetail" or "todo_detail".

Identity

You usually don't manage identity at all — the SDK binds it to the auth lifecycle:

Auth eventWhat analytics does
Sign-inFlushes the buffer, then identify(userId:) — pre-login anonymous events link to the user.
User-initiated sign-out (pb.auth.signOut())Flushes the buffer, then reset() — new anonymous ID, new session.
Account deletedFlushes, then reset().
Session expiryNo reset. It's still the same person; the identity is kept so their next sign-in continues the same journey.
Token refreshNothing.

Call identify manually only if you identify users outside pb.auth. Re-identifying as the same user is idempotent (traits are re-sent); identifying as a different user while one is already identified auto-resets first and logs a console warning — call reset() yourself before switching users if you want that explicit. alias(from:to:) merges two distinct IDs and is rarely needed.

One identity across client and server

The analytics distinct ID (anonymous ID before sign-in, user ID after) is the same identity your backend calls carry — every generated call and pb.call sends it as the X-Distinct-Id header, resolved from the one storage instance the analytics client also reads. That means a funnel can span screen("Paywall") on the device and the POST /todos your backend traced, and an anonymous user who later signs in stitches into one continuous journey across both surfaces.

Delivery and buffering

Events are buffered in memory and flushed as a batch when 20 events accumulate or 30 seconds pass, whichever comes first (batches are capped at 100 events — the server rejects more). You don't manage this — with one exception:

@Environment(\.scenePhase) private var scenePhase

// ...
.onChange(of: scenePhase) { _, phase in
    if phase == .background {
        Task { await pb.analytics.flush() }
    }
}

flush() is best-effort delivery of whatever is buffered. Await it when the app is about to background so the tail of the session isn't stranded until next launch.

Warning: reset() drops anything still buffered. If those events matter, await pb.analytics.flush() first. (The automatic sign-out reset already flushes for you.)

Analytics posts straight to ingestPOST /v1/analytics/identify, /v1/analytics/alias and /v1/analytics/batch — rather than through the shared RPC path your backend calls use. So an analytics flush carries no Idempotency-Key, never triggers App Attest enrollment, and is unaffected by a backend that is still starting.

Opt-out

pb.analytics.setOptOut(true)   // stop all capture, clear the queue
pb.analytics.setOptOut(false)  // resume

While opted out, all capture calls are no-ops and nothing is sent. Opting out also clears anything already buffered, so nothing captured before the call leaks out afterwards. The choice is persisted — it survives app restarts and reinstalls — making it suitable as the storage behind a "share usage data" settings toggle.

Performance traces

pb.perf sits beside pb.analytics on the same façade and shares its transport. It is for custom traces — a checkout flow, an import, a decode you want percentiles for:

public struct PerfNamespace: Sendable {
    public func startTrace(_ name: String) -> PerfTrace
    public func record(_ name: String, valueMs: Double, attributes: [String: String]? = nil)
    public func trackScreen(_ name: String) -> ScreenRenderHandle
    public func setDisplayRefreshHz(_ hz: Int)
}
// Measure a span you don't already have a duration for.
let trace = pb.perf.startTrace("checkout")
trace.putAttribute("payment_method", "apple_pay")
trace.incrementMetric("items", by: Double(cart.count))
// … work …
trace.stop()

// Or record a duration you measured yourself.
pb.perf.record("db_query", valueMs: 42, attributes: ["table": "todos"])

Like analytics, every entry point is fire-and-forget. If perf is not configured yet, startTrace still returns a handle whose stop() is a harmless no-op, so a call site never has to branch.

trackScreen(_:) returns a ScreenRenderHandle for render timing — call firstContentRendered(slowFrames:frozenFrames:) when the screen is interactive, or dismissed() if it leaves first. The SDK is Foundation-only and never reads UIKit, so if you want ProMotion-aware thresholds you hand it the one value only UIKit knows:

pb.perf.setDisplayRefreshHz(UIScreen.main.maximumFramesPerSecond)

Two trace kinds are captured without pb.perf: network traces come from the SDK's single transport choke point, and app_start comes from the app-start tracker, which reads the real process start time and discards prewarm launches so a prewarmed cold start does not report as a 30-second app start. Perf batches post to POST /v1/analytics/perf.

Note: perf is a reserved namespace — a backend endpoint cannot be named perf, so a generated method can never shadow pb.perf. See Flags for the full list of nine.

Test traffic

pb.setTestDevice(true)

Marks this process's traffic as test traffic: analytics and perf flushes then carry X-Palbase-Test-Device: 1, and ingest sets is_test_traffic server-side so your QA and Simulator runs stay out of production percentiles and event counts. It is the identical wire signal the web SDK sends.

It is fire-and-forget, and it lasts only for the process lifetime — there is nothing persisted, so a fresh launch is production traffic again unless you call it again. Call it once early in a debug build:

#if DEBUG
pb.setTestDevice(true)
#endif
  • Auth — the sign-in/sign-out lifecycle analytics binds to.
  • Calling Your Backend — the calls that share the analytics identity.
  • Flags — the two Palbase-managed keys, and the reserved-namespace list.
  • Analytics (web) — the same surface in the web SDK.