Swift Logging for iOS & macOS — A Practical Guide

From Qiki
Jump to navigation Jump to search

Swift Logging for iOS & 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!)

  • debug: captured in memory only (and only when debug logging is enabled); purged per configuration. (Apple Developer)
  • info: initially in memory only; not moved to the data store unless config changes or when faults (and optionally errors) occur. (Apple Developer)
  • notice / log(_:): default level, written to memory and on‑disk log stores. (Apple Developer)
  • error: persisted to the data store. (Apple Developer)
  • fault: always persisted; intended for system‑level or multi‑process errors. (Apple Developer)

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



Choosing an API: print, NSLog, os_log, Logger

  • Logger (Swift, in OSLog) — the modern, structured API with level‑specific methods, privacy controls, and compiler‑assisted formatting. Use this for almost all app logging. (Apple Developer)
  • os_log / OSLog (C/Obj‑C/Swift overlay) — earlier interface; still available, but Apple recommends moving to the newer Swift Logger APIs. (Apple Developer)
  • NSLog — logs to the Apple System Log and may also write to stderr. Prefer Logger for modern apps, though NSLog can still be handy for quick debugging or in older code paths. (Apple Developer)
  • print — standard output; simple, but lacks levels, privacy, and unified logging integration. Use sparingly during development and avoid in production paths (prefer Logger). (Apple Developer)



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
  • privacy: .public shows the value; omit unless you intend to display that data.
  • privacy: .private always redacts; privacy: .sensitive(mask: .hash) redacts but still lets you correlate equal values via a hash. (Apple Developer)



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

  • Unified logs are binary & compressed, deferring stringification to the reader; this reduces overhead. (Apple Developer)

  • Choosing the right level matters: lower‑severity messages are typically in-memory, while notice/error/fault persist to disk. Overuse of persistent levels increases cost. (Apple Developer)

  • If building expensive payloads, either:

    • Guard by level using the C API before doing work:

      import OSLog
      
      let oslog = OSLog(subsystem: AppLog.subsystem, category: "network")
      if os_log_is_enabled(oslog, .debug) {
          let payload = computeExpensivePayload()
          AppLog.network.debug("Payload \(payload, privacy: .private)")
      }
      

      (Apple Developer)

    • Or compute summary stats (counts, sizes) instead of full bodies.

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
}
  • Create intervals with beginInterval(_:id:) and finish with endInterval.
  • To show activity in the “Points of Interest” lane, log signposts under the pointsOfInterest category. (Apple Developer)



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)

  • Connect a device and open Console.app. Use the search/filter to match your subsystem or category.
  • Show low‑severity logs: Action → Include Info Messages and Action → Include Debug Messages. (These toggles affect the live stream; archives already include them.) (Apple Support)

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

  • Apple docs — Unified Logging overview & behavior
    • Logging (overview and tools). (Apple Developer)
    • Viewing Log Messages (binary/efficient storage). (Apple Developer)
    • Generating Log Messages from Your Code (subsystem/category usage). (Apple Developer)
    • Level persistence details: debug (memory). info (memory unless configured / when faults happen). notice/log (default writes to memory+disk). fault (always persisted). (Apple Developer)
  • Apple docs — Swift Logger, formatters & privacy
    • Message Argument Formatters. (Apple Developer)
    • OSLogPrivacy (.public, .private, .sensitive(mask: .hash)); rationale that logs are user‑accessible. (Apple Developer)
  • Apple docs — Signposts & Instruments
  • Apple docs — Legacy & migration
    • os_log: “migrate away from legacy symbols” (prefer Logger). (Apple Developer)
    • NSLog / NSLogv: logs to Apple System Log (and possibly stderr). (Apple Developer)
  • Apple docs — Tools
    • Customizing logging behavior while debugging (enable more verbose levels for a subsystem). (Apple Developer)
    • Console.app user guide (Include Info/Debug Messages). (Apple Support)
    • Browse the log archive in Console. (Apple Support)
    • log CLI (collect/show/stream) reference. (SS64)
  • Apple videos
    • WWDC20 Explore logging in Swift (modern Swift logging, performance, privacy). (Apple Developer)
    • WWDC23 Debug with structured logging (Xcode 15 console experience). (Apple Developer)
  • App Store privacy & policy
    • App Privacy details on the App Store (disclose data collection, diagnostics). (Apple Developer)
    • User Privacy & Data Use (tracking consent, disclosures). (Apple Developer)
  • Apple guidance (forums)
    • “Leave logs enabled in production; choose levels appropriately.” (Quinn, Apple DTS). (Swift Forums)



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)