- Published on
- 6 min read Intermediate
> Structured Logging in Swift with Logger and os_log
print is the first logging tool most Swift developers reach for, and for throwaway debugging in a playground or a simulator run, it is fine. The problem is that it does not scale to a shipping app. Every call goes to stdout regardless of severity, there is no way to filter by subsystem, user-supplied strings end up in plain text in system logs, and in a tight loop it will happily tank your frame rate. Once you ship to real devices you need something better.
Apple's answer is the Logger type in the os module. It is a Swift-first wrapper over the unified logging system that has been standard on Apple platforms since iOS 10. You get levels, categorization, privacy redaction, and a very fast backend that only formats messages that are actually going to be persisted or viewed.
Why print Falls Short
When you call print("user id: \(user.id)") in a release build, a few things happen that you probably did not intend. The string is formatted eagerly even if nothing is listening. It goes to stdout, which on a device nobody is reading. If a TestFlight tester ships you a sysdiagnose, the PII is sitting there in plaintext. And when the archived build hits the App Store, those print calls still execute, just without anywhere useful for the output to go. The call site pays the formatting cost for zero benefit.
Unified logging solves all of that. Messages are tagged with a level and a category, they are stored in a ring buffer on the device, private arguments are redacted by default on user devices, and formatting is deferred until something actually consumes the log.
Creating a Logger
You create a Logger with a subsystem (usually your bundle identifier or a reverse-DNS string) and a category (a short label for the area of your app the logger covers):
import os
let logger = Logger(subsystem: "com.example.Recipes", category: "networking")
A common pattern is to hang one of these off each major subsystem in your app: networking, persistence, UI, and so on. Console.app and log stream both let you filter by subsystem and category, so the split pays off the first time you are trying to triage an issue and do not want to see every log line in the system.
Log Levels
Logger exposes six levels, in increasing order of severity:
logger.debug("Parsed \(payload.count) items")
logger.info("Request started for \(endpoint)")
logger.notice("Cache miss, refetching")
logger.warning("Retrying after transient failure")
logger.error("Decode failed: \(error.localizedDescription)")
logger.fault("Invariant violated: user id missing after sign-in")
The rough split is that debug and info only persist when something is actively streaming logs, while notice and above are stored to disk and show up in sysdiagnoses. error and fault are for things that indicate a real problem, with fault reserved for programmer errors or system-level issues. Apple's log level guidance is worth a read if you want the full rules, but in practice most app code lives in debug during development and notice/error in shipping builds.
Privacy Redaction
This is the feature that really earns Logger its keep. Any interpolated value in a log message is treated as potentially sensitive, and by default its value is redacted to <private> when the log is read from a device that is not attached to a debugger. You opt into visibility explicitly:
logger.info("Fetched profile for \(user.id, privacy: .public)")
logger.info("Fetched profile for \(user.email, privacy: .private)")
Static string literals are always visible because they cannot contain user data. Interpolated values default to private, which means if you forget to annotate them, the safe thing happens. That default is the opposite of what print does, and it is the main reason I reach for Logger even when I am only trying to trace a bug.
You can also hash a value so it is redacted in logs but still consistent across calls, which is useful for correlating events without exposing the underlying identifier:
logger.info("Session started for user \(user.id, privacy: .private(mask: .hash))")
When you are attached to Xcode, private values show up in full so you can still debug. On a user's device, or in a sysdiagnose they send you, they come back as <private>.
Viewing Logs
On a device connected to a Mac, Console.app is the usual entry point. Pick the device in the sidebar, then filter by your subsystem with subsystem:com.example.Recipes in the search bar. If you want the older records that were persisted to disk rather than the live stream, use the Action menu's "Include Info Messages" and "Include Debug Messages" toggles, since those are off by default.
From the command line, log stream tails logs in real time:
log stream --predicate 'subsystem == "com.example.Recipes"' --level debug
And log show pulls the historical buffer:
log show --last 1h --predicate 'subsystem == "com.example.Recipes"' --info --debug
Both commands take NSPredicate-style filters, so you can narrow down by category, process, or message content. For on-device troubleshooting, asking a user for a sysdiagnose (hold Volume Up + Volume Down + Side button on iOS) gives you the same buffer exported as a file.
What About os_log?
Before Logger arrived in iOS 14, the way into unified logging was the C-flavored os_log function:
import os.log
let log = OSLog(subsystem: "com.example.Recipes", category: "networking")
os_log("Fetched %{public}@ items", log: log, type: .info, String(count))
You will still see this in older codebases, in some of Apple's sample projects, and anywhere the deployment target drops below iOS 14. It works fine, and the underlying pipeline is identical, but the format-string syntax is clunky and easy to get wrong. If you are on iOS 14 or later, Logger is the better choice. The only reason to reach for os_log in new code is API compatibility with something that already uses it.
A Reasonable Default
If you are starting a new project today, drop an import os somewhere central and define one Logger per subsystem. Use debug for per-call detail, notice for lifecycle events, and error for anything a user could notice. Mark .public only the fields that would be useful in a field report and cannot leak anything sensitive. That takes about as much typing as print, and you get filtering, redaction, and a real persistence story for free.
Sample Project
Want to see this code in action? Check out the complete sample project on GitHub:
The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.
// Continue_Learning
Getting Started with App Intents and Siri Shortcuts
Expose your app's features to Siri, Shortcuts, and Spotlight with App Intents, the Swift-native replacement for SiriKit in iOS 16 and later.
App Tracking Transparency in SwiftUI
A practical guide to App Tracking Transparency in iOS, covering the Info.plist usage description, the ATTrackingManager API, authorization statuses, and when to actually prompt the user from SwiftUI.
withCheckedContinuation vs withUnsafeContinuation in Swift
Continuations bridge completion-handler APIs into async/await. The checked variant catches the two ways you can get it wrong, and the unsafe one trusts you completely.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.