Skip to content

Commit 30e5587

Browse files
committed
[proposal] back to always-task-local
1 parent 3083a76 commit 30e5587

1 file changed

Lines changed: 70 additions & 47 deletions

File tree

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

Lines changed: 70 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,25 @@ 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 an application-level facility for choosing the active instrument — like
81+
/// > ``InstrumentationSystem/bootstrap(_:)``, but scoped. Libraries do not call it — they emit through
82+
/// > ``InstrumentationSystem/instrument`` and `withSpan` / `startSpan`, which already observe whatever is
83+
/// > active.
8284
///
8385
/// - Parameters:
8486
/// - instrument: The instrument to make active for the duration of `operation`.
8587
/// - operation: The closure to run with `instrument` active.
8688
/// - Returns: The value returned by the closure.
8789
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
90+
@inlinable
8891
public func withInstrument<Result, Failure: Error>(
8992
_ instrument: any Instrument,
9093
_ operation: () throws(Failure) -> Result
@@ -93,27 +96,29 @@ public func withInstrument<Result, Failure: Error>(
9396
#if compiler(>=6.2)
9497
/// Makes `instrument` the active instrument for the current task and the child tasks it spawns, for the
9598
/// 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.
99+
/// semantics, fallback to the bootstrapped instrument, and the application-level guidance.
97100
///
98101
/// - Parameters:
99102
/// - instrument: The instrument to make active for the duration of `operation`.
100103
/// - operation: The async closure to run with `instrument` active.
101104
/// - Returns: The value returned by the closure.
102105
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
106+
@inlinable
103107
public nonisolated(nonsending) func withInstrument<Result, Failure: Error>(
104108
_ instrument: any Instrument,
105109
_ operation: nonisolated(nonsending) () async throws(Failure) -> Result
106110
) async throws(Failure) -> Result
107111
#else
108112
/// Makes `instrument` the active instrument for the current task and the child tasks it spawns, for the
109113
/// 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.
114+
/// semantics, fallback to the bootstrapped instrument, and the application-level guidance.
111115
///
112116
/// - Parameters:
113117
/// - instrument: The instrument to make active for the duration of `operation`.
114118
/// - operation: The async closure to run with `instrument` active.
115119
/// - Returns: The value returned by the closure.
116120
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
121+
@inlinable
117122
public func withInstrument<Result, Failure: Error>(
118123
_ instrument: any Instrument,
119124
isolation: isolated (any Actor)? = #isolation,
@@ -122,39 +127,57 @@ public func withInstrument<Result, Failure: Error>(
122127
#endif
123128
````
124129

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``.
130+
The scope is backed by an `@TaskLocal` on ``InstrumentationSystem``. ``InstrumentationSystem/instrument`` and
131+
``InstrumentationSystem/_findInstrument(where:)`` resolve it ahead of the bootstrapped instrument, falling back
132+
to the bootstrap when no scope is active. There is no wrapper instrument, and nothing is installed on first use.
133+
134+
The overload shapes follow sibling-package precedent: the free-function form and typed-throws forwarding mirror
135+
swift-metrics' `withMetricsFactory`, and the async overload's `nonisolated(nonsending)` (Swift 6.2+) versus
136+
`isolation: isolated (any Actor)? = #isolation` (earlier compilers) split mirrors swift-service-context's
137+
`ServiceContext.withValue(_:isolation:operation:)`.
127138

128139
### API stability
129140

130-
- Applications that never call ``withInstrument(_:_:)`` see no behavioral change.
141+
- Purely additive: a new free function and an internal task-local. Existing signatures are unchanged.
142+
- Applications that never call ``withInstrument(_:_:)`` see no behavioral change — resolution falls back to the
143+
bootstrapped instrument. Instrument lookup now consults a task-local before the bootstrap.
144+
- That task-local read is on every lookup, including the per-span `withSpan` / `startSpan` path — unlike
145+
swift-metrics, where the equivalent read happens once at metric creation. Measured with the
146+
`NoopTracing.startSpan_endSpan*` benchmarks (a NoOp backend, so a worst case for *relative* overhead, since
147+
the path does almost no other work), the extra read on the no-scope fallback path costs on the order of a few
148+
dozen instructions per span (p90, toolchain-dependent). Against a real tracer, whose per-span work dominates,
149+
the relative cost is far smaller. Reproduce with the committed benchmarks.
150+
- The `withInstrument(_:_:)` overloads are `@inlinable`, and the backing task-local and its helper are
151+
`@usableFromInline`, mirroring swift-metrics' `withMetricsFactory`, so the task-local bind can inline into the
152+
caller. This is a source-distributed package, so `@inlinable` affects cross-module optimization, not ABI.
131153

132154
### Future directions
133155

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.
156+
- **A `withInstrument(merging:)` convenience.** `withInstrument` replaces the active instrument. Composing with
157+
whatever is already active is possible by hand today —
158+
`withInstrument(MultiplexInstrument([InstrumentationSystem.instrument, myTracer]))` — because
159+
``InstrumentationSystem/instrument`` returns the concrete active instrument. A `withInstrument(merging:)`
160+
variant could build that ``MultiplexInstrument`` for the caller.
139161

140162
### Alternatives considered
141163

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``.
164+
**A task-local-aware instrument installed as the bootstrap.** Instead of ``InstrumentationSystem`` reading a
165+
task-local directly, install a dedicated wrapper instrument as the bootstrap that holds the task-local and
166+
falls back to an inner ``NoOpInstrument``. This keeps the task-local read off the resolution path for
167+
applications that only ``InstrumentationSystem/bootstrap(_:)`` and never scope. Rejected: it couples
168+
``withInstrument(_:_:)`` to the bootstrap state (it cannot be installed over a plainly-bootstrapped instrument),
169+
introduces a self-reference hazard when ``InstrumentationSystem/instrument`` is passed back in, and needs
170+
install-on-first-use. The always-checked slot is simpler and composes with any bootstrap.
145171

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.
172+
**A public task-local instrument type to bootstrap directly.** Expose a public wrapper type that applications
173+
bootstrap directly and enter scopes on via a static method. Rejected: a single free function matches the
174+
`withSpan` / `withMetricsFactory` precedent, and it avoids forcing a plain-vs-scoped choice at bootstrap time.
151175

152176
**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.
177+
rather than replaces, the enclosing one. Rejected: it is a hidden, surprising accumulation. Replacing is
178+
simpler and matches swift-metrics' `withMetricsFactory`. Augmenting whatever is already active is available by
179+
composing a ``MultiplexInstrument`` with ``InstrumentationSystem/instrument`` (see Future directions), and
180+
running several instruments at once is just passing a ``MultiplexInstrument``.
158181

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

0 commit comments

Comments
 (0)