From fd1caf40f6ee3ca40b84e8ab033d488e3b33e6da Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 19 May 2026 12:09:20 +0200 Subject: [PATCH 1/2] feat(nextjs): Add `withBraintrust` utility for auto-instrumentation --- .../scenario.test.ts | 93 +++++ .../template}/.gitignore | 0 .../template/app/api/edge/route.ts | 17 + .../template}/app/api/test/route.ts | 0 .../template/app/client-page/page.tsx | 6 + .../template}/app/layout.tsx | 0 .../template/app/page.tsx | 3 + .../template/next.config.mjs} | 36 +- .../template}/scenario.ts | 47 ++- .../template}/tsconfig.json | 0 .../versions/next-14/package.json | 14 + .../versions/next-14/pnpm-lock.yaml | 339 ++++++++++++++++++ .../versions/next-16}/package.json | 2 +- .../versions/next-16}/pnpm-lock.yaml | 0 .../app/page.tsx | 3 - .../scenario.test.ts | 22 -- 16 files changed, 537 insertions(+), 45 deletions(-) create mode 100644 e2e/scenarios/nextjs-auto-instrumentation/scenario.test.ts rename e2e/scenarios/{turbopack-auto-instrumentation => nextjs-auto-instrumentation/template}/.gitignore (100%) create mode 100644 e2e/scenarios/nextjs-auto-instrumentation/template/app/api/edge/route.ts rename e2e/scenarios/{turbopack-auto-instrumentation => nextjs-auto-instrumentation/template}/app/api/test/route.ts (100%) create mode 100644 e2e/scenarios/nextjs-auto-instrumentation/template/app/client-page/page.tsx rename e2e/scenarios/{turbopack-auto-instrumentation => nextjs-auto-instrumentation/template}/app/layout.tsx (100%) create mode 100644 e2e/scenarios/nextjs-auto-instrumentation/template/app/page.tsx rename e2e/scenarios/{turbopack-auto-instrumentation/next.config.ts => nextjs-auto-instrumentation/template/next.config.mjs} (54%) rename e2e/scenarios/{turbopack-auto-instrumentation => nextjs-auto-instrumentation/template}/scenario.ts (60%) rename e2e/scenarios/{turbopack-auto-instrumentation => nextjs-auto-instrumentation/template}/tsconfig.json (100%) create mode 100644 e2e/scenarios/nextjs-auto-instrumentation/versions/next-14/package.json create mode 100644 e2e/scenarios/nextjs-auto-instrumentation/versions/next-14/pnpm-lock.yaml rename e2e/scenarios/{turbopack-auto-instrumentation => nextjs-auto-instrumentation/versions/next-16}/package.json (75%) rename e2e/scenarios/{turbopack-auto-instrumentation => nextjs-auto-instrumentation/versions/next-16}/pnpm-lock.yaml (100%) delete mode 100644 e2e/scenarios/turbopack-auto-instrumentation/app/page.tsx delete mode 100644 e2e/scenarios/turbopack-auto-instrumentation/scenario.test.ts diff --git a/e2e/scenarios/nextjs-auto-instrumentation/scenario.test.ts b/e2e/scenarios/nextjs-auto-instrumentation/scenario.test.ts new file mode 100644 index 000000000..eab4ca64b --- /dev/null +++ b/e2e/scenarios/nextjs-auto-instrumentation/scenario.test.ts @@ -0,0 +1,93 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { describe, test } from "vitest"; +import { + prepareScenarioDir, + readInstalledPackageVersion, + resolveScenarioDir, + withScenarioHarness, +} from "../../helpers/scenario-harness"; + +const TIMEOUT_MS = 180_000; +const originalScenarioDir = resolveScenarioDir(import.meta.url); +const generatedScenarioRoot = path.resolve( + originalScenarioDir, + "../../.bt-tmp/generated-scenarios/nextjs-auto-instrumentation", +); +const nextVersionScenarios = [ + { + bundlers: ["webpack"], + generatedDirName: "nextjs-auto-instrumentation-next-14", + label: "Next 14", + versionDir: "next-14", + }, + { + bundlers: ["webpack", "turbopack"], + generatedDirName: "nextjs-auto-instrumentation-next-16", + label: "Next 16", + versionDir: "next-16", + }, +] as const; + +const preparedScenarios = await Promise.all( + nextVersionScenarios.map(async (scenario) => { + const sourceDir = path.join( + generatedScenarioRoot, + scenario.generatedDirName, + ); + + await fs.rm(sourceDir, { force: true, recursive: true }); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.cp(path.join(originalScenarioDir, "template"), sourceDir, { + recursive: true, + }); + await fs.cp( + path.join(originalScenarioDir, "versions", scenario.versionDir), + sourceDir, + { recursive: true }, + ); + + // Next must be installed under the real package name for the CLI, type + // plugin, and config resolver paths to match user projects. Generate one + // install root per version instead of using package aliases. + // + // Keep real node_modules in the prepared app. Turbopack follows symlink + // realpaths during its root checks, which can reject cached e2e installs + // before it applies the explicit root from next.config.mjs. + const scenarioDir = await prepareScenarioDir({ + linkDependencies: false, + scenarioDir: sourceDir, + }); + return { + ...scenario, + scenarioDir, + version: await readInstalledPackageVersion(scenarioDir, "next"), + }; + }), +); + +for (const scenario of preparedScenarios) { + describe(`nextjs-auto-instrumentation ${scenario.label} (${scenario.version})`, () => { + for (const bundler of scenario.bundlers) { + test( + `${bundler}: Next.js build output contains OpenAI instrumentation`, + async () => { + await withScenarioHarness(async ({ runScenarioDir }) => { + await runScenarioDir({ + env: { + NEXTJS_E2E_BUNDLER: bundler, + }, + runContext: { + cassette: false, + variantKey: `${scenario.versionDir}-${bundler}`, + }, + scenarioDir: scenario.scenarioDir, + timeoutMs: TIMEOUT_MS, + }); + }); + }, + TIMEOUT_MS, + ); + } + }); +} diff --git a/e2e/scenarios/turbopack-auto-instrumentation/.gitignore b/e2e/scenarios/nextjs-auto-instrumentation/template/.gitignore similarity index 100% rename from e2e/scenarios/turbopack-auto-instrumentation/.gitignore rename to e2e/scenarios/nextjs-auto-instrumentation/template/.gitignore diff --git a/e2e/scenarios/nextjs-auto-instrumentation/template/app/api/edge/route.ts b/e2e/scenarios/nextjs-auto-instrumentation/template/app/api/edge/route.ts new file mode 100644 index 000000000..413aca6f7 --- /dev/null +++ b/e2e/scenarios/nextjs-auto-instrumentation/template/app/api/edge/route.ts @@ -0,0 +1,17 @@ +import OpenAI from "openai"; + +export const dynamic = "force-dynamic"; +export const runtime = "edge"; + +export async function GET() { + const client = new OpenAI({ + apiKey: "test", + dangerouslyAllowBrowser: true, + maxRetries: 0, + }); + + return Response.json({ + runtime, + openaiCreate: typeof client.chat.completions.create === "function", + }); +} diff --git a/e2e/scenarios/turbopack-auto-instrumentation/app/api/test/route.ts b/e2e/scenarios/nextjs-auto-instrumentation/template/app/api/test/route.ts similarity index 100% rename from e2e/scenarios/turbopack-auto-instrumentation/app/api/test/route.ts rename to e2e/scenarios/nextjs-auto-instrumentation/template/app/api/test/route.ts diff --git a/e2e/scenarios/nextjs-auto-instrumentation/template/app/client-page/page.tsx b/e2e/scenarios/nextjs-auto-instrumentation/template/app/client-page/page.tsx new file mode 100644 index 000000000..896682788 --- /dev/null +++ b/e2e/scenarios/nextjs-auto-instrumentation/template/app/client-page/page.tsx @@ -0,0 +1,6 @@ +"use client"; + +// This page makes the client compiler run through the Next.js wrapper. +export default function Page() { + return
Next.js auto-instrumentation test
; +} diff --git a/e2e/scenarios/turbopack-auto-instrumentation/app/layout.tsx b/e2e/scenarios/nextjs-auto-instrumentation/template/app/layout.tsx similarity index 100% rename from e2e/scenarios/turbopack-auto-instrumentation/app/layout.tsx rename to e2e/scenarios/nextjs-auto-instrumentation/template/app/layout.tsx diff --git a/e2e/scenarios/nextjs-auto-instrumentation/template/app/page.tsx b/e2e/scenarios/nextjs-auto-instrumentation/template/app/page.tsx new file mode 100644 index 000000000..df06fb56f --- /dev/null +++ b/e2e/scenarios/nextjs-auto-instrumentation/template/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Next.js auto-instrumentation test
; +} diff --git a/e2e/scenarios/turbopack-auto-instrumentation/next.config.ts b/e2e/scenarios/nextjs-auto-instrumentation/template/next.config.mjs similarity index 54% rename from e2e/scenarios/turbopack-auto-instrumentation/next.config.ts rename to e2e/scenarios/nextjs-auto-instrumentation/template/next.config.mjs index 52a479610..fc702e9c9 100644 --- a/e2e/scenarios/turbopack-auto-instrumentation/next.config.ts +++ b/e2e/scenarios/nextjs-auto-instrumentation/template/next.config.mjs @@ -2,15 +2,19 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { NextConfig } from "next"; +import { withBraintrust } from "braintrust/next"; const require = createRequire(import.meta.url); const scenarioDir = path.dirname(fileURLToPath(import.meta.url)); +const nextVersion = require("next/package.json").version; const packageDirs = [ path.dirname(require.resolve("next/package.json")), path.dirname(require.resolve("braintrust/package.json")), ].map((packageDir) => fs.realpathSync(packageDir)); +// Turbopack refuses to compile files outside its root. In this e2e fixture, +// Next and the local braintrust package can resolve through workspace/cache +// symlinks, so widen the root until both package realpaths are included. let turbopackRoot = scenarioDir; for (const packageDir of packageDirs) { while (true) { @@ -30,18 +34,20 @@ for (const packageDir of packageDirs) { } } -const nextConfig: NextConfig = { - turbopack: { - root: turbopackRoot, - rules: { - // Apply the loader to all JS/MJS/CJS files from node_modules. - // condition: "foreign" restricts the rule to third-party packages only. - "*.{js,mjs,cjs}": { - condition: "foreign", - loaders: [{ loader: require.resolve("braintrust/webpack-loader") }], - }, - }, - }, -}; +const nextMajorVersion = Number.parseInt(nextVersion.split(".")[0] ?? "", 10); +const nextConfig = + Number.isFinite(nextMajorVersion) && nextMajorVersion >= 15 + ? { + turbopack: { + root: turbopackRoot, + }, + } + : { + experimental: { + turbo: { + root: turbopackRoot, + }, + }, + }; -export default nextConfig; +export default withBraintrust(nextConfig); diff --git a/e2e/scenarios/turbopack-auto-instrumentation/scenario.ts b/e2e/scenarios/nextjs-auto-instrumentation/template/scenario.ts similarity index 60% rename from e2e/scenarios/turbopack-auto-instrumentation/scenario.ts rename to e2e/scenarios/nextjs-auto-instrumentation/template/scenario.ts index a6c672222..259a2bf29 100644 --- a/e2e/scenarios/turbopack-auto-instrumentation/scenario.ts +++ b/e2e/scenarios/nextjs-auto-instrumentation/template/scenario.ts @@ -1,11 +1,17 @@ import { spawn } from "node:child_process"; import http from "node:http"; +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { runMain, runNodeSubprocess } from "../../helpers/scenario-runtime"; +const require = createRequire(import.meta.url); const scenarioDir = path.dirname(fileURLToPath(import.meta.url)); const PORT = 3999; +const bundler = + process.env.NEXTJS_E2E_BUNDLER === "turbopack" ? "turbopack" : "webpack"; +const nextVersion = require("next/package.json").version as string; +const nextMajorVersion = Number.parseInt(nextVersion.split(".")[0] ?? "", 10); // Resolve next CLI relative to the scenario's own node_modules, since the // scenario runs in a copy of this directory without .bin symlinks. @@ -15,10 +21,18 @@ const nextBin = new URL("./node_modules/next/dist/bin/next", import.meta.url) function withScenarioEnv( env: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - return { + const nextEnv: NodeJS.ProcessEnv = { ...env, NEXT_TELEMETRY_DISABLED: "1", }; + + if (bundler === "turbopack") { + nextEnv.TURBOPACK = "1"; + } else { + delete nextEnv.TURBOPACK; + } + + return nextEnv; } function httpGet(url: string): Promise<{ status: number; body: string }> { @@ -51,8 +65,15 @@ async function waitForServer(timeoutMs = 30_000): Promise { // function and run it through the shared scenario wrapper. async function main() { const env = withScenarioEnv(process.env); + const buildArgs = + bundler === "turbopack" + ? [nextBin, "build", "--turbopack"] + : Number.isFinite(nextMajorVersion) && nextMajorVersion >= 16 + ? [nextBin, "build", "--webpack"] + : [nextBin, "build"]; + await runNodeSubprocess({ - args: [nextBin, "build"], + args: buildArgs, cwd: scenarioDir, env, timeoutMs: 180_000, @@ -77,13 +98,31 @@ async function main() { if (!data.instrumented) { throw new Error( - "OpenAI tracing channel did not fire — Turbopack instrumentation is not working", + `OpenAI tracing channel did not fire; Next.js ${bundler} instrumentation is not working`, ); } console.log( - "✓ OpenAI tracing channel fired at runtime — Turbopack instrumentation is active", + `OpenAI tracing channel fired at runtime; Next.js ${bundler} instrumentation is active`, ); + + const edgeResponse = await httpGet(`http://localhost:${PORT}/api/edge`); + const edgeData = JSON.parse(edgeResponse.body) as { + openaiCreate: boolean; + runtime: string; + }; + + if (edgeResponse.status !== 200 || edgeData.runtime !== "edge") { + throw new Error( + `Edge runtime route failed for Next.js ${bundler}: ${edgeResponse.status} ${edgeResponse.body}`, + ); + } + + if (!edgeData.openaiCreate) { + throw new Error( + `OpenAI client was not usable in the Next.js ${bundler} edge runtime route`, + ); + } } finally { server.kill(); } diff --git a/e2e/scenarios/turbopack-auto-instrumentation/tsconfig.json b/e2e/scenarios/nextjs-auto-instrumentation/template/tsconfig.json similarity index 100% rename from e2e/scenarios/turbopack-auto-instrumentation/tsconfig.json rename to e2e/scenarios/nextjs-auto-instrumentation/template/tsconfig.json diff --git a/e2e/scenarios/nextjs-auto-instrumentation/versions/next-14/package.json b/e2e/scenarios/nextjs-auto-instrumentation/versions/next-14/package.json new file mode 100644 index 000000000..472f1d754 --- /dev/null +++ b/e2e/scenarios/nextjs-auto-instrumentation/versions/next-14/package.json @@ -0,0 +1,14 @@ +{ + "name": "@braintrust/e2e-nextjs-auto-instrumentation-next-14", + "private": true, + "dependencies": { + "next": "14.2.35", + "openai": "6.32.0", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/react": "18.3.27", + "@types/react-dom": "18.3.7" + } +} diff --git a/e2e/scenarios/nextjs-auto-instrumentation/versions/next-14/pnpm-lock.yaml b/e2e/scenarios/nextjs-auto-instrumentation/versions/next-14/pnpm-lock.yaml new file mode 100644 index 000000000..d45432c7f --- /dev/null +++ b/e2e/scenarios/nextjs-auto-instrumentation/versions/next-14/pnpm-lock.yaml @@ -0,0 +1,339 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + next: + specifier: 14.2.35 + version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + openai: + specifier: 6.32.0 + version: 6.32.0 + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: 18.3.27 + version: 18.3.27 + '@types/react-dom': + specifier: 18.3.7 + version: 18.3.7(@types/react@18.3.27) + +packages: + + '@next/env@14.2.35': + resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} + + '@next/swc-darwin-arm64@14.2.33': + resolution: {integrity: sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@14.2.33': + resolution: {integrity: sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@14.2.33': + resolution: {integrity: sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@14.2.33': + resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@14.2.33': + resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@14.2.33': + resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@14.2.33': + resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-ia32-msvc@14.2.33': + resolution: {integrity: sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@next/swc-win32-x64-msvc@14.2.33': + resolution: {integrity: sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.5': + resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.27': + resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@14.2.35: + resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + sass: + optional: true + + openai@6.32.0: + resolution: {integrity: sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + styled-jsx@5.1.1: + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + +snapshots: + + '@next/env@14.2.35': {} + + '@next/swc-darwin-arm64@14.2.33': + optional: true + + '@next/swc-darwin-x64@14.2.33': + optional: true + + '@next/swc-linux-arm64-gnu@14.2.33': + optional: true + + '@next/swc-linux-arm64-musl@14.2.33': + optional: true + + '@next/swc-linux-x64-gnu@14.2.33': + optional: true + + '@next/swc-linux-x64-musl@14.2.33': + optional: true + + '@next/swc-win32-arm64-msvc@14.2.33': + optional: true + + '@next/swc-win32-ia32-msvc@14.2.33': + optional: true + + '@next/swc-win32-x64-msvc@14.2.33': + optional: true + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.5': + dependencies: + '@swc/counter': 0.1.3 + tslib: 2.8.1 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.27)': + dependencies: + '@types/react': 18.3.27 + + '@types/react@18.3.27': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + caniuse-lite@1.0.30001793: {} + + client-only@0.0.1: {} + + csstype@3.2.3: {} + + graceful-fs@4.2.11: {} + + js-tokens@4.0.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + nanoid@3.3.12: {} + + next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@next/env': 14.2.35 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001793 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.1(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.33 + '@next/swc-darwin-x64': 14.2.33 + '@next/swc-linux-arm64-gnu': 14.2.33 + '@next/swc-linux-arm64-musl': 14.2.33 + '@next/swc-linux-x64-gnu': 14.2.33 + '@next/swc-linux-x64-musl': 14.2.33 + '@next/swc-win32-arm64-msvc': 14.2.33 + '@next/swc-win32-ia32-msvc': 14.2.33 + '@next/swc-win32-x64-msvc': 14.2.33 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + openai@6.32.0: {} + + picocolors@1.1.1: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + source-map-js@1.2.1: {} + + streamsearch@1.1.0: {} + + styled-jsx@5.1.1(react@18.3.1): + dependencies: + client-only: 0.0.1 + react: 18.3.1 + + tslib@2.8.1: {} diff --git a/e2e/scenarios/turbopack-auto-instrumentation/package.json b/e2e/scenarios/nextjs-auto-instrumentation/versions/next-16/package.json similarity index 75% rename from e2e/scenarios/turbopack-auto-instrumentation/package.json rename to e2e/scenarios/nextjs-auto-instrumentation/versions/next-16/package.json index 38689b438..3feea4145 100644 --- a/e2e/scenarios/turbopack-auto-instrumentation/package.json +++ b/e2e/scenarios/nextjs-auto-instrumentation/versions/next-16/package.json @@ -1,5 +1,5 @@ { - "name": "@braintrust/e2e-turbopack-auto-instrumentation", + "name": "@braintrust/e2e-nextjs-auto-instrumentation-next-16", "private": true, "dependencies": { "next": "16.2.1", diff --git a/e2e/scenarios/turbopack-auto-instrumentation/pnpm-lock.yaml b/e2e/scenarios/nextjs-auto-instrumentation/versions/next-16/pnpm-lock.yaml similarity index 100% rename from e2e/scenarios/turbopack-auto-instrumentation/pnpm-lock.yaml rename to e2e/scenarios/nextjs-auto-instrumentation/versions/next-16/pnpm-lock.yaml diff --git a/e2e/scenarios/turbopack-auto-instrumentation/app/page.tsx b/e2e/scenarios/turbopack-auto-instrumentation/app/page.tsx deleted file mode 100644 index 18eff6993..000000000 --- a/e2e/scenarios/turbopack-auto-instrumentation/app/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Page() { - return
Turbopack auto-instrumentation test
; -} diff --git a/e2e/scenarios/turbopack-auto-instrumentation/scenario.test.ts b/e2e/scenarios/turbopack-auto-instrumentation/scenario.test.ts deleted file mode 100644 index 1ee1fbd72..000000000 --- a/e2e/scenarios/turbopack-auto-instrumentation/scenario.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { test } from "vitest"; -import { - prepareScenarioDir, - resolveScenarioDir, - withScenarioHarness, -} from "../../helpers/scenario-harness"; - -const scenarioDir = await prepareScenarioDir({ - linkDependencies: false, - scenarioDir: resolveScenarioDir(import.meta.url), -}); -const TIMEOUT_MS = 180_000; - -test( - "turbopack-auto-instrumentation: Next.js build output contains OpenAI instrumentation", - async () => { - await withScenarioHarness(async ({ runScenarioDir }) => { - await runScenarioDir({ scenarioDir, timeoutMs: TIMEOUT_MS }); - }); - }, - TIMEOUT_MS, -); From 62b23ba486b8d9bc32f6cf6ef7fe581fe31383ef Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 20 May 2026 10:57:10 +0200 Subject: [PATCH 2/2] cleanup --- .changeset/slimy-papayas-speak.md | 5 + AGENTS.md | 2 +- .../template/next.config.mjs | 4 +- js/package.json | 6 + js/src/auto-instrumentations/README.md | 20 ++ js/src/auto-instrumentations/bundler/next.ts | 308 ++++++++++++++++++ .../auto-instrumentations/bundler/plugin.ts | 37 +-- .../bundler/webpack-loader.ts | 35 +- .../auto-instrumentations/configs/all.test.ts | 40 +++ js/src/auto-instrumentations/configs/all.ts | 82 +++++ js/src/auto-instrumentations/hook.mts | 75 +---- .../auto-instrumentations/next-config.test.ts | 254 +++++++++++++++ .../transformation.test.ts | 1 - js/tsup.config.ts | 1 + 14 files changed, 731 insertions(+), 139 deletions(-) create mode 100644 .changeset/slimy-papayas-speak.md create mode 100644 js/src/auto-instrumentations/bundler/next.ts create mode 100644 js/src/auto-instrumentations/configs/all.test.ts create mode 100644 js/src/auto-instrumentations/configs/all.ts create mode 100644 js/tests/auto-instrumentations/next-config.test.ts diff --git a/.changeset/slimy-papayas-speak.md b/.changeset/slimy-papayas-speak.md new file mode 100644 index 000000000..930838c7b --- /dev/null +++ b/.changeset/slimy-papayas-speak.md @@ -0,0 +1,5 @@ +--- +"braintrust": minor +--- + +feat(nextjs): Add `wrapNextjsConfigWithBraintrust` as canonical setup utility instead of webpack loader/plugin diff --git a/AGENTS.md b/AGENTS.md index 7efa16332..19bb1d0d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ pnpm run test # Run all workspace tests via turbo ## Linting & Formatting -Run from the repo root. **Always run formatting before committing** — there is a pre-commit hook that will reject unformatted code. +Run from the repo root. **Always run `fix:formatting` before committing** — there is a pre-commit hook that will reject unformatted code. ```bash pnpm run formatting # Check formatting (prettier) diff --git a/e2e/scenarios/nextjs-auto-instrumentation/template/next.config.mjs b/e2e/scenarios/nextjs-auto-instrumentation/template/next.config.mjs index fc702e9c9..9d1acad89 100644 --- a/e2e/scenarios/nextjs-auto-instrumentation/template/next.config.mjs +++ b/e2e/scenarios/nextjs-auto-instrumentation/template/next.config.mjs @@ -2,7 +2,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { withBraintrust } from "braintrust/next"; +import { wrapNextjsConfigWithBraintrust } from "braintrust/next"; const require = createRequire(import.meta.url); const scenarioDir = path.dirname(fileURLToPath(import.meta.url)); @@ -50,4 +50,4 @@ const nextConfig = }, }; -export default withBraintrust(nextConfig); +export default wrapNextjsConfigWithBraintrust(nextConfig); diff --git a/js/package.json b/js/package.json index b7863a590..2afb485f0 100644 --- a/js/package.json +++ b/js/package.json @@ -87,6 +87,12 @@ "module": "./dist/auto-instrumentations/bundler/webpack.mjs", "require": "./dist/auto-instrumentations/bundler/webpack.cjs" }, + "./next": { + "types": "./dist/auto-instrumentations/bundler/next.d.ts", + "import": "./dist/auto-instrumentations/bundler/next.mjs", + "module": "./dist/auto-instrumentations/bundler/next.mjs", + "require": "./dist/auto-instrumentations/bundler/next.cjs" + }, "./webpack-loader": { "types": "./dist/auto-instrumentations/bundler/webpack-loader.d.ts", "require": "./dist/auto-instrumentations/bundler/webpack-loader.cjs" diff --git a/js/src/auto-instrumentations/README.md b/js/src/auto-instrumentations/README.md index 4fbc04f2a..98b69c62d 100644 --- a/js/src/auto-instrumentations/README.md +++ b/js/src/auto-instrumentations/README.md @@ -279,6 +279,26 @@ Setting `browser: true` ensures the code-transformer injects `dc-browser` import - Plugin code uses the iso pattern and adapts automatically - Only the transformed SDK code is affected by the `browser` option +### Next.js Setup + +Wrap your Next.js config with `wrapNextjsConfigWithBraintrust` to let Braintrust choose the +webpack plugin or Turbopack loader based on the active Next.js build: + +```javascript +// next.config.mjs +import { wrapNextjsConfigWithBraintrust } from "braintrust/next"; + +const nextConfig = {}; + +export default wrapNextjsConfigWithBraintrust(nextConfig); +``` + +For webpack builds, Braintrust uses Next.js' webpack build context to pass +`browser: true` for client and edge bundles and `browser: false` for Node.js +server bundles. For Turbopack builds, Braintrust inserts webpack loader +rules with runtime conditions so client and edge bundles use `browser: true` +and Node.js server bundles use `browser: false`. + ## Advanced: Custom Plugins Third parties can create custom plugins by extending `BasePlugin`: diff --git a/js/src/auto-instrumentations/bundler/next.ts b/js/src/auto-instrumentations/bundler/next.ts new file mode 100644 index 000000000..53112eb0d --- /dev/null +++ b/js/src/auto-instrumentations/bundler/next.ts @@ -0,0 +1,308 @@ +import { createRequire } from "node:module"; +import { isAbsolute, join, relative } from "node:path"; +import { webpackPlugin } from "./webpack"; + +type MaybePromise = T | Promise; +type NextConfigFunction = ( + this: unknown, + ...args: unknown[] +) => MaybePromise; +type ExportedNextConfig = NextConfigObject | NextConfigFunction; + +interface WebpackBuildContext { + isServer?: boolean; + nextRuntime?: string; +} + +interface WebpackConfig { + plugins?: unknown[]; + [key: string]: unknown; +} + +interface TurbopackConfig { + resolveAlias?: Record; + rules?: Record; + [key: string]: unknown; +} + +interface NextConfigObject { + experimental?: { + turbo?: TurbopackConfig; + [key: string]: unknown; + }; + turbopack?: TurbopackConfig; + webpack?: + | ((config: WebpackConfig, context: WebpackBuildContext) => unknown) + | null; + [key: string]: unknown; +} + +// Resolve the loader from the app's package graph, because Next evaluates this +// from the user's config file rather than from our source tree. +const requireFromProject = createRequire(join(process.cwd(), "package.json")); +const TURBOPACK_RULE_MATCHER = "*.{js,mjs,cjs}"; + +export function wrapNextjsConfigWithBraintrust(nextConfig: C): C { + // Preserve the exported config shape: object configs stay objects, function + // configs stay functions, and async function configs are patched after they + // resolve. + const castNextConfig = (nextConfig ?? {}) as ExportedNextConfig; + if (typeof castNextConfig === "function") { + return function ( + this: unknown, + ...args: unknown[] + ): ReturnType { + const maybeConfig = castNextConfig.apply(this, args); + if (isThenable(maybeConfig)) { + return maybeConfig.then((resolvedConfig) => + createConfigObject(resolvedConfig), + ); + } + + return createConfigObject(maybeConfig as NextConfigObject); + } as C; + } + + return createConfigObject(castNextConfig) as C; +} + +function createConfigObject( + nextConfig: NextConfigObject | undefined, +): NextConfigObject { + const config = { ...(nextConfig ?? {}) }; + const activeBundler = detectBundler(); + + if (activeBundler === "turbopack") { + // Next has used both `experimental.turbo` and `turbopack`; patch the stable + // key when present, otherwise preserve apps already using the older shape. + if (config.turbopack || !config.experimental?.turbo) { + return { + ...config, + turbopack: wrapTurbopackConfig(config.turbopack), + }; + } + + return { + ...config, + experimental: { + ...config.experimental, + turbo: wrapTurbopackConfig(config.experimental.turbo), + }, + }; + } + + return { + ...config, + webpack: wrapWebpackConfig(config.webpack), + }; +} + +function detectBundler(): "turbopack" | "webpack" { + if (process.argv.includes("--webpack")) { + return "webpack"; + } + + // At config-evaluation time there is no compiler object yet, so the most + // direct Turbopack signal is the environment/CLI flag Next sets for that + // build. + const turbopackEnv = process.env.TURBOPACK?.trim().toLowerCase(); + if ( + (turbopackEnv && turbopackEnv !== "0" && turbopackEnv !== "false") || + process.argv.includes("--turbo") || + process.argv.includes("--turbopack") + ) { + return "turbopack"; + } + + // Next 16 defaults production builds to Turbopack unless the user passes + // `--webpack`, so use the installed Next major as a final auto-detection + // signal when no explicit bundler flag is present. + const nextMajorVersion = getNextMajorVersion(); + if (nextMajorVersion !== undefined && nextMajorVersion >= 16) { + return "turbopack"; + } + + return "webpack"; +} + +function wrapWebpackConfig( + userWebpack: NextConfigObject["webpack"], +): NonNullable { + return (incomingConfig, buildContext) => { + const rawConfig = + typeof userWebpack === "function" + ? userWebpack(incomingConfig, buildContext) + : incomingConfig; + const config = ((rawConfig as WebpackConfig | undefined) ?? + incomingConfig) as WebpackConfig; + const existingPlugins = Array.isArray(config.plugins) ? config.plugins : []; + + const runtime = buildContext.isServer + ? buildContext.nextRuntime === "edge" || + buildContext.nextRuntime === "experimental-edge" + ? "edge" + : "server" + : "client"; + + const plugin = webpackPlugin({ + browser: runtime === "client" || runtime === "edge", + }); + + return { + ...config, + plugins: [...existingPlugins, plugin], + }; + }; +} + +function wrapTurbopackConfig( + turbopackConfig: TurbopackConfig | undefined, +): TurbopackConfig { + const config = { ...(turbopackConfig ?? {}) }; + const resolveAlias = + config.resolveAlias && + typeof config.resolveAlias === "object" && + !Array.isArray(config.resolveAlias) + ? config.resolveAlias + : {}; + const rules = + config.rules && + typeof config.rules === "object" && + !Array.isArray(config.rules) + ? config.rules + : {}; + let dcBrowserPath: string | undefined; + + try { + dcBrowserPath = createRequire( + requireFromProject.resolve("braintrust/package.json"), + ).resolve("dc-browser"); + } catch { + try { + dcBrowserPath = requireFromProject.resolve("dc-browser"); + } catch { + dcBrowserPath = undefined; + } + } + + // Absolute Turbopack aliases are interpreted as server-relative imports, so + // make the resolved dependency path relative to the app config directory. + if (dcBrowserPath && isAbsolute(dcBrowserPath)) { + const relativeDcBrowserPath = relative( + process.cwd(), + dcBrowserPath, + ).replace(/\\/g, "/"); + dcBrowserPath = relativeDcBrowserPath.startsWith(".") + ? relativeDcBrowserPath + : `./${relativeDcBrowserPath}`; + } + + return { + ...config, + // Turbopack resolves modules emitted by our loader from the app graph. The + // browser diagnostics-channel shim is Braintrust's dependency, so alias it + // to the copy installed with Braintrust while still letting user aliases win. + resolveAlias: + dcBrowserPath && resolveAlias["dc-browser"] === undefined + ? { ...resolveAlias, "dc-browser": dcBrowserPath } + : config.resolveAlias, + rules: addBraintrustTurbopackRule(rules), + }; +} + +function addBraintrustTurbopackRule( + rules: Record, +): Record { + const loaderPath = getWebpackLoaderPath(); + const braintrustRules = [ + { + condition: { all: ["foreign", "browser"] }, + loaders: [ + { + loader: loaderPath, + options: { browser: true }, + }, + ], + }, + { + condition: { all: ["foreign", "edge-light"] }, + loaders: [ + { + loader: loaderPath, + options: { browser: true }, + }, + ], + }, + { + condition: { all: ["foreign", "node"] }, + loaders: [ + { + loader: loaderPath, + options: { browser: false }, + }, + ], + }, + ]; + const existingRule = rules[TURBOPACK_RULE_MATCHER]; + + if (!existingRule) { + return { + ...rules, + // Turbopack exposes the active runtime through rule conditions, so keep + // client, edge, and node transforms separate instead of inferring later. + [TURBOPACK_RULE_MATCHER]: braintrustRules, + }; + } + + if (Array.isArray(existingRule)) { + return { + ...rules, + [TURBOPACK_RULE_MATCHER]: [...existingRule, ...braintrustRules], + }; + } + + if (typeof existingRule === "object" && existingRule !== null) { + return { + ...rules, + [TURBOPACK_RULE_MATCHER]: [existingRule, ...braintrustRules], + }; + } + + return rules; +} + +function getWebpackLoaderPath(): string { + try { + return requireFromProject.resolve("braintrust/webpack-loader"); + } catch { + return "braintrust/webpack-loader"; + } +} + +function getNextMajorVersion(): number | undefined { + try { + const nextPackageJson = requireFromProject("next/package.json") as { + version?: unknown; + }; + if (typeof nextPackageJson.version !== "string") { + return undefined; + } + + const major = Number.parseInt(nextPackageJson.version.split(".")[0] ?? ""); + return Number.isFinite(major) ? major : undefined; + } catch { + return undefined; + } +} + +function isThenable(value: unknown): value is PromiseLike { + // Do structural thenable detection so async config wrappers work across + // Promise implementations and module boundaries. + return ( + value !== null && + value !== undefined && + (typeof value === "object" || typeof value === "function") && + "then" in value && + typeof (value as { then?: unknown }).then === "function" + ); +} diff --git a/js/src/auto-instrumentations/bundler/plugin.ts b/js/src/auto-instrumentations/bundler/plugin.ts index 267224ac4..ba215101d 100644 --- a/js/src/auto-instrumentations/bundler/plugin.ts +++ b/js/src/auto-instrumentations/bundler/plugin.ts @@ -20,21 +20,7 @@ import { extname, join, sep } from "path"; import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import moduleDetailsFromPath from "module-details-from-path"; -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 { cohereConfigs } from "../configs/cohere"; -import { groqConfigs } from "../configs/groq"; -import { genkitConfigs } from "../configs/genkit"; -import { gitHubCopilotConfigs } from "../configs/github-copilot"; +import { getDefaultInstrumentationConfigs } from "../configs/all"; export interface BundlerPluginOptions { /** @@ -75,24 +61,9 @@ function getModuleVersion(basedir: string): string | undefined { } export const unplugin = createUnplugin((options = {}) => { - const allInstrumentations = [ - ...openaiConfigs, - ...openAICodexConfigs, - ...anthropicConfigs, - ...aiSDKConfigs, - ...claudeAgentSDKConfigs, - ...cursorSDKConfigs, - ...googleGenAIConfigs, - ...huggingFaceConfigs, - ...openRouterConfigs, - ...openRouterAgentConfigs, - ...mistralConfigs, - ...cohereConfigs, - ...groqConfigs, - ...genkitConfigs, - ...gitHubCopilotConfigs, - ...(options.instrumentations || []), - ]; + const allInstrumentations = getDefaultInstrumentationConfigs({ + additionalInstrumentations: options.instrumentations, + }); // Default to browser build, use polyfill unless explicitly disabled const dcModule = options.browser === false ? undefined : "dc-browser"; diff --git a/js/src/auto-instrumentations/bundler/webpack-loader.ts b/js/src/auto-instrumentations/bundler/webpack-loader.ts index 97162162d..f7c5d1a6f 100644 --- a/js/src/auto-instrumentations/bundler/webpack-loader.ts +++ b/js/src/auto-instrumentations/bundler/webpack-loader.ts @@ -29,20 +29,7 @@ import { import { extname, join, sep } from "path"; import { readFileSync } from "fs"; import moduleDetailsFromPath from "module-details-from-path"; -import { openaiConfigs } from "../configs/openai"; -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 { cohereConfigs } from "../configs/cohere"; -import { groqConfigs } from "../configs/groq"; -import { genkitConfigs } from "../configs/genkit"; -import { gitHubCopilotConfigs } from "../configs/github-copilot"; +import { getDefaultInstrumentationConfigs } from "../configs/all"; import { type BundlerPluginOptions } from "./plugin"; /** @@ -69,23 +56,9 @@ const matcherCache = new Map(); * Get or create a matcher instance, caching by config hash */ function getMatcher(options: BundlerPluginOptions): InstrumentationMatcher { - const allInstrumentations = [ - ...openaiConfigs, - ...anthropicConfigs, - ...aiSDKConfigs, - ...claudeAgentSDKConfigs, - ...cursorSDKConfigs, - ...googleGenAIConfigs, - ...huggingFaceConfigs, - ...openRouterConfigs, - ...openRouterAgentConfigs, - ...mistralConfigs, - ...cohereConfigs, - ...groqConfigs, - ...genkitConfigs, - ...gitHubCopilotConfigs, - ...(options.instrumentations ?? []), - ]; + const allInstrumentations = getDefaultInstrumentationConfigs({ + additionalInstrumentations: options.instrumentations, + }); const dcModule = options.browser ? "dc-browser" : undefined; const configHash = JSON.stringify({ allInstrumentations, dcModule }); diff --git a/js/src/auto-instrumentations/configs/all.test.ts b/js/src/auto-instrumentations/configs/all.test.ts new file mode 100644 index 000000000..af0c2920b --- /dev/null +++ b/js/src/auto-instrumentations/configs/all.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import { aiSDKConfigs } from "./ai-sdk"; +import { getDefaultInstrumentationConfigs } from "./all"; +import { googleADKConfigs } from "./google-adk"; +import { openaiConfigs } from "./openai"; +import { openAICodexConfigs } from "./openai-codex"; + +describe("getDefaultInstrumentationConfigs", () => { + it("includes config families that used to drift between entrypoints", () => { + const configs = getDefaultInstrumentationConfigs(); + + expect(configs).toContain(openAICodexConfigs[0]); + expect(configs).toContain(googleADKConfigs[0]); + }); + + it("appends custom instrumentations after the defaults", () => { + const customConfig: InstrumentationConfig = { + ...openaiConfigs[0], + channelName: "custom.test", + }; + + const configs = getDefaultInstrumentationConfigs({ + additionalInstrumentations: [customConfig], + }); + + expect(configs[configs.length - 1]).toBe(customConfig); + }); + + it("filters disabled integration aliases for the load-time hook", () => { + const configs = getDefaultInstrumentationConfigs({ + disabledIntegrations: new Set(["openai-codex", "googleadk", "vercel-ai"]), + }); + + expect(configs).not.toContain(openAICodexConfigs[0]); + expect(configs).not.toContain(googleADKConfigs[0]); + expect(configs).not.toContain(aiSDKConfigs[0]); + expect(configs).toContain(openaiConfigs[0]); + }); +}); diff --git a/js/src/auto-instrumentations/configs/all.ts b/js/src/auto-instrumentations/configs/all.ts new file mode 100644 index 000000000..be558f31a --- /dev/null +++ b/js/src/auto-instrumentations/configs/all.ts @@ -0,0 +1,82 @@ +import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import { aiSDKConfigs } from "./ai-sdk"; +import { anthropicConfigs } from "./anthropic"; +import { claudeAgentSDKConfigs } from "./claude-agent-sdk"; +import { cohereConfigs } from "./cohere"; +import { cursorSDKConfigs } from "./cursor-sdk"; +import { genkitConfigs } from "./genkit"; +import { gitHubCopilotConfigs } from "./github-copilot"; +import { googleADKConfigs } from "./google-adk"; +import { googleGenAIConfigs } from "./google-genai"; +import { groqConfigs } from "./groq"; +import { huggingFaceConfigs } from "./huggingface"; +import { mistralConfigs } from "./mistral"; +import { openaiConfigs } from "./openai"; +import { openAICodexConfigs } from "./openai-codex"; +import { openRouterConfigs } from "./openrouter"; +import { openRouterAgentConfigs } from "./openrouter-agent"; + +interface InstrumentationConfigGroup { + disabledNames: readonly string[]; + configs: readonly InstrumentationConfig[]; +} + +const defaultInstrumentationConfigGroups: readonly InstrumentationConfigGroup[] = + [ + { disabledNames: ["openai"], configs: openaiConfigs }, + { + disabledNames: ["openai-codex", "openai-codex-sdk", "codex", "codex-sdk"], + configs: openAICodexConfigs, + }, + { disabledNames: ["anthropic"], configs: anthropicConfigs }, + { + disabledNames: ["aisdk", "ai-sdk", "vercel-ai"], + configs: aiSDKConfigs, + }, + { + disabledNames: ["claudeagentsdk", "claude-agent-sdk"], + configs: claudeAgentSDKConfigs, + }, + { disabledNames: ["cursor", "cursor-sdk"], configs: cursorSDKConfigs }, + { + disabledNames: ["google", "google-genai"], + configs: googleGenAIConfigs, + }, + { disabledNames: ["huggingface"], configs: huggingFaceConfigs }, + { disabledNames: ["openrouter"], configs: openRouterConfigs }, + { + disabledNames: ["openrouteragent", "openrouter-agent"], + configs: openRouterAgentConfigs, + }, + { disabledNames: ["mistral"], configs: mistralConfigs }, + { disabledNames: ["googleadk", "google-adk"], configs: googleADKConfigs }, + { disabledNames: ["cohere"], configs: cohereConfigs }, + { disabledNames: ["groq", "groq-sdk"], configs: groqConfigs }, + { + disabledNames: ["genkit", "firebase-genkit"], + configs: genkitConfigs, + }, + { + disabledNames: ["githubcopilot", "github-copilot", "copilot-sdk"], + configs: gitHubCopilotConfigs, + }, + ]; + +export function getDefaultInstrumentationConfigs({ + additionalInstrumentations, + disabledIntegrations, +}: { + additionalInstrumentations?: readonly InstrumentationConfig[]; + disabledIntegrations?: ReadonlySet; +} = {}): InstrumentationConfig[] { + return [ + ...defaultInstrumentationConfigGroups.flatMap( + ({ configs, disabledNames }) => + disabledIntegrations && + disabledNames.some((name) => disabledIntegrations.has(name)) + ? [] + : configs, + ), + ...(additionalInstrumentations ?? []), + ]; +} diff --git a/js/src/auto-instrumentations/hook.mts b/js/src/auto-instrumentations/hook.mts index 215320a3c..00f20ade3 100644 --- a/js/src/auto-instrumentations/hook.mts +++ b/js/src/auto-instrumentations/hook.mts @@ -14,22 +14,7 @@ */ 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 { getDefaultInstrumentationConfigs } from "./configs/all.js"; import { ModulePatch } from "./loader/cjs-patch.js"; import { patchTracingChannel } from "./patch-tracing-channel.js"; @@ -54,64 +39,12 @@ function readDisabledIntegrations(): Set { ); } -function isDisabled(disabled: Set, ...names: string[]): boolean { - return names.some((name) => disabled.has(name)); -} - -const disabledIntegrations = readDisabledIntegrations(); - // 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), -]; +const allConfigs = getDefaultInstrumentationConfigs({ + disabledIntegrations: readDisabledIntegrations(), +}); // 1. Register ESM loader for ESM modules register("./loader/esm-hook.mjs", { diff --git a/js/tests/auto-instrumentations/next-config.test.ts b/js/tests/auto-instrumentations/next-config.test.ts new file mode 100644 index 000000000..0333faab9 --- /dev/null +++ b/js/tests/auto-instrumentations/next-config.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/auto-instrumentations/bundler/webpack.js", () => ({ + webpackPlugin: vi.fn((options: unknown) => ({ + apply: () => {}, + name: "braintrust-test-webpack-plugin", + options, + })), +})); + +import { webpackPlugin } from "../../src/auto-instrumentations/bundler/webpack.js"; +import { wrapNextjsConfigWithBraintrust } from "../../src/auto-instrumentations/bundler/next.js"; + +const originalArgv = [...process.argv]; +const originalTurbopackEnv = process.env.TURBOPACK; + +describe("wrapNextjsConfigWithBraintrust", () => { + beforeEach(() => { + delete process.env.TURBOPACK; + process.argv = originalArgv.filter( + (arg) => + arg !== "--turbo" && arg !== "--turbopack" && arg !== "--webpack", + ); + vi.clearAllMocks(); + }); + + afterEach(() => { + process.argv = originalArgv; + if (originalTurbopackEnv === undefined) { + delete process.env.TURBOPACK; + } else { + process.env.TURBOPACK = originalTurbopackEnv; + } + }); + + it("wraps object configs with a webpack plugin by default", () => { + const userWebpack = vi.fn((config) => ({ + ...config, + plugins: [...config.plugins, { name: "user-plugin" }], + })); + const config = wrapNextjsConfigWithBraintrust({ + webpack: userWebpack, + }) as any; + + const result = config.webpack({ plugins: [] }, { isServer: false }); + + expect(userWebpack).toHaveBeenCalledOnce(); + expect(webpackPlugin).toHaveBeenCalledWith({ browser: true }); + expect(result.plugins).toHaveLength(2); + expect(result.plugins[0]).toEqual({ name: "user-plugin" }); + expect(result.plugins[1].options).toEqual({ browser: true }); + }); + + it("passes browser false for node server webpack builds", () => { + const config = wrapNextjsConfigWithBraintrust({}) as any; + + const result = config.webpack( + { plugins: [] }, + { isServer: true, nextRuntime: "nodejs" }, + ); + + expect(result.plugins[0].options).toEqual({ browser: false }); + }); + + it.each(["edge", "experimental-edge"])( + "passes browser true for %s webpack builds", + (nextRuntime) => { + const config = wrapNextjsConfigWithBraintrust({}) as any; + + const result = config.webpack( + { plugins: [] }, + { isServer: true, nextRuntime }, + ); + + expect(result.plugins[0].options).toEqual({ browser: true }); + }, + ); + + it("supports async function configs", async () => { + const config = wrapNextjsConfigWithBraintrust(async (phase: string) => ({ + env: { phase }, + })) as any; + + const result = await config("phase-production-build"); + + expect(result.env).toEqual({ phase: "phase-production-build" }); + expect(typeof result.webpack).toBe("function"); + }); + + it("adds a Turbopack loader rule when Turbopack is active", () => { + process.env.TURBOPACK = "1"; + + const config = wrapNextjsConfigWithBraintrust({ + turbopack: { + rules: { + "*.css": { loaders: ["css-loader"] }, + }, + }, + }) as any; + + const rules = config.turbopack.rules["*.{js,mjs,cjs}"]; + expect(rules).toHaveLength(3); + expect(rules).toMatchObject([ + { + condition: { all: ["foreign", "browser"] }, + loaders: [ + { + options: { browser: true }, + }, + ], + }, + { + condition: { all: ["foreign", "edge-light"] }, + loaders: [ + { + options: { browser: true }, + }, + ], + }, + { + condition: { all: ["foreign", "node"] }, + loaders: [ + { + options: { browser: false }, + }, + ], + }, + ]); + expect( + rules.every((rule: { loaders: Array<{ loader: string }> }) => + rule.loaders[0].loader.includes("webpack-loader"), + ), + ).toBe(true); + expect(config.turbopack.resolveAlias["dc-browser"]).toContain("dc-browser"); + expect(config.turbopack.rules["*.css"]).toEqual({ + loaders: ["css-loader"], + }); + }); + + it("preserves user Turbopack aliases", () => { + process.env.TURBOPACK = "1"; + + const config = wrapNextjsConfigWithBraintrust({ + turbopack: { + resolveAlias: { + "dc-browser": "/custom/dc-browser.js", + "user-module": "/custom/user-module.js", + }, + rules: {}, + }, + }) as any; + + expect(config.turbopack.resolveAlias["dc-browser"]).toBe( + "/custom/dc-browser.js", + ); + expect(config.turbopack.resolveAlias["user-module"]).toBe( + "/custom/user-module.js", + ); + }); + + it("honors explicit webpack builds even when Turbopack env is set", () => { + process.env.TURBOPACK = "1"; + process.argv = [...process.argv, "--webpack"]; + + const config = wrapNextjsConfigWithBraintrust({ + turbopack: { + rules: {}, + }, + }) as any; + + expect(typeof config.webpack).toBe("function"); + expect(config.turbopack.rules).toEqual({}); + }); + + it("uses Turbopack by default for Next versions that default to Turbopack builds", async () => { + vi.resetModules(); + vi.doMock("node:module", async () => { + const actual = + await vi.importActual("node:module"); + const mockedRequire = Object.assign( + (specifier: string) => { + if (specifier === "next/package.json") { + return { version: "16.2.1" }; + } + + throw new Error(`Cannot find module ${specifier}`); + }, + { + resolve: (specifier: string) => { + if (specifier === "braintrust/webpack-loader") { + return "/braintrust/webpack-loader.cjs"; + } + + if (specifier === "braintrust/package.json") { + return "/braintrust/package.json"; + } + + if (specifier === "dc-browser") { + return "/braintrust/node_modules/dc-browser/dist/index.js"; + } + + throw new Error(`Cannot resolve module ${specifier}`); + }, + }, + ); + + return { + ...actual, + createRequire: () => mockedRequire, + }; + }); + + try { + const { wrapNextjsConfigWithBraintrust: withMockedBraintrust } = + await import("../../src/auto-instrumentations/bundler/next.js"); + + const config = withMockedBraintrust({}) as any; + + expect(config.turbopack.rules["*.{js,mjs,cjs}"]).toHaveLength(3); + expect(config.webpack).toBeUndefined(); + } finally { + vi.doUnmock("node:module"); + vi.resetModules(); + } + }); + + it("appends to an existing Turbopack rule", () => { + process.env.TURBOPACK = "1"; + const config = wrapNextjsConfigWithBraintrust({ + turbopack: { + rules: { + "*.{js,mjs,cjs}": { + condition: "foreign", + loaders: [{ loader: "existing-loader" }], + }, + }, + }, + }) as any; + + const rules = config.turbopack.rules["*.{js,mjs,cjs}"]; + expect(rules).toHaveLength(4); + expect(rules[0]).toEqual({ + condition: "foreign", + loaders: [{ loader: "existing-loader" }], + }); + expect( + rules.slice(1).map((rule: { condition: unknown }) => rule.condition), + ).toEqual([ + { all: ["foreign", "browser"] }, + { all: ["foreign", "edge-light"] }, + { all: ["foreign", "node"] }, + ]); + }); +}); diff --git a/js/tests/auto-instrumentations/transformation.test.ts b/js/tests/auto-instrumentations/transformation.test.ts index c51f12758..44ce291ca 100644 --- a/js/tests/auto-instrumentations/transformation.test.ts +++ b/js/tests/auto-instrumentations/transformation.test.ts @@ -274,7 +274,6 @@ describe("Orchestrion Transformation Tests", () => { __dirname, "../../dist/auto-instrumentations/bundler/webpack-loader.cjs", ); - async function runWebpackWithLoader( config: object, ): Promise<{ errors: string[]; output: string }> { diff --git a/js/tsup.config.ts b/js/tsup.config.ts index a22208781..ed1425524 100644 --- a/js/tsup.config.ts +++ b/js/tsup.config.ts @@ -93,6 +93,7 @@ export default defineConfig([ "src/auto-instrumentations/loader/get-package-version.ts", "src/auto-instrumentations/bundler/vite.ts", "src/auto-instrumentations/bundler/webpack.ts", + "src/auto-instrumentations/bundler/next.ts", "src/auto-instrumentations/bundler/esbuild.ts", "src/auto-instrumentations/bundler/rollup.ts", ],