OSLog and Logger in Swift: Structured Logging, Privacy Levels, and OSLogStore in iOS 26
A practical guide to Apple's unified logging in Swift for iOS 26: how Logger replaces print, when to use each log level, how privacy annotations protect PII, and how OSLogStore and OSSignposter power in-app diagnostics and performance tracing.
OSLog is Apple's unified logging system, and Logger is the Swift API you use to write to it, replacing print statements with structured, filterable, privacy-aware messages that persist across iOS, macOS, watchOS, tvOS, and visionOS. In iOS 26 and Xcode 26 the story is finally coherent: the modern Logger type covers every log level, privacy annotations are enforced by the compiler, and OSLogStore lets you read historical entries directly from your own app. I've been migrating three of my shipping apps off print this year, and the payoff (filterable logs in Console.app, no PII leaks in sysdiagnose bundles, and signpost-based performance traces) is worth the two-line switch.
Logger (from the os module) is the Swift-native, Xcode-25+ replacement for the older C-style os_log and for print.
Every log call carries a subsystem (usually your bundle ID) and a category so Console.app and Xcode can filter tens of thousands of lines to the ones you care about.
Interpolated values default to <private> in release builds, so mark safe values .public or you'll spend an hour wondering why Console shows nothing.
The six log levels (debug, info, notice, warning/error, fault, critical) map directly to persistence: debug is memory-only, error and above are always kept.
OSLogStore reads historical entries in-process on macOS 12+ and iOS 15+, useful for in-app bug reports and shake-to-log flows.
OSSignposter gives you microsecond-precision performance intervals you can view in Instruments alongside CPU and Time Profiler traces.
What is OSLog and Logger in Swift?
OSLog is the unified logging subsystem that has shipped on every Apple platform since iOS 10 and macOS 10.12. It is not a file that your app writes into. It's a system-wide, memory-plus-disk ring buffer maintained by logd, the logging daemon. When your app calls a log function, the message is compiled into a compact binary format, tagged with your subsystem and category, timestamped, and handed to logd. The daemon decides how long to keep it based on the log level and the device's storage pressure.
Logger, introduced in iOS 14 and now the recommended entry point in iOS 26, is a Swift-friendly wrapper around that system. It gives you string interpolation with per-argument privacy control, compile-time format validation, and one method per log level. Under the hood it still routes through the same os_log C API, so everything you write with Logger is visible in Console.app, in Xcode's debug console, in sysdiagnose bundles, and (via OSLogStore) inside your own app. Because the storage layer is the same across Apple platforms, the same Logger code runs unchanged on iPad, Mac Catalyst, Vision Pro, and Apple Watch. What differs, and I'll cover this below, is where you read the output.
How to use Logger vs print in Swift
The direct replacement is two lines. Import os and create a Logger instance, then swap print for a level-specific method:
Honestly, the differences matter more than people think. print writes to stdout, which is stripped in release builds when launched from Springboard and never reaches Console.app. Logger writes to a persistent, filterable, structured store (the same one Apple's own frameworks write to) and it survives app termination. print has no notion of severity, so a debug trace and a fatal error look identical in the console. Logger attaches a level that decides both colour-coding in Xcode and how long the message is retained. And critically, print has no privacy model: every value you interpolate is dumped verbatim into whatever log capture the user sends you, including access tokens and email addresses. Logger hides them by default.
The one time I still reach for print is inside SwiftUI previews, where the persistent log store adds noise you don't want. Everywhere else, Logger is the right default.
Log levels and when to use each
The unified logging system defines six levels, and each has a specific persistence and performance contract. Getting the level right is not stylistic. It decides whether the message survives long enough to help you debug a customer report.
Level
Method
Persistence
Use for
Debug
logger.debug()
Memory only, dropped under pressure
Verbose tracing during development
Info
logger.info()
Memory; disk only if collected
Non-essential context, request IDs
Notice (default)
logger.log() / notice()
Disk, medium retention
Normal but significant events
Error
logger.error()
Disk, longer retention
Recoverable failures
Fault
logger.fault()
Disk, longest retention
System-level or programmer errors
Critical
logger.critical()
Same as fault, escalated
Data loss, security-relevant events
I default to notice for anything I might want to see in a sysdiagnose from a real user, error for handled failures with an Error instance, and fault for programmer errors like an unexpected enum case or an invariant violation. Reserve debug for genuinely noisy tracing you want compiled out in Release. The compiler can strip debug calls when the interpolation would otherwise be expensive, so wrapping a String(describing:) of a large model in logger.debug is genuinely cheaper than print.
Every value you interpolate into a Logger message carries a privacy annotation. The default is .private, which means the value is replaced with <private> when the log is read from another process (that includes Console.app on a device you're not directly attached to, and sysdiagnose bundles). Attach a debugger from Xcode and you'll see the value; hand the same device to a customer and you won't.
logger.info("User \(userID, privacy: .public) opened \(url, privacy: .public)")
logger.info("Auth token: \(token)") // by default
logger.info("Auth token: \(token, privacy: .private)") // explicit
logger.info("Card number: \(pan, privacy: .sensitive)") // stronger redaction
logger.info("Email: \(email, privacy: .private(mask: .hash))") // hashed for correlation
logger.info("Debug: \(payload, privacy: .public)") // NEVER for PII
Four levels are available. .public is always visible, so use it for IDs, URLs, error codes, HTTP status codes. .private hides the value in release logs but keeps it locally during development. .sensitive is redacted even more aggressively and is intended for regulated data like payment card numbers. .private(mask: .hash) replaces the value with a stable hash, which is what you want when you need to correlate log lines for the same user without ever writing the identity to disk.
Apple's Logger documentation covers the full matrix, but the mental model is straightforward: default to .private, opt into .public only for values you'd be comfortable posting on GitHub. If you're building anything that touches health data, look at the sibling article on HealthKit in SwiftUI for the extra guarantees HealthKit imposes on top of OSLog's defaults.
Subsystems and categories: organizing your logs
Every Logger is created with a subsystem and a category. The subsystem is a reverse-DNS identifier that names your codebase (almost always your app's bundle identifier, sometimes suffixed for a Swift package). The category is a free-form string that groups related messages within that subsystem. Together they form the two dimensions Console.app and Xcode let you filter on, and getting the naming right up front pays dividends the first time you try to find one message in a stream of ten thousand.
I keep the Log enum in a small shared module so every target (the main app, the widget extension, the intents extension, the watch companion) writes to the same subsystem. When a customer sends a sysdiagnose, I can filter by subsystem:com.hiroshi.myapp category:sync and see the CloudKit trail across every process. Categories are cheap. Create one per subsystem area, not one per file.
Viewing logs in Console.app and Xcode 26
There are three places to read OSLog output, and each has a different reason for existing. In Xcode 26, the debug console now colour-codes log levels and includes a subsystem/category filter above the transcript. This is where you spend day-to-day debugging. In Console.app on macOS, attaching your iPhone or Vision Pro over USB (or wirelessly, if paired) streams live logs from the device with a full filter bar; save a common filter as a smart group and it will re-apply automatically the next time you connect. In a sysdiagnose (a system-wide diagnostic bundle a user can capture with a hardware chord) the archive contains a logarchive you can open in Console.app after the fact.
The single filter I use more than any other is subsystem:com.myapp.bundleid. It cuts noise from every other process on the device. From there I add category:network or eventMessage CONTAINS "profile". If values you expected to see are showing as <private>, either they need a .public annotation, or you need to install the debug profile that unmasks private data (see the Apple profiles and logs page for the current URL to the logging configuration profile). I install that profile on my personal test devices; I do not install it on customer devices, and you shouldn't either.
How do I read historical logs with OSLogStore?
OSLogStore is the missing piece. It lets your app read the same log entries that Console.app sees, in-process, so you can build in-app diagnostics without spelunking through Terminal. It has shipped on macOS since 10.15 and, since iOS 15, on every iOS-family platform. In iOS 26 it works unchanged in the main app, widget extensions, and Vision Pro.
import OSLog
func recentLogs(within seconds: TimeInterval = 3600) throws -> [String] {
let store = try OSLogStore(scope: .currentProcessIdentifier)
let since = store.position(date: Date().addingTimeInterval(-seconds))
let subsystem = Bundle.main.bundleIdentifier ?? ""
let predicate = NSPredicate(format: "subsystem == %@", subsystem)
return try store
.getEntries(at: since, matching: predicate)
.compactMap { $0 as? OSLogEntryLog }
.map { "[\($0.date)] [\($0.category)] \($0.composedMessage)" }
}
The predicate is a real NSPredicate against the log entry model, so you can match on subsystem, category, level, process, and composedMessage. I use this in a "Send Bug Report" flow: on tap, I pull the last hour of my subsystem's entries, ZIP them alongside a JSON snapshot of app state, and hand the archive to MFMailComposeViewController or ShareLink. Users appreciate not having to explain what went wrong. The log tells you.
Two caveats. First, entries whose values were logged as .private come back redacted even to your own app; there is no way around this without the debug profile. Second, on iOS the scope is limited to .currentProcessIdentifier, so you cannot read the system-wide store, only what your own process has written. That is deliberate and correct.
Performance tracing with OSSignposter
OSSignposter is the Swift API for signposts, which are timed intervals that show up as coloured bars in Instruments, alongside your Time Profiler and Allocations traces. If you have ever wondered why a SwiftUI list is dropping frames on scroll, signposts turn "the UI feels slow" into "row 42 spent 180ms in decodeImage". I hit this exact issue shipping a photo grid last month, and one afternoon of signposts saved me a week of guessing. Pair it with the workflow in the Xcode Instruments guide for SwiftUI for the full loop.
import os.signpost
let signposter = OSSignposter(subsystem: "com.hiroshi.myapp", category: "images")
func decodeImage(_ data: Data) -> UIImage? {
let state = signposter.beginInterval("decode", "size=\(data.count)")
defer { signposter.endInterval("decode", state) }
return UIImage(data: data)
}
// Or with the closure form (auto-ends on throw or return):
func fetchProfile(_ id: String) async throws -> Profile {
try await signposter.withIntervalSignpost("fetch", id: signposter.makeSignpostID()) {
try await api.profile(id: id)
}
}
The id lets you correlate concurrent intervals, which is critical when you have three image decodes in flight and want Instruments to render them as three separate lanes rather than one overlapping mess. Interval metadata (the second argument to beginInterval) shows up in the Instruments detail pane and is searchable. Signposts are almost free in Release builds. The runtime cost is nanoseconds per call when no trace is recording.
OSLog on watchOS, macOS, and visionOS differences
The API is identical across every Apple platform, but the ergonomics diverge. On macOS everything works: full OSLogStore scopes including .system, Console.app can attach to your own process without a device pairing, and sysdiagnose captures via Ctrl-Option-Shift-Command-Period are two seconds away. On iOS and iPadOS you get .currentProcessIdentifier scope only, and remote streaming requires Console.app on a paired Mac.
On watchOS the log volume is deliberately clipped. The daemon aggressively drops debug and info under memory pressure, which happens constantly on a Watch. Assume nothing below notice will survive to a sysdiagnose captured from the paired iPhone. On visionOS the API is unchanged from iOS, but the developer strap or Reality Composer Pro attach flow is required for streaming; without it, you're limited to what OSLogStore can read in-process. On tvOS everything works the same as iOS, with the added wrinkle that most tvOS devices are shared demo units and you should be aggressive about .private annotations.
The practical takeaway from shipping across all five platforms: pick your subsystem once, keep the category names identical across platforms, and never assume a log written on one device is retrievable from another. If you need cross-device diagnostics, ship them to your own backend behind an explicit user opt-in.
Common mistakes and best practices
Three mistakes I see repeatedly, and one convention that saves everyone time. First, people leave print statements in shipping code and wonder why they can't find them in Console. Second, people interpolate an entire Codable model into a log line without a privacy annotation. The object stringifies to <private> in Release and to a wall of text in Debug, neither of which is useful. Third, people create a new Logger instance inside every function; that is not free, and the instance should be a private let at file scope or a static on a namespacing enum.
The convention that saves time is to write a small Log namespace early in a new project (see the subsystems section above), commit it, and use it from day one. Retrofitting logging into a mature codebase is boring work; putting it in at the start costs nothing. If you're also building tests around the code you're instrumenting, the patterns in the Swift Testing guide pair nicely with a shared Log namespace, since you can assert on emitted log entries in integration tests with OSLogStore. Combine that with the Instruments workflow for SwiftUI hangs, wire up a shake-to-log flow using OSLogStore, and you have a diagnostics story that scales from your first TestFlight build to your ten-thousandth crash report.
Frequently Asked Questions
What is the difference between Logger and print in Swift?
print writes to stdout, is stripped from Springboard-launched release builds, and has no severity, filtering, or privacy model. Logger writes to the unified logging system with a subsystem, category, level, and per-value privacy annotations. The messages are filterable in Console.app, persist across app launches, and hide private values in customer sysdiagnose bundles.
Why do my logs show <private> in Console.app?
Interpolated values default to .private, so they are redacted when read from another process. Either annotate safe values with privacy: .public, attach Xcode directly to unmask locally, or install Apple's logging configuration profile on your test device to see private values from Console.app.
Is Logger thread-safe in Swift?
Yes. Logger is a value type that wraps a thread-safe underlying handle, and every log method is safe to call concurrently from any thread or actor. You can (and should) declare a single private let logger at file scope and share it across your codebase.
How do I read past logs from my own app?
Use OSLogStore. On iOS 15+ and macOS 12+ you can open the store with .currentProcessIdentifier scope, build an NSPredicate against subsystem/category/level, and call getEntries(at:matching:) to iterate historical OSLogEntryLog values. This is how in-app bug reporters and shake-to-log flows pull recent context.
Does OSLog work the same on watchOS and visionOS?
The API is identical, but watchOS drops debug and info messages aggressively under memory pressure, so lift diagnostics to notice or higher. visionOS behaves like iOS for OSLogStore scope and requires a paired Mac (or the developer strap) for live Console.app streaming.
SwiftUI's PhaseAnimator walks a view through ordered animation phases from a single trigger, replacing @State flags and asyncAfter chains. Learn per-phase curves, trigger patterns, and how to combine it with CustomAnimation, plus real-world code for like buttons and toasts.
Profile SwiftUI apps with Xcode Instruments in Xcode 26. A real-device workflow for finding hangs, frame drops, and memory leaks, plus custom signposts with OSSignposter.
Ship real HealthKit features in SwiftUI on iOS 26: async authorization, hourly step buckets, live heart rate as an AsyncSequence, workout builders, and background delivery, with all the Info.plist gotchas laid out.