@@ -40,13 +40,10 @@ const ATTR_NET_PEER_PORT = 'net.peer.port';
4040 * `context` object. Documented here rather than imported because orchestrion's
4141 * runtime doesn't export it — see `node_modules/@apm-js-collab/code-transformer/lib/transforms.js`.
4242 *
43- * `arguments` is the *live* args array the wrapper passes to the wrapped function:
44- * orchestrion splices the user's callback out and inserts its own wrapper at
45- * the same index before publishing `start`. We mutate that last entry again in
46- * our `start` hook so the callback (and any nested `connection.query(...)`)
47- * runs inside `withActiveSpan(parent, …)` — mysql v2 loses ALS state when it
48- * dispatches callbacks from its socket handler, which would otherwise cause
49- * nested queries to begin a fresh root trace.
43+ * `arguments` is the *live* args array passed to the wrapped function: orchestrion
44+ * splices the user's callback out and inserts its own wrapper at the same index
45+ * before publishing `start`. The `start` hook re-wraps that entry to restore the
46+ * caller's scope across mysql's async callback dispatch (see below).
5047 */
5148interface MysqlQueryChannelContext {
5249 arguments : unknown [ ] ;
@@ -76,16 +73,12 @@ const _mysqlChannelIntegration = (() => {
7673 DEBUG_BUILD && debug . log ( `[orchestrion:mysql] subscribing to channel "${ CHANNELS . MYSQL_QUERY } "` ) ;
7774 const queryCh = diagnosticsChannel . tracingChannel ( CHANNELS . MYSQL_QUERY ) ;
7875
79- // Each `context` object is shared across start/end/asyncStart/asyncEnd/error
80- // for one call (orchestrion creates one per invocation). We key the span
81- // off the same identity. WeakMap so we don't leak if a path never reaches
82- // asyncEnd for some reason.
76+ // Orchestrion creates one `context` object per call, shared across all
77+ // lifecycle hooks. We key both maps off that identity; `WeakMap` so an
78+ // unfinished path can't leak its entries.
8379 const spans = new WeakMap < object , Span > ( ) ;
84-
85- // The scope active when the query was issued, keyed by the same per-call
86- // `context` object. The streamable no-callback path needs it in `end`
87- // (where `ctx.result` first becomes available) to bind the returned
88- // `Query` emitter's listeners to it — see the `end` hook.
80+ // The scope active when the query was issued, consumed in `end` to bind
81+ // the streamed `Query` emitter's listeners to it.
8982 const parentScopes = new WeakMap < object , Scope > ( ) ;
9083
9184 // `subscribe()` requires all five lifecycle hooks. The orchestrion
@@ -101,18 +94,10 @@ const _mysqlChannelIntegration = (() => {
10194 // (ctx.result is the Query
10295 // emitter, no async events)
10396 //
104- // We end the span on `asyncEnd` for the two callback paths (so the span
105- // covers the full network round-trip + callback duration). For the
106- // sync-throw path, `end` finishes the span because `ctx.error` is set
107- // there. For the streamable no-callback path, `end` finishes by
108- // attaching `'end'`/`'error'` listeners to `ctx.result` (the returned
109- // `Query` emitter).
110- //
111- // The discriminator between "end fired before any error" and "end fired
112- // after a sync throw" is whether `ctx.error` is set when `end` runs —
113- // orchestrion populates it before publishing `error`. The discriminator
114- // between callback and no-callback is whether `ctx.result` is set — only
115- // the `wrapPromise` (no-callback) path stores it.
97+ // Where the span closes depends on the path: `asyncEnd` for callbacks (so
98+ // it spans the full round-trip + callback), or `end` for the sync-throw
99+ // and streamable paths. The `end` hook tells those apart via `ctx.error`
100+ // / `ctx.result` — see there.
116101 queryCh . subscribe ( {
117102 start ( rawCtx ) {
118103 const ctx = rawCtx as MysqlQueryChannelContext ;
@@ -137,25 +122,19 @@ const _mysqlChannelIntegration = (() => {
137122 } ) ;
138123 spans . set ( rawCtx , span ) ;
139124
140- // Restore the Sentry/OTel context across mysql's internal callback
141- // dispatch. The orchestrion transform has already spliced the user's
142- // callback out of `ctx.arguments` and put its own wrapper
143- // (`__apm$wrappedCb`) at the same index. mysql v2 drains callbacks
144- // from a socket data handler — by the time the response arrives, the
145- // AsyncLocalStorage store backing `getActiveSpan()` no longer
146- // reflects the caller's context. We re-wrap orchestrion's wrapper so
147- // the user's callback (and any nested `connection.query(...)` inside
148- // it) runs with the parent span active again.
149- //
150- // This must happen at `start` (we're synchronously inside the
151- // caller's `connection.query` call, so OTel context is still
152- // correct). `asyncStart`/`asyncEnd` fire from the same lost context
153- // as the callback itself, so they're too late.
125+ // Capture the scope while we're still synchronously inside the
126+ // caller's `connection.query` call. mysql v2 drains callbacks and
127+ // emits streamed-query events from its socket data handler, where the
128+ // AsyncLocalStorage store backing the active span no longer reflects
129+ // the caller's context — and `asyncStart`/`asyncEnd` fire from that
130+ // same lost context, so capturing has to happen now.
154131 const scope = getCurrentScope ( ) ;
155-
156- // Keep track of the scope that was active when the query was issued.
157132 parentScopes . set ( rawCtx , scope ) ;
158133
134+ // Callback path: orchestrion has spliced the user's callback out of
135+ // `ctx.arguments` and put its own wrapper (`__apm$wrappedCb`) at the
136+ // same index. Re-wrap it so the callback — and any nested
137+ // `connection.query(...)` — runs with the captured scope active.
159138 if ( ctx . arguments . length > 0 ) {
160139 const cbIdx = ctx . arguments . length - 1 ;
161140 const orchestrionWrappedCb = ctx . arguments [ cbIdx ] ;
@@ -184,27 +163,22 @@ const _mysqlChannelIntegration = (() => {
184163 // fires `asyncStart`/`asyncEnd`. The returned `Query` is an
185164 // `EventEmitter` that emits `'end'` on success and `'error'` on
186165 // failure — hook those to close the span.
187- // TODO: streaming spans aren't finished on `connection.destroy()` —
188- // mysql guarantees no further events/callbacks for a destroyed
189- // connection, so neither `'end'` nor `'error'` fires and the span
190- // never ends (it's dropped, never reported). Closing this gap needs
191- // connection-level lifecycle hooks, which the per-query channel
192- // context doesn't expose here. The `WeakMap` still prevents a leak.
166+ // Note: a streamed span never finishes if the connection is destroyed
167+ // mid-flight — mysql then emits neither `'end'` nor `'error'`, so the
168+ // span is dropped (the `WeakMap` still prevents a leak). Closing this
169+ // needs connection-level hooks the per-query context doesn't expose.
193170 const result = ctx . result ;
194171 if ( result && typeof result === 'object' && hasOnMethod ( result ) ) {
195172 const span = spans . get ( rawCtx ) ;
196173 if ( ! span ) return ;
197174
198- // Bind the streamed `Query`'s listeners to the scope that was active
199- // when the query was issued. mysql v2 emits `'end'`/`'error'`/
200- // `'fields'`/… from its socket data handler, where the
201- // AsyncLocalStorage store backing `getActiveSpan()` no longer
202- // reflects the caller's context. Without this, a span started inside
203- // a user's stream listener would begin a fresh root trace instead of
204- // nesting under the active parent. `bindScopeToEmitter` patches the
205- // emitter's `on`/`addListener`/… so listeners the user adds after
206- // `query()` returns inherit the scope — mirroring what
207- // `@opentelemetry/instrumentation-mysql` does via `context.bind`.
175+ // Bind the captured scope to the streamed `Query` emitter: its
176+ // `'end'`/`'error'`/`'fields'`/… events fire from mysql's socket
177+ // handler with the caller's context lost, so without this a span
178+ // started in a user's stream listener would begin a fresh root trace
179+ // instead of nesting under the parent. `bindScopeToEmitter` patches
180+ // `on`/`addListener`/… so listeners added after `query()` returns
181+ // inherit the scope (like OTel's `context.bind`).
208182 const parentScope = parentScopes . get ( rawCtx ) ;
209183 if ( parentScope ) {
210184 bindScopeToEmitter ( result , parentScope ) ;
0 commit comments