From a2516767211e6663e970310ef5365ed434d26c8d Mon Sep 17 00:00:00 2001 From: Vladimir Kukushkin Date: Mon, 27 Apr 2026 12:57:50 +0100 Subject: [PATCH 1/2] SLG-0006: task-local logger proposal --- .../Proposals/SLG-0006-task-local-logger.md | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 Sources/Logging/Docs.docc/Proposals/SLG-0006-task-local-logger.md diff --git a/Sources/Logging/Docs.docc/Proposals/SLG-0006-task-local-logger.md b/Sources/Logging/Docs.docc/Proposals/SLG-0006-task-local-logger.md new file mode 100644 index 00000000..6726b5b7 --- /dev/null +++ b/Sources/Logging/Docs.docc/Proposals/SLG-0006-task-local-logger.md @@ -0,0 +1,340 @@ +# SLG-0006: Task-local logger with automatic metadata propagation + +Accumulate structured logging metadata across async call stacks using task-local storage. + +## Overview + +- Proposal: SLG-0006 +- Author(s): [Vladimir Kukushkin](https://github.com/kukushechkin) +- Status: **Awaiting Review** +- Issue: [apple/swift-log#261](https://github.com/apple/swift-log/issues/261) +- Implementation: [apple/swift-log#414](https://github.com/apple/swift-log/pull/414) +- Feature flag: none +- Related links: + - [Lightweight proposals process description](https://github.com/apple/swift-log/blob/main/Sources/Logging/Docs.docc/Proposals/Proposals.md) + +### Introduction + +This proposal adds task-local logger storage to enable progressive metadata accumulation without explicit logger +parameters. + +### Motivation + +#### Problem 1: Metadata propagation requires threading loggers through every layer + +```swift +func handleHTTPRequest(_ request: HTTPRequest, logger: Logger) async throws { + var logger = logger + logger[metadataKey: "request.id"] = "\(request.id)" + try await processBusinessLogic(request, logger: logger) +} + +func processBusinessLogic(_ request: HTTPRequest, logger: Logger) async throws { + let user = try await authenticate(request, logger: logger) + var logger = logger + logger[metadataKey: "user.id"] = "\(user.id)" + try await accessDatabase(user, logger: logger) +} + +func accessDatabase(_ user: User, logger: Logger) async throws { + var logger = logger + logger[metadataKey: "table"] = "users" + logger.info("Query") +} +``` + +Every layer must accept, mutate, and forward a logger parameter. This is verbose and error-prone. + +#### Problem 2: Libraries must choose between API pollution and lost context + +```swift +// Option A: Pollute public APIs with logger parameter +public func query(_ sql: String, logger: Logger) async throws -> [Row] { ... } + +// Option B: Create ad-hoc loggers, lose all parent metadata +public func query(_ sql: String) async throws -> [Row] { + let logger = Logger(label: "database") // Lost: request.id, user.id, trace.id + logger.debug("Query") + ... +} + +// Option C: Do not log at all +``` + +### Proposed solution + +Use Swift's `@TaskLocal` storage to propagate a logger with accumulated metadata: + +```swift +func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse { + try await withLogger(mergingMetadata: ["request.id": "\(request.id)"]) { logger in + logger.info("Handling request") + let user = try await authenticate(request) // No logger parameter needed + return try await processRequest(request, user: user) + } +} + +func authenticate(_ request: HTTPRequest) async throws -> User { + Logger.current.debug("Authenticating") // Has request.id automatically +} +``` + +Libraries get clean APIs with full context: + +```swift +public struct DatabaseClient { + public func query(_ sql: String) async throws -> [Row] { + Logger.current.debug("Query", metadata: ["sql": "\(sql)"]) // Has all parent metadata + return try await performQuery(sql) + } +} +``` + +Metadata accumulates through nesting: + +```swift +withLogger(mergingMetadata: ["request.id": "\(request.id)"]) { _ in + withLogger(mergingMetadata: ["user.id": "\(user.id)"]) { _ in + withLogger(mergingMetadata: ["operation": "payment"]) { logger in + logger.info("Processing") // Has request.id, user.id, AND operation + } + } +} +``` + +Child tasks inherit parent context automatically through structured concurrency. `Task.detached` does not inherit +context — capture the logger explicitly if needed. + +### Detailed design + +#### `Logger.current` + +Returns the current task-local logger, or a fallback logger if none is set. + +```swift +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension Logger { + /// The current task-local logger. + /// + /// This property provides direct access to the logger stored in task-local storage. + /// Use this when you need quick access to the logger without a closure. + /// + /// If no task-local logger has been set up, this returns the globally bootstrapped logger + /// with the label "task-local-fallback" and emits a warning (once per process) to help with adoption. + /// Use ``withLogger(_:_:)-6n3m5`` to properly initialize the task-local logger. + /// + /// > Tip: For performance-critical code with many log calls, consider extracting the logger once + /// > instead of accessing ``Logger/current`` repeatedly: + /// > ```swift + /// > let logger = Logger.current + /// > for item in items { + /// > logger.debug("Processing", metadata: ["id": "\(item.id)"]) + /// > } + /// > ``` + /// + /// > Important: Task-local values are **not** inherited by detached tasks created with `Task.detached`. + /// > If you need logger context in a detached task, capture the logger explicitly. + @inlinable + public static var current: Logger { get } +} +``` + +#### `withLogger` free functions + +Four free functions: two overload groups, each with sync and async variants. The closure always receives the logger +as a parameter for convenience and to avoid repeated task-local lookups inside the closure body. + +> Note: The API uses `rethrows` instead of `throws(Failure)` because the underlying `TaskLocal.withValue` API uses +> untyped throws. This is a known deviation from the project preference against `rethrows` in public API, forced by +> the standard library limitation. Once `TaskLocal.withValue` gains typed throws support, these signatures can be +> updated to `throws(Failure)` without breaking source compatibility, since `rethrows` is more restrictive. +> +> The async variants do not constrain `Result: Sendable` for the same reason. + +**Bind a specific logger:** + +```swift +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + +/// Runs the given closure with a logger bound to the task-local context. +/// +/// This is the primary way to set up a task-local logger. All code within the closure can access the logger +/// via ``Logger/current`` without explicit parameter passing. +/// +/// - Parameters: +/// - logger: The logger to bind to the task-local context. +/// - operation: The closure to run with the logger bound. +/// - Returns: The value returned by the closure. +@inlinable +public func withLogger( + _ logger: Logger, + _ operation: (Logger) throws -> Result +) rethrows -> Result + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + +/// Runs the given async closure with a logger bound to the task-local context. +/// +/// Async variant of the synchronous ``withLogger(_:_:)-6n3m5``. +/// +/// - Parameters: +/// - logger: The logger to bind to the task-local context. +/// - operation: The async closure to run with the logger bound. +/// - Returns: The value returned by the closure. +@inlinable +nonisolated(nonsending) +public func withLogger( + _ logger: Logger, + _ operation: nonisolated(nonsending) (Logger) async throws -> Result +) async rethrows -> Result +``` + +**Modify the current task-local logger:** + +```swift +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + +/// Runs the given closure with a modified task-local logger. +/// +/// This function modifies the current task-local logger by specifying any combination of log level, +/// metadata, and metadata provider. Only the specified parameters modify the current logger; `nil` +/// parameters leave the current values unchanged. +/// +/// - Parameters: +/// - logLevel: Optional log level. If provided, sets this log level on the logger. +/// - mergingMetadata: Optional metadata to merge with the current logger's metadata. +/// - metadataProvider: Optional metadata provider to set on the logger. +/// - operation: The closure to run with the modified task-local logger. +/// - Returns: The value returned by the closure. +@inlinable +public func withLogger( + logLevel: Logger.Level? = nil, + mergingMetadata: Logger.Metadata? = nil, + metadataProvider: Logger.MetadataProvider? = nil, + _ operation: (Logger) throws -> Result +) rethrows -> Result + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + +/// Runs the given async closure with a modified task-local logger. +/// +/// Async variant. See the synchronous +/// ``withLogger(logLevel:mergingMetadata:metadataProvider:_:)-3urd2`` for detailed documentation. +/// +/// - Parameters: +/// - logLevel: Optional log level. If provided, sets this log level on the logger. +/// - mergingMetadata: Optional metadata to merge with the current logger's metadata. +/// - metadataProvider: Optional metadata provider to set on the logger. +/// - operation: The async closure to run with the modified task-local logger. +/// - Returns: The value returned by the closure. +@inlinable +nonisolated(nonsending) +public func withLogger( + logLevel: Logger.Level? = nil, + mergingMetadata: Logger.Metadata? = nil, + metadataProvider: Logger.MetadataProvider? = nil, + _ operation: nonisolated(nonsending) (Logger) async throws -> Result +) async rethrows -> Result +``` + +To create a new logger with a specific handler, construct it and pass it to `withLogger`: + +```swift +let logger = Logger(label: "app", factory: { myHandler }) +withLogger(logger) { logger in + logger.info("Using custom handler") +} +``` + +#### Fallback behavior + +When `Logger.current` is accessed without prior setup: + +1. Returns a logger created from the globally bootstrapped handler with label `"task-local-fallback"`. The fallback + logger is not cached so that changes to `LoggingSystem.bootstrap()` are always reflected. +2. Emits a `.warning`-level log through that logger on first access (once per process). The warning is thread-safe. +3. Applications continue to work, making incremental adoption easy. + +```swift +// Phase 1: Library code works immediately with global bootstrap (warns once) +LoggingSystem.bootstrap(StreamLogHandler.standardError) +Logger.current.info("Works") + +// Phase 2: Add task-local context at entry points +let logger = Logger(label: "app", factory: { StreamLogHandler.standardError(label: $0) }) +withLogger(logger) { logger in + // No more fallback warning, full metadata propagation +} +``` + +#### Performance considerations + +- `Logger.current` performs a task-local lookup on each access. +- `withLogger { logger in }` does a single lookup; use the closure's `logger` parameter for repeated logging. +- Use explicit parameter passing in tight loops if profiling identifies task-local access as a bottleneck. + +### API stability + +**For existing `Logger` users:** No changes. All existing call sites continue to compile and behave identically. +The new API is purely additive. + +**For existing `LogHandler` implementations:** No changes required. No new protocol requirements are added, no +default implementations are introduced that handlers need to be aware of. Task-local loggers use the same +`LogHandler` interface. `MaxLogLevel` traits from SLG-0002 work correctly with task-local loggers since +`Logger.current` returns a standard `Logger` instance. + +**Platform requirements**: macOS 10.15+, iOS 13.0+, watchOS 6.0+, tvOS 13.0+ (requires `@TaskLocal`). + +### Future directions + +None currently planned. + +### Alternatives considered + +#### Task-local metadata dictionary instead of task-local logger + +Make only the metadata dictionary task-local, so ad-hoc `Logger(label:)` calls automatically merge it. + +Rejected because it changes default behavior for all existing logger creation (breaking semantic change), decouples +logger from its metadata in a confusing way, and overlaps with `swift-distributed-tracing`'s context propagation. + +#### Public `taskLocalLogger` property + +Rejected — exposes implementation detail, more verbose than `Logger.current`. + +#### Static methods on `Logger` instead of free functions + +`Logger.withCurrent(...)` instead of `withLogger(...)`. + +Rejected — inconsistent with `withSpan(...)` from `swift-distributed-tracing` and `withMetricsFactory(...)` from +`swift-metrics`. Free functions follow the established ecosystem convention. + +#### Use `ServiceContext` from swift-distributed-tracing instead of a new task-local + +Store metadata in the existing `ServiceContext` that `swift-distributed-tracing` propagates, rather than introducing +a second `@TaskLocal`. + +Rejected because: + +- `ServiceContext` is server-specific infrastructure; `swift-log` is a general-purpose API for all platforms (iOS, + macOS, embedded, CLI tools), not just server workloads. +- Adding this to `swift-log` directly avoids requiring a separate package dependency, simplifying usage and + discoverability for the majority of adopters who do not use distributed tracing. +- `swift-log` is standalone with no dependency on `swift-distributed-tracing`. Coupling them would create a circular + dependency. +- The task-local logger carries more than metadata — it holds the `LogHandler`, log level, label, and metadata + provider. +- `ServiceContext` values are set once at boundaries; logger metadata accumulates progressively through nested scopes. +- The existing `MetadataProvider` already bridges the two: loggers can read trace IDs from `ServiceContext` at + log-emission time without coupling the packages at the propagation level. + +#### No closure parameter — require `Logger.current` inside the closure + +Instead of `withLogger(logger) { logger in ... }`, use `withLogger(logger) { ... }` and require accessing +`Logger.current` inside the closure body. + +Rejected because: + +- Passing the logger to the closure avoids repeated task-local lookups in code that logs multiple times. +- It follows the `withSpan` pattern from `swift-distributed-tracing`, which also passes the span to the closure. +- The closure parameter makes it clear which logger is being used, improving readability. From b00f4f96aa98e487c5281db8740fc30cea4ddd98 Mon Sep 17 00:00:00 2001 From: Vladimir Kukushkin Date: Mon, 27 Apr 2026 12:58:02 +0100 Subject: [PATCH 2/2] SLG-0006: task-local logger implementation --- .../NoTraitsBenchmarks/NoTraits.swift | 29 + Sources/Logging/Logger+With.swift | 230 +++++ Sources/Logging/Logger.swift | 120 +++ Tests/LoggingTests/TaskLocalLoggerTest.swift | 910 ++++++++++++++++++ 4 files changed, 1289 insertions(+) create mode 100644 Sources/Logging/Logger+With.swift create mode 100644 Tests/LoggingTests/TaskLocalLoggerTest.swift diff --git a/Benchmarks/NoTraits/Benchmarks/NoTraitsBenchmarks/NoTraits.swift b/Benchmarks/NoTraits/Benchmarks/NoTraitsBenchmarks/NoTraits.swift index b7d94ce3..f07a42fc 100644 --- a/Benchmarks/NoTraits/Benchmarks/NoTraitsBenchmarks/NoTraits.swift +++ b/Benchmarks/NoTraits/Benchmarks/NoTraitsBenchmarks/NoTraits.swift @@ -24,4 +24,33 @@ public let benchmarks: @Sendable () -> Void = { makeBenchmark(loggerLevel: .error, logLevel: .debug, "_generic") { logger in logger.log(level: .debug, "hello, benchmarking world") } + + let iterations = 1_000 + let metrics: [BenchmarkMetric] = [.instructions, .objectAllocCount] + + Benchmark( + "deeply_nested_withLogger_20_levels", + configuration: .init( + metrics: metrics, + maxIterations: iterations + ) + ) { _ in + var logger = Logger(label: "bench") + logger.handler = NoOpLogHandler(label: "bench") + logger.logLevel = .error + + func nest(depth: Int) { + if depth == 0 { + Logger.current.error("bottom") + return + } + withLogger(mergingMetadata: ["d\(depth)": "\(depth)"]) { _ in + nest(depth: depth - 1) + } + } + + withLogger(logger) { _ in + nest(depth: 20) + } + } } diff --git a/Sources/Logging/Logger+With.swift b/Sources/Logging/Logger+With.swift new file mode 100644 index 00000000..23adf532 --- /dev/null +++ b/Sources/Logging/Logger+With.swift @@ -0,0 +1,230 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Logging API open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Logging API project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Logging API project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +extension Logger { + /// Merge additional metadata into this logger, returning a new instance. + /// + /// Creates a copy of this logger with additional metadata merged in. Values in `additionalMetadata` + /// override existing values for the same keys. The original logger is not modified. + /// + /// ```swift + /// let requestLogger = logger.with(additionalMetadata: ["request.id": "\(requestID)"]) + /// requestLogger.info("Handling request") + /// ``` + /// + /// - Parameter additionalMetadata: The metadata dictionary to merge. Values in `additionalMetadata` + /// will override existing values for the same keys. + /// - Returns: A new `Logger` instance with the merged metadata. + @inlinable + public func with(additionalMetadata: Logger.Metadata) -> Logger { + var newLogger = self + for (key, value) in additionalMetadata { + newLogger[metadataKey: key] = value + } + return newLogger + } + + /// Update this logger's optional properties in place. + @usableFromInline + internal mutating func update( + logLevel: Logger.Level? = nil, + mergingMetadata: Logger.Metadata? = nil, + metadataProvider: Logger.MetadataProvider? = nil + ) { + if let logLevel { + self.logLevel = logLevel + } + if let mergingMetadata { + for (key, value) in mergingMetadata { + self[metadataKey: key] = value + } + } + if let metadataProvider { + self.handler.metadataProvider = metadataProvider + } + } +} + +// MARK: - withLogger() free functions for task-local logger + +// Note on throws(Failure) and Sendable: +// The public API uses `rethrows` instead of `throws(Failure)` and does not constrain `Result: Sendable` +// on async variants. This is because the underlying `TaskLocal.withValue` API uses untyped throws, +// making it impossible to propagate typed throws through the closure chain. Once the standard library +// adopts typed throws on TaskLocal, these signatures can be updated. + +// MARK: Bind a specific logger + +/// Runs the given closure with a logger bound to the task-local context. +/// +/// This is the primary way to set up a task-local logger. All code within the closure can access the logger +/// via `Logger.current` without explicit parameter passing. +/// +/// ## Example: Setting up task-local logger at application entry point +/// +/// ```swift +/// func main() async { +/// let logger = Logger(label: "app") +/// await withLogger(logger) { logger in +/// logger.info("Application started") +/// await handleRequests() // All nested code has access via Logger.current +/// } +/// } +/// ``` +/// +/// ## Example: Bridging from explicit logger to task-local +/// +/// ```swift +/// func handleRequest(logger: Logger) async { +/// await withLogger(logger) { _ in +/// await processRequest() // Now uses Logger.current +/// } +/// } +/// ``` +/// +/// > Warning: When nesting `withLogger` calls, always use the closure's `logger` parameter — not a +/// > captured variable from an outer scope. Using an outer `logger` variable silently loses any metadata +/// > accumulated by inner `withLogger` calls: +/// > ```swift +/// > withLogger(someLogger) { outerLogger in +/// > withLogger(mergingMetadata: ["key": "value"]) { innerLogger in +/// > innerLogger.info("correct — has key") // ✓ uses inner logger +/// > outerLogger.info("wrong — missing key") // ✗ stale outer reference +/// > } +/// > } +/// > ``` +/// +/// - Parameters: +/// - logger: The logger to bind to the task-local context. +/// - operation: The closure to run with the logger bound. +/// - Returns: The value returned by the closure. +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +@inlinable +public func withLogger( + _ logger: Logger, + _ operation: (Logger) throws -> Result +) rethrows -> Result { + try Logger.withTaskLocalLogger(logger) { + try operation(logger) + } +} + +/// Runs the given async closure with a logger bound to the task-local context. +/// +/// Async variant of the synchronous `withLogger`. See that function for detailed documentation. +/// +/// - Parameters: +/// - logger: The logger to bind to the task-local context. +/// - operation: The async closure to run with the logger bound. +/// - Returns: The value returned by the closure. +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +@inlinable +nonisolated(nonsending) + public func withLogger( + _ logger: Logger, + _ operation: nonisolated(nonsending) (Logger) async throws -> Result + ) async rethrows -> Result +{ + try await Logger.withTaskLocalLogger(logger) { + try await operation(logger) + } +} + +// MARK: Modify current task-local logger + +/// Runs the given closure with a modified task-local logger. +/// +/// This function modifies the current task-local logger by specifying any combination of log level, +/// metadata, and metadata provider. Only the specified parameters modify the current logger; `nil` parameters +/// leave the current values unchanged. +/// +/// ## Example: Progressive metadata accumulation +/// +/// ```swift +/// withLogger(mergingMetadata: ["request.id": "\(request.id)"]) { logger in +/// logger.info("Handling request") +/// +/// withLogger(mergingMetadata: ["user.id": "\(user.id)"]) { logger in +/// logger.info("Authenticated") // Has both request.id and user.id +/// } +/// } +/// ``` +/// +/// ## Example: Changing log level in a scope +/// +/// ```swift +/// withLogger(logLevel: .debug) { logger in +/// logger.debug("Detailed debugging information") +/// } +/// ``` +/// +/// > Important: Task-local values are **not** inherited by detached tasks created with `Task.detached`. +/// > If you need logger context in a detached task, capture the logger explicitly or use structured +/// > concurrency (`async let`, `withTaskGroup`, etc.) instead. +/// +/// > Warning: The `metadataProvider` parameter **replaces** the logger's existing metadata provider — it does +/// > not compose with it. If you need to combine multiple providers, use `Logger.MetadataProvider.multiplex()`: +/// > ```swift +/// > let combined = Logger.MetadataProvider.multiplex([existingProvider, newProvider]) +/// > withLogger(metadataProvider: combined) { logger in ... } +/// > ``` +/// +/// - Parameters: +/// - logLevel: Optional log level. If provided, sets this log level on the logger. +/// - mergingMetadata: Optional metadata to merge with the current logger's metadata. +/// - metadataProvider: Optional metadata provider to set on the logger. +/// - operation: The closure to run with the modified task-local logger. +/// - Returns: The value returned by the closure. +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +@inlinable +public func withLogger( + logLevel: Logger.Level? = nil, + mergingMetadata: Logger.Metadata? = nil, + metadataProvider: Logger.MetadataProvider? = nil, + _ operation: (Logger) throws -> Result +) rethrows -> Result { + var logger = Logger.current + logger.update(logLevel: logLevel, mergingMetadata: mergingMetadata, metadataProvider: metadataProvider) + return try Logger.withTaskLocalLogger(logger) { + try operation(logger) + } +} + +/// Runs the given async closure with a modified task-local logger. +/// +/// Async variant. See the synchronous `withLogger(logLevel:mergingMetadata:metadataProvider:_:)` +/// for detailed documentation. +/// +/// - Parameters: +/// - logLevel: Optional log level. If provided, sets this log level on the logger. +/// - mergingMetadata: Optional metadata to merge with the current logger's metadata. +/// - metadataProvider: Optional metadata provider to set on the logger. +/// - operation: The async closure to run with the modified task-local logger. +/// - Returns: The value returned by the closure. +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +@inlinable +nonisolated(nonsending) + public func withLogger( + logLevel: Logger.Level? = nil, + mergingMetadata: Logger.Metadata? = nil, + metadataProvider: Logger.MetadataProvider? = nil, + _ operation: nonisolated(nonsending) (Logger) async throws -> Result + ) async rethrows -> Result +{ + var logger = Logger.current + logger.update(logLevel: logLevel, mergingMetadata: mergingMetadata, metadataProvider: metadataProvider) + return try await Logger.withTaskLocalLogger(logger) { + try await operation(logger) + } +} diff --git a/Sources/Logging/Logger.swift b/Sources/Logging/Logger.swift index 6fc33842..82acf309 100644 --- a/Sources/Logging/Logger.swift +++ b/Sources/Logging/Logger.swift @@ -1436,6 +1436,126 @@ extension Logger.MetadataValue: ExpressibleByArrayLiteral { } } +// MARK: - Task-local logger storage + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension Logger { + /// Task-local storage for implicit logger context propagation. + /// + /// This storage enables ``Logger/current`` and the ``withLogger(_:_:)-6n3m5`` free functions to work + /// without requiring explicit logger parameters throughout the call stack. + /// + /// > Warning: This property is implementation detail and should not be accessed directly. + /// > Use ``Logger/current`` or ``withLogger(_:_:)-6n3m5`` to access or modify the task-local logger. + /// + /// > Important: Task-local values are **not** inherited by detached tasks created with `Task.detached`. + /// > If you need logger context in a detached task, capture the logger explicitly or use structured + /// > concurrency (`async let`, `withTaskGroup`, etc.) instead. + /// + /// This property provides access to the logger stored in task-local storage. It initializes to nil, + /// and when accessed without prior setup via ``Logger/current``, a fallback logger is created from + /// the globally bootstrapped handler. Users should explicitly set up a logger with an appropriate + /// handler at application entry points using ``withLogger(_:_:)-6n3m5`` to enable actual logging. + @usableFromInline + @TaskLocal + static var taskLocalLogger: Logger? + + /// Internal state for warning about task-local fallback usage. + private static let taskLocalFallbackWarningLock = Lock() + private nonisolated(unsafe) static var hasWarnedAboutTaskLocalFallback = false + + private static func warnOnceAboutTaskLocalFallback(logger: Logger) { + guard !hasWarnedAboutTaskLocalFallback else { return } + let shouldWarn = taskLocalFallbackWarningLock.withLock { + guard !hasWarnedAboutTaskLocalFallback else { return false } + hasWarnedAboutTaskLocalFallback = true + return true + } + if shouldWarn { + logger.warning( + """ + Logger.current accessed without task-local context. \ + Using globally bootstrapped logger as fallback. \ + For proper task-local logging, use withLogger() to set up the logging context. + """ + ) + } + } + + /// Creates a fallback logger using the globally bootstrapped handler. + /// + /// This intentionally does not cache the logger so that changes to `LoggingSystem.bootstrap()` + /// are always reflected. Each call allocates a new `Logger`, invokes the factory, and acquires + /// the warn-once lock. Code that accesses ``Logger/current`` without a prior ``withLogger(_:_:)-6n3m5`` + /// call pays this cost on every access. Use ``withLogger(_:_:)-6n3m5`` at application entry points + /// to avoid the fallback path entirely. + @usableFromInline + static func makeFallbackLogger() -> Logger { + let logger = Logger( + label: "task-local-fallback", + LoggingSystem.factory("task-local-fallback", LoggingSystem.metadataProvider) + ) + warnOnceAboutTaskLocalFallback(logger: logger) + return logger + } + + /// Resets the warn-once state for task-local fallback usage. **Testing only.** + static func _resetTaskLocalFallbackWarning() { + taskLocalFallbackWarningLock.withLock { + hasWarnedAboutTaskLocalFallback = false + } + } + + @usableFromInline + nonisolated(nonsending) + static func withTaskLocalLogger( + _ value: Logger, + operation: nonisolated(nonsending)() async throws(Failure) -> Return + ) async rethrows -> Return + { + try await Self.$taskLocalLogger.withValue(value, operation: operation) + } + + @usableFromInline + static func withTaskLocalLogger( + _ value: Logger, + operation: () throws(Failure) -> Return + ) rethrows -> Return { + try Self.$taskLocalLogger.withValue(value, operation: operation) + } + + /// The current task-local logger. + /// + /// This property provides direct access to the logger stored in task-local storage. + /// Use this when you need quick access to the logger without a closure. + /// + /// If no task-local logger has been set up, this returns the globally bootstrapped logger + /// with the label "task-local-fallback" and emits a warning (once per process) to help with adoption. + /// Use ``withLogger(_:_:)-6n3m5`` to properly initialize the task-local logger. + /// + /// > Tip: For performance-critical code with many log calls, consider extracting the logger once + /// > instead of accessing ``Logger/current`` repeatedly: + /// > ```swift + /// > // Instead of this (multiple task-local lookups): + /// > for item in items { + /// > Logger.current.debug("Processing", metadata: ["id": "\(item.id)"]) + /// > } + /// > + /// > // Do this (single lookup, then use extracted logger): + /// > let logger = Logger.current + /// > for item in items { + /// > logger.debug("Processing", metadata: ["id": "\(item.id)"]) + /// > } + /// > ``` + /// + /// > Important: Task-local values are **not** inherited by detached tasks created with `Task.detached`. + /// > If you need logger context in a detached task, capture the logger explicitly. + @inlinable + public static var current: Logger { + Self.taskLocalLogger ?? Self.makeFallbackLogger() + } +} + // MARK: - Sendable support helpers extension Logger.MetadataValue: Sendable {} diff --git a/Tests/LoggingTests/TaskLocalLoggerTest.swift b/Tests/LoggingTests/TaskLocalLoggerTest.swift new file mode 100644 index 00000000..70065b8f --- /dev/null +++ b/Tests/LoggingTests/TaskLocalLoggerTest.swift @@ -0,0 +1,910 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Logging API open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Logging API project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Logging API project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Testing + +@testable import Logging + +/// Tests for task-local logger functionality. +/// +/// These tests demonstrate that task-local storage provides automatic isolation +/// between tasks, enabling concurrent test execution without serialization. +/// Each task maintains its own independent logger context. +struct TaskLocalLoggerTest { + // MARK: - Basic task-local access + + @Test func withLoggerModifyNothingReturnsFallback() { + // Test that withLogger provides a fallback logger when no context is set + withLogger { logger in + #expect(logger.label == "task-local-fallback") + } + } + + @Test func withLoggerSyncVoid() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + withLogger(logger) { _ in + withLogger(mergingMetadata: ["test": "value"]) { logger in + logger.info("test message") + } + } + + logging.history.assertExist( + level: .info, + message: "test message", + metadata: ["test": "value"] + ) + } + + @Test func withLoggerSyncReturning() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + let result = withLogger(logger) { logger in + logger.info("computing") + return 42 + } + + #expect(result == 42) + logging.history.assertExist(level: .info, message: "computing") + } + + @Test func withLoggerAsyncVoid() async { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + await withLogger(logger) { _ in + await withLogger(mergingMetadata: ["test": "async"]) { logger in + logger.info("async message") + } + } + + logging.history.assertExist( + level: .info, + message: "async message", + metadata: ["test": "async"] + ) + } + + @Test func withLoggerAsyncReturning() async { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + let result = await withLogger(logger) { _ in + await withLogger(mergingMetadata: ["test": "async"]) { logger in + logger.info("computing async") + return "result" + } + } + + #expect(result == "result") + logging.history.assertExist(level: .info, message: "computing async") + } + + // MARK: - withLogger() with modifications + + @Test func withLoggerMetadataSyncVoid() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + withLogger(logger) { _ in + withLogger(mergingMetadata: ["key": "value"]) { logger in + logger.info("test") + } + } + + logging.history.assertExist( + level: .info, + message: "test", + metadata: ["key": "value"] + ) + } + + @Test func withLoggerMetadataSyncReturning() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + let result = withLogger(logger) { _ in + withLogger(mergingMetadata: ["key": "value"]) { logger in + logger.info("computing") + return 100 + } + } + + #expect(result == 100) + logging.history.assertExist(level: .info, message: "computing", metadata: ["key": "value"]) + } + + @Test func withLoggerMetadataAsyncVoid() async { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + await withLogger(logger) { _ in + await withLogger(mergingMetadata: ["async": "true"]) { logger in + logger.info("async test") + } + } + + logging.history.assertExist( + level: .info, + message: "async test", + metadata: ["async": "true"] + ) + } + + @Test func withLoggerMetadataAsyncReturning() async { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + let result = await withLogger(logger) { _ in + await withLogger(mergingMetadata: ["async": "true"]) { logger in + logger.info("async computing") + return "async result" + } + } + + #expect(result == "async result") + logging.history.assertExist(level: .info, message: "async computing") + } + + @Test func withLoggerLogLevel() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + withLogger(logger) { _ in + withLogger(logLevel: .warning) { logger in + logger.debug("should not appear") + logger.warning("should appear") + } + } + + logging.history.assertNotExist(level: .debug, message: "should not appear") + logging.history.assertExist(level: .warning, message: "should appear") + } + + @Test func withLoggerHandler() { + let logging1 = TestLogging() + let logging2 = TestLogging() + let logger = Logger(label: "test", factory: { logging1.make(label: $0) }) + + let customHandler = logging2.make(label: "custom") + + withLogger(logger) { _ in + withLogger(Logger(label: "custom", customHandler)) { logger in + logger.info("custom handler message") + } + } + + // Should appear in custom handler (logging2), not default (logging1) + logging1.history.assertNotExist(level: .info, message: "custom handler message") + logging2.history.assertExist(level: .info, message: "custom handler message") + } + + @Test func withLoggerMetadataAndLogLevel() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + withLogger(logger) { _ in + withLogger( + logLevel: .error, + mergingMetadata: ["combined": "test"] + ) { logger in + logger.info("should not appear") + logger.error("should appear") + } + } + + logging.history.assertNotExist(level: .info, message: "should not appear") + logging.history.assertExist( + level: .error, + message: "should appear", + metadata: ["combined": "test"] + ) + } + + // MARK: - Metadata accumulation + + @Test func nestedWithLoggerAccumulatesMetadata() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + withLogger(logger) { _ in + withLogger(mergingMetadata: ["level1": "first"]) { logger in + logger.info("level 1") + + withLogger(mergingMetadata: ["level2": "second"]) { logger in + logger.info("level 2") + } + } + } + + logging.history.assertExist( + level: .info, + message: "level 1", + metadata: ["level1": "first"] + ) + logging.history.assertExist( + level: .info, + message: "level 2", + metadata: ["level1": "first", "level2": "second"] + ) + } + + @Test func nestedMetadataOverrides() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + withLogger(logger) { _ in + withLogger(mergingMetadata: ["key": "original"]) { _ in + withLogger(mergingMetadata: ["key": "override"]) { logger in + logger.info("test") + } + } + } + + logging.history.assertExist( + level: .info, + message: "test", + metadata: ["key": "override"] + ) + } + + // MARK: - Task isolation (enables concurrent tests!) + + @Test func tasksHaveIndependentContext() async { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + await withLogger(logger) { _ in + await withLogger(mergingMetadata: ["context": "parent"]) { _ in + await withTaskGroup(of: Void.self) { group in + // Task 1 + group.addTask { + withLogger(mergingMetadata: ["task": "1"]) { logger in + logger.info("task 1 message") + } + } + + // Task 2 + group.addTask { + withLogger(mergingMetadata: ["task": "2"]) { logger in + logger.info("task 2 message") + } + } + + // Task 3 + group.addTask { + Logger.current.info("task 3 message") + } + } + } + } + + // Each task logged with its own independent metadata + logging.history.assertExist( + level: .info, + message: "task 1 message", + metadata: ["task": "1", "context": "parent"] + ) + logging.history.assertExist( + level: .info, + message: "task 2 message", + metadata: ["task": "2", "context": "parent"] + ) + logging.history.assertExist( + level: .info, + message: "task 3 message", + metadata: ["context": "parent"] + ) + } + + @Test func childTaskInheritsParentContext() async { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + await withLogger(logger) { _ in + await withLogger(mergingMetadata: ["parent": "value"]) { logger in + logger.info("parent message") + + // Create child task — inherits task-local context + await Task { + Logger.current.info("child message") + }.value + } + } + + // Both parent and child should have the metadata + logging.history.assertExist( + level: .info, + message: "parent message", + metadata: ["parent": "value"] + ) + logging.history.assertExist( + level: .info, + message: "child message", + metadata: ["parent": "value"] + ) + } + + @Test func detachedTaskDoesNotInheritContext() async { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + await withLogger(logger) { _ in + await withLogger(mergingMetadata: ["parent": "value"]) { logger in + logger.info("parent message") + + // Detached task does NOT inherit task-local context + await Task.detached { + Logger.current.info("detached message") + }.value + } + } + + logging.history.assertExist( + level: .info, + message: "parent message", + metadata: ["parent": "value"] + ) + // Detached task should NOT have the parent metadata — it uses the fallback logger, + // so the message does not appear in our test logging history at all. + logging.history.assertNotExist(level: .info, message: "detached message") + } + + @Test func childTaskCanOverrideContext() async { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + await withLogger(logger) { _ in + await withLogger(mergingMetadata: ["parent": "original"]) { logger in + logger.info("parent") + + // Child overrides context + await Task { + withLogger(mergingMetadata: ["parent": "overridden"]) { logger in + logger.info("child") + } + }.value + + // Parent context unchanged after child completes + logger.info("parent again") + } + } + + logging.history.assertExist( + level: .info, + message: "parent", + metadata: ["parent": "original"] + ) + logging.history.assertExist( + level: .info, + message: "child", + metadata: ["parent": "overridden"] + ) + logging.history.assertExist( + level: .info, + message: "parent again", + metadata: ["parent": "original"] + ) + } + + // MARK: - Async propagation + + @Test func contextPreservedAcrossAwaitBoundaries() async throws { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + try await withLogger(logger) { _ in + try await withLogger(mergingMetadata: ["request": "123"]) { logger in + logger.info("before await") + + // Simulate async work + try await Task.sleep(nanoseconds: 1_000_000) + + logger.info("after await") + } + } + + // Context preserved across await + logging.history.assertExist( + level: .info, + message: "before await", + metadata: ["request": "123"] + ) + logging.history.assertExist( + level: .info, + message: "after await", + metadata: ["request": "123"] + ) + } + + @Test func contextPreservedThroughAsyncFunctions() async { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + func innerAsync() async { + Logger.current.info("inner function") + } + + func outerAsync() async { + Logger.current.info("outer before") + await innerAsync() + Logger.current.info("outer after") + } + + await withLogger(logger) { _ in + await withLogger(mergingMetadata: ["flow": "async"]) { _ in + await outerAsync() + } + } + + // All functions see the same context + logging.history.assertExist( + level: .info, + message: "outer before", + metadata: ["flow": "async"] + ) + logging.history.assertExist( + level: .info, + message: "inner function", + metadata: ["flow": "async"] + ) + logging.history.assertExist( + level: .info, + message: "outer after", + metadata: ["flow": "async"] + ) + } + + // MARK: - Log level modification + + @Test func logLevelFilteringWorks() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + withLogger(logger) { _ in + withLogger(logLevel: .warning) { logger in + logger.trace("trace - should not appear") + logger.debug("debug - should not appear") + logger.info("info - should not appear") + logger.warning("warning - should appear") + logger.error("error - should appear") + } + } + + #expect(logging.history.entries.count == 2) + logging.history.assertNotExist(level: .trace, message: "trace - should not appear") + logging.history.assertNotExist(level: .debug, message: "debug - should not appear") + logging.history.assertNotExist(level: .info, message: "info - should not appear") + logging.history.assertExist(level: .warning, message: "warning - should appear") + logging.history.assertExist(level: .error, message: "error - should appear") + } + + @Test func logLevelCanBeChanged() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + withLogger(logger) { _ in + withLogger(logLevel: .error) { logger in + logger.info("first - should not appear") + + withLogger(logLevel: .info) { logger in + logger.info("second - should appear") + } + + logger.info("third - should not appear") + } + } + + logging.history.assertNotExist(level: .info, message: "first - should not appear") + logging.history.assertExist(level: .info, message: "second - should appear") + logging.history.assertNotExist(level: .info, message: "third - should not appear") + } + + // MARK: - Instance with() methods + + @Test func withAdditionalMetadata() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + let copied = logger.with(additionalMetadata: ["copied": "metadata"]) + + copied.info("test message") + + logging.history.assertExist( + level: .info, + message: "test message", + metadata: ["copied": "metadata"] + ) + } + + @Test func withLogLevel() { + let logging = TestLogging() + var logger = Logger(label: "test", factory: { logging.make(label: $0) }) + logger.logLevel = .error + + // Create a copy and change its log level + var copied = logger + copied.logLevel = .debug + + copied.debug("should appear") + logger.debug("should not appear") + + #expect(logging.history.entries.count == 1) + logging.history.assertExist(level: .debug, message: "should appear") + } + + @Test func withHandler() { + let logging1 = TestLogging() + let logging2 = TestLogging() + let logger = Logger(label: "test", factory: { logging1.make(label: $0) }) + + // Create a new logger with a different handler + let copied = Logger(label: "copied", factory: { logging2.make(label: $0) }) + + logger.info("original") + copied.info("copied") + + logging1.history.assertExist(level: .info, message: "original") + logging1.history.assertNotExist(level: .info, message: "copied") + + logging2.history.assertNotExist(level: .info, message: "original") + logging2.history.assertExist(level: .info, message: "copied") + } + + @Test func withDoesNotMutateOriginal() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + let copied = logger.with(additionalMetadata: ["key": "value"]) + + logger.info("original") + copied.info("copied") + + // Original should not have the metadata + logging.history.assertExist(level: .info, message: "original", metadata: nil) + logging.history.assertExist( + level: .info, + message: "copied", + metadata: ["key": "value"] + ) + } + + // MARK: - Real-world scenarios + + @Test func requestHandlerPattern() async { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + func processRequest(id: String) async { + await withLogger(mergingMetadata: ["request.id": "\(id)"]) { logger in + logger.info("Request received") + + await authenticateUser(username: "alice") + + logger.info("Request completed") + } + } + + func authenticateUser(username: String) async { + withLogger(mergingMetadata: ["user": "\(username)"]) { logger in + logger.debug("Authenticating user") + } + } + + await withLogger(logger) { _ in + await processRequest(id: "req-123") + } + + logging.history.assertExist( + level: .info, + message: "Request received", + metadata: ["request.id": "req-123"] + ) + logging.history.assertExist( + level: .debug, + message: "Authenticating user", + metadata: ["request.id": "req-123", "user": "alice"] + ) + logging.history.assertExist( + level: .info, + message: "Request completed", + metadata: ["request.id": "req-123"] + ) + } + + @Test func libraryEntryPointPattern() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + // Library code that doesn't require logger parameter + struct DatabaseClient { + func query(_ sql: String) { + Logger.current.debug("Executing query", metadata: ["sql": "\(sql)"]) + } + } + + // Application sets up context + withLogger(logger) { _ in + withLogger(mergingMetadata: ["request.id": "123"]) { _ in + let db = DatabaseClient() + db.query("SELECT * FROM users") + } + } + + logging.history.assertExist( + level: .debug, + message: "Executing query", + metadata: ["request.id": "123", "sql": "SELECT * FROM users"] + ) + } + + // MARK: - Throwing closure propagation + + @Test func withLoggerSyncPropagatesThrow() { + struct TestError: Error, Equatable {} + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + #expect(throws: TestError.self) { + try withLogger(logger) { _ in + throw TestError() + } + } + } + + @Test func withLoggerAsyncPropagatesThrow() async { + struct TestError: Error, Equatable {} + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + await #expect(throws: TestError.self) { + try await withLogger(logger) { _ in + throw TestError() + } + } + } + + @Test func withLoggerModifySyncPropagatesThrow() { + struct TestError: Error, Equatable {} + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + #expect(throws: TestError.self) { + try withLogger(logger) { _ in + try withLogger(mergingMetadata: ["key": "value"]) { _ in + throw TestError() + } + } + } + } + + @Test func withLoggerModifyAsyncPropagatesThrow() async { + struct TestError: Error, Equatable {} + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + await #expect(throws: TestError.self) { + try await withLogger(logger) { _ in + try await withLogger(mergingMetadata: ["key": "value"]) { _ in + throw TestError() + } + } + } + } + + // MARK: - Fallback with modifications + + @Test func withLoggerModifyWithoutPriorContext() { + // When no task-local logger is set, withLogger(mergingMetadata:) should use + // the fallback logger and still merge metadata correctly. + withLogger(mergingMetadata: ["key": "value"]) { logger in + #expect(logger.label == "task-local-fallback") + #expect(logger.handler.metadata["key"] == "value") + } + } + + // MARK: - MetadataProvider interaction + + @Test func withLoggerMetadataProvider() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + let provider = Logger.MetadataProvider { + ["provider-key": "provider-value"] + } + + withLogger(logger) { _ in + withLogger(metadataProvider: provider) { logger in + logger.info("with provider") + } + } + + logging.history.assertExist( + level: .info, + message: "with provider", + metadata: ["provider-key": "provider-value"] + ) + } + + // MARK: - Direct Logger.current access + + @Test func loggerCurrentReturnsFallbackWithoutContext() { + let logger = Logger.current + #expect(logger.label == "task-local-fallback") + } + + // MARK: - async let inheritance + + @Test func asyncLetInheritsContext() async { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + await withLogger(logger) { _ in + await withLogger(mergingMetadata: ["parent": "value"]) { _ in + async let result: Void = { + Logger.current.info("async let message") + }() + await result + } + } + + logging.history.assertExist( + level: .info, + message: "async let message", + metadata: ["parent": "value"] + ) + } + + // MARK: - Sequential sibling scope isolation + + @Test func sequentialSiblingScopes() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + withLogger(logger) { _ in + withLogger(mergingMetadata: ["a": "1"]) { logger in + logger.info("first scope") + } + withLogger(mergingMetadata: ["b": "2"]) { logger in + logger.info("second scope") + } + } + + logging.history.assertExist( + level: .info, + message: "first scope", + metadata: ["a": "1"] + ) + // second scope must NOT have "a" from the first scope + logging.history.assertExist( + level: .info, + message: "second scope", + metadata: ["b": "2"] + ) + let secondEntry = logging.history.entries.first { $0.message == "second scope" } + #expect(secondEntry?.metadata?["a"] == nil) + } + + // MARK: - All three modification parameters combined + + @Test func withLoggerAllModifications() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + let provider = Logger.MetadataProvider { + ["provider-key": "from-provider"] + } + + withLogger(logger) { _ in + withLogger( + logLevel: .warning, + mergingMetadata: ["explicit-key": "explicit-value"], + metadataProvider: provider + ) { logger in + logger.debug("should not appear") + logger.warning("should appear") + } + } + + logging.history.assertNotExist(level: .debug, message: "should not appear") + logging.history.assertExist( + level: .warning, + message: "should appear", + metadata: ["explicit-key": "explicit-value", "provider-key": "from-provider"] + ) + } + + // MARK: - Deeply nested withLogger + + @Test func deeplyNestedWithLogger() { + let logging = TestLogging() + let logger = Logger(label: "test", factory: { logging.make(label: $0) }) + + func nest(depth: Int) { + if depth == 0 { + Logger.current.info("bottom") + return + } + withLogger(mergingMetadata: ["depth-\(depth)": "\(depth)"]) { _ in + nest(depth: depth - 1) + } + } + + withLogger(logger) { _ in + nest(depth: 20) + } + + let entry = logging.history.entries.first { $0.message == "bottom" } + #expect(entry != nil) + #expect(entry?.metadata?.count == 20) + } + + // MARK: - MultiplexLogHandler interaction + + @Test func multiplexLogHandlerWithTaskLocal() { + let logging1 = TestLogging() + let logging2 = TestLogging() + + let handler1 = logging1.make(label: "handler1") + let handler2 = logging2.make(label: "handler2") + let multiplex = MultiplexLogHandler([handler1, handler2]) + let logger = Logger(label: "multiplex", multiplex) + + withLogger(logger) { _ in + withLogger(mergingMetadata: ["key": "value"]) { logger in + logger.info("multiplex message") + } + } + + logging1.history.assertExist( + level: .info, + message: "multiplex message", + metadata: ["key": "value"] + ) + logging2.history.assertExist( + level: .info, + message: "multiplex message", + metadata: ["key": "value"] + ) + } + + // MARK: - Bootstrap interaction + + @Test func fallbackUsesBootstrappedHandler() { + let logging = TestLogging() + LoggingSystem.bootstrapInternal(logging.make) + + let logger = Logger.current + #expect(logger.label == "task-local-fallback") + + logger.warning("from fallback") + logging.history.assertExist(level: .warning, message: "from fallback") + + // Reset to default + LoggingSystem.bootstrapInternal(StreamLogHandler.standardOutput) + } + + // MARK: - Detached task calling withLogger(mergingMetadata:) + + @Test func detachedTaskWithModification() async { + await Task.detached { + withLogger(mergingMetadata: ["key": "value"]) { logger in + #expect(logger.label == "task-local-fallback") + #expect(logger.handler.metadata["key"] == "value") + } + }.value + } +}