From e821623cb2353c732b32f2fe58b5224a1edcc17c Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Fri, 8 May 2026 16:45:04 -0700 Subject: [PATCH 1/3] fix: prevent duplicate channel subscriptions when multiple SDK instances coexist When the Braintrust SDK is loaded from two different module paths in the same process (e.g. a cloud runner's bundled copy + the user's scorer dependency), each PluginRegistry instance independently subscribed to the same process-global diagnostics_channel, causing every OpenAI call to produce two LLM spans (BT-5139). Fix uses Symbol.for("braintrust.registry.enabled") on globalThis as a cross-instance guard: only the first PluginRegistry to call enable() sets up channel subscriptions; subsequent instances skip. disable() clears the flag so test enable/disable cycles continue to work. Co-Authored-By: Claude Sonnet 4.6 --- js/src/instrumentation/registry.test.ts | 22 ++++++++++++++++++++++ js/src/instrumentation/registry.ts | 13 +++++++++++++ 2 files changed, 35 insertions(+) diff --git a/js/src/instrumentation/registry.test.ts b/js/src/instrumentation/registry.test.ts index 9e8c6a889..5f5522403 100644 --- a/js/src/instrumentation/registry.test.ts +++ b/js/src/instrumentation/registry.test.ts @@ -50,6 +50,28 @@ describe("Plugin Registry", () => { testRegistry.disable(); }); + it("should block a second instance from subscribing when another is already enabled", () => { + // Regression test for BT-5139: when the SDK is loaded from two different + // module paths in the same process, each gets its own PluginRegistry + // instance. Without cross-instance deduplication, both would subscribe to + // the same diagnostics_channel, causing every OpenAI call to produce two + // LLM spans. + const instanceA = new (registry.constructor as any)(); + const instanceB = new (registry.constructor as any)(); + + try { + instanceA.enable(); + expect(instanceA.isEnabled()).toBe(true); + + // instanceB should be blocked — the channel is already subscribed + instanceB.enable(); + expect(instanceB.isEnabled()).toBe(false); + } finally { + instanceA.disable(); + instanceB.disable(); + } + }); + it("should warn if configureInstrumentation is called after enable", () => { const testRegistry = new (registry.constructor as any)(); const warnSpy = [] as string[]; diff --git a/js/src/instrumentation/registry.ts b/js/src/instrumentation/registry.ts index 44fa2f024..8b9d5e0c0 100644 --- a/js/src/instrumentation/registry.ts +++ b/js/src/instrumentation/registry.ts @@ -8,6 +8,11 @@ import { BraintrustPlugin } from "./braintrust-plugin"; import iso from "../isomorph"; +// Process-global deduplication key: prevents multiple SDK instances loaded +// from different module paths from each subscribing to the same +// diagnostics_channel, which would create duplicate spans per API call. +const GLOBAL_REGISTRY_ENABLED_KEY = Symbol.for("braintrust.registry.enabled"); + export interface InstrumentationConfig { /** * Configuration for individual SDK integrations. @@ -60,7 +65,14 @@ class PluginRegistry { return; } + // If another SDK instance in the same process already registered plugins, + // skip to avoid duplicate diagnostics_channel subscriptions. + if ((globalThis as Record)[GLOBAL_REGISTRY_ENABLED_KEY]) { + return; + } + this.enabled = true; + (globalThis as Record)[GLOBAL_REGISTRY_ENABLED_KEY] = true; // Read config from environment variables const envConfig = this.readEnvConfig(); @@ -87,6 +99,7 @@ class PluginRegistry { } this.enabled = false; + delete (globalThis as Record)[GLOBAL_REGISTRY_ENABLED_KEY]; if (this.braintrustPlugin) { this.braintrustPlugin.disable(); From 4282b07a160585bd02e761021bd530828d459916 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Fri, 8 May 2026 16:47:19 -0700 Subject: [PATCH 2/3] chore: add changeset for BT-5139 Co-Authored-By: Claude Sonnet 4.6 --- .changeset/fix-bt-5139-duplicate-spans.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-bt-5139-duplicate-spans.md diff --git a/.changeset/fix-bt-5139-duplicate-spans.md b/.changeset/fix-bt-5139-duplicate-spans.md new file mode 100644 index 000000000..200b8f487 --- /dev/null +++ b/.changeset/fix-bt-5139-duplicate-spans.md @@ -0,0 +1,5 @@ +--- +"braintrust": patch +--- + +fix: prevent duplicate LLM spans when multiple SDK instances are loaded in the same process From 96f4d221fa1d1dbd9412280bc134c18eecf720dc Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Fri, 8 May 2026 16:57:45 -0700 Subject: [PATCH 3/3] fix: scope registry dedup to shared braintrust state instead of a standalone global flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach stored a boolean on globalThis[Symbol.for("braintrust.registry.enabled")], which was not cleared when vi.resetModules() discards the module cache in tests — causing the edge-runtime-bootstrap tests to fail on the second test in each describe.each block. The braintrust state (globalThis[Symbol.for("braintrust-state")]) is already shared across all SDK instances loaded in the same process via _internalSetInitialState, and is deleted by the test cleanup in restoreRuntimeEnvironment. Storing the registry marker on that object naturally handles both scenarios: - BT-5139: two concurrent SDK instances share the same state → second instance blocked by the marker left by the first. - vi.resetModules(): test deletes the state before re-import → fresh state has no marker → new module instance can subscribe. Co-Authored-By: Claude Sonnet 4.6 --- js/src/instrumentation/registry.test.ts | 9 ++++++ js/src/instrumentation/registry.ts | 43 ++++++++++++++++++++----- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/js/src/instrumentation/registry.test.ts b/js/src/instrumentation/registry.test.ts index 5f5522403..732676c99 100644 --- a/js/src/instrumentation/registry.test.ts +++ b/js/src/instrumentation/registry.test.ts @@ -56,6 +56,14 @@ describe("Plugin Registry", () => { // instance. Without cross-instance deduplication, both would subscribe to // the same diagnostics_channel, causing every OpenAI call to produce two // LLM spans. + // + // The dedup mechanism checks globalThis[Symbol.for("braintrust-state")], + // which is the shared state object that all SDK instances reuse (see + // _internalSetInitialState in logger.ts). We simulate that here. + const sharedState = {}; + const stateKey = Symbol.for("braintrust-state"); + (globalThis as any)[stateKey] = sharedState; + const instanceA = new (registry.constructor as any)(); const instanceB = new (registry.constructor as any)(); @@ -69,6 +77,7 @@ describe("Plugin Registry", () => { } finally { instanceA.disable(); instanceB.disable(); + delete (globalThis as any)[stateKey]; } }); diff --git a/js/src/instrumentation/registry.ts b/js/src/instrumentation/registry.ts index 8b9d5e0c0..60fea52a5 100644 --- a/js/src/instrumentation/registry.ts +++ b/js/src/instrumentation/registry.ts @@ -8,10 +8,30 @@ import { BraintrustPlugin } from "./braintrust-plugin"; import iso from "../isomorph"; -// Process-global deduplication key: prevents multiple SDK instances loaded -// from different module paths from each subscribing to the same -// diagnostics_channel, which would create duplicate spans per API call. -const GLOBAL_REGISTRY_ENABLED_KEY = Symbol.for("braintrust.registry.enabled"); +// Key used to stamp the active PluginRegistry instance onto the shared +// braintrust state object (globalThis[Symbol.for("braintrust-state")]). +// +// The braintrust state is already shared across all SDK instances loaded in +// the same process (see _internalSetInitialState in logger.ts), so using it +// as the carrier gives us cross-instance deduplication for free: +// +// - BT-5139 scenario: two SDK instances share the same state object → the +// second instance sees the marker left by the first and skips subscription, +// preventing duplicate diagnostics_channel listeners. +// +// - vi.resetModules() test scenario: the test deletes the state from +// globalThis between runs, so the next import creates a fresh state with no +// marker and can subscribe normally. +const REGISTRY_STATE_KEY = Symbol.for("braintrust.registry"); + +function getSharedState(): Record | undefined { + const state = (globalThis as Record)[ + Symbol.for("braintrust-state") + ]; + return state && typeof state === "object" + ? (state as Record) + : undefined; +} export interface InstrumentationConfig { /** @@ -67,12 +87,15 @@ class PluginRegistry { // If another SDK instance in the same process already registered plugins, // skip to avoid duplicate diagnostics_channel subscriptions. - if ((globalThis as Record)[GLOBAL_REGISTRY_ENABLED_KEY]) { - return; + const sharedState = getSharedState(); + if (sharedState) { + if (sharedState[REGISTRY_STATE_KEY] !== undefined) { + return; + } + sharedState[REGISTRY_STATE_KEY] = this; } this.enabled = true; - (globalThis as Record)[GLOBAL_REGISTRY_ENABLED_KEY] = true; // Read config from environment variables const envConfig = this.readEnvConfig(); @@ -99,7 +122,11 @@ class PluginRegistry { } this.enabled = false; - delete (globalThis as Record)[GLOBAL_REGISTRY_ENABLED_KEY]; + + const sharedState = getSharedState(); + if (sharedState && sharedState[REGISTRY_STATE_KEY] === this) { + delete sharedState[REGISTRY_STATE_KEY]; + } if (this.braintrustPlugin) { this.braintrustPlugin.disable();