Skip to content

Commit caadb06

Browse files
committed
[proposal] back to always-task-local
1 parent 7a7f43d commit caadb06

1 file changed

Lines changed: 67 additions & 47 deletions

File tree

Sources/Tracing/Docs.docc/Proposals/SDT-0001-task-local-instrument.md

Lines changed: 67 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
# SDT-0001: task-local instrument
22

3-
A `withInstrument(_:_:)` free function that scopes an ``Instrument`` to the current task. Opt-in, with no
4-
cost to apps that don't use it.
3+
A `withInstrument(_:_:)` free function that scopes an ``Instrument`` to the current task, resolved ahead of
4+
the process-wide ``InstrumentationSystem/bootstrap(_:)`` and falling back to it outside the scope.
55

66
## Overview
77

88
- Proposal: SDT-0001
99
- Author(s): [Vladimir Kukushkin](https://github.com/kukushechkin)
1010
- Status: **In Review**
1111
- Issue: [apple/swift-distributed-tracing#168](https://github.com/apple/swift-distributed-tracing/issues/168)
12-
- Implementation: TBD
12+
- Implementation: on the `SDT-0001-task-local-instrument-implementation` branch
1313

1414
### Introduction
1515

1616
This proposal adds the ``withInstrument(_:_:)`` free function. It runs a closure with a chosen ``Instrument``
1717
active for the current task and any child tasks it spawns. Unlike ``InstrumentationSystem/bootstrap(_:)``,
18-
which is set once per process, it can bind a different instrument per region of work, for example per test.
18+
which is set once per process, it can bind a different instrument per region of work, for example per test. The
19+
binding is resolved ahead of the bootstrapped instrument and falls back to it outside the scope.
1920

2021
### Motivation
2122

@@ -35,29 +36,31 @@ second `bootstrap` call crashes, so parallel tests can't each install their own.
3536
await withInstrument(tracer) {
3637
await withSpan("op") { _ in } // emits into `tracer`
3738
}
38-
#expect(tracer.spans.count == 1)
39+
#expect(tracer.finishedSpans.count == 1)
3940
}
4041
```
4142

4243
Inside the closure — and in any child tasks it spawns — `instrument` is the active instrument, so span
4344
creation (``InstrumentationSystem/tracer``, `withSpan` / `startSpan`) and propagation (`inject` / `extract`)
44-
resolve through it. The binding is task-local, so a nested `withInstrument` applies only within its own
45-
closure. It replaces rather than merges: to run several instruments at once, pass a ``MultiplexInstrument``,
46-
exactly as you would to `bootstrap`.
45+
resolve it ahead of whatever ``InstrumentationSystem/bootstrap(_:)`` set. Outside the closure, resolution falls
46+
back to the bootstrapped instrument. The binding is task-local, so a nested `withInstrument` applies only
47+
within its own closure and replaces rather than merges — to run several instruments at once, pass a
48+
``MultiplexInstrument``, exactly as you would to `bootstrap`. Propagation runs every member of a
49+
``MultiplexInstrument``, but span creation uses the first ``Tracer`` in it.
4750

4851
### Detailed design
4952

5053
````swift
5154
/// Makes `instrument` the active instrument for the current task and the child tasks it spawns, for the
5255
/// duration of `operation`.
5356
///
54-
/// `withInstrument(_:_:)` works through a dedicated task-local instrument that the ``InstrumentationSystem``
55-
/// holds as its bootstrap. The first call installs it — a one-time global bootstrap, done only when nothing
56-
/// else is bootstrapped — and every call then sets `instrument` as the active instrument for the closure,
57-
/// replacing any instrument an enclosing `withInstrument(_:_:)` set. Discovery (``InstrumentationSystem/tracer``,
58-
/// free-function `withSpan` / `startSpan`) and propagation (`inject` / `extract`) resolve through `instrument`.
59-
/// To keep several instruments active at once, pass a ``MultiplexInstrument`` built from the instruments you
60-
/// hold.
57+
/// Inside the closure, ``InstrumentationSystem/instrument``, discovery (``InstrumentationSystem/tracer``,
58+
/// free-function `withSpan` / `startSpan`), and propagation (`inject` / `extract`) resolve `instrument` ahead
59+
/// of whatever was set with ``InstrumentationSystem/bootstrap(_:)``. Outside the closure, resolution falls back
60+
/// to the bootstrapped instrument. An unstructured `Task { }` inherits the binding, but `Task.detached` does
61+
/// not. A nested `withInstrument(_:_:)` replaces the active instrument for its own scope rather than merging
62+
/// with it. To keep several instruments active at once, pass a ``MultiplexInstrument`` built from the
63+
/// instruments you hold.
6164
///
6265
/// ```swift
6366
/// // Parallel-safe. The binding is task-local, so concurrent tests don't interfere.
@@ -66,25 +69,24 @@ exactly as you would to `bootstrap`.
6669
/// await withInstrument(tracer) {
6770
/// await withSpan("op") { _ in } // emits into `tracer`
6871
/// }
69-
/// #expect(tracer.spans.count == 1)
72+
/// #expect(tracer.finishedSpans.count == 1)
7073
/// }
7174
/// ```
7275
///
73-
/// > Important: This is an application-level facility — call it from code that owns the instrumentation setup,
74-
/// > never from a library. It can install its instrument only when the ``InstrumentationSystem`` is otherwise
75-
/// > un-bootstrapped. Calling it after a plain ``Instrument`` has been bootstrapped crashes. Libraries emit
76-
/// > through ``InstrumentationSystem/instrument`` and `withSpan` / `startSpan`, which already observe whatever
77-
/// > is active.
76+
/// This chooses the active *instrument* (the backend), not the trace *context* — propagating context is
77+
/// ``ServiceContext``'s job. It does not alter cancellation: an error thrown by `operation`, including
78+
/// `CancellationError`, propagates out unchanged.
7879
///
79-
/// > Important: `instrument` replaces the active instrument for the scope, it does not merge with it. Include
80-
/// > a tracer (directly or inside a ``MultiplexInstrument``) if you want spans recorded inside the closure.
81-
/// > Passing ``InstrumentationSystem/instrument`` back in is rejected with a crash.
80+
/// > Note: This is a scoped alternative to ``InstrumentationSystem/bootstrap(_:)``, resolved ahead of it for
81+
/// > the duration of `operation`. ``InstrumentationSystem/instrument`` and `withSpan` / `startSpan` observe
82+
/// > whichever is active.
8283
///
8384
/// - Parameters:
8485
/// - instrument: The instrument to make active for the duration of `operation`.
8586
/// - operation: The closure to run with `instrument` active.
8687
/// - Returns: The value returned by the closure.
8788
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
89+
@inlinable
8890
public func withInstrument<Result, Failure: Error>(
8991
_ instrument: any Instrument,
9092
_ operation: () throws(Failure) -> Result
@@ -93,27 +95,29 @@ public func withInstrument<Result, Failure: Error>(
9395
#if compiler(>=6.2)
9496
/// Makes `instrument` the active instrument for the current task and the child tasks it spawns, for the
9597
/// duration of `operation`. See the synchronous `withInstrument(_:_:)` for the full discussion — replace
96-
/// semantics, the application-level / plain-bootstrap rules, and the self-reference crash.
98+
/// semantics and fallback to the bootstrapped instrument.
9799
///
98100
/// - Parameters:
99101
/// - instrument: The instrument to make active for the duration of `operation`.
100102
/// - operation: The async closure to run with `instrument` active.
101103
/// - Returns: The value returned by the closure.
102104
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
105+
@inlinable
103106
public nonisolated(nonsending) func withInstrument<Result, Failure: Error>(
104107
_ instrument: any Instrument,
105108
_ operation: nonisolated(nonsending) () async throws(Failure) -> Result
106109
) async throws(Failure) -> Result
107110
#else
108111
/// Makes `instrument` the active instrument for the current task and the child tasks it spawns, for the
109112
/// duration of `operation`. See the synchronous `withInstrument(_:_:)` for the full discussion — replace
110-
/// semantics, the application-level / plain-bootstrap rules, and the self-reference crash.
113+
/// semantics and fallback to the bootstrapped instrument.
111114
///
112115
/// - Parameters:
113116
/// - instrument: The instrument to make active for the duration of `operation`.
114117
/// - operation: The async closure to run with `instrument` active.
115118
/// - Returns: The value returned by the closure.
116119
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
120+
@inlinable
117121
public func withInstrument<Result, Failure: Error>(
118122
_ instrument: any Instrument,
119123
isolation: isolated (any Actor)? = #isolation,
@@ -122,39 +126,55 @@ public func withInstrument<Result, Failure: Error>(
122126
#endif
123127
````
124128

125-
That dedicated instrument is an internal type, `TaskLocalInstrument` — not public, so applications cannot
126-
construct or name it. It holds the `@TaskLocal` override and falls back to an inner ``NoOpInstrument``.
129+
The scope is backed by an `@TaskLocal` on ``InstrumentationSystem``. ``InstrumentationSystem/instrument`` and
130+
``InstrumentationSystem/_findInstrument(where:)`` resolve it ahead of the bootstrapped instrument, falling back
131+
to the bootstrap when no scope is active. There is no wrapper instrument, and nothing is installed on first use.
132+
133+
The overload shapes follow sibling-package precedent: the free-function form and typed-throws forwarding mirror
134+
swift-metrics' `withMetricsFactory`, and the async overload's `nonisolated(nonsending)` (Swift 6.2+) versus
135+
`isolation: isolated (any Actor)? = #isolation` (earlier compilers) split mirrors swift-service-context's
136+
`ServiceContext.withValue(_:isolation:operation:)`.
127137

128138
### API stability
129139

130-
- Applications that never call ``withInstrument(_:_:)`` see no behavioral change.
140+
- Purely additive: a new free function and an internal task-local. Existing signatures are unchanged.
141+
- Applications that never call ``withInstrument(_:_:)`` see no behavioral change — resolution falls back to the
142+
bootstrapped instrument. Instrument lookup now consults a task-local before the bootstrap.
143+
- That task-local read is on every lookup, including the per-span `withSpan` / `startSpan` path. While adding
144+
task-local instrument to the hot path adds a ~5% overhead comparing to the old task-local-free implementation,
145+
actually **adopting task-local instrument improves** `withSpan` performance by ~5% measured against a
146+
`NoOp` tracer. Against a real tracer, whose per-span work dominates, the relative cost is even smaller.
147+
- The `withInstrument(_:_:)` overloads are `@inlinable`, and the backing task-local and its helper are
148+
`@usableFromInline`, mirroring swift-metrics' `withMetricsFactory`, so the task-local bind can inline into the
149+
caller. This is a source-distributed package, so `@inlinable` affects cross-module optimization, not ABI.
131150

132151
### Future directions
133152

134-
- **Accumulate active instruments, not just replacing.** `withInstrument` replaces. A future variant —
135-
`withInstrument(merging:)`, or a form that passes the current instrument into a builder closure — could let
136-
a caller compose with whatever is already active without having to name it. The library would supply the
137-
concrete current instrument, keeping it safe from the self-reference crash that blocks composing
138-
``InstrumentationSystem/instrument`` by hand today.
153+
- **A `withInstrument(merging:)` convenience.** `withInstrument` replaces the active instrument. Composing with
154+
whatever is already active is possible by hand today —
155+
`withInstrument(MultiplexInstrument([InstrumentationSystem.instrument, myTracer]))` — because
156+
``InstrumentationSystem/instrument`` returns the concrete active instrument. A `withInstrument(merging:)`
157+
variant could build that ``MultiplexInstrument`` for the caller.
139158

140159
### Alternatives considered
141160

142-
**Task-local slot on ``InstrumentationSystem/instrument``.** Read the task-local on every `instrument` access
143-
rather than behind an installed instrument. Rejected: it taxes every read even when no scope is active and
144-
pushes scope-awareness into ``InstrumentationSystem``.
161+
**A task-local-aware instrument installed as the bootstrap.** Instead of ``InstrumentationSystem`` reading a
162+
task-local directly, install a dedicated wrapper instrument as the bootstrap that holds the task-local and
163+
falls back to an inner ``NoOpInstrument``. This keeps the task-local read off the resolution path for
164+
applications that only ``InstrumentationSystem/bootstrap(_:)`` and never scope. Rejected: it couples
165+
``withInstrument(_:_:)`` to the bootstrap state (it cannot be installed over a plainly-bootstrapped instrument),
166+
introduces a self-reference hazard when ``InstrumentationSystem/instrument`` is passed back in, and needs
167+
install-on-first-use. The always-checked slot is simpler and composes with any bootstrap.
145168

146-
**Public `TaskLocalInstrument` to bootstrap directly.** Make `TaskLocalInstrument` a public type that
147-
applications bootstrap directly (`InstrumentationSystem.bootstrap(TaskLocalInstrument(tracer))`), entering
148-
scopes via a static `TaskLocalInstrument.with(_:_:)`. Rejected: a single free function matches the `withSpan`
149-
/ `withMetricsFactory` precedent and avoids forcing a plain-vs-wrapped choice at bootstrap and compatibilities
150-
with libraries doing instrumentation.
169+
**A public task-local instrument type to bootstrap directly.** Expose a public wrapper type that applications
170+
bootstrap directly and enter scopes on via a static method. Rejected: a single free function matches the
171+
`withSpan` / `withMetricsFactory` precedent, and it avoids forcing a plain-vs-scoped choice at bootstrap time.
151172

152173
**Accumulate nested scopes instead of replacing.** Push onto a task-local stack so a nested scope adds to,
153-
rather than replaces, the enclosing one. Rejected: it is a hidden, surprising accumulation, and the use case
154-
it serves — augmenting whatever is already active — cannot be offered cleanly anyway, since
155-
``InstrumentationSystem/instrument`` is a scope-aware wrapper rather than a concrete value (see above).
156-
Replacing is simpler and matches swift-metrics' `withMetricsFactory`. Pass a ``MultiplexInstrument`` to run
157-
several instruments at once.
174+
rather than replaces, the enclosing one. Rejected: it is a hidden, surprising accumulation. Replacing is
175+
simpler and matches swift-metrics' `withMetricsFactory`. Augmenting whatever is already active is available by
176+
composing a ``MultiplexInstrument`` with ``InstrumentationSystem/instrument`` (see Future directions), and
177+
running several instruments at once is just passing a ``MultiplexInstrument``.
158178

159179
**Task-local ``Tracer`` only.** Rejected: leaves ``InstrumentationSystem/instrument`` global, so `extract` /
160180
`inject` wouldn't see the scope — a test would capture spans but miss incoming trace IDs.

0 commit comments

Comments
 (0)