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:
captureis not async — don'tawaitit. Onlyflush()is async. If you writeawait 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
| Method | Signature | What it does |
|---|---|---|
capture | func capture(_ event: String, properties: [String: AnyCodableValue]? = nil) | Record a behavioral event. |
screen | func screen(_ name: String, properties: [String: AnyCodableValue]? = nil) | Record a screen view. |
identify | func identify(userId: String, traits: [String: AnyCodableValue]? = nil) | Bind the anonymous identity to a known user. Called automatically on sign-in. |
alias | func alias(from: String, to: String) | Merge two distinct IDs (advanced). |
reset | func reset() | Rotate the anonymous ID and start a new session. Called automatically on user-initiated sign-out. |
setOptOut | func setOptOut(_ on: Bool) | GDPR opt-out; persists across reinstalls. |
flush | func flush() async | Send 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 —
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 arepalbase.debug_consoleandpalbase.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 event | What analytics does |
|---|---|
| Sign-in | Flushes 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 deleted | Flushes, then reset(). |
| Session expiry | No reset. It's still the same person; the identity is kept so their next sign-in continues the same journey. |
| Token refresh | Nothing. |
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 ingest — POST /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:
perfis a reserved namespace — a backend endpoint cannot be namedperf, so a generated method can never shadowpb.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
Related
- 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.