From d5fccd3c256de008651ea63efc49389804fdd8d7 Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:38:51 +0000 Subject: [PATCH 1/2] fix(instrumentation): Initialize tracing automatically in bundles --- .../src/worker.ts | 4 -- .../template/app/api/test/route.ts | 1 - .../auto-instrumentations/bundler/plugin.ts | 14 ++++++- .../bundler/webpack-loader.ts | 11 ++++- .../orchestrion-js/transformer.ts | 31 +++++++++++++- js/tests/auto-instrumentations/test-bundle.ts | 5 +++ .../transformation.test.ts | 41 +++++++++++++++++++ 7 files changed, 97 insertions(+), 10 deletions(-) diff --git a/e2e/scenarios/cloudflare-think-instrumentation/src/worker.ts b/e2e/scenarios/cloudflare-think-instrumentation/src/worker.ts index 4d509747c..155edbf3f 100644 --- a/e2e/scenarios/cloudflare-think-instrumentation/src/worker.ts +++ b/e2e/scenarios/cloudflare-think-instrumentation/src/worker.ts @@ -1,13 +1,9 @@ -// Bundler transforms and runtime consumers are enabled independently. import * as cloudflareThink from "@cloudflare/think"; import { createOpenAI } from "@ai-sdk/openai"; import { flush, initLogger, wrapCloudflareThink } from "braintrust"; import { tool } from "ai"; import { z } from "zod"; -if (process.env.CLOUDFLARE_THINK_INSTRUMENTATION !== "manual") - await import("braintrust/auto"); - const OPENAI_MODEL = "gpt-5-nano"; type Env = { diff --git a/e2e/scenarios/nextjs-auto-instrumentation/template/app/api/test/route.ts b/e2e/scenarios/nextjs-auto-instrumentation/template/app/api/test/route.ts index 66c3af088..cdc4bb6e1 100644 --- a/e2e/scenarios/nextjs-auto-instrumentation/template/app/api/test/route.ts +++ b/e2e/scenarios/nextjs-auto-instrumentation/template/app/api/test/route.ts @@ -1,4 +1,3 @@ -import "braintrust/auto"; // Registers Braintrust's global instrumentation hooks. import OpenAI from "openai"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; diff --git a/js/src/auto-instrumentations/bundler/plugin.ts b/js/src/auto-instrumentations/bundler/plugin.ts index 561144130..fcb841c07 100644 --- a/js/src/auto-instrumentations/bundler/plugin.ts +++ b/js/src/auto-instrumentations/bundler/plugin.ts @@ -1,5 +1,6 @@ import { createUnplugin, type UnpluginInstance } from "unplugin"; import { create, type InstrumentationConfig } from "../orchestrion-js"; +import { Transformer } from "../orchestrion-js/transformer"; import { extname, isAbsolute, join, sep } from "path"; import { readFileSync } from "fs"; import { fileURLToPath } from "url"; @@ -129,7 +130,12 @@ export const unplugin: UnpluginInstance = browser, }); if (patched !== null) { - return { code: patched, map: null }; + return new Transformer( + moduleName, + moduleVersion ?? "", + normalizedModulePath, + [], + ).transform(patched, isModule ? "esm" : "cjs", "braintrust/auto"); } // If no version found @@ -155,7 +161,11 @@ export const unplugin: UnpluginInstance = try { // Transform the code const moduleType = isModule ? "esm" : "cjs"; - const result = transformer.transform(code, moduleType); + const result = transformer.transform( + code, + moduleType, + "braintrust/auto", + ); return { code: result.code, map: result.map, diff --git a/js/src/auto-instrumentations/bundler/webpack-loader.ts b/js/src/auto-instrumentations/bundler/webpack-loader.ts index 80874304e..be12605f6 100644 --- a/js/src/auto-instrumentations/bundler/webpack-loader.ts +++ b/js/src/auto-instrumentations/bundler/webpack-loader.ts @@ -9,6 +9,7 @@ */ import { create } from "../orchestrion-js"; +import { Transformer } from "../orchestrion-js/transformer"; import { extname, join, sep } from "path"; import { readFileSync } from "fs"; import moduleDetailsFromPath from "module-details-from-path"; @@ -120,7 +121,13 @@ function codeTransformerLoader( browser: options.browser ?? false, }); if (patched !== null) { - return callback(null, patched); + const result = new Transformer( + moduleName, + "", + normalizedModulePath, + [], + ).transform(patched, isModule ? "esm" : "cjs", "braintrust/auto"); + return callback(null, result.code, result.map); } const moduleVersion = getModuleVersion(moduleDetails.basedir); @@ -141,7 +148,7 @@ function codeTransformerLoader( try { const moduleType: ModuleType = isModule ? "esm" : "cjs"; - const result = transformer.transform(code, moduleType); + const result = transformer.transform(code, moduleType, "braintrust/auto"); callback(null, result.code, result.map ?? undefined); } catch (error) { console.warn( diff --git a/js/src/auto-instrumentations/orchestrion-js/transformer.ts b/js/src/auto-instrumentations/orchestrion-js/transformer.ts index 41de794f5..e5d581cf5 100644 --- a/js/src/auto-instrumentations/orchestrion-js/transformer.ts +++ b/js/src/auto-instrumentations/orchestrion-js/transformer.ts @@ -47,7 +47,11 @@ export class Transformer { * Instruments `code` by injecting global tracing hooks around the * target functions defined by this transformer's configs. */ - transform(code: string | Buffer, moduleType: ModuleType): TransformOutput { + transform( + code: string | Buffer, + moduleType: ModuleType, + runtimeImport?: string, + ): TransformOutput { if (Buffer.isBuffer(code)) { code = code.toString(); } @@ -126,6 +130,31 @@ export class Transformer { ); } + if (runtimeImport) { + // Bundlers need the consumers in the application, whereas Node hooks + // register them in their preload. Insert into the AST to preserve both + // directive prologues (especially CJS strict mode) and source mappings. + ast ??= parse(code, { + loc: true, + ranges: true, + raw: true, + module: moduleType === "esm", + }); + const statement = + moduleType === "esm" + ? `import ${JSON.stringify(runtimeImport)};` + : `require(${JSON.stringify(runtimeImport)});`; + const initialization = parse(statement, { module: moduleType === "esm" }); + let index = 0; + while ( + ast.body[index]?.type === "ExpressionStatement" && + typeof ast.body[index].expression.value === "string" + ) { + index++; + } + ast.body.splice(index, 0, ...initialization.body); + } + if (ast) { const file = `${this.moduleName}/${this.filePath}`; const sourceMap = new SourceMapGenerator({ file }); diff --git a/js/tests/auto-instrumentations/test-bundle.ts b/js/tests/auto-instrumentations/test-bundle.ts index 7b863db6f..2320e8d86 100644 --- a/js/tests/auto-instrumentations/test-bundle.ts +++ b/js/tests/auto-instrumentations/test-bundle.ts @@ -13,6 +13,8 @@ export type Bundler = "esbuild" | "vite" | "rollup"; * Bundles a fixture entry (which imports packages from fixtures/node_modules) * with the Braintrust plugin for the given bundler and writes an ESM bundle to * `outfile`. + * Keep the runtime external so these tests isolate the generated hooks; e2e + * scenarios verify bundled consumer initialization and span creation. */ export async function bundleFixture( bundler: Bundler, @@ -34,6 +36,7 @@ export async function bundleFixture( absWorkingDir: fixturesDir, preserveSymlinks: true, // Don't dereference symlinks platform: "node", + external: ["braintrust/auto"], }); } else if (bundler === "vite") { const { braintrustVitePlugin } = @@ -49,6 +52,7 @@ export async function bundleFixture( outDir: path.dirname(outfile), emptyOutDir: false, minify: false, + rolldownOptions: { external: ["braintrust/auto"] }, }, plugins: [braintrustVitePlugin(options)], logLevel: "error", @@ -59,6 +63,7 @@ export async function bundleFixture( await import("../../src/auto-instrumentations/bundler/rollup.js"); const bundle = await rollup({ input: entryPoint, + external: ["braintrust/auto"], plugins: [ { name: "resolve-fixture-packages", diff --git a/js/tests/auto-instrumentations/transformation.test.ts b/js/tests/auto-instrumentations/transformation.test.ts index ec6ec4c03..371dd3432 100644 --- a/js/tests/auto-instrumentations/transformation.test.ts +++ b/js/tests/auto-instrumentations/transformation.test.ts @@ -13,6 +13,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { runInNewContext } from "node:vm"; +import { SourceMapConsumer } from "source-map"; +import { Transformer } from "../../src/auto-instrumentations/orchestrion-js/transformer"; import { create, type InstrumentationConfig, @@ -81,6 +83,7 @@ function transformTestCode( } function expectGlobalHookTransform(output: string): void { + expect(output).toContain("braintrust/auto"); expect(output).toContain("__braintrust_instrumentation_hooks"); expect(output).toContain("braintrust.global-instrumentation-hooks.registry"); expect(output).toContain("braintrust.global-instrumentation-hooks.hook"); @@ -97,6 +100,37 @@ function expectGlobalHookTransform(output: string): void { } describe("Orchestrion Transformation Tests", () => { + it("initializes consumers before CommonJS calls without changing strict mode or source maps", async () => { + const source = + '"use strict";\nfunction request() { return this; }\nmodule.exports = request();'; + const transformer = new Transformer("test-sdk", "1.0.0", "index.cjs", [ + testConfig({ functionName: "request", kind: "Sync" }), + ]); + const result = transformer.transform(source, "cjs", "braintrust/auto"); + const loadedModule = { exports: "not called" }; + const require = vi.fn((id: string) => { + expect(id).toBe("braintrust/auto"); + expect(loadedModule.exports).toBe("not called"); + }); + + Function("require", "module", result.code)(require, loadedModule); + expect(require).toHaveBeenCalledOnce(); + expect(loadedModule.exports).toBeUndefined(); + + const lines = result.code.split("\n"); + const generatedLine = + lines.findIndex((line) => line.startsWith("module.exports")) + 1; + await SourceMapConsumer.with(result.map!, null, (consumer) => { + expect( + consumer.originalPositionFor({ line: generatedLine, column: 0 }), + ).toMatchObject({ + source: "test-sdk/index.cjs", + line: 3, + column: 0, + }); + }); + }); + beforeAll(() => { // Create output directory if (!fs.existsSync(outputDir)) { @@ -705,6 +739,7 @@ describe("Orchestrion Transformation Tests", () => { library: { type: "module" }, }, experiments: { outputModule: true }, + externals: { "braintrust/auto": "module braintrust/auto" }, mode: "development", resolve: { modules: [nodeModulesDir, "node_modules"] }, plugins: [braintrustWebpackPlugin({ browser })], @@ -758,6 +793,7 @@ describe("Orchestrion Transformation Tests", () => { library: { type: "module" }, }, experiments: { outputModule: true }, + externals: { "braintrust/auto": "module braintrust/auto" }, mode: "development", resolve: { modules: [nodeModulesDir, "node_modules"] }, // No plugins — only the loader, mirroring turbopack's constraint @@ -826,6 +862,7 @@ describe("Orchestrion Transformation Tests", () => { experiments: { outputModule: true }, mode: "development", target, + externals: { "braintrust/auto": "module braintrust/auto" }, module: { rules: [ { @@ -842,9 +879,11 @@ describe("Orchestrion Transformation Tests", () => { expect(errors).toHaveLength(0); if (shouldPatch) { + expect(output).toContain("braintrust/auto"); expect(output).toContain("__braintrustObservabilityClass"); expect(output).toContain("@mastra/observability"); } else { + expect(output).not.toContain("braintrust/auto"); expect(output).not.toContain("__braintrustObservabilityClass"); expect(output).not.toContain("node:module"); } @@ -862,6 +901,7 @@ describe("Orchestrion Transformation Tests", () => { experiments: { outputModule: true }, mode: "development", target: "web", + externals: { "braintrust/auto": "module braintrust/auto" }, module: { rules: [ { @@ -878,6 +918,7 @@ describe("Orchestrion Transformation Tests", () => { expect(errors).toHaveLength(0); expect(output).toContain("__btPatchAPIPromise"); + expect(output).toContain("braintrust/auto"); expect(output).toContain("__btParsePatched"); }); }); From 796b385170026043d07d994d4b8d070e208a2574 Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:59:44 +0000 Subject: [PATCH 2/2] Update PR #2630 --- .changeset/chilly-ends-appear.md | 5 +++++ js/src/imports.test.ts | 18 ++++++++---------- 2 files changed, 13 insertions(+), 10 deletions(-) create mode 100644 .changeset/chilly-ends-appear.md diff --git a/.changeset/chilly-ends-appear.md b/.changeset/chilly-ends-appear.md new file mode 100644 index 000000000..51e1b5719 --- /dev/null +++ b/.changeset/chilly-ends-appear.md @@ -0,0 +1,5 @@ +--- +"braintrust": patch +--- + +fix(instrumentation): Initialize tracing automatically in bundles diff --git a/js/src/imports.test.ts b/js/src/imports.test.ts index a81e59237..11ef3190e 100644 --- a/js/src/imports.test.ts +++ b/js/src/imports.test.ts @@ -113,19 +113,17 @@ describe("Import restrictions", () => { const content = fs.readFileSync(filePath, "utf-8"); const lines = content.split("\n"); - // Exception: the Mastra observability patch *generates* CJS source code - // (a string of JavaScript that is appended to @mastra/core's - // dist/mastra/index.cjs). That source legitimately contains - // `require(...)` because it runs inside a CommonJS module's evaluation - // — it isn't a require() call from our SDK code itself. Normalize the - // separator so the exemption matches on Windows (`\\`) too. - const isMastraPatchGenerator = relativePath - .replaceAll(path.sep, "/") - .includes("auto-instrumentations/loader/mastra-observability-patch.ts"); + // These generators emit CJS source containing require() calls for the + // instrumented module to execute, rather than calling require() in the SDK. + // Normalize separators so the exceptions also match on Windows. + const isCommonJsGenerator = [ + "auto-instrumentations/loader/mastra-observability-patch.ts", + "auto-instrumentations/orchestrion-js/transformer.ts", + ].includes(relativePath.replaceAll(path.sep, "/")); lines.forEach((line, index) => { // Check for require() calls - if (/\brequire\s*\(/.test(line) && !isMastraPatchGenerator) { + if (/\brequire\s*\(/.test(line) && !isCommonJsGenerator) { violations.push( `${relativePath}:${index + 1} - Found require() statement: "${line.trim()}"`, );