Stop Shipping NSLog: A Practical Guide to Apple's Unified Logging
Apple's Unified Logging (os.Logger) replaces NSLog and print with leveled, privacy-redacting, low-overhead diagnostics. Here's how to adopt it in Swift and pull logs off a real device.
25 Sept 2025, 13:51 UTC

If your app still calls print() or NSLog in production paths, you have two problems: those messages are synchronous and always written, and they leak whatever you interpolate into them to anyone who attaches Console. Apple's Unified Logging system — the os framework, exposed in Swift as Logger — exists specifically to fix both. It is the supported, low-overhead way to get diagnostics out of a device in the field, and adopting it is mostly a matter of changing habits rather than architecture.
This post walks through how the system is organized, what a real adoption looks like in Swift, and the one behavior — privacy redaction — that surprises everyone the first time.
How Unified Logging is organized
Every message you emit carries three pieces of metadata beyond the text itself:
- Subsystem — usually your bundle identifier in reverse-DNS form, e.g.
com.example.myapp. This is the coarse filter. - Category — a component name you choose, like
networkingorsync. This is the fine filter. - Level —
debug,info,notice(the default),error, orfault.
The level controls both cost and persistence. Debug messages are cheap and, by default, are not persisted to disk — they exist for live streaming while you reproduce a problem. Error and fault messages are captured so you can retrieve them later from a device that was in the field. This is the opposite of NSLog, which writes everything, always, synchronously. Because non-persisted logging is asynchronous and memory-backed, you can put debug calls in hot paths — per-frame or per-packet code — without the measurable slowdown NSLog would cause. (Don't treat that as a license to log unboundedly; "cheap" is not "free," and Apple publishes no guaranteed retention windows.)
A worked example in Swift
The modern entry point is os.Logger. A typical setup is one logger per component, created once and reused:
import OSLog
extension Logger {
private static let subsystem = "com.example.myapp"
static let networking = Logger(subsystem: subsystem, category: "networking")
static let sync = Logger(subsystem: subsystem, category: "sync")
}Usage at a call site:
func fetchProfile(id: String) async throws {
Logger.networking.debug("Fetching profile")
do {
let profile = try await api.profile(id: id)
Logger.networking.info("Fetched profile, age \(profile.ageDays, privacy: .public) days")
return profile
} catch {
Logger.networking.error("Profile fetch failed: \(error.localizedDescription, privacy: .public)")
throw error
}
}Two things to notice. First, the subsystem and category are fixed at the logger, so every call site stays short. Second, the privacy: .public annotations — which brings us to the part that trips people up.
The redaction surprise
Unified Logging is privacy-first: any interpolated dynamic value — strings, numbers — is redacted by default in collected logs. If you write Logger.networking.info("user \(id)") and later pull logs off a device, you will see user <private>. Static string literals in the message are visible; interpolated values are not, unless you explicitly mark them privacy: .public.
This is a feature, not a bug — it maps directly onto App Store privacy expectations, because you cannot accidentally ship a log line that dumps a user's email into a sysdiagnose. But it means your first field retrieval will look useless until you audit which values are actually safe to expose. A reasonable rule: identifiers you need for correlation (request IDs, counts, durations, error descriptions) get .public; anything user-supplied or personally identifying stays private. If you genuinely need a private value during a debugging session, you can enable it temporarily via a logging profile on a test device rather than weakening the annotation in shipped code.
Getting logs off a device
The workflow that replaces a custom file-logging stack:
- Reproduce the issue on a physical device (logging behavior differs from the Simulator, which you can also just watch live).
- Stream live in Console.app with the device selected, filtering by your subsystem — or from a terminal on your Mac:
log stream --predicate 'subsystem == "com.example.myapp"' --level debug. Run this as your normal user; no sudo needed for streaming your own device's logs. - For after-the-fact retrieval, collect a log archive on the Mac the device is attached to:
sudo log collect --device --last 1h --output myapp.logarchive, then inspect it withlog show myapp.logarchive --predicate 'subsystem == "com.example.myapp"'.
Expect to see your error and notice messages in the archive, and expect debug messages to be absent — that absence is the persistence model working as designed, not a misconfiguration. If nothing appears at all, check the predicate spelling first; subsystem matching is exact.
What it doesn't do
Unified Logging is a diagnostic tool, not a telemetry pipeline. It does not replace crash reporting, analytics, or alerting — logs live on the device until collected, retention is bounded and not guaranteed, and there is no built-in upload path. If you need aggregate trends or proactive alerting, you still need a metrics or crash-reporting product alongside it. There is also an API surface caveat worth checking before you copy the example above wholesale: Logger and its privacy modifiers require a reasonably recent deployment target (iOS 14 / macOS 11 era), while older codebases use the C-style os_log functions. Verify availability annotations against your minimum OS version before committing.
Where to start
Pick your noisiest component — usually networking — define one Logger for it, convert its print calls with sensible levels, and mark two or three correlation values as .public. Then run the log stream command above against a real device and confirm you can see the messages with the redaction you expect. That fifteen-minute loop tells you more than any amount of reading, and once the pattern is in place, extending it to the rest of the app is mechanical.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.