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
1616This proposal adds the `` withInstrument(_:_:) `` free function. It runs a closure with a chosen `` Instrument ``
1717active 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
4243Inside the closure — and in any child tasks it spawns — ` instrument ` is the active instrument, so span
4344creation (`` 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
8891public 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
103107public 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
117122public 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