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/nextjs-instrumentation-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"braintrust": patch
---

fix: Inject instrumentation hook on Next.js versions <15
7 changes: 3 additions & 4 deletions e2e/scenarios/nextjs-instrumentation/next.config.mjs
Original file line number Diff line number Diff line change
@@ -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);
17 changes: 14 additions & 3 deletions js/src/auto-instrumentations/bundler/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -97,7 +107,9 @@ function createConfigObject(
};
}

function detectBundler(): "turbopack" | "webpack" {
function detectBundler(
nextMajorVersion: number | undefined,
): "turbopack" | "webpack" {
if (process.argv.includes("--webpack")) {
return "webpack";
}
Expand All @@ -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";
}
Expand Down
140 changes: 104 additions & 36 deletions js/tests/auto-instrumentations/next-config.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("node:module")>()),
createRequire: () => requireFromProject,
}));

vi.mock("../../src/auto-instrumentations/bundler/webpack.js", () => ({
webpackPlugin: vi.fn((options: unknown) => ({
apply: () => {},
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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<typeof import("node:module")>("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", () => {
Expand Down
Loading