Logging on iOS: useful structure, sane defaults, and better debugging
Most iOS logging is either print spam that nobody reads or silence that hides the bug. A reasonable logging setup is small, structured, and actually useful when something breaks at 2 AM.
Most iOS logging is useless.
It is either print statements left over from a prototype, flooding the console with noise nobody reads, or it is silence so deep you cannot tell whether the app crashed or just got bored.
Neither helps when a user reports a bug you cannot reproduce and the only evidence is “it froze.”
Good logging is not about volume. It is about signal. A small number of well-placed, well-structured log events will save you more time than a thousand print("here") breadcrumbs scattered through every view controller.
1. Stop using print
print is fine for a five-minute script. It is not fine for production.
It writes to stdout, which means:
- it disappears in release builds unless you actively prevent that
- it has no structure, no level, no category
- it cannot be filtered in Instruments or the Console app
- it offers no privacy controls
Replace it with OSLog. Not because it is trendy. Because it is the system-standard logging facility and it solves problems print pretends do not exist.
import os
private let logger = Logger(subsystem: "dev.vburojevic.app", category: "networking")
logger.info("Request started: \(url)")
logger.error("Request failed: \(error.localizedDescription)")
Now your logs are:
- indexed by subsystem and category
- visible in Console.app and Xcode
- filterable by level
- efficient enough to leave in release builds
2. Subsystems and categories are not decorative
A common mistake is creating one logger and passing it around like a universal megaphone.
let logger = Logger(subsystem: "MyApp", category: "General")
“General” is not a category. It is an admission that you have not thought about what you are trying to observe.
Use subsystems to identify the app or module. Use categories to identify the layer:
networkingpersistencesyncuilifecyclepurchases
This matters because when you are debugging a sync issue at 2 AM, you want to filter the console to subsystem:my.app category:sync and see only the relevant timeline. Not every button tap and layout pass in the app.
Structure is not bureaucracy. It is the difference between reading a story and searching a landfill.
3. Levels are not suggestions
OSLog has levels for a reason:
debug— detailed information, stripped in release buildsinfo— normal operational eventsnotice— events that might require attentionerror— errors that did not stop executionfault— errors that require immediate attention
Most teams treat everything as info or debug and wonder why the logs are unreadable.
A better default:
debug— entry/exit of complex functions, state dumps during developmentinfo— significant state transitions (“sync started”, “user logged in”)notice— unusual but handled conditions (“network slow, retrying”)error— failures the user might notice (“save failed”)fault— programming errors or invariant violations (“database returned nil for required field”)
If every log is info, none of them are.
4. Privacy is part of the design
Logging is a security boundary.
A crash report or sysdiagnose can include logs. If those logs contain user emails, session tokens, or location coordinates, you have just leaked private data into a file the user might share with support.
OSLog handles this with privacy annotations:
logger.info("User signed in: \(email, privacy: .private)")
logger.info("Request to: \(url, privacy: .public)")
Public values are visible in the console. Private values are redacted in most contexts and only visible on the originating device with the right entitlements.
This is not optional. If you log user data without privacy annotations, you are one sysdiagnose away from an awkward conversation.
Treat every interpolated value as private by default. Mark something public only when you are sure it contains no identifying information.
5. Log events, not code paths
A log that says Entered viewDidLoad is worthless.
A log that says Inbox refresh started, last sync 4 hours ago is useful.
The difference is context. Good logs answer questions:
- what happened?
- why did it happen?
- what was the state at the time?
- what was the outcome?
Bad logs just mark territory:
logger.debug("Here")
logger.debug("Step 1 done")
logger.debug("About to call API")
Nobody knows what “here” means three months later. Probably not even you.
Instead:
logger.info("Sync started: \(pendingChanges) pending changes")
logger.info("Sync completed: \(syncedCount) changes applied, \(conflictCount) conflicts")
logger.error("Sync failed: \(error), will retry in \(retryDelay)s")
Now the log tells a story.
6. Structured data beats clever sentences
When you need to log something complex, prefer consistent fields over freeform text.
logger.info("Request completed: status=\(statusCode), duration=\(duration)ms, cached=\(fromCache)")
This is easier to parse, search, and aggregate than:
logger.info("The request to the server finished with code \(statusCode) and it took a while")
Consistency matters more than elegance. If every network log uses the same field names, you can grep for duration= and find slow requests without reading prose.
7. Signposts are for performance, not decoration
If you are trying to measure how long something takes, use os_signpost instead of manual timestamp math.
import os
private let log = OSLog(subsystem: "dev.vburojevic.app", category: "performance")
func sync() async {
let signpostID = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "Sync", signpostID: signpostID)
defer {
os_signpost(.end, log: log, name: "Sync", signpostID: signpostID)
}
// work
}
Now Instruments shows you a timeline. You can see where time is spent, how often sync runs, and whether it overlaps with other work.
Manual Date() subtraction in log statements is the poor person’s profiler. Use the tool that exists.
8. Do not log inside tight loops
A log inside a for loop that processes ten thousand items will ruin performance and bury anything useful under a mountain of repetition.
If you need to observe loop progress, log at intervals or summarize at the end:
logger.info("Processing \(items.count) items")
// ... loop ...
logger.info("Processed \(processedCount) items, \(failedCount) failures")
If you really need per-item logging, use debug level and accept that it is a development-only tool.
Logging is not free. String interpolation, formatting, and I/O all cost CPU. A few careless log statements in a hot path can turn a fast operation into a stutter.
9. Failures deserve more detail than successes
When something works, a single info log is enough.
When something fails, you need enough detail to reconstruct what went wrong without attaching a debugger.
logger.error("Save failed")
Wrong.
logger.error("Save failed: entity=\(entityID), error=\(error), retryCount=\(retryCount), queueDepth=\(queue.count)")
Better.
The goal is to answer:
- what operation failed?
- what were the inputs?
- what was the error?
- what was the context (retries, queue state, user action)?
If your error log does not contain at least three of those, it is probably not enough.
10. Logs should be testable, but not tested
You should not write unit tests that assert a log was emitted. That is testing a side effect, not behavior.
But your logging should be structured enough that you could use it for diagnostics. If a test fails, the test output should contain enough log context to understand the failure without re-running under a breakpoint.
In practice this means:
- log significant state transitions in test-visible code paths
- use consistent categories so you can filter noise
- avoid logging in pure utility functions that tests call constantly
The log is a diagnostic tool, not a test assertion.
11. The practical baseline I would ship
For most apps, this is enough:
- one
Loggerper subsystem/category pair debugfor development noise, stripped in releaseinfofor normal operational eventserrorfor failures with contextfaultfor invariant violations that should never happen- privacy annotations on every interpolated value
- signposts for performance-critical paths
- no
printstatements anywhere - no logging in tight loops
- consistent field naming for structured events
That is not a large system. It is maybe a hundred lines of logging code across the whole app.
What makes it useful is discipline, not volume.
12. The real goal is reconstructing state from a distance
The user is in another timezone. The bug only happens on their device. You have a crash report, a screenshot, and a sysdiagnose.
Your logs are the only witness.
If they are structured, relevant, and respectful of privacy, you can often reconstruct the exact sequence of events that led to the failure. If they are a mess of print statements and “here” markers, you are guessing.
Good logging is not about being comprehensive. It is about being useful when nothing else is.
Write fewer logs. Make each one count.