Skip to content

Commit b00f4f9

Browse files
committed
SLG-0006: task-local logger implementation
1 parent a251676 commit b00f4f9

4 files changed

Lines changed: 1289 additions & 0 deletions

File tree

Benchmarks/NoTraits/Benchmarks/NoTraitsBenchmarks/NoTraits.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,33 @@ public let benchmarks: @Sendable () -> Void = {
2424
makeBenchmark(loggerLevel: .error, logLevel: .debug, "_generic") { logger in
2525
logger.log(level: .debug, "hello, benchmarking world")
2626
}
27+
28+
let iterations = 1_000
29+
let metrics: [BenchmarkMetric] = [.instructions, .objectAllocCount]
30+
31+
Benchmark(
32+
"deeply_nested_withLogger_20_levels",
33+
configuration: .init(
34+
metrics: metrics,
35+
maxIterations: iterations
36+
)
37+
) { _ in
38+
var logger = Logger(label: "bench")
39+
logger.handler = NoOpLogHandler(label: "bench")
40+
logger.logLevel = .error
41+
42+
func nest(depth: Int) {
43+
if depth == 0 {
44+
Logger.current.error("bottom")
45+
return
46+
}
47+
withLogger(mergingMetadata: ["d\(depth)": "\(depth)"]) { _ in
48+
nest(depth: depth - 1)
49+
}
50+
}
51+
52+
withLogger(logger) { _ in
53+
nest(depth: 20)
54+
}
55+
}
2756
}

Sources/Logging/Logger+With.swift

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift Logging API open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift Logging API project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift Logging API project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
extension Logger {
16+
/// Merge additional metadata into this logger, returning a new instance.
17+
///
18+
/// Creates a copy of this logger with additional metadata merged in. Values in `additionalMetadata`
19+
/// override existing values for the same keys. The original logger is not modified.
20+
///
21+
/// ```swift
22+
/// let requestLogger = logger.with(additionalMetadata: ["request.id": "\(requestID)"])
23+
/// requestLogger.info("Handling request")
24+
/// ```
25+
///
26+
/// - Parameter additionalMetadata: The metadata dictionary to merge. Values in `additionalMetadata`
27+
/// will override existing values for the same keys.
28+
/// - Returns: A new `Logger` instance with the merged metadata.
29+
@inlinable
30+
public func with(additionalMetadata: Logger.Metadata) -> Logger {
31+
var newLogger = self
32+
for (key, value) in additionalMetadata {
33+
newLogger[metadataKey: key] = value
34+
}
35+
return newLogger
36+
}
37+
38+
/// Update this logger's optional properties in place.
39+
@usableFromInline
40+
internal mutating func update(
41+
logLevel: Logger.Level? = nil,
42+
mergingMetadata: Logger.Metadata? = nil,
43+
metadataProvider: Logger.MetadataProvider? = nil
44+
) {
45+
if let logLevel {
46+
self.logLevel = logLevel
47+
}
48+
if let mergingMetadata {
49+
for (key, value) in mergingMetadata {
50+
self[metadataKey: key] = value
51+
}
52+
}
53+
if let metadataProvider {
54+
self.handler.metadataProvider = metadataProvider
55+
}
56+
}
57+
}
58+
59+
// MARK: - withLogger() free functions for task-local logger
60+
61+
// Note on throws(Failure) and Sendable:
62+
// The public API uses `rethrows` instead of `throws(Failure)` and does not constrain `Result: Sendable`
63+
// on async variants. This is because the underlying `TaskLocal.withValue` API uses untyped throws,
64+
// making it impossible to propagate typed throws through the closure chain. Once the standard library
65+
// adopts typed throws on TaskLocal, these signatures can be updated.
66+
67+
// MARK: Bind a specific logger
68+
69+
/// Runs the given closure with a logger bound to the task-local context.
70+
///
71+
/// This is the primary way to set up a task-local logger. All code within the closure can access the logger
72+
/// via `Logger.current` without explicit parameter passing.
73+
///
74+
/// ## Example: Setting up task-local logger at application entry point
75+
///
76+
/// ```swift
77+
/// func main() async {
78+
/// let logger = Logger(label: "app")
79+
/// await withLogger(logger) { logger in
80+
/// logger.info("Application started")
81+
/// await handleRequests() // All nested code has access via Logger.current
82+
/// }
83+
/// }
84+
/// ```
85+
///
86+
/// ## Example: Bridging from explicit logger to task-local
87+
///
88+
/// ```swift
89+
/// func handleRequest(logger: Logger) async {
90+
/// await withLogger(logger) { _ in
91+
/// await processRequest() // Now uses Logger.current
92+
/// }
93+
/// }
94+
/// ```
95+
///
96+
/// > Warning: When nesting `withLogger` calls, always use the closure's `logger` parameter — not a
97+
/// > captured variable from an outer scope. Using an outer `logger` variable silently loses any metadata
98+
/// > accumulated by inner `withLogger` calls:
99+
/// > ```swift
100+
/// > withLogger(someLogger) { outerLogger in
101+
/// > withLogger(mergingMetadata: ["key": "value"]) { innerLogger in
102+
/// > innerLogger.info("correct — has key") // ✓ uses inner logger
103+
/// > outerLogger.info("wrong — missing key") // ✗ stale outer reference
104+
/// > }
105+
/// > }
106+
/// > ```
107+
///
108+
/// - Parameters:
109+
/// - logger: The logger to bind to the task-local context.
110+
/// - operation: The closure to run with the logger bound.
111+
/// - Returns: The value returned by the closure.
112+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
113+
@inlinable
114+
public func withLogger<Result>(
115+
_ logger: Logger,
116+
_ operation: (Logger) throws -> Result
117+
) rethrows -> Result {
118+
try Logger.withTaskLocalLogger(logger) {
119+
try operation(logger)
120+
}
121+
}
122+
123+
/// Runs the given async closure with a logger bound to the task-local context.
124+
///
125+
/// Async variant of the synchronous `withLogger`. See that function for detailed documentation.
126+
///
127+
/// - Parameters:
128+
/// - logger: The logger to bind to the task-local context.
129+
/// - operation: The async closure to run with the logger bound.
130+
/// - Returns: The value returned by the closure.
131+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
132+
@inlinable
133+
nonisolated(nonsending)
134+
public func withLogger<Result>(
135+
_ logger: Logger,
136+
_ operation: nonisolated(nonsending) (Logger) async throws -> Result
137+
) async rethrows -> Result
138+
{
139+
try await Logger.withTaskLocalLogger(logger) {
140+
try await operation(logger)
141+
}
142+
}
143+
144+
// MARK: Modify current task-local logger
145+
146+
/// Runs the given closure with a modified task-local logger.
147+
///
148+
/// This function modifies the current task-local logger by specifying any combination of log level,
149+
/// metadata, and metadata provider. Only the specified parameters modify the current logger; `nil` parameters
150+
/// leave the current values unchanged.
151+
///
152+
/// ## Example: Progressive metadata accumulation
153+
///
154+
/// ```swift
155+
/// withLogger(mergingMetadata: ["request.id": "\(request.id)"]) { logger in
156+
/// logger.info("Handling request")
157+
///
158+
/// withLogger(mergingMetadata: ["user.id": "\(user.id)"]) { logger in
159+
/// logger.info("Authenticated") // Has both request.id and user.id
160+
/// }
161+
/// }
162+
/// ```
163+
///
164+
/// ## Example: Changing log level in a scope
165+
///
166+
/// ```swift
167+
/// withLogger(logLevel: .debug) { logger in
168+
/// logger.debug("Detailed debugging information")
169+
/// }
170+
/// ```
171+
///
172+
/// > Important: Task-local values are **not** inherited by detached tasks created with `Task.detached`.
173+
/// > If you need logger context in a detached task, capture the logger explicitly or use structured
174+
/// > concurrency (`async let`, `withTaskGroup`, etc.) instead.
175+
///
176+
/// > Warning: The `metadataProvider` parameter **replaces** the logger's existing metadata provider — it does
177+
/// > not compose with it. If you need to combine multiple providers, use `Logger.MetadataProvider.multiplex()`:
178+
/// > ```swift
179+
/// > let combined = Logger.MetadataProvider.multiplex([existingProvider, newProvider])
180+
/// > withLogger(metadataProvider: combined) { logger in ... }
181+
/// > ```
182+
///
183+
/// - Parameters:
184+
/// - logLevel: Optional log level. If provided, sets this log level on the logger.
185+
/// - mergingMetadata: Optional metadata to merge with the current logger's metadata.
186+
/// - metadataProvider: Optional metadata provider to set on the logger.
187+
/// - operation: The closure to run with the modified task-local logger.
188+
/// - Returns: The value returned by the closure.
189+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
190+
@inlinable
191+
public func withLogger<Result>(
192+
logLevel: Logger.Level? = nil,
193+
mergingMetadata: Logger.Metadata? = nil,
194+
metadataProvider: Logger.MetadataProvider? = nil,
195+
_ operation: (Logger) throws -> Result
196+
) rethrows -> Result {
197+
var logger = Logger.current
198+
logger.update(logLevel: logLevel, mergingMetadata: mergingMetadata, metadataProvider: metadataProvider)
199+
return try Logger.withTaskLocalLogger(logger) {
200+
try operation(logger)
201+
}
202+
}
203+
204+
/// Runs the given async closure with a modified task-local logger.
205+
///
206+
/// Async variant. See the synchronous `withLogger(logLevel:mergingMetadata:metadataProvider:_:)`
207+
/// for detailed documentation.
208+
///
209+
/// - Parameters:
210+
/// - logLevel: Optional log level. If provided, sets this log level on the logger.
211+
/// - mergingMetadata: Optional metadata to merge with the current logger's metadata.
212+
/// - metadataProvider: Optional metadata provider to set on the logger.
213+
/// - operation: The async closure to run with the modified task-local logger.
214+
/// - Returns: The value returned by the closure.
215+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
216+
@inlinable
217+
nonisolated(nonsending)
218+
public func withLogger<Result>(
219+
logLevel: Logger.Level? = nil,
220+
mergingMetadata: Logger.Metadata? = nil,
221+
metadataProvider: Logger.MetadataProvider? = nil,
222+
_ operation: nonisolated(nonsending) (Logger) async throws -> Result
223+
) async rethrows -> Result
224+
{
225+
var logger = Logger.current
226+
logger.update(logLevel: logLevel, mergingMetadata: mergingMetadata, metadataProvider: metadataProvider)
227+
return try await Logger.withTaskLocalLogger(logger) {
228+
try await operation(logger)
229+
}
230+
}

Sources/Logging/Logger.swift

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1436,6 +1436,126 @@ extension Logger.MetadataValue: ExpressibleByArrayLiteral {
14361436
}
14371437
}
14381438

1439+
// MARK: - Task-local logger storage
1440+
1441+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
1442+
extension Logger {
1443+
/// Task-local storage for implicit logger context propagation.
1444+
///
1445+
/// This storage enables ``Logger/current`` and the ``withLogger(_:_:)-6n3m5`` free functions to work
1446+
/// without requiring explicit logger parameters throughout the call stack.
1447+
///
1448+
/// > Warning: This property is implementation detail and should not be accessed directly.
1449+
/// > Use ``Logger/current`` or ``withLogger(_:_:)-6n3m5`` to access or modify the task-local logger.
1450+
///
1451+
/// > Important: Task-local values are **not** inherited by detached tasks created with `Task.detached`.
1452+
/// > If you need logger context in a detached task, capture the logger explicitly or use structured
1453+
/// > concurrency (`async let`, `withTaskGroup`, etc.) instead.
1454+
///
1455+
/// This property provides access to the logger stored in task-local storage. It initializes to nil,
1456+
/// and when accessed without prior setup via ``Logger/current``, a fallback logger is created from
1457+
/// the globally bootstrapped handler. Users should explicitly set up a logger with an appropriate
1458+
/// handler at application entry points using ``withLogger(_:_:)-6n3m5`` to enable actual logging.
1459+
@usableFromInline
1460+
@TaskLocal
1461+
static var taskLocalLogger: Logger?
1462+
1463+
/// Internal state for warning about task-local fallback usage.
1464+
private static let taskLocalFallbackWarningLock = Lock()
1465+
private nonisolated(unsafe) static var hasWarnedAboutTaskLocalFallback = false
1466+
1467+
private static func warnOnceAboutTaskLocalFallback(logger: Logger) {
1468+
guard !hasWarnedAboutTaskLocalFallback else { return }
1469+
let shouldWarn = taskLocalFallbackWarningLock.withLock {
1470+
guard !hasWarnedAboutTaskLocalFallback else { return false }
1471+
hasWarnedAboutTaskLocalFallback = true
1472+
return true
1473+
}
1474+
if shouldWarn {
1475+
logger.warning(
1476+
"""
1477+
Logger.current accessed without task-local context. \
1478+
Using globally bootstrapped logger as fallback. \
1479+
For proper task-local logging, use withLogger() to set up the logging context.
1480+
"""
1481+
)
1482+
}
1483+
}
1484+
1485+
/// Creates a fallback logger using the globally bootstrapped handler.
1486+
///
1487+
/// This intentionally does not cache the logger so that changes to `LoggingSystem.bootstrap()`
1488+
/// are always reflected. Each call allocates a new `Logger`, invokes the factory, and acquires
1489+
/// the warn-once lock. Code that accesses ``Logger/current`` without a prior ``withLogger(_:_:)-6n3m5``
1490+
/// call pays this cost on every access. Use ``withLogger(_:_:)-6n3m5`` at application entry points
1491+
/// to avoid the fallback path entirely.
1492+
@usableFromInline
1493+
static func makeFallbackLogger() -> Logger {
1494+
let logger = Logger(
1495+
label: "task-local-fallback",
1496+
LoggingSystem.factory("task-local-fallback", LoggingSystem.metadataProvider)
1497+
)
1498+
warnOnceAboutTaskLocalFallback(logger: logger)
1499+
return logger
1500+
}
1501+
1502+
/// Resets the warn-once state for task-local fallback usage. **Testing only.**
1503+
static func _resetTaskLocalFallbackWarning() {
1504+
taskLocalFallbackWarningLock.withLock {
1505+
hasWarnedAboutTaskLocalFallback = false
1506+
}
1507+
}
1508+
1509+
@usableFromInline
1510+
nonisolated(nonsending)
1511+
static func withTaskLocalLogger<Return, Failure: Error>(
1512+
_ value: Logger,
1513+
operation: nonisolated(nonsending)() async throws(Failure) -> Return
1514+
) async rethrows -> Return
1515+
{
1516+
try await Self.$taskLocalLogger.withValue(value, operation: operation)
1517+
}
1518+
1519+
@usableFromInline
1520+
static func withTaskLocalLogger<Return, Failure: Error>(
1521+
_ value: Logger,
1522+
operation: () throws(Failure) -> Return
1523+
) rethrows -> Return {
1524+
try Self.$taskLocalLogger.withValue(value, operation: operation)
1525+
}
1526+
1527+
/// The current task-local logger.
1528+
///
1529+
/// This property provides direct access to the logger stored in task-local storage.
1530+
/// Use this when you need quick access to the logger without a closure.
1531+
///
1532+
/// If no task-local logger has been set up, this returns the globally bootstrapped logger
1533+
/// with the label "task-local-fallback" and emits a warning (once per process) to help with adoption.
1534+
/// Use ``withLogger(_:_:)-6n3m5`` to properly initialize the task-local logger.
1535+
///
1536+
/// > Tip: For performance-critical code with many log calls, consider extracting the logger once
1537+
/// > instead of accessing ``Logger/current`` repeatedly:
1538+
/// > ```swift
1539+
/// > // Instead of this (multiple task-local lookups):
1540+
/// > for item in items {
1541+
/// > Logger.current.debug("Processing", metadata: ["id": "\(item.id)"])
1542+
/// > }
1543+
/// >
1544+
/// > // Do this (single lookup, then use extracted logger):
1545+
/// > let logger = Logger.current
1546+
/// > for item in items {
1547+
/// > logger.debug("Processing", metadata: ["id": "\(item.id)"])
1548+
/// > }
1549+
/// > ```
1550+
///
1551+
/// > Important: Task-local values are **not** inherited by detached tasks created with `Task.detached`.
1552+
/// > If you need logger context in a detached task, capture the logger explicitly.
1553+
@inlinable
1554+
public static var current: Logger {
1555+
Self.taskLocalLogger ?? Self.makeFallbackLogger()
1556+
}
1557+
}
1558+
14391559
// MARK: - Sendable support helpers
14401560

14411561
extension Logger.MetadataValue: Sendable {}

0 commit comments

Comments
 (0)