From 18aac063f9d35a6afd389d48bfa36442e0f012c3 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 21 May 2026 14:56:10 +0200 Subject: [PATCH 1/2] feat: Add `braintrust/apply-instrumentation` entrypoint for CJS/TS patching --- js/package.json | 14 ++ .../auto-instrumentations/default-configs.ts | 59 +++++++ js/src/auto-instrumentations/hook.mts | 149 +++++------------- js/src/instrumentation/braintrust-plugin.ts | 23 +-- js/src/instrumentation/config.ts | 120 ++++++++++++++ js/src/instrumentation/registry.ts | 87 ++-------- js/src/node/apply-instrumentation-entry.ts | 74 +++++++++ .../non-node/apply-instrumentation-entry.ts | 1 + .../runtime-apply-side-effect-cjs.cjs | 6 + .../runtime-apply-side-effect-esm.mjs | 3 + .../auto-instrumentations/loader-hook.test.ts | 55 ++++++- js/tsup.config.ts | 7 +- 12 files changed, 387 insertions(+), 211 deletions(-) create mode 100644 js/src/auto-instrumentations/default-configs.ts create mode 100644 js/src/instrumentation/config.ts create mode 100644 js/src/node/apply-instrumentation-entry.ts create mode 100644 js/src/non-node/apply-instrumentation-entry.ts create mode 100644 js/tests/auto-instrumentations/fixtures/runtime-apply-side-effect-cjs.cjs create mode 100644 js/tests/auto-instrumentations/fixtures/runtime-apply-side-effect-esm.mjs diff --git a/js/package.json b/js/package.json index b701b3c8d..5e71ed0a7 100644 --- a/js/package.json +++ b/js/package.json @@ -11,6 +11,7 @@ "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", + "sideEffects": true, "browser": { "./dist/index.js": "./dist/browser.js", "./dist/index.d.ts": "./dist/browser.d.ts", @@ -50,6 +51,19 @@ "require": "./dist/browser.js", "default": "./dist/browser.mjs" }, + "./apply-instrumentation": { + "types": "./dist/apply-instrumentation.d.ts", + "edge-light": "./dist/apply-instrumentation.browser.mjs", + "workerd": "./dist/apply-instrumentation.browser.mjs", + "node": { + "import": "./dist/apply-instrumentation.mjs", + "require": "./dist/apply-instrumentation.js" + }, + "browser": "./dist/apply-instrumentation.browser.mjs", + "import": "./dist/apply-instrumentation.mjs", + "require": "./dist/apply-instrumentation.js", + "default": "./dist/apply-instrumentation.mjs" + }, "./node": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", diff --git a/js/src/auto-instrumentations/default-configs.ts b/js/src/auto-instrumentations/default-configs.ts new file mode 100644 index 000000000..c11cd10a3 --- /dev/null +++ b/js/src/auto-instrumentations/default-configs.ts @@ -0,0 +1,59 @@ +import type { InstrumentationConfig as CodeTransformerInstrumentationConfig } from "@apm-js-collab/code-transformer"; +import { + isInstrumentationIntegrationDisabled, + readDisabledInstrumentationEnvConfig, + type InstrumentationIntegrationsConfig, +} from "../instrumentation/config"; +import { openaiConfigs } from "./configs/openai"; +import { openAICodexConfigs } from "./configs/openai-codex"; +import { anthropicConfigs } from "./configs/anthropic"; +import { aiSDKConfigs } from "./configs/ai-sdk"; +import { claudeAgentSDKConfigs } from "./configs/claude-agent-sdk"; +import { cursorSDKConfigs } from "./configs/cursor-sdk"; +import { googleGenAIConfigs } from "./configs/google-genai"; +import { huggingFaceConfigs } from "./configs/huggingface"; +import { openRouterAgentConfigs } from "./configs/openrouter-agent"; +import { openRouterConfigs } from "./configs/openrouter"; +import { mistralConfigs } from "./configs/mistral"; +import { googleADKConfigs } from "./configs/google-adk"; +import { cohereConfigs } from "./configs/cohere"; +import { groqConfigs } from "./configs/groq"; +import { genkitConfigs } from "./configs/genkit"; +import { gitHubCopilotConfigs } from "./configs/github-copilot"; + +type AutoInstrumentationConfigGroup = { + integrations: (keyof InstrumentationIntegrationsConfig)[]; + configs: CodeTransformerInstrumentationConfig[]; +}; + +const autoInstrumentationConfigGroups: AutoInstrumentationConfigGroup[] = [ + { integrations: ["openai"], configs: openaiConfigs }, + { integrations: ["openaiCodexSDK"], configs: openAICodexConfigs }, + { integrations: ["anthropic"], configs: anthropicConfigs }, + { integrations: ["aisdk", "vercel"], configs: aiSDKConfigs }, + { integrations: ["claudeAgentSDK"], configs: claudeAgentSDKConfigs }, + { integrations: ["cursor", "cursorSDK"], configs: cursorSDKConfigs }, + { integrations: ["google", "googleGenAI"], configs: googleGenAIConfigs }, + { integrations: ["huggingface"], configs: huggingFaceConfigs }, + { integrations: ["openrouter"], configs: openRouterConfigs }, + { integrations: ["openrouterAgent"], configs: openRouterAgentConfigs }, + { integrations: ["mistral"], configs: mistralConfigs }, + { integrations: ["googleADK"], configs: googleADKConfigs }, + { integrations: ["cohere"], configs: cohereConfigs }, + { integrations: ["groq"], configs: groqConfigs }, + { integrations: ["genkit"], configs: genkitConfigs }, + { integrations: ["gitHubCopilot"], configs: gitHubCopilotConfigs }, +]; + +export function getDefaultAutoInstrumentationConfigs(): CodeTransformerInstrumentationConfig[] { + const integrations = readDisabledInstrumentationEnvConfig( + process.env.BRAINTRUST_DISABLE_INSTRUMENTATION, + ).integrations; + + return autoInstrumentationConfigGroups.flatMap( + ({ integrations: keys, configs }) => + isInstrumentationIntegrationDisabled(integrations, ...keys) + ? [] + : configs, + ); +} diff --git a/js/src/auto-instrumentations/hook.mts b/js/src/auto-instrumentations/hook.mts index 215320a3c..a3807525c 100644 --- a/js/src/auto-instrumentations/hook.mts +++ b/js/src/auto-instrumentations/hook.mts @@ -14,129 +14,54 @@ */ import { register } from "node:module"; -import { openaiConfigs } from "./configs/openai.js"; -import { openAICodexConfigs } from "./configs/openai-codex.js"; -import { anthropicConfigs } from "./configs/anthropic.js"; -import { aiSDKConfigs } from "./configs/ai-sdk.js"; -import { claudeAgentSDKConfigs } from "./configs/claude-agent-sdk.js"; -import { cursorSDKConfigs } from "./configs/cursor-sdk.js"; -import { googleGenAIConfigs } from "./configs/google-genai.js"; -import { huggingFaceConfigs } from "./configs/huggingface.js"; -import { openRouterAgentConfigs } from "./configs/openrouter-agent.js"; -import { openRouterConfigs } from "./configs/openrouter.js"; -import { mistralConfigs } from "./configs/mistral.js"; -import { googleADKConfigs } from "./configs/google-adk.js"; -import { cohereConfigs } from "./configs/cohere.js"; -import { groqConfigs } from "./configs/groq.js"; -import { genkitConfigs } from "./configs/genkit.js"; -import { gitHubCopilotConfigs } from "./configs/github-copilot.js"; +import { getDefaultAutoInstrumentationConfigs } from "./default-configs.js"; import { ModulePatch } from "./loader/cjs-patch.js"; import { patchTracingChannel } from "./patch-tracing-channel.js"; +const state = ((globalThis as any)[ + Symbol.for("braintrust.applyInstrumentation") +] ??= {}) as { applied?: boolean }; +const alreadyApplied = state.applied; + // Patch diagnostics_channel.tracePromise to handle APIPromise correctly. // MUST be done here (before any SDK code runs) to fix Anthropic APIPromise incompatibility. // Construct the module path dynamically to prevent build from stripping "node:" prefix. -const dcPath = ["node", "diagnostics_channel"].join(":"); -const dc: any = await import(/* @vite-ignore */ dcPath as any); -patchTracingChannel(dc.tracingChannel); - -function readDisabledIntegrations(): Set { - const raw = process.env.BRAINTRUST_DISABLE_INSTRUMENTATION; - if (!raw) { - return new Set(); - } - - return new Set( - raw - .split(",") - .map((value) => value.trim().toLowerCase()) - .filter((value) => value.length > 0), - ); +if (!alreadyApplied) { + const dcPath = ["node", "diagnostics_channel"].join(":"); + const dc: any = await import(/* @vite-ignore */ dcPath as any); + patchTracingChannel(dc.tracingChannel); } -function isDisabled(disabled: Set, ...names: string[]): boolean { - return names.some((name) => disabled.has(name)); -} - -const disabledIntegrations = readDisabledIntegrations(); +const allConfigs = getDefaultAutoInstrumentationConfigs(); -// Combine all instrumentation configs. -// Respect BRAINTRUST_DISABLE_INSTRUMENTATION here too so load-time -// transformation and runtime plugins stay aligned. -const allConfigs = [ - ...(isDisabled(disabledIntegrations, "openai") ? [] : openaiConfigs), - ...(isDisabled( - disabledIntegrations, - "openai-codex", - "openai-codex-sdk", - "codex", - "codex-sdk", - ) - ? [] - : openAICodexConfigs), - ...(isDisabled(disabledIntegrations, "anthropic") ? [] : anthropicConfigs), - ...(isDisabled(disabledIntegrations, "aisdk", "ai-sdk", "vercel-ai") - ? [] - : aiSDKConfigs), - ...(isDisabled(disabledIntegrations, "claudeagentsdk", "claude-agent-sdk") - ? [] - : claudeAgentSDKConfigs), - ...(isDisabled(disabledIntegrations, "cursor", "cursor-sdk") - ? [] - : cursorSDKConfigs), - ...(isDisabled(disabledIntegrations, "google", "google-genai") - ? [] - : googleGenAIConfigs), - ...(isDisabled(disabledIntegrations, "huggingface") - ? [] - : huggingFaceConfigs), - ...(isDisabled(disabledIntegrations, "openrouter") ? [] : openRouterConfigs), - ...(isDisabled(disabledIntegrations, "openrouteragent", "openrouter-agent") - ? [] - : openRouterAgentConfigs), - ...(isDisabled(disabledIntegrations, "mistral") ? [] : mistralConfigs), - ...(isDisabled(disabledIntegrations, "googleadk", "google-adk") - ? [] - : googleADKConfigs), - ...(isDisabled(disabledIntegrations, "cohere") ? [] : cohereConfigs), - ...(isDisabled(disabledIntegrations, "groq", "groq-sdk") ? [] : groqConfigs), - ...(isDisabled(disabledIntegrations, "genkit", "firebase-genkit") - ? [] - : genkitConfigs), - ...(isDisabled( - disabledIntegrations, - "githubcopilot", - "github-copilot", - "copilot-sdk", - ) - ? [] - : gitHubCopilotConfigs), -]; +if (!alreadyApplied) { + // 1. Register ESM loader for ESM modules + register("./loader/esm-hook.mjs", { + parentURL: import.meta.url, + data: { instrumentations: allConfigs }, + } as any); -// 1. Register ESM loader for ESM modules -register("./loader/esm-hook.mjs", { - parentURL: import.meta.url, - data: { instrumentations: allConfigs }, -} as any); + state.applied = true; -// 2. Also load CJS register for CJS modules (many apps use mixed ESM/CJS) -try { - const patch = new ModulePatch({ instrumentations: allConfigs }); - patch.patch(); + // 2. Also load CJS register for CJS modules (many apps use mixed ESM/CJS) + try { + const patch = new ModulePatch({ instrumentations: allConfigs }); + patch.patch(); - if (process.env.DEBUG === "@braintrust*" || process.env.DEBUG === "*") { - console.log( - "[Braintrust] Auto-instrumentation active (ESM + CJS) for:", - allConfigs.map((c) => c.channelName).join(", "), - ); - } -} catch (err) { - // CJS patch failed, but ESM hook is still active - if (process.env.DEBUG === "@braintrust*" || process.env.DEBUG === "*") { - console.log( - "[Braintrust] Auto-instrumentation active (ESM only) for:", - allConfigs.map((c) => c.channelName).join(", "), - ); - console.error("[Braintrust] CJS patch failed:", err); + if (process.env.DEBUG === "@braintrust*" || process.env.DEBUG === "*") { + console.log( + "[Braintrust] Auto-instrumentation active (ESM + CJS) for:", + allConfigs.map((c) => c.channelName).join(", "), + ); + } + } catch (err) { + // CJS patch failed, but ESM hook is still active + if (process.env.DEBUG === "@braintrust*" || process.env.DEBUG === "*") { + console.log( + "[Braintrust] Auto-instrumentation active (ESM only) for:", + allConfigs.map((c) => c.channelName).join(", "), + ); + console.error("[Braintrust] CJS patch failed:", err); + } } } diff --git a/js/src/instrumentation/braintrust-plugin.ts b/js/src/instrumentation/braintrust-plugin.ts index cc03dcfc2..066095a1e 100644 --- a/js/src/instrumentation/braintrust-plugin.ts +++ b/js/src/instrumentation/braintrust-plugin.ts @@ -15,29 +15,10 @@ import { CoherePlugin } from "./plugins/cohere-plugin"; import { GroqPlugin } from "./plugins/groq-plugin"; import { GenkitPlugin } from "./plugins/genkit-plugin"; import { GitHubCopilotPlugin } from "./plugins/github-copilot-plugin"; +import type { InstrumentationIntegrationsConfig } from "./config"; export interface BraintrustPluginConfig { - integrations?: { - openai?: boolean; - anthropic?: boolean; - vercel?: boolean; - aisdk?: boolean; - google?: boolean; - googleGenAI?: boolean; - huggingface?: boolean; - claudeAgentSDK?: boolean; - cursor?: boolean; - cursorSDK?: boolean; - openrouter?: boolean; - openrouterAgent?: boolean; - mistral?: boolean; - googleADK?: boolean; - cohere?: boolean; - groq?: boolean; - genkit?: boolean; - gitHubCopilot?: boolean; - openaiCodexSDK?: boolean; - }; + integrations?: InstrumentationIntegrationsConfig; } function getIntegrationConfig( diff --git a/js/src/instrumentation/config.ts b/js/src/instrumentation/config.ts new file mode 100644 index 000000000..8ff2537e2 --- /dev/null +++ b/js/src/instrumentation/config.ts @@ -0,0 +1,120 @@ +export interface InstrumentationIntegrationsConfig { + openai?: boolean; + anthropic?: boolean; + vercel?: boolean; + aisdk?: boolean; + google?: boolean; + googleGenAI?: boolean; + googleADK?: boolean; + huggingface?: boolean; + claudeAgentSDK?: boolean; + cursor?: boolean; + cursorSDK?: boolean; + openrouter?: boolean; + openrouterAgent?: boolean; + mistral?: boolean; + cohere?: boolean; + groq?: boolean; + genkit?: boolean; + gitHubCopilot?: boolean; + openaiCodexSDK?: boolean; +} + +export interface InstrumentationConfig { + /** + * Configuration for individual SDK integrations. + * Set to false to disable instrumentation for that SDK. + */ + integrations?: InstrumentationIntegrationsConfig; +} + +const envIntegrationAliases: Record< + string, + keyof InstrumentationIntegrationsConfig +> = { + openai: "openai", + "openai-codex": "openaiCodexSDK", + "openai-codex-sdk": "openaiCodexSDK", + openaicodexsdk: "openaiCodexSDK", + codex: "openaiCodexSDK", + "codex-sdk": "openaiCodexSDK", + anthropic: "anthropic", + aisdk: "aisdk", + "ai-sdk": "aisdk", + "vercel-ai": "aisdk", + vercel: "vercel", + claudeagentsdk: "claudeAgentSDK", + "claude-agent-sdk": "claudeAgentSDK", + cursor: "cursor", + "cursor-sdk": "cursorSDK", + cursorsdk: "cursorSDK", + google: "google", + "google-genai": "googleGenAI", + googlegenai: "googleGenAI", + huggingface: "huggingface", + openrouter: "openrouter", + openrouteragent: "openrouterAgent", + "openrouter-agent": "openrouterAgent", + mistral: "mistral", + googleadk: "googleADK", + "google-adk": "googleADK", + cohere: "cohere", + groq: "groq", + "groq-sdk": "groq", + genkit: "genkit", + "firebase-genkit": "genkit", + githubcopilot: "gitHubCopilot", + "github-copilot": "gitHubCopilot", + "copilot-sdk": "gitHubCopilot", +}; + +export function getDefaultInstrumentationIntegrations(): Record< + keyof InstrumentationIntegrationsConfig, + boolean +> { + return { + openai: true, + openaiCodexSDK: true, + anthropic: true, + vercel: true, + aisdk: true, + google: true, + googleGenAI: true, + googleADK: true, + huggingface: true, + claudeAgentSDK: true, + cursor: true, + cursorSDK: true, + openrouter: true, + openrouterAgent: true, + mistral: true, + cohere: true, + groq: true, + genkit: true, + gitHubCopilot: true, + }; +} + +export function readDisabledInstrumentationEnvConfig( + disabledList: string | undefined, +): InstrumentationConfig { + const integrations: Record = {}; + + if (disabledList) { + for (const value of disabledList.split(",")) { + const sdk = value.trim().toLowerCase(); + if (sdk.length > 0) { + integrations[envIntegrationAliases[sdk] ?? sdk] = false; + } + } + } + + return { integrations }; +} + +export function isInstrumentationIntegrationDisabled( + integrations: InstrumentationIntegrationsConfig | undefined, + ...names: (keyof InstrumentationIntegrationsConfig)[] +): boolean { + return names.some((name) => integrations?.[name] === false); +} diff --git a/js/src/instrumentation/registry.ts b/js/src/instrumentation/registry.ts index 6ee4755ea..a28f7964a 100644 --- a/js/src/instrumentation/registry.ts +++ b/js/src/instrumentation/registry.ts @@ -7,6 +7,13 @@ import { BraintrustPlugin } from "./braintrust-plugin"; import iso from "../isomorph"; +import { + getDefaultInstrumentationIntegrations, + readDisabledInstrumentationEnvConfig, + type InstrumentationConfig, +} from "./config"; + +export type { InstrumentationConfig } from "./config"; // Key used to stamp the active PluginRegistry instance onto the shared // braintrust state object (globalThis[Symbol.for("braintrust-state")]). @@ -33,34 +40,6 @@ function getSharedState(): Record | undefined { : undefined; } -export interface InstrumentationConfig { - /** - * Configuration for individual SDK integrations. - * Set to false to disable instrumentation for that SDK. - */ - integrations?: { - openai?: boolean; - anthropic?: boolean; - vercel?: boolean; - aisdk?: boolean; - google?: boolean; - googleGenAI?: boolean; - googleADK?: boolean; - huggingface?: boolean; - claudeAgentSDK?: boolean; - cursor?: boolean; - cursorSDK?: boolean; - openrouter?: boolean; - openrouterAgent?: boolean; - mistral?: boolean; - cohere?: boolean; - groq?: boolean; - genkit?: boolean; - gitHubCopilot?: boolean; - openaiCodexSDK?: boolean; - }; -} - class PluginRegistry { private braintrustPlugin: BraintrustPlugin | null = null; private config: InstrumentationConfig = {}; @@ -151,27 +130,7 @@ class PluginRegistry { * Get default configuration (all integrations enabled). */ private getDefaultConfig(): Record { - return { - openai: true, - openaiCodexSDK: true, - anthropic: true, - vercel: true, - aisdk: true, - google: true, - googleGenAI: true, - googleADK: true, - huggingface: true, - claudeAgentSDK: true, - cursor: true, - cursorSDK: true, - openrouter: true, - openrouterAgent: true, - mistral: true, - cohere: true, - groq: true, - genkit: true, - gitHubCopilot: true, - }; + return getDefaultInstrumentationIntegrations(); } /** @@ -179,33 +138,9 @@ class PluginRegistry { * Supports: BRAINTRUST_DISABLE_INSTRUMENTATION=openai,anthropic,... */ private readEnvConfig(): InstrumentationConfig { - const integrations: Record = {}; - - const disabledList = iso.getEnv("BRAINTRUST_DISABLE_INSTRUMENTATION"); - if (disabledList) { - const disabled = disabledList - .split(",") - .map((s) => s.trim().toLowerCase()) - .filter((s) => s.length > 0); - - for (const sdk of disabled) { - if (sdk === "cursor-sdk") { - integrations.cursorSDK = false; - } else if ( - sdk === "githubcopilot" || - sdk === "github-copilot" || - sdk === "copilot-sdk" - ) { - integrations.gitHubCopilot = false; - } else if (sdk === "openai-codex-sdk") { - integrations.openaiCodexSDK = false; - } else { - integrations[sdk] = false; - } - } - } - - return { integrations }; + return readDisabledInstrumentationEnvConfig( + iso.getEnv("BRAINTRUST_DISABLE_INSTRUMENTATION"), + ); } } diff --git a/js/src/node/apply-instrumentation-entry.ts b/js/src/node/apply-instrumentation-entry.ts new file mode 100644 index 000000000..33faf7b14 --- /dev/null +++ b/js/src/node/apply-instrumentation-entry.ts @@ -0,0 +1,74 @@ +import * as diagnostics_channel from "node:diagnostics_channel"; +import { register } from "node:module"; +import { pathToFileURL } from "node:url"; +import { getDefaultAutoInstrumentationConfigs } from "../auto-instrumentations/default-configs"; +import { ModulePatch } from "../auto-instrumentations/loader/cjs-patch"; +import { patchTracingChannel } from "../auto-instrumentations/patch-tracing-channel"; + +interface ApplyInstrumentationState { + applied?: boolean; +} + +const stateKey = Symbol.for("braintrust.applyInstrumentation"); +const existingState = Object.getOwnPropertyDescriptor( + globalThis, + stateKey, +)?.value; +const state: ApplyInstrumentationState = isApplyInstrumentationState( + existingState, +) + ? existingState + : {}; + +if (state !== existingState) { + Object.defineProperty(globalThis, stateKey, { + configurable: false, + enumerable: false, + value: state, + writable: false, + }); +} + +if (!state.applied) { + patchTracingChannel(diagnostics_channel.tracingChannel); + + const allConfigs = getDefaultAutoInstrumentationConfigs(); + + const currentModuleUrl = getCurrentModuleUrl(); + register("./auto-instrumentations/loader/esm-hook.mjs", { + parentURL: currentModuleUrl, + data: { instrumentations: allConfigs }, + }); + + state.applied = true; + + try { + const patch = new ModulePatch({ instrumentations: allConfigs }); + patch.patch(); + } catch { + // ESM instrumentation is already active; keep user code running if CJS patching fails. + } +} + +function isApplyInstrumentationState( + value: unknown, +): value is ApplyInstrumentationState { + return typeof value === "object" && value !== null; +} + +function getCurrentModuleUrl(): string { + if (typeof __filename !== "undefined") { + return pathToFileURL(__filename).href; + } + + const stack = new Error().stack ?? ""; + const match = + stack.match(/\((file:\/\/[^)]+)\)/) ?? stack.match(/\s(file:\/\/\S+)/); + if (match) { + return match[1].replace(/:\d+:\d+$/, ""); + } + + return pathToFileURL(process.argv[1] ?? process.cwd()).href; +} + +export {}; diff --git a/js/src/non-node/apply-instrumentation-entry.ts b/js/src/non-node/apply-instrumentation-entry.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/js/src/non-node/apply-instrumentation-entry.ts @@ -0,0 +1 @@ +export {}; diff --git a/js/tests/auto-instrumentations/fixtures/runtime-apply-side-effect-cjs.cjs b/js/tests/auto-instrumentations/fixtures/runtime-apply-side-effect-cjs.cjs new file mode 100644 index 000000000..a11da5a16 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/runtime-apply-side-effect-cjs.cjs @@ -0,0 +1,6 @@ +async function main() { + require("braintrust/apply-instrumentation"); + await import("./test-app-esm.mjs"); +} + +main(); diff --git a/js/tests/auto-instrumentations/fixtures/runtime-apply-side-effect-esm.mjs b/js/tests/auto-instrumentations/fixtures/runtime-apply-side-effect-esm.mjs new file mode 100644 index 000000000..6d65c53db --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/runtime-apply-side-effect-esm.mjs @@ -0,0 +1,3 @@ +import "braintrust/apply-instrumentation"; + +await import("./test-app-esm.mjs"); diff --git a/js/tests/auto-instrumentations/loader-hook.test.ts b/js/tests/auto-instrumentations/loader-hook.test.ts index 54809fef0..451e68cda 100644 --- a/js/tests/auto-instrumentations/loader-hook.test.ts +++ b/js/tests/auto-instrumentations/loader-hook.test.ts @@ -20,6 +20,14 @@ const helperPromisePath = path.join( fixturesDir, "test-api-promise-preservation.mjs", ); +const runtimeApplySideEffectEsmPath = path.join( + fixturesDir, + "runtime-apply-side-effect-esm.mjs", +); +const runtimeApplySideEffectCjsPath = path.join( + fixturesDir, + "runtime-apply-side-effect-cjs.cjs", +); interface TestResult { events: { start: any[]; end: any[]; error: any[] }; @@ -76,9 +84,53 @@ describe("Unified Loader Hook Integration Tests", () => { expect(result.constructorName).toBe("HelperPromise"); }); }); + + describe("apply-instrumentation side-effect runtime setup", () => { + it("should apply instrumentation through the side-effect ESM export", async () => { + const result = await runWithWorker({ + execArgv: ["--import", listenerPath], + script: runtimeApplySideEffectEsmPath, + }); + + expect(result.events.start.length).toBe(1); + expect(result.events.end.length).toBe(1); + }); + + it("should apply instrumentation through the side-effect CJS export", async () => { + const result = await runWithWorker({ + execArgv: ["--import", listenerPath], + script: runtimeApplySideEffectCjsPath, + }); + + expect(result.events.start.length).toBe(1); + expect(result.events.end.length).toBe(1); + }); + + it("should respect BRAINTRUST_DISABLE_INSTRUMENTATION", async () => { + const result = await runWithWorker({ + env: { BRAINTRUST_DISABLE_INSTRUMENTATION: "openai" }, + execArgv: ["--import", listenerPath], + script: runtimeApplySideEffectEsmPath, + }); + + expect(result.events.start.length).toBe(0); + expect(result.events.end.length).toBe(0); + }); + + it("should not double-apply when import hook and side-effect export both run", async () => { + const result = await runWithWorker({ + execArgv: ["--import", listenerPath, "--import", hookPath], + script: runtimeApplySideEffectEsmPath, + }); + + expect(result.events.start.length).toBe(1); + expect(result.events.end.length).toBe(1); + }); + }); }); async function runWithWorker(options: { + env?: NodeJS.ProcessEnv; execArgv: string[]; script: string; }): Promise { @@ -89,6 +141,7 @@ async function runWithWorker(options: { } async function runWithWorkerMessage(options: { + env?: NodeJS.ProcessEnv; execArgv: string[]; messageType: string; script: string; @@ -123,7 +176,7 @@ async function runWithWorkerMessage(options: { const worker = new Worker(scriptUrl, { execArgv, - env: { ...process.env, NODE_OPTIONS: "" }, + env: { ...process.env, ...options.env, NODE_OPTIONS: "" }, }); worker.on("message", (msg) => { diff --git a/js/tsup.config.ts b/js/tsup.config.ts index a22208781..9fd5fae82 100644 --- a/js/tsup.config.ts +++ b/js/tsup.config.ts @@ -3,7 +3,10 @@ import { defineConfig } from "tsup"; export default defineConfig([ // Node.js entrypoint { - entry: ["src/node/index.ts"], + entry: { + index: "src/node/index.ts", + "apply-instrumentation": "src/node/apply-instrumentation-entry.ts", + }, format: ["cjs", "esm"], outDir: "dist", external: ["zod"], @@ -63,6 +66,8 @@ export default defineConfig([ browser: "src/browser/index.ts", "edge-light": "src/edge-light/index.ts", workerd: "src/workerd/index.ts", + "apply-instrumentation.browser": + "src/non-node/apply-instrumentation-entry.ts", }, format: ["cjs", "esm"], outDir: "dist", From ae5a73be1be406b9e2fdb2b8e87e726b7ca433e3 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 21 May 2026 15:29:52 +0200 Subject: [PATCH 2/2] cs --- .changeset/honest-buckets-grab.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/honest-buckets-grab.md diff --git a/.changeset/honest-buckets-grab.md b/.changeset/honest-buckets-grab.md new file mode 100644 index 000000000..6cea279a1 --- /dev/null +++ b/.changeset/honest-buckets-grab.md @@ -0,0 +1,5 @@ +--- +"braintrust": minor +--- + +feat: Add `braintrust/apply-instrumentation` entrypoint for CJS/TS patching