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/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 53%
rename from e2e/scenarios/turbopack-auto-instrumentation/next.config.ts
rename to e2e/scenarios/nextjs-auto-instrumentation/template/next.config.mjs
index 52a479610..9d1acad89 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 { wrapNextjsConfigWithBraintrust } 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 wrapNextjsConfigWithBraintrust(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,
-);
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",
],