A swift-log backend that writes your application logs as JSON Lines (one compact JSON object per line) to a file or to standard output, built on top of SwiftLogExport.
- Valid JSONL guaranteed: every record is encoded as one compact JSON object terminated by
\n; embedded newlines are escaped by the encoder, so no record can ever split across lines. - Built on the SwiftLogExport pipeline: records flow through a batching
BatchLogRecordProcessorinto aJSONLLogRecordExporter, so you get queueing, scheduled exports and graceful drain for free. - Two destinations: append to a file (
O_WRONLY | O_CREAT | O_APPEND, created with0644, subject to the process umask) or stream to standard output. - Efficient writes: a whole export batch is joined into one contiguous buffer and appended with a single
write(2)in the normal path (retried onEINTRand short writes). - Stable, machine-readable fields: ISO-8601 UTC timestamp with fractional seconds, log level, label, message, flattened metadata, source location (
file,function,line) — encoded withsortedKeysso field order never changes. - ServiceLifecycle-friendly:
bootstrap(_:)hands you the processor back; run it in aServiceGroupand shutdown drains the buffer and closes the file. - Strict-concurrency clean: the sink is an actor (no locks), every public type is
Sendable.
- Swift 6.1+
- macOS 13+, iOS 16+, watchOS 9+, tvOS 16+, or visionOS 1+
- Linux is supported and covered by CI (see .github/workflows/test.yml); Windows is not supported
- apple/swift-log 1.5+ and SwiftLogExport 1.0+ (both are declared as dependencies)
Add the following to your Package.swift file:
dependencies: [
.package(url: "https://github.com/apple/swift-log", from: "1.5.0"),
.package(url: "https://github.com/atacan/SwiftLogExport.git", from: "1.0.0"),
.package(url: "https://github.com/atacan/jsonl-swift-log.git", from: "1.0.0"),
]
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "JSONLLogging", package: "jsonl-swift-log"),
]
)
]Call JSONLLogging.bootstrap(_:) once at the very top of your entry point — before the first Logger is created. It installs the backend into LoggingSystem.bootstrap and returns the BatchLogRecordProcessor driving it. You must drive that processor, otherwise buffered records are never flushed to disk. Calling bootstrap more than once traps (swift-log semantics).
Running the processor inside a ServiceGroup means graceful shutdown drains every buffered record through the exporter and closes the file. ServiceLifecycle comes in transitively once any of your dependencies provides it.
import Logging
import ServiceLifecycle // available transitively; declare it yourself if nothing else pulls it in
import JSONLLogging
// 1. Install the backend BEFORE creating your first `Logger`.
var configuration = JSONLLoggingConfiguration()
configuration.destination = .file(path: "logs/app.jsonl")
configuration.level = .debug
configuration.baseMetadata = ["host": "web-1"]
let processor = JSONLLogging.bootstrap(configuration)
// 2. Log as usual through swift-log.
let logger = Logger(label: "app")
logger.info("Application started")
logger.warning("Cache miss rate high", metadata: ["rate": "0.87"])
// 3. Run until shutdown; cancellation drains and flushes the buffer, then closes the sink.
let serviceGroup = ServiceGroup(services: [processor])
try await serviceGroup.run()If you do not use ServiceLifecycle, run the processor on a detached task and cancel that task when your application shuts down — run() reacts to cancellation by exporting everything still queued and shutting the exporter down.
import Logging
import JSONLLogging
let configuration = JSONLLoggingConfiguration(
destination: .file(path: "logs/app.jsonl"),
level: .info
)
let processor = JSONLLogging.bootstrap(configuration)
let processorTask = Task { try await processor.run() }
let logger = Logger(label: "app")
logger.error("Payment provider unreachable")
// On shutdown: canceling triggers the final drain and closes the file.
processorTask.cancel()
try? await processorTask.valueEach line of the destination is one self-contained JSON object (shown here pretty-wrapped only because it is long — in the file it is exactly one line):
{"file":"/Sources/App/Checkout.swift","function":"submitOrder()","label":"checkout","level":"info","line":42,"message":"Order received","metadata":{"order.id":"ord-815","user.id":"u-17"},"source":"Checkout","timestamp":"2026-08-21T09:30:12.481Z"}
Records without metadata omit the "metadata" key entirely, and decoding is tolerant of timestamps written without fractional seconds.
Prefer plain-text logs on the console while also persisting JSONL? Build the pipeline yourself and hand both handlers to a MultiplexLogHandler. This bypasses JSONLLogging.bootstrap entirely, which is why no global bootstrap trap applies here — just make sure something drives the processor.
import Logging
import SwiftLogExport
import JSONLLogging
let exporter = JSONLLogRecordExporter(destination: .file(path: "logs/app.jsonl"))
let processor = BatchLogRecordProcessor<JSONLLogRecord, JSONLLogRecordExporter, ContinuousClock>(
exporter: exporter,
configuration: BatchLogRecordProcessorConfiguration(scheduleDelay: .seconds(5))
)
LoggingSystem.bootstrap { label in
let consoleHandler = StreamLogHandler.standardOutput(label: label)
let jsonHandler = LoggingHandler(
label: label,
processor: processor,
level: .debug,
metadata: ["service": "checkout"]
)
return MultiplexLogHandler([consoleHandler, jsonHandler])
}
// Don't forget the driver here either.
Task { try await processor.run() }Every knob lives on JSONLLoggingConfiguration:
| Option | Default | Effect |
|---|---|---|
destination |
.standardOutput |
.file(path:) appends to the given path (created with 0644, subject to the process umask; parent directories are not created); .standardOutput writes to file descriptor 1, ideal under container log collectors. |
level |
.info |
Minimum level handled by every logger created during bootstrap. Per-logger levels can still be changed afterwards via Logger.logLevel. |
baseMetadata |
[:] |
Merged into every record unless a logging call overrides a key. Nested values are flattened lossily to strings (see Limitations). |
processorConfiguration.scheduleDelay |
.seconds(1) |
Maximum delay between two exports — effectively your flush latency for quiet periods. Lower it if you tail the file. |
processorConfiguration.maximumExportBatchSize |
512 |
Maximum number of records handed to the exporter in one export call; larger queues are drained in chunks of this size. It does not trigger earlier exports — timing is governed by scheduleDelay and the maximumQueueSize threshold. |
processorConfiguration.maximumQueueSize |
2048 |
Number of records buffered between exports; when the buffer count reaches this size an export is triggered immediately instead of waiting for scheduleDelay. It is not a hard cap — records are never dropped, so a sustained burst can grow the buffer further. |
processorConfiguration.exportTimeout |
.seconds(30) |
How long a single export may run before it is cancelled. |
For example, a low-latency file logger with a larger burst buffer:
let configuration = JSONLLoggingConfiguration(
destination: .file(path: "/var/log/myapp/log.jsonl"),
level: .debug,
baseMetadata: ["service": "api-gateway"],
processorConfiguration: BatchLogRecordProcessorConfiguration(
maximumQueueSize: 4096,
scheduleDelay: .milliseconds(250),
maximumExportBatchSize: 1024,
exportTimeout: .seconds(10)
)
)
JSONLLogging.bootstrap(configuration)You can also flush eagerly without waiting for the schedule: try await processor.forceFlush() drains every buffered record through the exporter and fsyncs the destination (a no-op for standard output).
- Metadata is flattened lossily.
Logger.MetadataValue.dictionaryentries become dotted keys ("parent.child"),.arrayvalues collapse into comma-separated strings, and colliding keys overwrite each other. Structured metadata fidelity beyond strings is out of scope. - Append-only. The file sink opens its descriptor with
O_APPENDand keeps appending forever; there is no built-in rotation, compression or retention. Pointdestinationat a freshly rotated file or let your supervisor handle rotation. - Export errors are swallowed by design. SwiftLogExport's
BatchLogRecordProcessordiscards errors thrown by exporters, soFileSinkhandles failures internally: a failed write is retried once after reopening the file (writing only the bytes that did not land, so a partially written batch is never duplicated); if that also fails the batch is dropped and a one-time notice is printed to standard error. Losing logs never takes your application down. - A vanished standard-output reader cannot kill you — at the cost of one process-global setting. POSIX delivers
SIGPIPE(default action: terminate) during a write to a pipe whose reader is gone, beforewritecan reportEPIPE. Creating a descriptor-backed sink (the.standardOutputdestination, or any descriptor handed toFileSink) therefore ignoresSIGPIPEonce for the whole process; the failed write then surfaces asEPIPEand is tolerated for borrowed descriptors. If your application relies on dying fromSIGPIPEon its own pipes, re-check that assumption. - Windows is unsupported. The sink uses POSIX file descriptors directly. Linux and Apple platforms are covered by CI.
- Powered by SwiftLogExport — the
LogRecord,LogRecordExporterandBatchLogRecordProcessorpipeline this package plugs into, whose batching machinery draws inspiration from the swift-otel project. - And of course apple/swift-log for the
LoggerAPI itself.
This package is available under the MIT License.