Logging

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does), with swift-log 1.16.1 and console-kit 4.16.1, the versions Vapor resolved on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package, the test suite passes with VaporTesting and Swift Testing, and the console and JSON output shown is captured from the running service with curl. Vapor 5 is at 5.0.0-beta.4 and is not released yet; its logging setup differs and is described at the end. The page extends the Bookshelf service of the earlier pages of this section. Swift itself is covered by the Swift Reference, and task-local values by Async/Await and Tasks.

What swift-log is

Vapor does not have a logging system of its own. It uses swift-log, the Swift on Server logging API, which separates three things:

Piece Role

Logger

The value your code calls: logger.info("...", metadata: [...]). It holds a label, a log level, metadata and a handler. It is a struct with value semantics.

LogHandler

The backend that formats a message and writes it somewhere (console, JSON on standard output, a file, a vendor agent). Each Logger owns one handler instance.

LoggingSystem

The process-wide registry that creates a handler for every new Logger(label:). LoggingSystem.bootstrap selects the handler factory.

The split is the point of the design. Libraries (Vapor, Fluent, AsyncHTTPClient, SwiftNIO extensions) write to Logger without knowing where the text goes, and the application chooses the backend once at start-up. Vapor ships one backend: console-kit’s ConsoleLogger, which prints one human-readable line per message. Everything else, JSON for a log shipper for example, is a handler you write or add as a package.

Logging pipeline: Vapor and your code call Logger, the LoggingSystem created the LogHandler at bootstrap, handlers write console text or JSON lines to standard output, and a container runtime or collector ships them

Log levels

Logger.Level has seven cases, ordered from least to most severe, and the Comparable conformance follows that order. A logger emits a message only when the message level is at or above the logger’s logLevel.

Level Use it for Typical choice

trace

Fine-grained diagnostics for one hard problem: state of caches, every step of an algorithm.

Off everywhere except a targeted debugging session.

debug

Information that explains behaviour to a developer, without internals.

Development; sometimes enabled briefly in production.

info

Normal events worth recording: a request arrived, a job ran, a user signed in.

Default outside production.

notice

Normal but significant events: the server started, a migration ran, a feature flag changed.

Vapor’s default in production.

warning

Something unexpected that the application handled: a retried call, a rejected request.

Always on.

error

An operation failed and someone should look.

Always on; alert on it.

critical

The application cannot continue or data may be lost.

Always on; pages a person.

The Swift server working group’s log level guidance adds a rule for library authors that applies to your own shared modules as well: a library should log at trace and debug, and report failures by throwing or returning an Error instead of logging them, because the application knows whether a failure is an error or an expected outcome.

Both the message and the metadata are @autoclosure parameters, so nothing is built when the level is disabled. an interpolated call inside a trace message is not evaluated in production. This is why a disabled debug line is cheap, and also why a log statement must not contain code with side effects.

Where Vapor’s loggers come from

Logger Details

app.logger

The application’s logger, label codes.vapor.application, created in Application.make. Use it in configure, lifecycle handlers and commands, where no request exists.

req.logger

A copy of the application logger for one request, with the metadata key request-id already set. Use it in every handler and middleware.

Your own Logger(label:)

For services and libraries that must not depend on Request. It has no request metadata unless a metadata provider adds it (shown below).

Request.init reads the X-Request-Id header and uses its value as req.id when present, and a fresh UUID string otherwise. It stores the value as the request-id metadata of req.logger. Because Logger is a value type, the request’s copy is independent: changing its level or metadata affects only that request, never app.logger or another request.

app.get("shelves", ":name") { req async throws -> String in
    let name = try req.parameters.require("name")
    // Metadata set per message, on top of the request-id Vapor already put on req.logger.
    req.logger.info("Listing shelf", metadata: ["shelf": "\(name)"])
    return "42"
}

Two properties of that copy are easy to get wrong.

  • Metadata set once sticks to the request. Assigning req.logger[metadataKey: "user-id"] in an authenticator makes every later line of the request carry the key, including lines written by Fluent and ErrorMiddleware, which use the same req.logger. This is the cheapest way to add context, and the request-id handling below does exactly that.

  • The level does not change globally. req.logger.logLevel = .trace raises verbosity for the rest of this request only; it is a quick way to trace one troubled route without redeploying. Likewise app.logger.logLevel = .debug changes only app.logger and loggers copied from it after the change, not loggers created with Logger(label:).

What Vapor logs on its own

Source Level Message

RouteLoggingMiddleware

info

GET /shelves/fiction, one line per request, on the request logger. It is installed by default (see the middleware page).

ErrorMiddleware

warning for Abort and unknown errors, including 500s (a DebuggableError picks its own logLevel)

The error’s reason, with the method, url and userAgent metadata. See the error handling page.

HTTP server

notice, debug

Server started on http://127.0.0.1:8080 at notice; starting, shutting down and connection details at debug.

.env loading

debug

Could not load .env file: Not found: ... for each file that is absent.

Two consequences follow. In production the default level is notice (next section; see also Production and Performance), so the info line of RouteLoggingMiddleware is not written: a production service has no access log unless you ask for one. And because ErrorMiddleware logs a 500 at warning, an alert on the error level never fires for unhandled exceptions; alert on the message or metadata instead, or log at error yourself in a custom error middleware. In a release environment the response body hides the cause of an unknown error (Something went wrong.) but the log line keeps the full String(reflecting: error), so those lines must not contain personal data either.

Choosing the level: --log and LOG_LEVEL

Logger.Level.detect(from:) decides the level that LoggingSystem.bootstrap(from:) uses. In order of precedence:

Source Effect

--log debug on the command line

The level, one of trace, debug, info, notice, warning, error, critical.

LOG_LEVEL=debug in the process environment

The same, when --log is absent.

neither

notice when the environment is production, info otherwise.

$ swift run App serve --log debug
$ LOG_LEVEL=warning swift run App serve
$ swift run App serve --env production            # notice: no access log
$ swift run App serve --env production --log info # access log back on

Captured output of the Bookshelf service for one GET /shelves/fiction and one GET /fail, which throws Abort(.badRequest):

== --env production
[ NOTICE ] Server started on http://127.0.0.1:8191
[ WARNING ] Abort.400: Unknown shelf [method: GET, request-id: AC3781F9-2494-4315-9C17-68927AD841F3, url: /fail, userAgent: [curl/8.7.1]]

== --env production --log info
[ NOTICE ] Server started on http://127.0.0.1:8192
[ INFO ] GET /shelves/fiction [request-id: req-7]
[ INFO ] Listing shelf [request-id: req-7, shelf: fiction]
[ INFO ] GET /fail [request-id: 93968AF0-D4CC-4B65-B54B-48867A872F9C]
[ WARNING ] Abort.400: Unknown shelf [method: GET, request-id: 93968AF0-D4CC-4B65-B54B-48867A872F9C, url: /fail, userAgent: [curl/8.7.1]]

== LOG_LEVEL=warning, development
[ WARNING ] Abort.400: Unknown shelf [method: GET, request-id: 537F2A00-D6C3-4634-A6CD-80429E646274, url: /fail, userAgent: [curl/8.7.1]]

With --log debug the console handler also appends the source location, (App/routes.swift:8), to every line.

LOG_LEVEL is read from the process environment, not from the .env file. Logging is bootstrapped before Application.make, and Application.make is the step that loads .env (see the application lifecycle page), so a LOG_LEVEL=debug line in .env is silently ignored. Set it in the shell, in the docker run -e flags or in the Compose file (see Deployment with Docker and Deployment Targets); the configuration and environment page explains the two environments.

Bootstrapping

Logging is configured once per process, at the top of the entrypoint and before Application.make, so that the first log lines already use the right backend:

@main
enum Entrypoint {
    static func main() async throws {
        var env = try Environment.detect()
        try LoggingSystem.bootstrap(from: &env)   // consumes --log, reads LOG_LEVEL
        let app = try await Application.make(env)
        // ...
    }
}

The inout parameter exists because the call removes the --log option from env.commandInput; otherwise the serve command would reject an option it does not know. There are three ways to go further.

  1. Factory overload. LoggingSystem.bootstrap(from: &env) { level in ... } keeps the level detection and replaces the handler. The closure receives the detected level and returns a @Sendable (String) -> LogHandler. It does not take a metadata provider.

  2. swift-log’s own bootstrap. LoggingSystem.bootstrap(_:metadataProvider:) takes the provider. Call Logger.Level.detect(from: &env) yourself to keep --log and LOG_LEVEL working. This is what the Bookshelf example below does.

  3. Multiple backends. swift-log’s MultiplexLogHandler([a, b]) sends each event to several handlers, for example console text for a developer and JSON for a collector.

swift-log documents that bootstrap can be called at most once per process and that a second call is undefined behaviour, most likely a crash. Two situations trigger it: a test target that bootstraps in every test, and an entrypoint that bootstraps a second backend after the first. Bootstrap in main only; tests skip it and replace app.logger instead (see "Testing logs" below and the Testing page).

Structured logging and metadata

A log line has a message for people and metadata for machines. Keep the message constant and move the variable parts to metadata, so a log search can group by message and filter by key:

// Harder to search: the message is different every time.
req.logger.info("Book \(book.id) loaned to \(user.id)")

// Searchable: constant message, typed keys.
req.logger.info("Book loaned", metadata: ["book": "\(book.id)", "user": "\(user.id)"])

Logger.Metadata is a dictionary of Logger.MetadataValue, an enum with four cases: .string, .stringConvertible (any CustomStringConvertible & Sendable, converted when the line is written), .dictionary and .array. String literals and string interpolations become .string through the literal conformances, which is why a literal such as ["shelf": "fiction"] and an interpolated value both compile.

Metadata comes from three places, merged by the handler when it writes the line, later ones overriding earlier ones in the handlers shown here:

  1. the metadata provider configured at bootstrap (task-local data, below);

  2. the logger’s metadata, set with logger[metadataKey:] (request-id on req.logger);

  3. the per-message metadata argument;

The merge order is the handler’s decision. swift-log defines the inputs and leaves precedence to the handler; the JSONLogHandler below and the handlers of swift-log’s documentation use the order above.

A request id for code that has no Request

req.logger carries the request id only where you have the request. A service struct or a repository does not, and passing the request through every layer only for logging is noise. swift-log’s metadata provider solves it: a closure that the handler calls for every event and that can read task-local values. The middleware stores the request id in a @TaskLocal for the duration of next.respond; the provider reads it.

The middleware also fixes a security problem. Vapor copies the incoming X-Request-Id header into the log metadata unchanged, and the header is attacker-controlled: a client can send a 10 KB value, forge another request’s id, or fill the id with text that confuses your log tooling. The middleware accepts at most 64 ASCII letters, digits, - and _, and replaces anything else with a new UUID.

import Vapor

/// Task-local request data that code without a Request can still log.
enum RequestContext {
    @TaskLocal static var requestID: String?
}

extension Logger.MetadataProvider {
    /// Adds request-id to every log line written inside a request's task.
    static let bookshelf = Logger.MetadataProvider {
        guard let id = RequestContext.requestID else { return [:] }
        return ["request-id": .string(id)]
    }
}

/// Replaces an untrusted X-Request-Id, binds the id to the task and echoes it on the response.
struct RequestIDMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        let incoming = request.headers.first(name: .xRequestId)
        let id = incoming.flatMap(Self.validated) ?? UUID().uuidString
        request.logger[metadataKey: "request-id"] = .string(id)

        let response = try await RequestContext.$requestID.withValue(id) {
            try await next.respond(to: request)
        }
        response.headers.replaceOrAdd(name: .xRequestId, value: id)
        return response
    }

    /// Accepts at most 64 characters from a safe alphabet; anything else is replaced.
    static func validated(_ value: String) -> String? {
        guard !value.isEmpty, value.utf8.count <= 64,
              value.utf8.allSatisfy({ $0.isASCIILetterOrDigit || $0 == UInt8(ascii: "-") || $0 == UInt8(ascii: "_") })
        else { return nil }
        return value
    }
}

private extension UInt8 {
    var isASCIILetterOrDigit: Bool {
        (UInt8(ascii: "a")...UInt8(ascii: "z")).contains(self)
            || (UInt8(ascii: "A")...UInt8(ascii: "Z")).contains(self)
            || (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(self)
    }
}

Source: Vapor docs: Middleware; the metadata provider is the swift-log documentation (see Logger.MetadataProvider).

Register the middleware first so the id covers everything, including the default RouteLoggingMiddleware:

func configure(_ app: Application) async throws {
    app.middleware.use(RequestIDMiddleware(), at: .beginning)
    try routes(app)
}

A service that owns a plain Logger now logs the request id without receiving the request:

import Logging

struct ShelfService: Sendable {
    let logger = Logger(label: "bookshelf.shelf")

    func countBooks(on shelf: String) async -> Int {
        logger.debug("Counting books", metadata: ["shelf": "\(shelf)"])
        return 42
    }
}

The provider only works with a handler that calls it. ConsoleLogger (console-kit 4.16.1) and the handler below do; swift-log’s default StreamLogHandler does too. A custom handler that ignores metadataProvider silently drops the data. Task-local values are inherited by child tasks (async let, task groups) but not by Task.detached, so background work started with a detached task loses the request id unless you pass it explicitly. Swift concurrency details are on the Swift concurrency in Vapor page.

The same mechanism carries trace identifiers when you use swift-distributed-tracing: the Swift server guidance suggests populating the log metadata from the tracing context so that every line of a request carries its trace id. Tracing itself is covered on the Tracing, Metrics & OpenTelemetry page.

A JSON log handler

Container platforms, Kubernetes and log shippers read standard output and parse each line. Human-readable text does not survive that: the timestamp, level and fields have to be recovered with regular expressions. JSON lines do. Writing a handler is short; it conforms to LogHandler, which requires a logLevel, a metadata dictionary, a metadataProvider, a metadata subscript and one method, log(event:).

import Foundation
import Logging

/// Writes one JSON object per log event to standard output.
struct JSONLogHandler: LogHandler {
    let label: String
    var logLevel: Logger.Level
    var metadata: Logger.Metadata = [:]
    var metadataProvider: Logger.MetadataProvider?

    init(label: String, logLevel: Logger.Level, metadataProvider: Logger.MetadataProvider? = nil) {
        self.label = label
        self.logLevel = logLevel
        self.metadataProvider = metadataProvider
    }

    subscript(metadataKey key: String) -> Logger.Metadata.Value? {
        get { metadata[key] }
        set { metadata[key] = newValue }
    }

    func log(event: LogEvent) {
        // Precedence, lowest to highest: provider, handler metadata, per-message metadata.
        var merged = metadataProvider?.get() ?? [:]
        merged.merge(metadata) { _, new in new }
        if let own = event.metadata { merged.merge(own) { _, new in new } }

        var object: [String: Any] = [
            "timestamp": Date.now.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true)),
            "level": event.level.rawValue,
            "label": label,
            "message": event.message.description,
            "source": event.source,
            "file": event.file,
            "line": event.line,
        ]
        if !merged.isEmpty { object["metadata"] = merged.mapValues(Self.jsonValue) }
        if let error = event.error { object["error"] = String(describing: error) }

        // JSONSerialization escapes quotes and control characters (including newlines), so one event is always one line.
        guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes]) else {
            return
        }
        // One write call per event keeps lines from different threads from interleaving in practice.
        FileHandle.standardOutput.write(data + Data("\n".utf8))
    }

    private static func jsonValue(_ value: Logger.Metadata.Value) -> Any {
        switch value {
        case .string(let string): string
        case .stringConvertible(let value): value.description
        case .array(let array): array.map(jsonValue)
        case .dictionary(let dictionary): dictionary.mapValues(jsonValue)
        }
    }
}

Points that matter when you write a handler:

  • Value semantics. logLevel and metadata must behave as values: changing them on one Logger must not affect another. A struct with stored properties does this for free; a class with shared state does not.

  • log(event:). swift-log 1.16.1 declares log(event:) as the requirement and deprecates the older seven-argument method. Vapor allows swift-log from 1.8.0, where the event-based method does not exist; a handler that must build against both implements the older method, which compiles with a deprecation warning on current releases.

  • Escaping. JSONSerialization escapes control characters, so a user-supplied newline in a message cannot start a second, forged line in the output. A text handler must do this itself (see "Log injection").

  • Cost. The handler runs on the thread of the logging call, inside the request. Format and write quickly; push expensive work (shipping over the network) to a background task or let the platform’s log driver do it.

  • Ready-made backends. Packages exist for JSON output, OSLog, files and vendor agents; they are ordinary LogHandler`s and bootstrap the same way. Check that a package implements `metadataProvider before relying on task-local metadata.

The bootstrap that selects the handler, with LOG_FORMAT=json as the switch. It keeps --log and LOG_LEVEL, and installs the provider so both the console and the JSON handler show the request id for loggers that never saw a request:

import Vapor

/// Call once per process, before Application.make.
func bootstrapLogging(_ env: inout Environment) throws {
    // Keeps Vapor's --log / LOG_LEVEL detection and the production = notice default.
    let level = try Logger.Level.detect(from: &env)

    if Environment.get("LOG_FORMAT") == "json" {
        LoggingSystem.bootstrap({ label, provider in
            JSONLogHandler(label: label, logLevel: level, metadataProvider: provider)
        }, metadataProvider: .bookshelf)
    } else {
        let console = Terminal()
        LoggingSystem.bootstrap({ label, provider in
            ConsoleLogger(label: label, console: console, level: level, metadata: [:], metadataProvider: provider)
        }, metadataProvider: .bookshelf)
    }
}

and the entrypoint calls it in place of LoggingSystem.bootstrap(from:):

@main
enum Entrypoint {
    static func main() async throws {
        var env = try Environment.detect()
        try bootstrapLogging(&env)

        let app = try await Application.make(env)
        do {
            try await configure(app)
            try await app.execute()
        } catch {
            app.logger.report(error: error)
            try? await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }
}

LOG_FORMAT is read with Environment.get, which at that point sees only the process environment, for the same reason as LOG_LEVEL. Running the service with LOG_FORMAT=json LOG_LEVEL=debug swift run App serve and sending curl -H 'X-Request-Id: abc-123' localhost:8080/shelves/fiction writes:

{"file":"Vapor/RouteLoggingMiddleware.swift","label":"codes.vapor.application","level":"info","line":14,"message":"GET /shelves/fiction","metadata":{"request-id":"abc-123"},"source":"Vapor","timestamp":"2026-10-17T18:00:15.986Z"}
{"file":"App/routes.swift","label":"codes.vapor.application","level":"info","line":8,"message":"Listing shelf","metadata":{"request-id":"abc-123","shelf":"fiction"},"source":"App","timestamp":"2026-10-17T18:00:15.986Z"}
{"file":"App/ShelfService.swift","label":"bookshelf.shelf","level":"debug","line":8,"message":"Counting books","metadata":{"request-id":"abc-123","shelf":"fiction"},"source":"App","timestamp":"2026-10-17T18:00:15.987Z"}

The third line comes from ShelfService, which never saw the request, and still carries "request-id":"abc-123". A request with an invalid id such as bad id! gets a generated UUID in the X-Request-Id response header instead.

In production the typical arrangement is JSON on standard output, nothing written to files by the application, and the platform (Docker’s log driver, the Kubernetes node agent, a sidecar) shipping the stream, as the twelve-factor app describes. The Docker side is covered by the Docker logging page, and the Rust equivalent by logging and tracing in Axum.

What not to log

A log is a second database that is usually less protected than the first: it is copied to a vendor, kept for months, readable by many engineers, and rarely deleted on request. Treat everything written to it as published.

Never log Instead

Passwords, password hashes, API keys, bearer and refresh tokens, JWTs, session cookies, Authorization and Cookie headers

Log that authentication happened and the user id. Never the credential, even partially.

Request and response bodies of login, payment and profile routes

Log the route, status and size.

Email addresses, phone numbers, names, addresses, IP addresses where privacy law treats them as personal data

Log the internal user id and look the person up when needed.

Full SQL with bound values, Fluent queries from debug-level driver output

Keep database debug logging out of production, or check what the driver writes first.

Whole Content models, AppConfig structs and Environment dumps

Log selected fields.

Three techniques help.

Make the safe form the only form. A wrapper whose description is a constant means that logging it, interpolating it or printing it from a debugger shows nothing, whatever the call site does:

/// Wraps a value so that logging it, interpolating it or printing it never reveals it.
struct Redacted<Value: Sendable>: CustomStringConvertible, CustomDebugStringConvertible, Sendable {
    let value: Value
    init(_ value: Value) { self.value = value }
    var description: String { "<redacted>" }
    var debugDescription: String { "<redacted>" }
}

app.post("login") { req async throws -> HTTPStatus in
    struct Credentials: Content { var email: String; var password: String }
    let credentials = try req.content.decode(Credentials.self)
    // Wrong: req.logger.info("Login \(credentials)") prints the password.
    req.logger.info("Login attempt", metadata: [
        "email": .stringConvertible(Redacted(credentials.email)),
        "password": .stringConvertible(Redacted(credentials.password)),
    ])
    return .noContent
}

POST /login with a placeholder body then writes "email":"<redacted>","password":"<redacted>". The Configuration and Environment page uses the same idea for its Secret type, so that logging the whole AppConfig is safe.

Log identifiers, not records. A user id, a book id and a count are enough to find the data again in the database, where access is controlled and deletions are honoured.

Allow-list instead of block-list. If you must log headers, copy the ones you name (User-Agent, Content-Length) and drop the rest. A block-list of Authorization misses X-Api-Key, Proxy-Authorization and the header added next year.

Log injection

A log line is text assembled from values a client controls: the path, the user agent, a header, a form field. A value containing a newline can, in a plain-text log, begin a second line that looks like a genuine event (INFO user admin logged in), and a value shaped like JSON can confuse a parser. Defences, in order of value:

  1. Prefer a structured handler. The JSON handler above escapes control characters, so a newline becomes \n inside a string.

  2. Put client data in metadata values and keep it out of the message text.

  3. Bound the size of what you log: the X-Request-Id handling above is the pattern, and the same applies to User-Agent and to free-text fields.

  4. In a text handler, replace carriage returns and line feeds in client-supplied strings before they reach the message.

The OWASP Logging Cheat Sheet lists these and the events a service should record for security purposes (sign-in failures, access-control failures, input validation failures); the security hardening page of this section puts them in context.

Testing logs

A test can assert that the right event was logged, with the right level and metadata, by installing a handler that stores events. Do not call LoggingSystem.bootstrap in tests (see "Bootstrapping"); instead replace app.logger, because every Request is created from the application’s logger and so inherits the capturing handler.

swift-log also ships an InMemoryLogging library product with an InMemoryLogHandler that does this; the handler below is the same idea in the few lines needed, and shows the LogHandler shape once more.

import Logging
import NIOConcurrencyHelpers

/// Collects every event so a test can assert on level, message and metadata.
struct CapturingLogHandler: LogHandler {
    struct Entry: Sendable {
        let level: Logger.Level
        let message: String
        let metadata: Logger.Metadata
    }

    final class Sink: Sendable {
        private let storage = NIOLockedValueBox<[Entry]>([])
        var entries: [Entry] { storage.withLockedValue { $0 } }
        func append(_ entry: Entry) { storage.withLockedValue { $0.append(entry) } }
    }

    let sink: Sink
    var logLevel: Logger.Level = .trace
    var metadata: Logger.Metadata = [:]
    var metadataProvider: Logger.MetadataProvider?

    subscript(metadataKey key: String) -> Logger.Metadata.Value? {
        get { metadata[key] }
        set { metadata[key] = newValue }
    }

    func log(event: LogEvent) {
        var merged = metadata
        if let own = event.metadata { merged.merge(own) { _, new in new } }
        sink.append(Entry(level: event.level, message: event.message.description, metadata: merged))
    }
}

The Sink is a Sendable class holding a locked array, because the handler is copied into every logger and may be called from any event loop thread. The tests:

import VaporTesting
import Testing
@testable import App

@Suite struct LoggingTests {
    @Test func requestLoggerCarriesRequestID() async throws {
        let sink = CapturingLogHandler.Sink()
        let app = try await Application.make(.testing)
        do {
            // Every Request is created with app.logger, so replacing it captures request logs too.
            app.logger = Logger(label: "test") { _ in CapturingLogHandler(sink: sink) }
            try await configure(app)

            try await app.testing().test(.GET, "shelves/fiction", headers: ["X-Request-Id": "req-7"]) { res async in
                #expect(res.status == .ok)
                #expect(res.headers.first(name: .xRequestId) == "req-7")
            }

            let listing = try #require(sink.entries.first { $0.message == "Listing shelf" })
            #expect(listing.level == .info)
            #expect(listing.metadata["request-id"] == "req-7")
            #expect(listing.metadata["shelf"] == "fiction")
        } catch { try await app.asyncShutdown(); throw error }
        try await app.asyncShutdown()
    }

    @Test func unsafeRequestIDIsReplaced() {
        #expect(RequestIDMiddleware.validated("req-7") == "req-7")
        #expect(RequestIDMiddleware.validated("bad id!") == nil)
        #expect(RequestIDMiddleware.validated(String(repeating: "a", count: 65)) == nil)
    }

    @Test func secretsAreRedacted() {
        let password = Redacted("placeholder-pw")
        #expect("\(password)" == "<redacted>")
        #expect(String(reflecting: password) == "<redacted>")
    }

    @Test func loggerIsAValue() {
        var a = Logger(label: "a", factory: { _ in CapturingLogHandler(sink: .init()) })
        let b = a
        a.logLevel = .error
        a[metadataKey: "tenant"] = "acme"
        #expect(b.logLevel == .trace)
        #expect(b[metadataKey: "tenant"] == nil)
    }
}

The task-local request id is not asserted here because the capturing handler does not read the provider; to test that, make the handler merge metadataProvider?.get() as the JSON handler does and install .bookshelf on the logger.

Version notes and outdated material

  • Vapor 3 to 4. Vapor 2 and 3 had their own logging protocol and a PrintLogger registered as a service in configure.swift; handlers asked the request’s container for a logger. Vapor 4 replaced all of it with swift-log: the logger is a property (app.logger, req.logger), the backend is chosen with LoggingSystem.bootstrap, and the level names are swift-log’s seven. A book or blog post that registers a logger service is describing a mechanism that no longer exists. What the books say about log levels and debugging in Xcode maps onto --log and LOG_LEVEL.

  • log(event:). Older swift-log handlers implement a seven-argument log method. It still works through a deprecated bridge in swift-log 1.16.1; new handlers implement log(event:).

  • Metadata providers arrived in swift-log 1.5 and are what Logger.MetadataProvider and the metadataProvider: bootstrap parameter refer to. Handlers written before then lack the property and silently ignore a provider.

  • LoggingSystem.bootstrap(from:) and Logger.Level.detect(from:) are Vapor 4 APIs. The .env limitation and the notice production default are in the 4.122.2 source (LoggingSystem+Environment.swift).

  • Vapor 5. Vapor 5 is 5.0.0-beta.4 and not released; everything below is read from that tag’s source and may change before the release. Its Sources/Vapor/Logging directory contains only Logger+Report.swift, so LoggingSystem.bootstrap(from:) and Logger.Level.detect(from:) are not there. The development entrypoint instead builds a swift-configuration ConfigReader from the command-line arguments and environment variables, calls console-kit’s ConsoleLogger.bootstrapWithConfigReader(config:), creates a Logger, and runs the application inside swift-log’s withLogger. The package depends on console-kit 5.0.0-beta and swift-log 1.14.0 or newer. The Logger API itself belongs to swift-log and is unchanged; bootstrapping and level selection move, and the req.logger and app.logger properties may still change before Vapor 5 is released. A custom LogHandler keeps working because it targets swift-log, not Vapor.

Common mistakes

Symptom Cause and fix

LOG_LEVEL in .env has no effect

The level is detected before .env is loaded. Set it in the process environment or pass --log.

No access log in production

The default level is notice and RouteLoggingMiddleware logs at info. Pass --log info, or log requests from your own middleware at notice.

Crash with a bootstrap precondition or undefined behaviour in tests

LoggingSystem.bootstrap was called twice. Bootstrap only in main; replace app.logger in tests.

The first log lines use a different format

Logging was bootstrapped after Application.make. Bootstrap first.

Task-local request id is missing in some lines

The code runs in Task.detached, or the handler ignores metadataProvider. Pass the id explicitly or use a handler that supports providers.

req.logger.logLevel = .debug has no effect on a service

The change applies to that copy only. Pass the logger to the service, or raise the level at bootstrap.

An alert on error never fires for 500s

ErrorMiddleware reports every error at warning. Alert on the message, or log at error in a custom middleware.

Secrets in the logs

A model, Content struct or config was interpolated into a message. Log selected fields and wrap secrets in a type that redacts itself.

One log line is split in two

A newline in a client-supplied value reached a text handler. Use a structured handler and keep user data in metadata.

error: call to main actor-isolated ... inside a handler

A handler is Sendable and called from any thread; do not touch main-actor state. Use locks or an actor for shared storage.