Swift Logging for iOS & macOS — A Practical Guide
Swift Logging for iOS & macOS — A Practical Guide
TL;DR (recommendations)
- Use Apple’s unified logging (
LoggerfromOSLog) 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
debugstays in memory only;infostays in memory unless configuration changes or when faults (and optionally errors) occur;notice/log,error,faultare 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
- What is Apple’s Unified Logging?
- Choosing an API:
print,NSLog,os_log,Logger - Set up a
Logger(subsystem/category) - Write logs by level (with privacy)
- Avoid leaking data: privacy & formatting
- Performance: overhead, disabling, and hot paths
- Measure performance with Signposts
- Viewing, filtering & collecting logs
- Shipping to the App Store with logging in place
- Patterns & snippets (grab bag)
- 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, inOSLog) — 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 SwiftLoggerAPIs. (Apple Developer)NSLog— logs to the Apple System Log and may also write tostderr. PreferLoggerfor modern apps, thoughNSLogcan 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 (preferLogger). (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: .publicshows the value; omit unless you intend to display that data.privacy: .privatealways 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/faultpersist 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)") }
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 withendInterval. - To show activity in the “Points of Interest” lane, log signposts under the
pointsOfInterestcategory. (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:
Privacy & redaction
- Treat logs as potentially user‑accessible. Use
OSLogPrivacyto keep PII/secrets redacted (.privateor.sensitive(mask: .hash)), only marking explicit values.publicwhen appropriate. (Apple Developer)
- Treat logs as potentially user‑accessible. Use
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)
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)
- Prefer low‑cost levels (
Legacy APIs
- Avoid introducing new
os_log/NSLogusage in modern Swift code; preferLogger. If you keep legacy calls, know thatNSLogwrites to the system log (and may also go tostderr). (Apple Developer)
- Avoid introducing new
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)")
}
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
- OSSignposter and sample flow (
beginInterval/endInterval). (Apple Developer) - Recording Performance Data (signpost timelines in Instruments). (Apple Developer)
- Points of Interest category. (Apple Developer)
- OSSignposter and sample flow (
- 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)
- os_log: “migrate away from legacy symbols” (prefer
- 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)
logCLI (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)