Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-bt-5139-duplicate-spans.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"braintrust": patch
---

fix: prevent duplicate LLM spans when multiple SDK instances are loaded in the same process
31 changes: 31 additions & 0 deletions js/src/instrumentation/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
40 changes: 40 additions & 0 deletions js/src/instrumentation/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<symbol, unknown> | undefined {
const state = (globalThis as Record<symbol, unknown>)[
Symbol.for("braintrust-state")
];
return state && typeof state === "object"
? (state as Record<symbol, unknown>)
: undefined;
}

export interface InstrumentationConfig {
/**
* Configuration for individual SDK integrations.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading