---
title: atacan/jsonl-swift-log
framework: Swift Package Catalog
role: article
path: packages/atacan/jsonl-swift-log
---

# atacan/jsonl-swift-log

A [swift-log](https://github.com/apple/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](https://github.com/atacan/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

- 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](.github/workflows/test.yml)); Windows is not supported - [apple/swift-log](https://github.com/apple/swift-log) 1.5+ and [SwiftLogExport](https://github.com/atacan/SwiftLogExport) 1.0+ (both are declared as dependencies)

## Installation

### Swift Package Manager

Add the following to your `Package.swift` file:

```swift 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.

```swift 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.

```swift 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.

```swift 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](#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:

```swift 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](https://github.com/atacan/SwiftLogExport) — the `LogRecord`, `LogRecordExporter` and `BatchLogRecordProcessor` pipeline this package plugs into, whose batching machinery draws inspiration from the [swift-otel](https://github.com/swift-otel/swift-otel) project. - And of course [apple/swift-log](https://github.com/apple/swift-log) for the `Logger` API itself.

## License

This package is available under the [MIT License](LICENSE).

## Package Metadata

Repository: atacan/jsonl-swift-log

Default branch: main

README: README.md
