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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slimy-papayas-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"braintrust": minor
---

feat(nextjs): Add `wrapNextjsConfigWithBraintrust` as canonical setup utility instead of webpack loader/plugin
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
93 changes: 93 additions & 0 deletions e2e/scenarios/nextjs-auto-instrumentation/scenario.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
});
}
Original file line number Diff line number Diff line change
@@ -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",
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"use client";

// This page makes the client compiler run through the Next.js wrapper.
export default function Page() {
return <div>Next.js auto-instrumentation test</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>Next.js auto-instrumentation test</div>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 }> {
Expand Down Expand Up @@ -51,8 +65,15 @@ async function waitForServer(timeoutMs = 30_000): Promise<void> {
// 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,
Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading