Skip to content

iOS Logging Best Practices

The difference between a productive AI-assisted debugging session and a frustrating one often comes down to log quality. This guide covers how to make your app’s logs actually useful when an AI agent is the one reading them.

print() in Swift writes to stdout. On simulators, this shows up in Xcode’s console. On physical devices, it goes nowhere useful — it doesn’t appear in the unified logging system and can’t be captured by any log collection tool.

For an AI agent trying to debug your app, print() output is invisible on physical devices and noisy on simulators.

os.log (via the Logger API in iOS 14+) writes to Apple’s unified logging system:

  • Structured metadata: subsystem, category, level (debug/info/notice/error/fault)
  • Captured everywhere: simulators, physical devices, crash reports
  • Filterable at source: Quern can filter by process, subsystem, level — before the data even enters its pipeline
  • Performance: Designed to be always-on with minimal overhead
  • Privacy: Supports \(value, privacy: .private) annotations that redact sensitive data
import os
extension Logger {
static let networking = Logger(subsystem: "com.example.myapp", category: "networking")
static let ui = Logger(subsystem: "com.example.myapp", category: "ui")
static let auth = Logger(subsystem: "com.example.myapp", category: "auth")
}
// Usage
Logger.networking.info("Fetching user profile for \(userId, privacy: .private)")
Logger.networking.error("Request failed: \(error.localizedDescription)")
Logger.auth.debug("Token refresh started")

With this in place, you can tell your agent things like “show me just the networking errors” or “what happened in the auth flow?” and get precise answers.

You have a large codebase full of print() calls. Rewriting them all isn’t happening today. Here’s a bridge:

import os
#if DEBUG
/// Redirects print() output to os.log so it's captured by Quern and log tools.
/// Only active in DEBUG builds — release builds use the standard print().
@_transparent
public func print(_ items: Any..., separator: String = " ", terminator: String = "\n") {
let message = items.map { String(describing: $0) }.joined(separator: separator)
let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "app", category: "print")
logger.debug("\(message, privacy: .public)")
// Also write to stdout so Xcode console still works
Swift.print(items, separator: separator, terminator: terminator)
}
#endif

Drop this in a file compiled in all targets. Now every print() also emits an os.log entry at debug level, tagged with the “print” category. Physical device debugging works, and your agent can see everything.

This is a stopgap — structured logs with proper levels and categories are always better.

Understanding how Quern filters logs helps you guide your agent to the signal faster.

Process-Level Filtering (Best Performance)

Section titled “Process-Level Filtering (Best Performance)”

When your agent sets a process filter, Quern passes it directly to the underlying log capture command. The OS does the filtering — entries that don’t match never enter Quern’s pipeline. This is dramatically more efficient than capturing everything.

Tell your agent: “Only capture logs from MyApp” — this activates process-level filtering.

Quern has built-in presets that drop common noise:

device-quiet (for physical devices) drops:

  • System daemons: remotepairingdeviced, symptomsd, bluetoothd, wifid, signpost_reporter, kernel
  • Display frameworks: CoreBrightness, ColourSensorFilterPlugin
  • Every com.apple.* entry below error, as simulator-quiet does. On a 20-second capture of a whole iPhone, the preset kept 2.9% of the lines and every error and fault it had kept before. This needs a pymobiledevice3 whose syslog live has --format json (11.19.4 does): only that form carries the os_log subsystem. start_device_logging says which form is in use (format), with a warning when it is text, and list_log_sources carries the same as note.

simulator-quiet (for simulators) drops:

  • HangTracer messages (frequent, unhelpful)
  • com.apple.CoreFoundation subsystem noise
  • Every com.apple.* entry below error: Apple’s frameworks talking inside your app (network connections, user defaults lookups, bundle resources). On the app it was measured on, that was over 99% of the volume. Their errors and faults are kept — a TLS trust failure is logged by com.apple.CFNetwork, and you want that one.

Your own set_log_filter can do the same for any subsystems with quiet_subsystems (prefixes) and quiet_below (the level to keep from, default error).

Two things to know. Applied without a source, a preset is global, so simulator-quiet also quiets com.apple.* lines from start_oslog_streaming. And each entry from a simulator, from a physical device in JSON form, or from macOS os_log names both its os_log subsystem (com.apple.CFNetwork) and, as sender, the library that logged it (CFNetwork). The opt-in syslog source is the exception: it has only the library, in subsystem: a subsystem name in subsystems or exclude_subsystems matches either, so a filter written with a library name still works, and so does one for a device line with no subsystem at all. quiet_subsystems matches the subsystem only.

Your agent applies these automatically when appropriate, but you can ask for them explicitly: “Filter out the system noise”

There are two levels of filtering, and the distinction matters:

Ingestion filtering happens at the source — the log capture subprocess only emits matching entries. This is fast and saves memory. Process filters work this way.

Query-time filtering happens when your agent searches the captured logs. Level, subsystem, text search — all applied at query time.

Bottom line: Always tell your agent which process to watch. Everything else is a bonus.

Filters operate at three scopes: device > source > global. A filter set for a specific device overrides the source-level default, which overrides the global default. Your agent manages this for you, but it’s useful to know if you’re monitoring multiple devices simultaneously.

Quern watches ~/Library/Logs/DiagnosticReports/ for new crash reports. When your app crashes on a simulator, the report appears within seconds. No configuration needed.

For physical devices, your agent pulls crash reports on demand. For iOS 17+ devices, this involves translating between identifier formats behind the scenes (see Device Pool).

Your agent parses crash reports into structured summaries. The key fields:

FieldWhat it means
EXC_BAD_ACCESSMemory issue — null pointer, dangling reference, use-after-free
EXC_CRASH (SIGABRT)Deliberate abort — assertion failure, uncaught exception, fatalError()
EXC_BREAKPOINT (SIGTRAP)Swift runtime trap — force-unwrap nil, array out of bounds, precondition failure

The top stack frames show where the crash happened. Your agent can cross-reference the crash timestamp with logs and network traffic to build the full picture:

  1. What API calls happened just before the crash?
  2. Were there any error-level log entries leading up to it?
  3. Did a network response contain unexpected data?

You can run a command whenever a crash is detected:

Terminal window
quern start --on-crash 'curl -X POST https://your-webhook.example.com -d @-'

The full crash report (JSON) is piped to stdin. The hook runs with a 60-second timeout.

Simulator crashes trigger a macOS crash dialog that blocks everything. Suppress it:

Terminal window
defaults write com.apple.CrashReporter DialogType none

Quern’s setup command offers to do this for you.

When your agent builds your project and something goes wrong, Quern can parse the Xcode build output into a structured summary: error count, warning count, failed targets, and specific error messages with file/line references. Much easier to work with than scrolling through pages of compiler output.

  • Log at the boundaries. Network request/response, view lifecycle (viewDidAppear/viewDidDisappear), user actions (button taps, form submissions). You don’t need to log every internal function — just where your code meets the outside world.

  • Use distinct subsystems and categories. “com.example.myapp.networking” is infinitely more useful than just dumping everything into one stream. It lets your agent slice the logs precisely.

  • Include correlation IDs. If your API returns a request ID, log it. When your agent sees a failed request in the network traffic and wants to find the corresponding log entries, a shared ID makes the connection instant.

  • Use fault level sparingly. Fault-level entries persist across reboots and have higher overhead. Reserve them for truly unexpected conditions (impossible states, assertion failures), not ordinary errors.

  • Set accessibility identifiers on debug UI elements. When your agent is debugging with logs AND UI automation simultaneously, identifiable elements make correlating “I tapped the refresh button” with “this log entry appeared” much easier.