Provider-Agnostic Logging in Swift with os.Logger
Apple's os.Logger is excellent. It's fast, it's built into every Apple platform, and it integrates with Console.app and Instruments. For local development, it's everything you need.
But in production, you need logs somewhere you can actually search them. Datadog, Segment, Kafka, Splunk, CloudWatch - whatever your team uses. The problem is that most logging SDKs want you to use their API directly, which means your code is littered with DatadogLogger.info() or Analytics.track() calls that you can't easily swap out.
This post builds a thin wrapper around os.Logger that forwards logs to any backend. Your code uses os.Logger everywhere. The wrapper handles the rest.
The Goal
Call sites should look like normal os.Logger usage:
import os
struct LandmarkStore {
private let logger = Logger(subsystem: "com.kylebrowning.landmarks", category: "LandmarkStore")
func fetch() async throws -> [Landmark] {
logger.info("Fetching landmarks")
let landmarks = try await service.fetchAll()
logger.info("Fetched \(landmarks.count) landmarks")
return landmarks
}
}
That's it. No special imports, no SDK-specific calls. The forwarding happens elsewhere, invisible to the code that produces logs.
The Log Forwarder Protocol
Define what a log destination looks like:
protocol LogDestination: Sendable {
func send(
level: OSLogType,
message: String,
subsystem: String,
category: String,
metadata: [String: String]
)
}
Every destination gets the same information: the log level, message, where it came from, and optional metadata.
The Log Router
The router sits between os.Logger and your destinations. It intercepts log output and fans it out:
actor LogRouter {
static let shared = LogRouter()
private var destinations: [LogDestination] = []
func addDestination(_ destination: LogDestination) {
destinations.append(destination)
}
func removeAll() {
destinations.removeAll()
}
func route(
level: OSLogType,
message: String,
subsystem: String,
category: String,
metadata: [String: String] = [:]
) {
for destination in destinations {
destination.send(
level: level,
message: message,
subsystem: subsystem,
category: category,
metadata: metadata
)
}
}
}
A Routed Logger
Wrap os.Logger with a version that also forwards to the router. Two details matter here.
First, use OSLogMessage as the parameter type instead of String. This is the same type os.Logger uses natively, which means callers get full access to os.log's privacy and formatting annotations:
logger.info("User \("john@email.com", privacy: .private) logged in")
logger.debug("Response body: \(body, privacy: .sensitive)")
The privacy annotations control what appears in Console.app and Instruments - redacted for other processes, visible to you in development. Remote destinations receive the full message text, which is appropriate since you control that service.
Second, wrap the message in @autoclosure. The interpolation is only evaluated if the log statement actually runs, so you avoid string-building work when a log level is disabled.
import os
struct AppLogger: Sendable {
private let osLogger: Logger
let subsystem: String
let category: String
init(subsystem: String = "com.kylebrowning.landmarks", category: String) {
self.osLogger = Logger(subsystem: subsystem, category: category)
self.subsystem = subsystem
self.category = category
}
func debug(_ message: @autoclosure () -> OSLogMessage, metadata: [String: String] = [:]) {
let msg = message()
osLogger.debug(msg)
Task { await LogRouter.shared.route(level: .debug, message: String(describing: msg), subsystem: subsystem, category: category, metadata: metadata) }
}
func info(_ message: @autoclosure () -> OSLogMessage, metadata: [String: String] = [:]) {
let msg = message()
osLogger.info(msg)
Task { await LogRouter.shared.route(level: .info, message: String(describing: msg), subsystem: subsystem, category: category, metadata: metadata) }
}
func warning(_ message: @autoclosure () -> OSLogMessage, metadata: [String: String] = [:]) {
let msg = message()
osLogger.warning(msg)
Task { await LogRouter.shared.route(level: .default, message: String(describing: msg), subsystem: subsystem, category: category, metadata: metadata) }
}
func error(_ message: @autoclosure () -> OSLogMessage, metadata: [String: String] = [:]) {
let msg = message()
osLogger.error(msg)
Task { await LogRouter.shared.route(level: .error, message: String(describing: msg), subsystem: subsystem, category: category, metadata: metadata) }
}
}
The os.Logger call is synchronous. The Task { } fires the forwarding to remote destinations asynchronously so it never blocks. One thing worth knowing: each log statement allocates a Task. That's a small but real cost. If your app logs frequently, the buffered approach below is worth adopting - it batches multiple messages into a single flush and reduces that overhead significantly.
Concrete Destinations
Datadog
struct DatadogDestination: LogDestination {
func send(
level: OSLogType,
message: String,
subsystem: String,
category: String,
metadata: [String: String]
) {
var attributes = metadata
attributes["subsystem"] = subsystem
attributes["category"] = category
// Use the Datadog SDK's logger
switch level {
case .debug:
DatadogLogs.Logger.shared.debug(message, attributes: attributes)
case .info:
DatadogLogs.Logger.shared.info(message, attributes: attributes)
case .error, .fault:
DatadogLogs.Logger.shared.error(message, attributes: attributes)
default:
DatadogLogs.Logger.shared.info(message, attributes: attributes)
}
}
}
Segment
struct SegmentDestination: LogDestination {
func send(
level: OSLogType,
message: String,
subsystem: String,
category: String,
metadata: [String: String]
) {
// Only forward warnings and errors as events
guard level == .error || level == .fault || level == .default else { return }
var properties = metadata
properties["level"] = logLevelString(level)
properties["subsystem"] = subsystem
properties["category"] = category
Analytics.shared.track(name: "app_log", properties: properties)
}
private func logLevelString(_ level: OSLogType) -> String {
switch level {
case .debug: "debug"
case .info: "info"
case .error: "error"
case .fault: "fault"
default: "default"
}
}
}
Kafka (via HTTP Endpoint)
For teams running Kafka, a simple HTTP producer endpoint works:
struct KafkaHTTPDestination: LogDestination {
let endpointURL: URL
let topic: String
func send(
level: OSLogType,
message: String,
subsystem: String,
category: String,
metadata: [String: String]
) {
let entry: [String: Any] = [
"timestamp": ISO8601DateFormatter().string(from: Date()),
"level": logLevelString(level),
"message": message,
"subsystem": subsystem,
"category": category,
"metadata": metadata
]
guard let body = try? JSONSerialization.data(withJSONObject: entry) else { return }
var request = URLRequest(url: endpointURL)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(topic, forHTTPHeaderField: "X-Kafka-Topic")
// Fire and forget - logging should never block the app
URLSession.shared.dataTask(with: request).resume()
}
private func logLevelString(_ level: OSLogType) -> String {
switch level {
case .debug: "debug"
case .info: "info"
case .error: "error"
case .fault: "fault"
default: "default"
}
}
}
Console Only (Development)
For development, you might want a destination that pretty-prints to the console with colors or extra context, without sending anything to a remote service:
struct ConsoleDestination: LogDestination {
func send(
level: OSLogType,
message: String,
subsystem: String,
category: String,
metadata: [String: String]
) {
let prefix: String
switch level {
case .debug: prefix = "[DEBUG]"
case .info: prefix = "[INFO]"
case .error: prefix = "[ERROR]"
case .fault: prefix = "[FAULT]"
default: prefix = "[LOG]"
}
let meta = metadata.isEmpty ? "" : " \(metadata)"
print("\(prefix) [\(category)] \(message)\(meta)")
}
}
Wiring It Up
Configure destinations at app launch based on the build configuration:
@main
struct LandmarksApp: App {
init() {
Task {
#if DEBUG
await LogRouter.shared.addDestination(ConsoleDestination())
#else
await LogRouter.shared.addDestination(DatadogDestination())
await LogRouter.shared.addDestination(SegmentDestination())
#endif
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
In debug builds, logs go to the console (in addition to os.Logger). In release builds, they go to Datadog and Segment. The call sites never change.
Usage
Replace Logger with AppLogger in your code:
struct LandmarkStore {
private let logger = AppLogger(category: "LandmarkStore")
func fetch() async throws -> [Landmark] {
logger.info("Fetching landmarks")
let landmarks = try await service.fetchAll()
logger.info("Fetched landmarks", metadata: ["count": "\(landmarks.count)"])
return landmarks
}
func toggleFavorite(id: Int) {
logger.info("Toggling favorite", metadata: ["landmark_id": "\(id)"])
}
}
The metadata dictionary is optional but powerful. It becomes searchable attributes in Datadog, event properties in Segment, and structured fields in Kafka.
Buffering and Batching
For production, you don't want to make an HTTP request for every log line. Add a buffer that batches logs and flushes periodically:
actor BufferedDestination {
private let wrapped: LogDestination
private var buffer: [LogEntry] = []
private let maxSize: Int
private let flushInterval: TimeInterval
init(
destination: LogDestination,
maxSize: Int = 50,
flushInterval: TimeInterval = 30
) {
self.wrapped = destination
self.maxSize = maxSize
self.flushInterval = flushInterval
startFlushTimer()
}
func append(_ entry: LogEntry) {
buffer.append(entry)
if buffer.count >= maxSize {
flush()
}
}
func flush() {
let entries = buffer
buffer.removeAll()
for entry in entries {
wrapped.send(
level: entry.level,
message: entry.message,
subsystem: entry.subsystem,
category: entry.category,
metadata: entry.metadata
)
}
}
private func startFlushTimer() {
Task {
while !Task.isCancelled {
try await Task.sleep(for: .seconds(flushInterval))
flush()
}
}
}
}
struct LogEntry: Sendable {
let level: OSLogType
let message: String
let subsystem: String
let category: String
let metadata: [String: String]
}
Wrap any destination with BufferedDestination to batch its output.
Testing
Test that logs reach the right destinations:
final class SpyDestination: LogDestination {
var logs: [(level: OSLogType, message: String)] = []
func send(
level: OSLogType,
message: String,
subsystem: String,
category: String,
metadata: [String: String]
) {
logs.append((level, message))
}
}
@Test func logsRouteToAllDestinations() async {
let spy1 = SpyDestination()
let spy2 = SpyDestination()
await LogRouter.shared.removeAll()
await LogRouter.shared.addDestination(spy1)
await LogRouter.shared.addDestination(spy2)
let logger = AppLogger(category: "test")
logger.info("hello")
// Give async routing a moment
try await Task.sleep(for: .milliseconds(100))
#expect(spy1.logs.count == 1)
#expect(spy2.logs.count == 1)
#expect(spy1.logs[0].message == "hello")
}
When to Use This vs. swift-log
If you're building a server with Vapor, use swift-log - it's the standard for server-side Swift and we covered it in the Structured Logging post. But on the client side, os.Logger is the right foundation. It's optimized for Apple platforms, integrates with system tools, and has zero overhead for disabled log levels. This wrapper adds remote forwarding without giving up any of those advantages.
Thanks to town-drunk on Reddit for the feedback on using OSLogMessage and autoclosures.