Most SwiftUI codebases we inherit have exactly one logging strategy: print, wrapped in an #if DEBUG, sprinkled wherever somebody was once confused. It works right up to the moment it matters — a tester hits a bug on their own phone, and there is nothing to read.
Apple's unified logging system solves that, and has since 2016. The Swift-native front end, Logger, has been available since iOS 14. It is fast, it is structured, it keeps a rolling buffer on device that you can retrieve after the fact, and it is aggressively private by default in a way that surprises people the first time they read a real device log.
This is the whole surface: how to structure loggers, what each level actually costs, why your strings come back as <private>, how to get logs off a customer's phone, and where all of this changes on the server.
Loggers are cheap, so make a lot of them
A Logger carries two labels: a subsystem, conventionally your bundle identifier, and a category, which is the part you will actually filter on. Both are just strings, and constructing a Logger is cheap enough to do inline — but do not, because then your categories are typos waiting to happen.
import OSLog
extension Logger {
private static let subsystem = Bundle.main
.bundleIdentifier ?? "com.example.app"
static let networking = Logger(
subsystem: subsystem,
category: "networking"
)
static let persistence = Logger(
subsystem: subsystem,
category: "persistence"
)
static let ui = Logger(
subsystem: subsystem,
category: "ui"
)
}
// At the call site:
Logger.networking.notice("Refresh started")Categories are the unit of filtering in Xcode's console, in Console.app, and in every command-line predicate you will write later. Pick them along the axis you debug: networking, persistence, auth, sync, payments. Not per file, and definitely not per type — a category with one call site in it is a category you will never filter by.
Levels, and what each one actually costs
Logger exposes nine methods, but there are only five distinct levels underneath. trace is a synonym for debug, warning for error, and critical for fault. The differences that matter are about persistence, not severity:
- debug — for development only. Not captured at all unless you explicitly enable it, so it is effectively free in production and useless in a bug report.
- info — captured to the in-memory buffer. It survives long enough to be included when logs are collected, but it is not written to disk on its own.
- notice — the default level, and what you get from logger.log(...). Persisted to disk. This is the level for "something meaningful happened".
- error — persisted, and the level to use for recoverable failures you expect to see in a support ticket.
- fault — persisted, and reserved for genuine bugs: a broken invariant, a state you wrote code to make impossible.
The practical rule: if you would want to see it in a report from a tester who cannot reproduce the bug on demand, it needs to be notice or above. Everything you only want while you are sitting in front of Xcode is debug.
Anything you log is compiled into a format string plus its arguments, and the arguments are only serialised if something is actually listening. That is why the guidance is the reverse of print: leave the log statements in. They cost close to nothing when nobody is reading them.
Privacy: the part that surprises everyone
Run this in Xcode and you will see the customer's email. Read the same line back from a device log and you will see <private>.
Logger.networking.notice(
"Signed in \(email) with \(retryCount) retries"
)The default privacy for every interpolation is .auto, and .auto means "redact anything that is not a scalar". Integers, doubles and booleans come through as written. Strings, arrays, dictionaries and objects — anything that could carry user data — are replaced with <private> once the log leaves the debugger. That is a deliberate, good default, and it is also why teams discover the redaction three months in, holding a log file that says <private> forty times in a row.
Say what you mean, per value:
Logger.networking.error(
"""
Upload failed for \(fileName, privacy: .public) \
user \(userID, privacy: .private(mask: .hash)) \
status \(statusCode)
"""
)- .public — always visible. Use it for values you chose: endpoint names, error codes, feature flags, enum cases.
- .private — redacted outside the debugger. The default for strings, and where anything a user typed or owns belongs.
- .private(mask: .hash) — the useful one. The value is replaced with a stable hash, so you can still tell that forty failures came from the same account without ever learning which account.
- .sensitive — stricter still, for the values that should not be in a log at all if you can avoid logging them in the first place.
Interpolation also takes formatting and alignment, which is worth knowing because it keeps columns readable when you are scanning hundreds of lines:
Logger.persistence.debug(
"""
Wrote \(rows, format: .decimal(minDigits: 4)) \
rows in \(ms, format: .fixed(precision: 1))ms
"""
)Getting logs out of Xcode and off a device
Xcode's console is the easy case: filter by category, or use the subsystem filter, and private values are visible because you are attached. Everything interesting happens when you are not attached.
On a Mac — including the Simulator — you can stream live:
log stream \
--predicate 'subsystem == "com.example.app"' \
--level debug \
--style compactdebug and info are not captured by default, so streaming with --level debug shows you nothing extra unless you have also turned them on for that subsystem:
sudo log config \
--mode "level:debug" \
--subsystem com.example.appTo take a snapshot off a connected device, collect an archive and open it in Console.app:
log collect \
--device \
--last 30m \
--output ~/Desktop/app.logarchiveAnd when the device is not connected — a tester on the other side of the world — ask for a sysdiagnose. Your notice, error and fault lines are in it. Your debug lines are not, which is the single most practical reason to be deliberate about levels.
Reading your own logs from inside the app
OSLogStore lets a running app read back its own entries. On iOS the only available scope is the current process, which is exactly the scope you want: it turns "attach the logs" into a button rather than a support conversation.
import OSLog
func recentLogLines(minutes: Double = 30) throws
-> [String]
{
let store = try OSLogStore(
scope: .currentProcessIdentifier
)
let since = Date()
.addingTimeInterval(-minutes * 60)
let position = store.position(date: since)
let subsystem = Bundle.main
.bundleIdentifier ?? ""
let entries = try store.getEntries(
at: position,
matching: NSPredicate(
format: "subsystem == %@",
subsystem
)
)
return entries
.compactMap { $0 as? OSLogEntryLog }
.map { entry in
"\(entry.date) [\(entry.category)] "
+ entry.composedMessage
}
}Attach the result to your in-app feedback flow and a bug report arrives with the last thirty minutes of context instead of the words "it froze".
Signposts, when the question is how long
Logging tells you what happened. Signposts tell you how long it took, and they show up as intervals in Instruments rather than lines in a console. Same subsystem-and-category shape, different tool:
import OSLog
let signposter = OSSignposter(
subsystem: "com.example.app",
category: "sync"
)
// Synchronous work: one call wraps the interval.
let parsed = signposter.withIntervalSignpost(
"parse"
) {
decode(payload)
}The wrapper takes a synchronous closure, so for async work — which, in a SwiftUI app, is most of it — you hold the interval state yourself:
func sync() async throws {
let id = signposter.makeSignpostID()
let state = signposter.beginInterval(
"sync",
id: id
)
defer {
signposter.endInterval("sync", state)
}
try await pullRemoteChanges()
try await pushLocalChanges()
}Where to log in a SwiftUI app
Not in body. A view's body runs whenever SwiftUI decides it needs to, which is more often than you think and never on a schedule you control. A log line in body produces a stream of noise proportional to your frame rate, and it is a side effect in a place that is supposed to be pure.
The places that are stable:
- In your model — the @Observable class or actor that owns the state. This is where the interesting transitions happen, and it logs once per transition rather than once per render.
- In .task and .onChange, where the trigger is an event rather than a redraw.
- In the networking layer, at the boundary: one line per request with the endpoint public and the payload private.
- In error paths. Every catch block that swallows an error should log it — the silent catch is the single most expensive habit in an iOS codebase.
If you genuinely need to know how often a view rebuilds, that is a signpost or Instruments' SwiftUI template, not a log line.
On the server, Logger means something else
OSLog is an Apple platform API. It does not exist on Linux, which is where your Vapor or Hummingbird service runs. Server-side Swift uses swift-log instead — a logging facade the whole ecosystem writes against, with the backend chosen once at startup by the application.
The two APIs look similar enough to cause real confusion. Both types are called Logger. The level ladders are different: swift-log has trace, debug, info, notice, warning, error, critical, with no fault. And swift-log carries structured metadata, which unified logging does not:
import Logging
var logger = Logger(label: "app.sync")
logger[metadataKey: "tenant"] = "acme"
logger.info(
"Sync finished",
metadata: [
"changes": "\(changeCount)",
"duration-ms": "\(elapsed)"
]
)In Vapor you rarely bootstrap it yourself — the standard entrypoint does it from the environment, so LOG_LEVEL in your deployment config sets the level for the whole process:
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)Then use req.logger rather than a global, because Vapor attaches a request identifier to it as metadata. Every line from one request is correlated, which is the server-side equivalent of a signpost ID and the thing you will miss most if you log to a global logger out of habit.
What we actually do
- One Logger extension per app, categories named after subsystems of the product, no inline Logger construction.
- notice for state transitions, error for recoverable failures, fault for broken invariants, debug for the rest — and no log statements deleted before shipping.
- Identifiers logged as .private(mask: .hash) so support can correlate without us holding user data.
- An OSLogStore dump attached to every in-app bug report, so the first reply is never "can you reproduce it?".
- Signposts around anything a user waits on: launch, sync, first paint of a data-backed screen.
- swift-log on the server, request-scoped, with the app and the API using the same correlation identifier so one incident reads as one story.
None of this is new API. It is a decade old, it is in the SDK you already ship against, and the whole of it fits in an afternoon. The reason to do it now rather than during the next incident is that unified logging only tells you about the past if it was already running.