iOS log streaming: per-app and structured

iOS log streaming: per-app and structured

iOS log streaming: per-app and structured

In about 10 minutes you’ll install FlowDeck, launch the interactive TUI (which walks you through trial activation on first run), open or scaffold a project, build and run it, and wire up AI agent skills. The whole loop, driven from the keyboard. No Xcode UI.

In about 10 minutes you’ll install FlowDeck, launch the interactive TUI (which walks you through trial activation on first run), open or scaffold a project, build and run it, and wire up AI agent skills. The whole loop, driven from the keyboard. No Xcode UI.

Apple's unified logging system arrived with macOS Sierra and iOS 10 in 2016. OSLog replaced the older NSLog and ASL world with something genuinely better: structured events with subsystems, categories, levels, and a single pipe everything could flow through. The capture surface, log stream, log show, the predicate language, was built for system-level observability. Excellent for SREs looking at process interactions. Less good for the developer trying to find their app's three lines of output during a login flow.

If you've ever:

  • Run log stream and watched 50 lines per second scroll by, none of them from your app.

  • Crafted a predicate like 'subsystem CONTAINS "com.myapp"' only to find half your print() calls don't have a subsystem.

  • Spelunked through xcrun simctl list devices, copied a UDID, and pasted it into a simctl spawn command to read a single line of log output.

  • Discovered that the same command doesn't work on physical devices because that's devicectl territory with completely different syntax.

  • Stopped streaming with Ctrl-C, then noticed an hour later the app you were debugging is still running.

…you've hit the friction this page is about. It's not that the unified logging system is bad. It's that the CLI surface around it was designed for a different reader.

Apple's logging tools were designed for system observability

The reason log stream sees every process is that the unified logging system is system-wide. Every process feeds into the same stream. The system can correlate events across processes because they share a pipe. For SREs debugging why a system daemon failed to talk to another, this is exactly the right design.

For app developers, the design is the wrong default. You don't usually care what SpringBoard or backboardd or nsurlsessiond emitted. You care about your app's lines. The tool gives you a predicate language to filter, but the predicate is your problem, not the tool's:

  • Predicates need to match how the app emits logs. If a third of your output is print() without a subsystem, predicates won't catch it.

  • Predicates need to know the simulator UDID, because log stream on the host talks to the host, not the simulator. simctl spawn <udid> log stream targets the simulator, but now you've nested two tools and a UDID lookup.

  • Predicates break across Xcode versions as Apple renames subsystems for OS components your app depends on.

  • Predicates don't carry across launch types. Simulator, macOS, and physical device each have a different command chain.

This is solvable, and many teams have solved it for themselves with shell aliases and wrapper scripts. But the solved version has shape: per-app filtering, by stable app ID, that works the same on simulator and device, without you having to write a predicate.

What changes with per-app filtering as the default

The shift is one command:

flowdeck logs <app-id
flowdeck logs <app-id
flowdeck logs <app-id

The app-id is a short identifier (the first 8 characters of an internal UUID, git-short-hash style) that FlowDeck assigns when it launches your app. The ID is stable across the app's life and across simulator reboots. flowdeck logs reads only that app's output, no other process bleeds in.

The conceptual move is that the developer's mental model becomes the tool's primary surface. You launched an app. The app has logs. You want to read the logs. Three nouns, one verb. No predicates, no UDIDs, no awareness of whether the app is running on a simulator or a device.

What makes it work is a small registry FlowDeck keeps locally, bundle ID, target (simulator UDID or device UDID), launch type, and a short ID that maps to that record. The registry doesn't store anything sensitive; it's a developer's local notebook of what was launched, not a telemetry pipeline. Run flowdeck apps to see what's in it.

What this unlocks

Per-app filtering changes the shape of three different workflows:

Debugging without context-switching to Xcode.
The most common case. The app does something wrong, you launch with flowdeck run --log, the logs stream in your terminal, you watch the suspicious behavior happen, you read the relevant lines, you fix the code. No Console.app, no Xcode console pane, no predicate. The feedback loop is text-only and fast.

CI failure artifacts that don't require digging.
When a CI test fails, the useful evidence is usually the app's logs at the moment of failure. With flowdeck run --log > app.log running alongside the test, the failed run produces a per-app log file you can attach to the CI artifacts. The reviewer doesn't have to extract their app's lines from a system-wide log dump.

Agent-readable runtime observability.
An AI agent debugging code it wrote needs to see what the app did, not what the OS did. flowdeck logs --json produces a stable event stream the agent can parse: timestamp, level, subsystem, category, message. The agent gets observability without having to learn a predicate language.

None of these are tasks log stream can't do. They're all tasks log stream makes you do the hard way.

Evaluating a log tool

If you're picking a log tool for your team, or arguing for replacing the shell aliases someone wrote three years ago, the criteria that matter:

Per-app scoping by default.
The default behavior should be "show this app's lines, nothing else." Showing everything is the predicate language's job, and only when you opt in.

Same command for simulator and physical device.
You shouldn't have to remember whether your app is running on a simulator (use simctl spawn) or a device (use devicectl). The tool should route automatically based on what was launched.

Stable IDs that survive reboots.
PIDs are useless across reboots; bundle IDs are ambiguous when the same app is on multiple targets. The tool should give you a stable handle that doesn't depend on either.

Structured output for parsers.
--json with a documented schema. Required for CI parsing, agent consumption, and anything you want to grep on subsystem or level.

Explicit stop semantics.
Ctrl-C on the log stream should not be the same as "stop the app." They're different things. A good tool keeps them separate and gives you a one-line command to stop the app cleanly.

Composition with log show and log stream.
For the cases the new tool doesn't cover (cross-process logs, system daemons, pre-launch logs), it should be easy to drop down to Apple's tools. A wrapper that fights the system instead of composing with it is more friction than the problem it's solving.

When raw log stream is still right

Per-app filtering is the right default for most app-developer workflows. The cases where Apple's broader log tools are the better answer:

  • Cross-process debugging. If your app's bug depends on logs from another process (a system daemon, an extension, an app group sibling), log stream on the whole system is the right surface. Filtering to one app would hide the thing you're looking for.

  • Historical lookups. log show --last 1m reads from the on-disk log archive, useful for "the app crashed five minutes ago, what was it doing right before?" Streaming tools can't show you the past; log show can.

  • Pre-launch logs. Anything emitted before your app finishes launching, dyld errors, code-signing problems, early system events, won't be in a per-app stream. log show with a time window is the tool.

  • System-wide investigations. "Why is this simulator slow today" or "why is this device losing network" are questions that span processes. The unified logging system is the right surface for those.

A useful log tool composes. FlowDeck's design assumes you'll still reach for log stream and log show when you need them. The goal isn't to replace the unified logging system; it's to give you a sane default for the case that comes up every hour.

Questions teams actually ask

Do I lose information by filtering to per-app logs?

Does this capture print() output, or just OSLog?

Why do I need a separate “stop” command?

How does this work for physical devices?

What about extensions, widgets, and app group siblings?

Can I use this in CI?

Can I stream multiple apps at the same time?

Why don’t I see any logs from my app?