Back to Blog

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.

8 min read

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:

  • networking
  • persistence
  • sync
  • ui
  • lifecycle
  • purchases

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 builds
  • info — normal operational events
  • notice — events that might require attention
  • error — errors that did not stop execution
  • fault — 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 development
  • info — 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:

  1. one Logger per subsystem/category pair
  2. debug for development noise, stripped in release
  3. info for normal operational events
  4. error for failures with context
  5. fault for invariant violations that should never happen
  6. privacy annotations on every interpolated value
  7. signposts for performance-critical paths
  8. no print statements anywhere
  9. no logging in tight loops
  10. 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.