JSONLLogging

1.0.0

JSON Lines (JSONL) logging backend for apple/swift-log, built on SwiftLogExport's batching pipeline
atacan/jsonl-swift-log

What's New

1.0.0

2026-08-22T08:54:59Z

JSONLLogging

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.

Features

  • 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 BatchLogRecordProcessor into a JSONLLogRecordExporter, so you get queueing, scheduled exports and graceful drain for free.
  • Two destinations: append to a file (O_WRONLY | O_CREAT | O_APPEND, created with 0644, 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 on EINTR and 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 with sortedKeys so field order never changes.
  • ServiceLifecycle-friendly: bootstrap(_:) hands you the processor back; run it in a ServiceGroup and shutdown drains the buffer and closes the file.
  • Strict-concurrency clean: the sink is an actor (no locks), every public type is Sendable.

Requirements

Installation

Swift Package Manager

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"),
        ]
    )
]

Usage

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).

Driven by ServiceGroup (recommended)

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()

Driven by a Task

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.value

Sample output

Each 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.

Mixing with other backends

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() }

Advanced Configuration

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).

Limitations

  • Metadata is flattened lossily. Logger.MetadataValue.dictionary entries become dotted keys ("parent.child"), .array values 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_APPEND and keeps appending forever; there is no built-in rotation, compression or retention. Point destination at a freshly rotated file or let your supervisor handle rotation.
  • Export errors are swallowed by design. SwiftLogExport's BatchLogRecordProcessor discards errors thrown by exporters, so FileSink handles 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, before write can report EPIPE. Creating a descriptor-backed sink (the .standardOutput destination, or any descriptor handed to FileSink) therefore ignores SIGPIPE once for the whole process; the failed write then surfaces as EPIPE and is tolerated for borrowed descriptors. If your application relies on dying from SIGPIPE on 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.

Acknowledgements

  • Powered by SwiftLogExport — the LogRecord, LogRecordExporter and BatchLogRecordProcessor pipeline this package plugs into, whose batching machinery draws inspiration from the swift-otel project.
  • And of course apple/swift-log for the Logger API itself.

License

This package is available under the MIT License.

Description

  • Swift Tools 6.1.0
View More Packages from this Author

Dependencies

Last updated: Mon Sep 07 2026 12:28:38 GMT-0900 (Hawaii-Aleutian Daylight Time)