Palbase
Sign inGet started

iOS SDK

Debug Console

Every Palbe app carries a network and log console. It records the SDK's own traffic from launch, your app can push its own logs and requests onto the same timeline, and a shipped build can be opened for one user without cutting a release — because the gate is a server-controlled flag, not #if DEBUG. There is nothing to install and nothing to switch on in code.

Quick example

import Palbe

struct RootView: View {
    @State private var showingConsole = false

    var body: some View {
        ContentView()
            .onLongPressGesture(minimumDuration: 2) {
                if pb.debug.isEnabled { showingConsole = true }
            }
            .sheet(isPresented: $showingConsole) { pb.debug.view }
    }
}

pb.debug.isEnabled is @MainActor and observable per key, so a body that reads it re-renders the moment the platform flips the flag mid-session.

What it records by itself

From the first pb.* call, with no setup:

  • every request the SDK makes — generated pb.<ns>.<op> calls, pb.call, uploads, auth, flags, analytics, perf
  • full request and response headers and bodies
  • the complete timing waterfall: DNS, TCP, TLS, request, waiting, download
  • the X-Request-Id the SDK minted for that request (req_<uuid>) and the server echoed, so a row in the console and a line in palbase logs are the same request

The console is built before the transport and handed to it at initialisation, which is why the auth, flags and realtime bootstrap requests — exactly the ones a developer opens the console for — are already in the timeline.

Note: pb.debug never triggers the SDK's lazy auto-configure. Auto-configure traps when there is no Palbase-Info.plist in the bundle, and a debugger must never be the line that crashes an already-broken app. A standalone console is built on demand and the client graph adopts that same instance later, so records captured before configuration stay on one timeline.

Who can see it — palbase.debug_console

pb.debug.view renders nothing, pb.debug.startLiveSession() refuses to arm, and pb.debug.isEnabled reads false unless the flag palbase.debug_console resolves to true for the signed-in user. One gate, three doors. What is behind it is request and response bodies, so an accidental opening in a shipped build is a disclosure, not an inconvenience.

It fails closed, everywhere. Absent, false, not yet synced, a failed fetch, an SDK that was never configured — every one of those reads as off. Only an affirmative true opens the door. There is no Environment kind here, no ref parsing and no build flag: all three were the device deciding for itself, which is the decision that had to move to the server.

palbase.debug_console is a Palbase-managed flag. No row for it exists on a fresh Environment — nothing serves a derived default — so until somebody declares it, the console is off for everyone. Declare it once from Studio or with a direct PUT /v1/management/flags/palbase.debug_console, then open it per user:

await Flags.$asService().setOverrideForUser(userId, "palbase.debug_console", true);
await Flags.$asService().clearOverrideForUser(userId, "palbase.debug_console");

or from the terminal, against the linked Environment:

palbase flags user set usr_123 palbase.debug_console --type boolean --value true
palbase flags user unset usr_123 palbase.debug_console

That user's next flag sync opens the console — no release, no TestFlight build, no reproducing the bug on your own device. An override of a key the Environment has never declared is a 404 key_not_found, so declare it first.

Recording is not gated. The recorder keeps running whatever the flag says, so flipping it on reveals the history that predates the flip — which is the whole point when the user has already hit the bug.

Opening it

The SDK hands you a view; where it goes is your decision:

.sheet(isPresented: $showingConsole) { pb.debug.view }

Use pb.debug.view.embedded() when the surrounding screen already provides a NavigationStack, so the two do not nest. PalbeConsoleView is the console's one public UI type.

The SDK deliberately presents nothing on its own — there is no present(), no floating bubble, no window. An SDK that can put itself on screen is an SDK that can put itself on screen in front of your customer. Reaching the console is your app's call: a row in a developer menu, a shake gesture you wire up, a hidden long-press.

Your own log lines

pb.debug.log("cart is empty", level: .warning, label: "checkout",
             metadata: ["sku": "A1", "userId": user.id])

pb.debug.error(error, label: "imageLoader")

Levels are .trace, .debug, .info, .notice, .warning, .error, .critical. label groups related entries and is filterable in the console. Source file, function and line are captured automatically. Neither call throws, and both are a no-op when the SDK is unconfigured or recording is stopped.

Your own network requests

The SDK records its own traffic. For everything else — an image fetch, a third-party API, a service you call directly — there are three ways in. The SDK does not swizzle URLSession, deliberately: global capture means reaching into private Foundation classes, which does not belong in an App Store binary.

Capture a whole session automatically

let session = pb.debug.session(label: "avatars")
let (data, response) = try await session.data(from: url)

pb.debug.session(...) returns a PBDebugSession — a URLSession-shaped wrapper with data(for:), data(from:), upload(for:from:), upload(for:fromFile:), download(for:) and dataTask(with:) — that records around every call, timing waterfall included. Reach session.underlying for anything it does not mirror. It is a wrapper rather than a plain URLSession because a session delegate never sees the async conveniences complete.

Wrap an existing delegate

URLSession(configuration: .default,
           delegate: pb.debug.urlSessionDelegate(wrapping: myDelegate),
           delegateQueue: nil)

Every callback the console does not implement is forwarded to your delegate untouched. If your code uses the async conveniences, prefer pb.debug.session(...).

Record one exchange by hand

do {
    let (data, response) = try await URLSession.shared.data(from: url)
    pb.debug.record(request: URLRequest(url: url), response: response,
                    data: data, label: "avatars")
} catch {
    pb.debug.record(request: URLRequest(url: url), error: error, label: "avatars")
}

Pass the URLSessionTaskMetrics from a didFinishCollecting callback as metrics: to get the timing waterfall too.

For a client that is not URLSession at all — gRPC, a socket protocol, a vendored SDK that only hands you strings — build the record yourself:

var record = PBNetworkRecord(
    label: "grpc", kind: .custom,
    method: "POST", url: "https://rpc.example.com/Orders/Create",
    statusCode: 503, duration: 2.41
)
record.errorDescription = "upstream unavailable"
record.state = .failure
pb.debug.record(record)

Credentials are removed before anything is stored

Redaction happens when a record is created, not when it is displayed. A session is written to disk and can leave the device, so a token that reached a record would have left the process no matter what the UI did with it.

Replaced with ***: apikey, authorization, proxy-authorization, cookie, set-cookie, x-api-key, x-auth-token, x-palbase-user-token and the App Attest headers. Credential query parameters (access_token, code, sig, signature, the AWS and SAS parameters, …) and JSON body fields (password, access_token, refresh_token, client_secret, private_key, mfa_token, …) are replaced at any depth, matched case-insensitively and ignoring _ and -.

The header name is kept. "No Authorization header" and "an Authorization header carrying a dead token" are different bugs, and a console that hid the difference could not diagnose either.

Records you build by hand are scrubbed on the way in too — you do not have to pre-clean them. A caller assembling headers by hand is the most likely to hand over a live Authorization.

The redaction set is not extensible from the server today. The SDK carries a dormant reader for a palbase.redaction_fields flag — a string holding a JSON array of extra field names, applied additively on every flags refresh — but no Environment can hold that key: only palbase.debug_console and palbase.force_update are known Palbase-managed keys, and any other palbase.* key is refused with 400 reserved_key_prefix at declaration and 404 key_not_found at override. Until that changes, a newly discovered leaking field name is redacted by an app release, not by a flag.

The Realtime tab

The console has a second tab for the realtime socket, because a socket is not an exchange: it opens once, lives for hours and changes continuously, so it cannot be a row on a timeline of completed requests. Three sections:

  • Connection — current state, when it came up, how long it has been up, how many times it has reconnected in this process, and the bytes and frame counts in each direction.
  • Channels — every channel the app wants, each with its status, when it joined, and — when the server refused it — the reason. This is where "the socket is fine but nothing is arriving" stops being invisible.
  • Frames — what actually crossed the wire, newest first: direction, topic, event, timestamp and size. Tap one for its body.

Frame bodies are redacted before they are stored — by field name at every nesting depth, and by value shape, so a token lands masked even under a key nobody anticipated. This matters more here than for HTTP: on this transport the access token rides in the BODY of the phx_join frame, where header-name redaction cannot see it.

Frames never leave the device. They are held in memory only — a bounded ring buffer, 500 frames or 2 MB, whichever comes first, oldest evicted — and are not written to disk, not included in a session export, and not streamed to a live viewer. Apple's own line is that data processed only on device is not "collected"; carrying it off the device is what crosses that threshold, and raw socket traffic is where credentials actually ride.

Recording follows the console's one switch: stopping recording stops this too.

Sessions and storage

One session is one app launch, so the console answers "what happened last run?" — the question os_log cannot answer once the app restarts. Switch between sessions from the console's toolbar.

Records live under PalbeConsole in the app's container (the shared App-Group container when one is configured). Bodies up to 32 KiB ride inside the record; larger ones are stored once and keyed by content hash, so a polled endpoint returning the same payload costs one copy. Bodies over 5 MiB are truncated and the console shows the true size. The store's disk budget is 128 MiB, and the oldest sessions are swept at launch.

Controlling recording

pb.debug.stop()        // stop recording; already-captured records are kept
pb.debug.start()       // resume
pb.debug.clear()       // discard everything, in memory and on disk
pb.debug.isRecording

What leaves the device on its own

Recording is local, but the console is not a closed box — three things can send records off the device, and it is worth knowing which is which.

  1. Breadcrumb batches. On an error or a crash, the SDK uploads the last 50 records of the session to POST /v1/analytics/console-breadcrumbs, with a five-minute cooldown between batches. This is signal-driven, not scheduled: a healthy session costs zero requests. Records are filed against the user id the server reads from the verified session token, never anything the device claims about itself.

  2. A user report.

    @discardableResult public func report() async -> Bool
    

    pb.debug.report() uploads this launch's recent records with the trigger user_report — the third signal, alongside errors and crashes. It returns false when nothing was sent, most often because a batch already went for this user inside the cooldown window. Whether bodies ride along is governed by a per-project switch enforced server-side; credentials are redacted on the device at capture either way.

  3. A live session you arm explicitly — see below.

Warning: The SDK's own doc comment for report() says the records can be read back "from Studio or palbase debug history --user <id>, days later". palbase debug history does not existpalbase debug has exactly two subcommands — and the CLI's own help explains why it was refused: reading records back days later needs a store that retains them, and this one keeps aggregates rather than one record per request. Treat the breadcrumb upload as a signal that reaches the platform, not as a CLI-readable archive.

Watching from a terminal

While an app runs in an iOS Simulator on this machine, you can watch the same records from the command line — useful when you, or a coding agent, need to see what the app actually did:

palbase debug tail --follow

tail reads the console files inside the simulator's app container directly. No network and no credentials are involved, and it works with the device offline. Flags are --follow/-f, --limit (default 50), --errors, --json, --app <bundle-id> and --device <udid>. A relaunch starts a new session file, and --follow re-resolves rather than tailing a dead one. See the CLI reference.

Watching a real device, live

tail reads a simulator's files. To watch a device that is not on your desk, the device arms a session and shows an 8-character code:

@discardableResult
public func startLiveSession(ttlSeconds: Int = 1800,
                             includeBodiesAndHeaders: Bool = false) async -> String?
public func stopLiveSession()
if let code = await pb.debug.startLiveSession() {
    showPairingCode(code)      // "K7M4-P2QX"
}

Whoever is watching runs:

palbase debug attach K7M4-P2QX

Arming POSTs to /realtime/v1/debug/sessions with a ttl_seconds (default 1800, 30 minutes); stopLiveSession() DELETEs it. Records are published on the topic debug:<environmentRef>:<sessionID>.

The code travels by human — read aloud, typed into a chat. Consent lives on the device: nobody can point at a user from Studio and start watching, because there is nothing to point at until that device arms.

What crosses: metadata only — method, redacted URL, status, duration, sizes, error, category. Bodies and header maps are stripped before broadcast. Full fidelity is a decision your build makes, never something a viewer can request:

await pb.debug.startLiveSession(includeBodiesAndHeaders: true)

Records are already credential-redacted, so what that flag adds to the wire is the end user's own data.

Nothing is replayed. The platform fans out and forgets, so a viewer who joins late sees what happens next, not what it missed. Nothing is stored server-side.

Streaming stops on stopLiveSession(), on the server-side TTL, when the app is backgrounded or terminated, and when the signed-in identity changes mid-session — the platform refuses the reconnecting publisher silently, so the SDK disarms rather than leaving an indicator lit over a dead session.

Show the indicator yourself

@MainActor @Observable public final class PBLiveConsoleSession {
    public private(set) var code: String?
    public private(set) var expiresAt: Date?
    public var isLive: Bool { code != nil }
}

The SDK presents nothing while a session is armed — no banner, no badge, no window, the same rule as the console view itself. Render it, so the person holding the phone can see that a session is live and for how long:

if pb.debug.liveSession.isLive {
    Label(pb.debug.liveSession.code ?? "", systemImage: "dot.radiowaves.left.and.right")
}

Pairing codes

Codes are 8 characters of Crockford base320123456789ABCDEFGHJKMNPQRSTVWXYZ, with no I, L, O or U. Dashes, spaces and letter case are normalised away, and the classic misreads are folded: I and L become 1, O becomes 0. So k7m4-p2qx, K7M4P2QX and K7M4-P2QX are the same code.

When it will not arm

startLiveSession() returns nil rather than throwing, in four cases — two permanent for a given build, two transient:

  • The console is not enabled for this user. The same palbase.debug_console answer as the view; live streaming is the wider door of the two, so it is gated identically. Not overridable from the app.
  • Nobody is signed in. Arming binds the session to the session token, and an anonymous token carries no identity to bind to.
  • Offline, or the SDK was never configured. The most common one during development, and the one most easily mistaken for the flag being off.
  • Rate limited by the platform, if you re-arm repeatedly in a short window.

When attach refuses

The platform answers a failed resolve with one of four reasons — unknown_session, invalid_code, expired, forbidden. unknown_session usually means the device disarmed (closing or backgrounding the app ends the session) rather than a typo; expired means the TTL ran out, and sessions are never extended, so have the device arm a new one.

Transport tracing, which is a different thing

Separately from the console, the SDK's HTTP layer can trace to os.Logger under the subsystem studio.palbase.sdk, category http. It is off unless the environment variable PALBASE_DEBUG is 1, true or yes (case-insensitive), or the app is launched with the argument -PalbaseDebug YES. Secrets are redacted and bodies truncated there too.

xcrun simctl spawn booted log stream --predicate 'subsystem == "studio.palbase.sdk"'

This is a build-time, developer-machine switch. It is not the console, it is not gated by palbase.debug_console, and it cannot be turned on for a user in the field.

Not available

  • Reading a user's console without them arming it. Records stay on the device unless it arms a session and displays the code, and they stop the moment it disarms. The breadcrumb batches above are the one exception, and they are signal-driven, bounded and filed server-side.
  • Reading records back through the CLI. There is no palbase debug history; palbase debug has exactly tail and attach.
  • The SDK opening the console, or showing a live-session banner, by itself. Both are your app's job.
  • The console on for everyone. The default is off on every Environment, and turning it on is a per-user override — deliberately one user at a time.
  • Automatic capture of your app's own traffic. The SDK does not swizzle URLSession; host-app capture is explicit through pb.debug.session(...), pb.debug.urlSessionDelegate(wrapping:) or pb.debug.record(...).
  • CLI: Debugpalbase debug tail and palbase debug attach, with their flags and failure messages.
  • Flags — how palbase.debug_console reaches the device.
  • Backend: Flags — declaring the managed key and writing a per-user override.
  • Error Handling — what the SDK throws when a request in the console failed.