In-app debug tooling for Apple-platform apps: a privacy-labelled logger, debug traces, and a debug server that language models can drive. You register debug endpoints once, and three things use them:
- Debug traces. A button captures recent logs, persisted feature breadcrumbs, a snapshot
of every traced endpoint, and device/process info into a zip. You can share the zip or
upload it. Builds from
build-and-signsign it with a per-build Ed25519 key. - An HTTP server you can query with
curl. It documents itself atGET /, validates every argument, and returns one JSON envelope with hints on every error. - An MCP endpoint (
POST /mcp), so an agent gets every endpoint as a tool. On the Mac,Tools/debugtrace-mcpgives every agent one entry for all running apps.
import DebugTrace
import DebugTraceServer
DebugTrace.configure(.init(subsystems: ["com.example.app", "com.example.engine"]))
DebugSurface.shared.register([
.query("player", "Player position and mode") { _ in
Player(x: game.x, y: game.y, flying: game.flying) // any Encodable
},
.command("teleport", "Move the player; returns the new position",
parameters: [.number("x", "world x", required: true),
.number("z", "world z", required: true)]) { args in
game.teleport(x: try args.requireDouble("x"), z: try args.requireDouble("z"))
},
])
DebugTrace.begin("world.streaming", "seed 42") // feature breadcrumbs, any thread
DebugTraceServer.startIfRequested() // dev builds from build-and-sign, or `bas --mcp`import DebugTraceUI
Section("Developer") { DebugTraceButton() } // opens captured; review, then send (↑) or shareDebugLogger is a drop-in for os.Logger: the same methods and privacy: spelling. Every
line lands in an in-memory ring, which the in-app console and traces read. It also goes to
the unified log, so Xcode and log stream still work. Nothing is written to disk.
Logging is its own dependency-free product, DebugLog (Linux-clean), that DebugTrace re-exports: a
package that only logs links DebugLog, and any other logger or sink plugs into its hub
(Docs/logging-and-privacy.md).
static let net = DebugLogger(subsystem: "com.example.app", category: "Net")
net.info("fetched \(count) items in \(ms, privacy: .public) ms from \(url)") // url: private
net.error("login failed for \(email, privacy: .private(mask: .hash)): \(code, privacy: .public)")
net.debug("frame \(index)") // lazy: not even built when debug capture is offPrivacy follows os_log. Numbers and booleans are public by default; everything else is
private unless marked .public. .sensitive values are never kept.
- Development builds show private values on the device's own console only.
- Exports withhold private values: traces, server replies, the clipboard and the OS log
all show
<private>(or a per-launch salted hash withmask: .hash), and the result is run through a secret redactor. - Release builds (App Store and TestFlight, detected automatically) never store private
values at all. They capture no debug lines unless asked to, and the debug server won't
start. Their traces include only endpoints marked
releaseSafe: true, so a user can send one to support. The user can read every file before sending.
Launch with build-and-sign --run --mcp --inject (or just call inject: if the app isn't running a patchable
build, it rebuilds and relaunches it that way through Apparition's command line), edit a Swift file, and the inject tool of the apps MCP
server replaces the changed function bodies in the running app in a few seconds, state intact. The app
needs no change: --inject embeds the patch runtime and DebugTraceServer loads it (or link
DebugTraceInject and call register() yourself). inject with run evaluates a Swift function body
in the live app. It cannot change stored properties, initializers or type headers; it says so and you
rebuild. The patch is signed with your development certificate and the device asks "Allow code
changes?" for every patch (or once for 15 minutes, if the person chooses "Allow for 15 Minutes"), so the debug token alone cannot make an app run code. An --inject build
uses the dynamic backend unless you choose another, and that makes CPU-heavy Swift about 25-35% slower (Oneiros's frame
work), or 11-15% with INJECT_SCOPE=app, which leaves packages optimized but unpatchable. It's fine for finding where time
goes; time things on a normal build. The experimental Apparition backend (--inject-backend apparition) costs little,
but it patches only functions declared as patch points before the build. See Docs/live-patching.md.
Every app with the server gets _profile and _memory:
_profile seconds=3 threads=all: samples every thread's stack for a few seconds and reports per thread how busy it was, the hottest functions (self and inclusive) and the hottest stacks. System frameworks' private functions are named from the device's own shared-cache symbols, Swift and C++ demangled. Costs ~7 µs per sample; not in App Store builds (it lives inDebugTraceServer)._memory: footprint, peak, headroom before the system kills the app, malloc and Metal totals, and the largest kinds of mapped memory. Call it before and after an action to see what grew.
Configuration(confidential: true) limits the server to queries marked releaseSafe and no commands
(no logs, snapshots, traces or patching), for apps that hold private messages.
Queries whose arguments all have defaults are added to every trace automatically. Pass
trace: .never to leave one out, or trace: .arguments([...]) to capture it with specific
arguments.
Adding it to an app: Docs/integration.md. Everything else (design rules, wire formats, the security model, live patching) is under Docs/; CLAUDE.md maps the code.
Apparition Store is the other half: a personal LAN/tailnet
app store whose bin/build-and-sign gives each dev build its own trace-signing key, and whose
web UI receives and verifies uploaded traces and shows the live debug servers of running apps.