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 streamand 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 yourprint()calls don't have a subsystem. - Spelunked through
xcrun simctl list devices, copied a UDID, and pasted it into asimctl spawncommand to read a single line of log output. - Discovered that the same command doesn't work on physical devices because that's
devicectlterritory 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 streamon the host talks to the host, not the simulator.simctl spawn <udid> log streamtargets 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>
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.logrunning 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 --jsonproduces 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 (usedevicectl). 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.
--jsonwith 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 showandlog 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 streamon the whole system is the right surface. Filtering to one app would hide the thing you're looking for. - Historical lookups.
log show --last 1mreads 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 showcan. - 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 showwith 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?
No, you just stop seeing the things you weren't going to read anyway. Anything your app emits to OSLog, print(), or stderr is captured. The lines that disappear are the ones from other processes, which the system also captured but which weren't yours. They're still available via log show or log stream if you need them.
Does this capture print() output, or just OSLog?
Both. print() writes to stdout, which the simulator routes through the same unified logging pipeline as OSLog. The difference shows up in the structured output: OSLog calls have subsystem, category, and level fields; print() calls don't. Either way, the line shows up in the stream.
Why do I need a separate "stop" command?
Because reading logs and running the app are separate concerns. Ctrl-C on a streaming tool closes the read connection; it doesn't terminate the app. log stream has the same behavior, but it doesn't help, the app keeps running on the simulator until you go find it. An explicit flowdeck stop <app-id> makes the lifecycle predictable: launch with one command, stream with another, stop with a third.
How does this work for physical devices?
Same command. When you launch the app on a physical device, FlowDeck records the launch type and routes flowdeck logs through the device's console path (the equivalent of devicectl device process launch --console) instead of the simulator path. From your terminal, the command is identical. The device just needs to be paired and trusted, which is a one-time Xcode setup.
What about extensions, widgets, and app group siblings?
Out of scope for the per-app filter. Extensions run in their own process, so logging from a widget extension is a separate stream that flowdeck logs on the host app won't catch. If your bug spans the host and an extension, raw log stream with a subsystem predicate that matches both is the right tool.
Can I use this in CI?
Yes, and it's one of the strongest cases for switching. The usual pattern: run the app with flowdeck run --log > app.log 2>&1 &, run your test, attach app.log as a CI artifact when the step fails. For structured CI parsing, --json emits one event per log line; pipe through jq to extract errors or to alert on specific subsystems.
Can I stream multiple apps at the same time?
Yes, by running multiple flowdeck logs processes in separate terminals (or in tmux panes). Each one is scoped to its own app ID. The tool doesn't multiplex multiple apps into one stream because mixing two apps' logs into the same terminal almost always makes them harder to read, not easier.
Why don't I see any logs from my app?
Three common causes. First, the build configuration: non-debug builds suppress the default logging filter, so a Release build can look silent. Rebuild with --configuration Debug. Second, the app crashed during launch and isn't actually running; check flowdeck apps to see if it's still in the running state. Third, the app's only emitting debug-level events and the default filter is hiding them; pass --json to see everything.
Going deeper
- Full command reference in the FlowDeck docs.
- iOS simulator management, managing the simulators your app runs on.
- iOS UI automation, driving the app while watching its logs in another terminal.