Swift Logging for iOS and macOS: A Practical Guide

TL;DR (recommendations)

  • Use Apple’s unified logging (Logger from OSLog) for app diagnostics. Prefer level‑appropriate calls (.debug, .info, .notice, .error, .fault). (Apple Developer)
  • Treat logs as user‑visible artifacts: don’t write sensitive data; use OSLogPrivacy (.private, .public, .sensitive(mask: .hash)). (Apple Developer)
  • Know what’s persisted: by default debug stays in memory only; info stays in memory unless configuration changes or when faults (and optionally errors) occur; notice/log, error, fault are written to on‑device stores. Use higher levels sparingly. (Apple Developer)
  • For performance analysis, add signposts (OSSignposter) and inspect them in Instruments. (Apple Developer)
  • Shipping with logging code is normal and expected; ensure your App Privacy disclosures are accurate if you upload or otherwise collect logs, and keep sensitive values redacted. (Apple Developer)

Table of Contents

  1. What is Apple’s Unified Logging?
  2. Choosing an API: print, NSLog, os_log, Logger
  3. Set up a Logger (subsystem/category)
  4. Write logs by level (with privacy)
  5. Avoid leaking data: privacy & formatting
  6. Performance: overhead, disabling, and hot paths
  7. Measure performance with Signposts
  8. Viewing, filtering & collecting logs
  9. Shipping to the App Store with logging in place
  10. Patterns & snippets (grab bag)
  11. References

What is Apple’s Unified Logging?

Apple’s unified logging system centralizes app and system telemetry, storing messages in binary, compressed form (in memory and on disk) so that logging is efficient even in production. You view logs in Xcode’s console, macOS Console.app, Instruments, or the log CLI. (Apple Developer)

Persistence by level (important!)

This architecture keeps routine diagnostics cheap while ensuring serious issues are preserved. (Apple Developer)


Choosing an API: print, NSLog, os_log, Logger


Set up a Logger (subsystem/category)

Define one Logger per functional area (category) under your app’s subsystem (use your bundle identifier).

import OSLog

enum AppLog {
    static let subsystem = Bundle.main.bundleIdentifier!

    static let app      = Logger(subsystem: subsystem, category: "app")
    static let network  = Logger(subsystem: subsystem, category: "network")
    static let storage  = Logger(subsystem: subsystem, category: "storage")
}

Subsystems & categories help filter noise in Console/Instruments. (Apple Developer)


Write logs by level (with privacy)

// App lifecycle
AppLog.app.notice("Launched build \(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?", privacy: .public)")

// Network (do not leak raw identifiers)
let userID = "12345-ABCDE"
AppLog.network.info("Fetching profile for user \(userID, privacy: .sensitive(mask: .hash))")

// Error cases
do {
    let data = try await api.fetch()
    AppLog.network.notice("Fetched \(data.count, privacy: .public) bytes")
} catch {
    AppLog.network.error("Fetch failed: \(error.localizedDescription, privacy: .public)")
}

// Debug-only breadcrumbs
#if DEBUG
AppLog.storage.debug("Cache warmup complete")
#endif

Avoid leaking data: privacy & formatting

Apple’s Message Argument Formatters drive privacy and presentation directly from string interpolation — don’t wrap messages in String before passing them to Logger, or you’ll lose these compiler/runtime optimizations. (Apple Developer)

Common patterns:

let email = "jane@example.com"
let ms: Double = 123.456

AppLog.app.info("User email \(email, privacy: .sensitive(mask: .hash))")
AppLog.app.info("Login took \(ms, privacy: .public) ms") // consider rounding before logging

Because people can access logs your app generates (e.g., via Console), use privacy modifiers for all PII and secrets. (Apple Developer)


Performance: overhead, disabling, and hot paths

Apple’s guidance is to leave logging enabled in production and select levels so that default behavior is efficient; enable more verbose levels only when needed. (Swift Forums)


Measure performance with Signposts

Use signposts to bracket operations and visualize timing in Instruments (os_signposts instrument).

import OSLog

let signposter = OSSignposter(subsystem: AppLog.subsystem, category: "network")

func download(_ url: URL) async throws -> Data {
    let state = signposter.beginInterval("Download", id: .exclusive)
    defer { signposter.endInterval("Download", state) }

    AppLog.network.notice("Starting download \(url.absoluteString, privacy: .public)")
    let (data, _) = try await URLSession.shared.data(from: url)
    AppLog.network.notice("Completed download \(url.lastPathComponent, privacy: .public)")
    return data
}

Viewing, filtering & collecting logs

Xcode debug console

Run under Xcode and use the structured logging UI (Xcode 15+). It understands levels, categories, file/line navigation, and filtering. (Apple Developer)

Console.app (macOS)

Command line (macOS)

## Live stream (include debug)
log stream --level debug --predicate 'subsystem == "com.yourco.yourapp"'

## Show recent logs from the last hour (by subsystem & category)
log show --last 1h --predicate 'subsystem == "com.yourco.yourapp" AND category == "network"'

## Collect a logarchive you can share
log collect --last 1h --output ~/Desktop/app-logs.logarchive

Open a .logarchive in Console to browse/share. (Apple Support)

You can also adjust logging configuration while debugging on macOS to include lower levels for a specific subsystem (see “Customizing logging behavior while debugging”). (Apple Developer)


Shipping to the App Store with logging in place

Bottom line: Apple expects production apps to use unified logging responsibly. Including logging code isn’t grounds for rejection. The important considerations are privacy, volume, and disclosure:

  1. Privacy & redaction

    • Treat logs as potentially user‑accessible. Use OSLogPrivacy to keep PII/secrets redacted (.private or .sensitive(mask: .hash)), only marking explicit values .public when appropriate. (Apple Developer)
  2. App Privacy disclosures (Store listing)

    • If your app collects logs (e.g., uploads to a server, support e‑mail, diagnostics upload), ensure your App Privacy answers are accurate and your privacy policy reflects this. Tracking behavior across apps/sites requires ATT consent; normal on‑device logging doesn’t, but sending identifiers/diagnostics off‑device may. (Apple Developer)
  3. Volume & performance

    • Prefer low‑cost levels (debug, info) during development; only persist (notice, error, fault) when it’s truly useful. Excessive persistent logs can impact performance and fill quotas sooner. (Apple Developer)
  4. Legacy APIs

    • Avoid introducing new os_log/NSLog usage in modern Swift code; prefer Logger. If you keep legacy calls, know that NSLog writes to the system log (and may also go to stderr). (Apple Developer)
  5. Debug‑only gates (optional)

    • It’s fine to compile out extremely verbose logs:

      #if DEBUG
      AppLog.app.debug("Verbose dev-only detail")
      #endif
      
    • But don’t rely solely on this; production diagnostics are often invaluable, and Apple’s logging stack is engineered for low overhead in release builds. (Apple Developer)


Patterns & snippets (grab bag)

A. Category‑per‑file helper

Keep categories consistent without hand‑typing.

import OSLog

extension Logger {
    /// Category derived from the calling file name, e.g., "SearchViewModel"
    static func forFile(_ file: String = #fileID) -> Logger {
        let category = file.split(separator: "/").last.map(String.init) ?? "app"
        return Logger(subsystem: Bundle.main.bundleIdentifier!, category: category)
    }
}

// Usage
let log = Logger.forFile()
log.notice("Started")

B. Sampling to avoid flood

Throttle hot‑loop logs.

struct LogSampler {
    private var last = DispatchTime(uptimeNanoseconds: 0)

    mutating func every(_ seconds: TimeInterval, _ block: () -> Void) {
        let now = DispatchTime.now()
        if now.uptimeNanoseconds - last.uptimeNanoseconds >= UInt64(seconds * 1_000_000_000) {
            last = now; block()
        }
    }
}

var sampler = LogSampler()
sampler.every(5) {
    AppLog.network.info("Progress heartbeat")
}

C. Guard expensive work behind “is logging enabled?”

If you must build large payloads:

import OSLog

let oslog = OSLog(subsystem: AppLog.subsystem, category: "export")
if os_log_is_enabled(oslog, .debug) {
    let big = computeLargeDiagnosticText()
    AppLog.app.debug("Export preview: \(big, privacy: .private)")
}

(Apple Developer)

D. Minimal network span with a signpost

import OSLog

let sp = OSSignposter(subsystem: AppLog.subsystem, category: "network")

func post(_ body: Data, to url: URL) async throws {
    let s = sp.beginInterval("POST", id: .exclusive)
    defer { sp.endInterval("POST", s) }
    // ... perform URLSession work
}

Inspect the interval in Instruments → os_signposts. (Apple Developer)

E. Console & CLI cheats

## See everything for your app live (debug+)
log stream --level debug --predicate 'subsystem == "com.yourco.yourapp"'

## Export a shareable archive (attach to bug reports)
log collect --last 30m --output ~/Desktop/yourapp.logarchive

Open the archive in Console. (Apple Support)


References


Appendix: Minimal migration from os_log to Logger

Before:

import os.log
let legacy = OSLog(subsystem: "com.yourco.yourapp", category: "network")
os_log("Request to %{public}s failed: %{public}s", log: legacy, type: .error, url.absoluteString, error.localizedDescription)

After:

import OSLog
let network = Logger(subsystem: "com.yourco.yourapp", category: "network")
network.error("Request to \(url.absoluteString, privacy: .public) failed: \(error.localizedDescription, privacy: .public)")

Apple recommends migrating to the Swift Logger API for readability, privacy, and performance. (Apple Developer)


If you want, tell me what frameworks your app uses (e.g., URLSession, CoreData), and I can add domain‑specific logging templates (network retries, decoding failures, background tasks, database migrations, etc.) tailored to your codebase.