From 7ab7a1ddd8b005d39154099ec066965885f740d4 Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:16:35 +0000 Subject: [PATCH 1/2] fix: Inject instrumentation hook on Next.js versions <15 --- .../nextjs-instrumentation/next.config.mjs | 7 +- js/src/auto-instrumentations/bundler/next.ts | 17 ++- .../auto-instrumentations/next-config.test.ts | 140 +++++++++++++----- 3 files changed, 121 insertions(+), 43 deletions(-) diff --git a/e2e/scenarios/nextjs-instrumentation/next.config.mjs b/e2e/scenarios/nextjs-instrumentation/next.config.mjs index 27c76c996..9504bb0a8 100644 --- a/e2e/scenarios/nextjs-instrumentation/next.config.mjs +++ b/e2e/scenarios/nextjs-instrumentation/next.config.mjs @@ -1,14 +1,13 @@ +import { wrapNextjsConfigWithBraintrust } from "braintrust/next"; + /** @type {import('next').NextConfig} */ const nextConfig = { eslint: { ignoreDuringBuilds: true, }, - experimental: { - instrumentationHook: true, - }, typescript: { ignoreBuildErrors: true, }, }; -export default nextConfig; +export default wrapNextjsConfigWithBraintrust(nextConfig); diff --git a/js/src/auto-instrumentations/bundler/next.ts b/js/src/auto-instrumentations/bundler/next.ts index 774a4ab98..8dff24e55 100644 --- a/js/src/auto-instrumentations/bundler/next.ts +++ b/js/src/auto-instrumentations/bundler/next.ts @@ -70,7 +70,17 @@ function createConfigObject( nextConfig: NextConfigObject | undefined, ): NextConfigObject { const config = { ...(nextConfig ?? {}) }; - const activeBundler = detectBundler(); + const nextMajorVersion = getNextMajorVersion(); + const activeBundler = detectBundler(nextMajorVersion); + + // Instrumentation is enabled by default in Next 15+. Earlier versions need + // this flag for instrumentation.ts to load and register the SDK. + if (nextMajorVersion !== undefined && nextMajorVersion < 15) { + config.experimental = { + ...config.experimental, + instrumentationHook: true, + }; + } if (activeBundler === "turbopack") { // Next has used both `experimental.turbo` and `turbopack`; patch the stable @@ -97,7 +107,9 @@ function createConfigObject( }; } -function detectBundler(): "turbopack" | "webpack" { +function detectBundler( + nextMajorVersion: number | undefined, +): "turbopack" | "webpack" { if (process.argv.includes("--webpack")) { return "webpack"; } @@ -117,7 +129,6 @@ function detectBundler(): "turbopack" | "webpack" { // 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"; } diff --git a/js/tests/auto-instrumentations/next-config.test.ts b/js/tests/auto-instrumentations/next-config.test.ts index 91d96d401..bdb7bf7f2 100644 --- a/js/tests/auto-instrumentations/next-config.test.ts +++ b/js/tests/auto-instrumentations/next-config.test.ts @@ -1,5 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const { requireFromProject } = vi.hoisted(() => ({ + requireFromProject: Object.assign(vi.fn(), { + resolve: vi.fn(() => "/braintrust/webpack-loader.cjs"), + }), +})); + +vi.mock("node:module", async (importOriginal) => ({ + ...(await importOriginal()), + createRequire: () => requireFromProject, +})); + vi.mock("../../src/auto-instrumentations/bundler/webpack.js", () => ({ webpackPlugin: vi.fn((options: unknown) => ({ apply: () => {}, @@ -22,6 +33,9 @@ describe("wrapNextjsConfigWithBraintrust", () => { arg !== "--turbo" && arg !== "--turbopack" && arg !== "--webpack", ); vi.clearAllMocks(); + requireFromProject.mockImplementation(() => { + throw new Error("Cannot find module next/package.json"); + }); }); afterEach(() => { @@ -168,48 +182,102 @@ describe("wrapNextjsConfigWithBraintrust", () => { 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"; - } + it("uses Turbopack by default for Next versions that default to Turbopack builds", () => { + requireFromProject.mockReturnValue({ version: "16.2.1" }); - throw new Error(`Cannot resolve module ${specifier}`); - }, - }, + const config = wrapNextjsConfigWithBraintrust({}) as any; + + expect(config.turbopack.rules["*.{js,mjs,cjs}"]).toHaveLength(3); + expect(config.webpack).toBeUndefined(); + }); + + it.each(["13.2.0", "13.5.11", "14.2.35", "14.3.0-canary.87"])( + "enables the instrumentation hook on Next %s", + (version) => { + requireFromProject.mockReturnValue({ version }); + + const config = wrapNextjsConfigWithBraintrust({}) as any; + + expect(config.experimental.instrumentationHook).toBe(true); + }, + ); + + it.each(["15.0.0", "15.0.0-rc.1", "16.2.1", "16.3.0-canary.1"])( + "does not add the experimental instrumentation hook on Next %s", + (version) => { + requireFromProject.mockReturnValue({ version }); + + const config = wrapNextjsConfigWithBraintrust({}) as any; + + expect(config.experimental).toBeUndefined(); + }, + ); + + it.each([{}, { version: 14 }, { version: "invalid" }])( + "does not add the instrumentation hook when the Next version is invalid: %j", + (packageJson) => { + requireFromProject.mockReturnValue(packageJson); + + const config = wrapNextjsConfigWithBraintrust({}) as any; + + expect(config.experimental).toBeUndefined(); + }, + ); + + it("does not add the instrumentation hook when Next cannot be resolved", () => { + const config = wrapNextjsConfigWithBraintrust({}) as any; + + expect(config.experimental).toBeUndefined(); + }); + + it.each([undefined, false, true])( + "enables the hook while preserving experimental options without mutation (existing flag: %s)", + (instrumentationHook) => { + requireFromProject.mockReturnValue({ version: "14.2.35" }); + process.argv.push("--webpack"); + const original = Object.freeze({ + experimental: Object.freeze({ instrumentationHook, cpus: 2 }), + }); + + const config = wrapNextjsConfigWithBraintrust(original); + + expect(config.experimental).toEqual({ + instrumentationHook: true, + cpus: 2, + }); + expect(original.experimental.instrumentationHook).toBe( + instrumentationHook, ); + }, + ); - return { - ...actual, - createRequire: () => mockedRequire, - }; - }); + it.each([false, true])( + "enables the hook for function configs (async: %s)", + async (asyncConfig) => { + requireFromProject.mockReturnValue({ version: "14.2.35" }); + const userConfig = { experimental: { cpus: 2 } }; + const config = wrapNextjsConfigWithBraintrust( + asyncConfig ? async () => userConfig : () => userConfig, + ); - try { - const { wrapNextjsConfigWithBraintrust: withMockedBraintrust } = - await import("../../src/auto-instrumentations/bundler/next.js"); + expect((await config()).experimental).toEqual({ + instrumentationHook: true, + cpus: 2, + }); + expect(userConfig.experimental).toEqual({ cpus: 2 }); + }, + ); - const config = withMockedBraintrust({}) as any; + it("preserves the injected hook when wrapping experimental Turbopack options", () => { + requireFromProject.mockReturnValue({ version: "14.2.35" }); + process.argv.push("--turbo"); - expect(config.turbopack.rules["*.{js,mjs,cjs}"]).toHaveLength(3); - expect(config.webpack).toBeUndefined(); - } finally { - vi.doUnmock("node:module"); - vi.resetModules(); - } + const config = wrapNextjsConfigWithBraintrust({ + experimental: { turbo: {} }, + }) as any; + + expect(config.experimental.instrumentationHook).toBe(true); + expect(config.experimental.turbo.rules["*.{js,mjs,cjs}"]).toHaveLength(3); }); it("appends to an existing Turbopack rule", () => { From 284ecf6f8d8af21420aa717cca55084b46eba6e5 Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:25:32 +0000 Subject: [PATCH 2/2] Update PR #2445 --- .changeset/nextjs-instrumentation-hook.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nextjs-instrumentation-hook.md diff --git a/.changeset/nextjs-instrumentation-hook.md b/.changeset/nextjs-instrumentation-hook.md new file mode 100644 index 000000000..9084929d8 --- /dev/null +++ b/.changeset/nextjs-instrumentation-hook.md @@ -0,0 +1,5 @@ +--- +"braintrust": patch +--- + +fix: Inject instrumentation hook on Next.js versions <15