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 diff --git a/js/src/instrumentation/registry.test.ts b/js/src/instrumentation/registry.test.ts index 9e8c6a889..732676c99 100644 --- a/js/src/instrumentation/registry.test.ts +++ b/js/src/instrumentation/registry.test.ts @@ -50,6 +50,37 @@ 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. + // + // 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)(); + + 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(); + delete (globalThis as any)[stateKey]; + } + }); + 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..60fea52a5 100644 --- a/js/src/instrumentation/registry.ts +++ b/js/src/instrumentation/registry.ts @@ -8,6 +8,31 @@ import { BraintrustPlugin } from "./braintrust-plugin"; import iso from "../isomorph"; +// 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 { /** * Configuration for individual SDK integrations. @@ -60,6 +85,16 @@ class PluginRegistry { return; } + // If another SDK instance in the same process already registered plugins, + // skip to avoid duplicate diagnostics_channel subscriptions. + const sharedState = getSharedState(); + if (sharedState) { + if (sharedState[REGISTRY_STATE_KEY] !== undefined) { + return; + } + sharedState[REGISTRY_STATE_KEY] = this; + } + this.enabled = true; // Read config from environment variables @@ -88,6 +123,11 @@ class PluginRegistry { this.enabled = false; + const sharedState = getSharedState(); + if (sharedState && sharedState[REGISTRY_STATE_KEY] === this) { + delete sharedState[REGISTRY_STATE_KEY]; + } + if (this.braintrustPlugin) { this.braintrustPlugin.disable(); this.braintrustPlugin = null;