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.