Skip to content

Commit 16bce4e

Browse files
committed
SLG-0006: task-local logger implementation
1 parent 64c48d7 commit 16bce4e

8 files changed

Lines changed: 952 additions & 39 deletions

File tree

Benchmarks/NoTraits/Benchmarks/NoTraitsBenchmarks/NoTraits.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,28 @@ 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+
// MARK: - Task-local logger benchmarks
29+
30+
makeBenchmark(loggerLevel: .error, logLevel: .error, "_current_read_fallback") { _ in
31+
blackHole(Logger.current)
32+
}
33+
34+
makeBenchmark(loggerLevel: .error, logLevel: .error, setScopedLogger: true, "_current_read_inside_scope") {
35+
logger in
36+
blackHole(Logger.current)
37+
}
38+
39+
makeBenchmark(loggerLevel: .error, logLevel: .error, setScopedLogger: true, "_withLogger_mergingMetadata") {
40+
logger in
41+
withLogger(mergingMetadata: ["key": "value"]) { inner in
42+
blackHole(inner)
43+
}
44+
}
45+
46+
makeBenchmark(loggerLevel: .error, logLevel: .error, setScopedLogger: true, "_withLogger_handler") { logger in
47+
withLogger(handler: logger.handler) { inner in
48+
blackHole(inner)
49+
}
50+
}
2751
}

Benchmarks/Sources/BenchmarksFactory/MakeBenchmark.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import Logging
1919
public func makeBenchmark(
2020
loggerLevel: Logger.Level,
2121
logLevel: Logger.Level,
22+
setScopedLogger: Bool = false,
2223
_ suffix: String = "",
2324
_ body: @escaping (Logger) -> Void
2425
) {
@@ -49,7 +50,17 @@ public func makeBenchmark(
4950
),
5051
]
5152
)
52-
) { _ in
53-
body(logger)
53+
) { benchmark in
54+
if setScopedLogger {
55+
withLogger(logger) { logger in
56+
benchmark.startMeasurement()
57+
body(logger)
58+
benchmark.stopMeasurement()
59+
}
60+
} else {
61+
benchmark.startMeasurement()
62+
body(logger)
63+
benchmark.stopMeasurement()
64+
}
5465
}
5566
}
Lines changed: 116 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
1-
# 003: Accepting loggers in libraries
1+
# 003: Logger propagation in libraries
22

3-
Accept loggers through method parameters to ensure proper metadata propagation.
3+
Propagate caller context by accepting a `Logger` parameter or reading the task-local
4+
``Logger/current`` — never by constructing your own logger.
45

56
## Overview
67

7-
Libraries should accept logger instances through method parameters rather than
8-
storing them as instance variables. This practice ensures metadata (such as
9-
correlation IDs) is properly propagated down the call stack, while giving
10-
applications control over logging configuration.
8+
Libraries should obtain a `Logger` in one of two ways: accept one through a method or
9+
initializer parameter, or read ``Logger/current`` from the task-local. Both approaches
10+
preserve the caller's metadata, log level, and handler choice. Constructing a logger
11+
inside a library — via ``Logger/init(label:)`` — takes those choices away from the
12+
application and breaks metadata propagation.
1113

1214
### Motivation
1315

14-
When libraries accept loggers as method parameters, they enable automatic
15-
propagation of contextual metadata attached to the logger instance. This is
16-
especially important for distributed systems where correlation IDs must flow
17-
through the entire request processing pipeline.
16+
The application controls logging: which backend, which log level, which metadata
17+
travels with each request. Libraries that participate in this picture obtain a logger
18+
the application has set up, rather than constructing their own from scratch. This
19+
ensures correlation IDs and other contextual metadata flow through the entire call
20+
stack, and gives the application a single place to redirect or filter all log output.
21+
22+
Two propagation mechanisms are available, and they coexist. Choose the explicit
23+
parameter when the library's API already accepts a `Logger` (or it's natural to add
24+
one) — the call site stays declarative about what gets logged where. Choose the
25+
task-local when adding a `logger:` parameter would pollute an API that otherwise has
26+
no logging concern in its signature. Application code drives the task-local binding;
27+
library code reads it.
1828

1929
### Example
2030

@@ -31,70 +41,141 @@ struct RequestProcessor {
3141
logger[metadataKey: "request.id"] = "\(request.id)"
3242

3343
logger.debug("Processing request")
34-
44+
3545
// Pass the logger down to maintain metadata context.
3646
let validatedData = try validateRequest(request, logger: logger)
3747
let result = try await executeBusinessLogic(validatedData, logger: logger)
38-
48+
3949
logger.debug("Request processed successfully")
4050
return result
4151
}
42-
52+
4353
private func validateRequest(_ request: HTTPRequest, logger: Logger) throws -> ValidatedRequest {
4454
logger.debug("Validating request parameters")
45-
// Include validation logic that uses the same logger context.
4655
return ValidatedRequest(request)
4756
}
48-
57+
4958
private func executeBusinessLogic(_ data: ValidatedRequest, logger: Logger) async throws -> HTTPResponse {
5059
logger.debug("Executing business logic")
51-
52-
// Further propagate the logger to other services.
5360
let dbResult = try await databaseService.query(data.query, logger: logger)
54-
5561
logger.debug("Business logic completed")
5662
return HTTPResponse(data: dbResult)
5763
}
5864
}
5965
```
6066

61-
#### Alternative: Accept logger through initializer when appropriate
67+
#### Recommended: Accept logger through initializer for long-lived components
6268

6369
```swift
6470
// ✅ Acceptable: Logger through initializer for long-lived components
6571
final class BackgroundJobProcessor {
6672
private let logger: Logger
67-
73+
6874
init(logger: Logger) {
6975
self.logger = logger
7076
}
71-
77+
7278
func run() async {
73-
// Execute some long running work
7479
logger.debug("Update about long running work")
75-
// Execute some more long running work
7680
}
7781
}
7882
```
7983

84+
#### Recommended: Read ``Logger/current`` from the task-local
85+
86+
When a `logger:` parameter would clutter an otherwise logger-unrelated API, read the
87+
task-local. The application's accumulated metadata (`request.id`, etc.) flows in
88+
automatically without an explicit hand-off.
89+
90+
```swift
91+
// ✅ Good: Library reads Logger.current; caller scopes context via withLogger.
92+
public struct AnalyticsClient {
93+
public func track(_ event: String) {
94+
Logger.current.info("event", metadata: ["event.name": "\(event)"])
95+
}
96+
}
97+
98+
// Application binds at @main and scopes per-request metadata.
99+
@main
100+
struct MyServer {
101+
static func main() async throws {
102+
let logger = Logger(label: "my-server")
103+
try await withLogger(logger) { _ in
104+
try await runServices()
105+
}
106+
}
107+
}
108+
109+
func handleRequest(_ req: HTTPRequest) async throws {
110+
try await withLogger(mergingMetadata: ["request.id": "\(req.id)"]) { _ in
111+
AnalyticsClient().track("request.received") // sees request.id automatically
112+
}
113+
}
114+
```
115+
116+
For per-statement metadata, pass it via the `metadata:` parameter on the log call —
117+
it does not propagate and does not leak into other code:
118+
119+
```swift
120+
Logger.current.info("step", metadata: ["step.name": "validate"])
121+
```
122+
123+
For a few back-to-back lines, take a local copy and stamp metadata there — also no
124+
propagation:
125+
126+
```swift
127+
var local = Logger.current
128+
local[metadataKey: "step.name"] = "validate"
129+
local.info("entering")
130+
local.info("done")
131+
```
132+
80133
#### Avoid: Libraries creating their own loggers
81134

82-
Libraries might create their own loggers; however, this leads to two problems.
83-
First, users of the library can't inject their own loggers which means they have
84-
no control in customizing the log level or log handler. Secondly, it breaks the
85-
metadata propagation since users can't pass in a logger with already attached
86-
metadata.
135+
Constructing ``Logger/init(label:)`` inside a library takes control over the handler, log
136+
level, and base metadata away from the application. The application cannot redirect,
137+
filter, or silence the library's output.
87138

88139
```swift
89-
// ❌ Bad: Library creates its own logger
140+
// ❌ Bad: Library creates its own logger — loses caller's context.
90141
final class MyLibrary {
91-
private let logger = Logger(label: "MyLibrary") // Loses all context
142+
private let logger = Logger(label: "MyLibrary")
92143
}
144+
```
93145

94-
// ✅ Good: Library accepts logger from caller
95-
final class MyLibrary {
96-
func operation(logger: Logger) {
97-
// Maintains caller's context and metadata
98-
}
146+
``Logger/init(label:)`` is for the application — typically at `@main`, paired with
147+
``LoggingSystem/bootstrap(_:)``. Library code, including internal application modules,
148+
should not construct loggers.
149+
150+
#### Avoid: Relying on ``Logger/current`` across non-Task boundaries
151+
152+
``Logger/current`` is backed by a `TaskLocal` and propagates through Swift's structured
153+
concurrency model only. Callbacks invoked on non-Task threads — GCD blocks,
154+
`URLSession` completion handlers, delegate methods dispatched onto specific queues,
155+
`NotificationCenter` observers, C-API callbacks — see the *default* fallback logger,
156+
not the bound one. Metadata bound by the calling `Task` is invisible inside those
157+
callbacks.
158+
159+
```swift
160+
// ❌ Bad: completion handler runs without the Task context; Logger.current is the fallback.
161+
try await withLogger(mergingMetadata: ["request.id": "r1"]) { _ in
162+
URLSession.shared.dataTask(with: req) { data, _, _ in
163+
Logger.current.info("response") // empty-label fallback, no request.id
164+
}.resume()
165+
}
166+
```
167+
168+
For libraries with async completion-handler APIs, accept an explicit `Logger` parameter.
169+
If capturing and rebinding across the boundary is the only option:
170+
171+
```swift
172+
// ✅ Good: capture before the boundary, rebind on the other side.
173+
try await withLogger(mergingMetadata: ["request.id": "r1"]) { _ in
174+
let captured = Logger.current
175+
URLSession.shared.dataTask(with: req) { data, _, _ in
176+
withLogger(captured) { logger in
177+
logger.info("response") // request.id preserved
178+
}
179+
}.resume()
99180
}
100181
```

0 commit comments

Comments
 (0)