From 452ad88c79a8d04fb320d9174573955b77f1f7cf Mon Sep 17 00:00:00 2001 From: Jason Calem Date: Thu, 21 May 2026 15:57:21 -0400 Subject: [PATCH 1/2] fix(spanner): don't tear down host app's OTel ContextManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ensureInitialContextManagerSet()` (added to make async/await tracing work for apps that haven't configured OpenTelemetry) currently tears down the host application's `ContextManager` on most calls. The guard is OR-joined: if (!context['_contextManager'] || context.active() === ROOT_CONTEXT) { context.disable(); // ...install fresh AsyncHooksContextManager } Two problems: 1. `context['_contextManager']` is not a public field on the API singleton. `setGlobalContextManager` writes into the package-private global registry (`registerGlobal` in `@opentelemetry/api/internal/global-utils.js`), not to a `_contextManager` property. So the first leg of the OR is effectively always true and the function runs its install path on every call. 2. Even with that, `context.active() === ROOT_CONTEXT` fires on every gRPC call made outside an active span — exactly what the Spanner session pool does during background warmup (BatchCreateSessions, pool maintenance). On those calls, the function `context.disable()`s the host app's already-installed `ContextManager` and replaces it with a fresh one. Any baggage the host had set before that moment is silently lost, along with span parent linkage. In practice this means: an app that uses OpenTelemetry, sets baggage on the root context (e.g. user id, request id), and then issues a Spanner call, will observe its baggage disappear on the next span emitted after Spanner's pool warms up. We hit this in a Node service that uses OTel + Spanner — chats emitted log records with `undefined` ids because host baggage had been wiped by Spanner's pool-warmup `startTrace` call. The fix uses the public API surface — `setGlobalContextManager()` already returns `false` when a manager is already registered. Try to install; if something else owns the slot, back out without touching anything. --- handwritten/spanner/src/instrument.ts | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/handwritten/spanner/src/instrument.ts b/handwritten/spanner/src/instrument.ts index 9537e51efd91..78990ebf56fe 100644 --- a/handwritten/spanner/src/instrument.ts +++ b/handwritten/spanner/src/instrument.ts @@ -25,7 +25,6 @@ import { context, trace, INVALID_SPAN_CONTEXT, - ROOT_CONTEXT, SpanAttributes, TimeInput, TracerProvider, @@ -98,22 +97,20 @@ const { } = require('@opentelemetry/context-async-hooks'); /* - * This function ensures that async/await works correctly by - * checking if context.active() returns an invalid/unset context - * and if so, sets a global AsyncHooksContextManager otherwise - * spans resulting from async/await invocations won't be correctly - * associated in their respective hierarchies. + * If no global ContextManager is registered, install an AsyncHooksContextManager + * so that async/await trace context propagation works for apps that haven't + * configured OpenTelemetry themselves. If the host app has already installed a + * ContextManager, leave it alone — tearing down a working manager breaks the + * host's baggage and span parentage on the next gRPC call. + * + * setGlobalContextManager() returns false when a manager is already registered, + * which is the documented signal that we shouldn't replace it. */ function ensureInitialContextManagerSet() { - if (!context['_contextManager'] || context.active() === ROOT_CONTEXT) { - // If no context manager is currently set, or if the active context is the ROOT_CONTEXT, - // trace context propagation cannot - // function correctly with async/await for OpenTelemetry - // See {@link https://opentelemetry.io/docs/languages/js/context/#active-context} - context.disable(); // Disable any prior contextManager. - const contextManager = new AsyncHooksContextManager(); - contextManager.enable(); - context.setGlobalContextManager(contextManager); + const contextManager = new AsyncHooksContextManager(); + contextManager.enable(); + if (!context.setGlobalContextManager(contextManager)) { + contextManager.disable(); } } From c0274fecd71ce4495c255e4c27d65b4a122da2d0 Mon Sep 17 00:00:00 2001 From: Jason Calem Date: Thu, 21 May 2026 16:02:21 -0400 Subject: [PATCH 2/2] make ensureInitialContextManagerSet idempotent across Spanner clients Latch on a module-level flag so we don't allocate + enable + disable a fresh AsyncHooksContextManager on every `new Spanner({...})` when the host app has already registered its own ContextManager. Addresses review feedback. --- handwritten/spanner/src/instrument.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/handwritten/spanner/src/instrument.ts b/handwritten/spanner/src/instrument.ts index 78990ebf56fe..cd5132056f4f 100644 --- a/handwritten/spanner/src/instrument.ts +++ b/handwritten/spanner/src/instrument.ts @@ -96,6 +96,8 @@ const { AsyncHooksContextManager, } = require('@opentelemetry/context-async-hooks'); +let contextManagerInstallAttempted = false; + /* * If no global ContextManager is registered, install an AsyncHooksContextManager * so that async/await trace context propagation works for apps that haven't @@ -104,9 +106,13 @@ const { * host's baggage and span parentage on the next gRPC call. * * setGlobalContextManager() returns false when a manager is already registered, - * which is the documented signal that we shouldn't replace it. + * which is the documented signal that we shouldn't replace it. The + * `contextManagerInstallAttempted` latch makes the call idempotent so we don't + * allocate a new AsyncHooksContextManager on each Spanner client construction. */ function ensureInitialContextManagerSet() { + if (contextManagerInstallAttempted) return; + contextManagerInstallAttempted = true; const contextManager = new AsyncHooksContextManager(); contextManager.enable(); if (!context.setGlobalContextManager(contextManager)) {