From c2fe5e47299a68908947ffd4538a4e6ea78706c1 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 1 Sep 2026 14:33:01 +0200 Subject: [PATCH] test(e2e): Add a static trace lifecycle Next.js 16 test app Adds `nextjs-16-static`, a copy of `nextjs-16` that keeps the `traceLifecycle: 'static'` pins and the transaction-based specs, so the static trace lifecycle keeps end-to-end coverage once the main Next.js suites move to span streaming. Its `sentryTest` variants are dropped, so it adds one CI row rather than four. The webpack, latest and canary dimensions stay covered by `nextjs-16` itself. Ref #23802 Co-Authored-By: Claude Opus 5 (1M context) --- .../nextjs-16-static/.gitignore | 44 +++++ .../nextjs-16-static/ai-mock-server.mjs | 93 ++++++++++ .../app/(nested-layout)/layout.tsx | 12 ++ .../nested-layout/[dynamic]/layout.tsx | 12 ++ .../nested-layout/[dynamic]/page.tsx | 15 ++ .../(nested-layout)/nested-layout/layout.tsx | 12 ++ .../(nested-layout)/nested-layout/page.tsx | 11 ++ .../app/ai-error-test/page.tsx | 50 +++++ .../nextjs-16-static/app/ai-test/page.tsx | 98 ++++++++++ .../app/api/cron-test-error/route.ts | 5 + .../app/api/cron-test/route.ts | 9 + .../api/endpoint-behind-middleware/route.ts | 3 + .../nextjs-16-static/app/api/openai/route.ts | 20 ++ .../app/api/queue-send/route.ts | 14 ++ .../app/api/queues/process-order/route.ts | 10 + .../app/api/v3/topic/[...params]/route.ts | 112 ++++++++++++ .../app/component-annotation/page.tsx | 18 ++ .../nextjs-16-static/app/db-page/page.tsx | 52 ++++++ .../nextjs-16-static/app/favicon.ico | Bin 0 -> 25931 bytes .../nextjs-16-static/app/global-error.tsx | 23 +++ .../app/isr-test/[product]/page.tsx | 17 ++ .../app/isr-test/static/page.tsx | 15 ++ .../nextjs-16-static/app/layout.tsx | 7 + .../nextjs-16-static/app/metrics/page.tsx | 34 ++++ .../app/metrics/route-handler/route.ts | 23 +++ .../app/nested-rsc-error/[param]/page.tsx | 17 ++ .../app/non-isr-test/[item]/page.tsx | 11 ++ .../nextjs-16-static/app/page.tsx | 3 + .../app/pageload-tracing/layout.tsx | 8 + .../app/pageload-tracing/page.tsx | 14 ++ .../parameterized/[one]/beep/[two]/page.tsx | 3 + .../app/parameterized/[one]/beep/page.tsx | 3 + .../app/parameterized/[one]/page.tsx | 3 + .../app/parameterized/static/page.tsx | 3 + .../nextjs-16-static/app/prefetching/page.tsx | 9 + .../app/prefetching/to-be-prefetched/page.tsx | 5 + .../app/redirect/destination/page.tsx | 7 + .../app/redirect/origin/page.tsx | 18 ++ .../[xoxo]/capture-exception/route.ts | 9 + .../[xoxo]/capture-message/route.ts | 9 + .../app/route-handler/[xoxo]/edge/route.ts | 8 + .../app/route-handler/[xoxo]/error/route.ts | 5 + .../app/route-handler/[xoxo]/node/route.ts | 7 + .../nextjs-16-static/app/sri-test/page.tsx | 20 ++ .../app/sri-test/target/page.tsx | 20 ++ .../[param]/client-page.tsx | 8 + .../app/streaming-rsc-error/[param]/page.tsx | 18 ++ .../app/suspense-error/page.tsx | 15 ++ .../app/third-party-filter/page.tsx | 24 +++ .../nextjs-16-static/docker-compose.yml | 28 +++ .../nextjs-16-static/eslint.config.mjs | 19 ++ .../nextjs-16-static/global-setup.mjs | 14 ++ .../nextjs-16-static/global-teardown.mjs | 12 ++ .../instrumentation-client.ts | 24 +++ .../nextjs-16-static/instrumentation.ts | 17 ++ .../nextjs-16-static/lib/queue.ts | 12 ++ .../nextjs-16-static/next.config.ts | 24 +++ .../nextjs-16-static/package.json | 46 +++++ .../nextjs-16-static/playwright.config.mjs | 33 ++++ .../nextjs-16-static/proxy.ts | 24 +++ .../nextjs-16-static/public/file.svg | 1 + .../nextjs-16-static/public/globe.svg | 1 + .../nextjs-16-static/public/next.svg | 1 + .../nextjs-16-static/public/vercel.svg | 1 + .../nextjs-16-static/public/window.svg | 1 + .../nextjs-16-static/sentry.edge.config.ts | 12 ++ .../nextjs-16-static/sentry.server.config.ts | 18 ++ .../nextjs-16-static/start-event-proxy.mjs | 14 ++ .../nextjs-16-static/tests/ai-error.test.ts | 48 +++++ .../nextjs-16-static/tests/ai-test.test.ts | 80 ++++++++ .../tests/async-params.test.ts | 14 ++ .../tests/build-output.test.ts | 9 + .../tests/component-annotation.test.ts | 35 ++++ .../nextjs-16-static/tests/db-page.test.ts | 57 ++++++ .../nextjs-16-static/tests/isDevMode.ts | 2 + .../nextjs-16-static/tests/isr-routes.test.ts | 94 ++++++++++ .../nextjs-16-static/tests/metrics.test.ts | 133 ++++++++++++++ .../nextjs-16-static/tests/middleware.test.ts | 155 ++++++++++++++++ .../tests/nested-rsc-error.test.ts | 38 ++++ .../tests/node-runtime-metrics.test.ts | 148 +++++++++++++++ .../nextjs-16-static/tests/openai.test.ts | 24 +++ .../tests/pageload-tracing.test.ts | 54 ++++++ .../tests/parameterized-routes.test.ts | 171 ++++++++++++++++++ .../tests/prefetch-spans.test.ts | 25 +++ .../tests/route-handler.test.ts | 136 ++++++++++++++ .../tests/server-action-redirect.test.ts | 47 +++++ .../tests/server-components.test.ts | 101 +++++++++++ .../nextjs-16-static/tests/sri.test.ts | 60 ++++++ .../tests/streaming-rsc-error.test.ts | 40 ++++ .../tests/suspense-error.test.ts | 25 +++ .../tests/third-party-filter.test.ts | 30 +++ .../tests/turbopack-dev-in-app.test.ts | 43 +++++ .../tests/vercel-cron-monitoring.test.ts | 147 +++++++++++++++ .../tests/vercel-queue.test.ts | 51 ++++++ .../nextjs-16-static/tsconfig.json | 27 +++ .../nextjs-16-static/vercel.json | 17 ++ 96 files changed, 3069 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/ai-mock-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/[dynamic]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/ai-error-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/ai-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/cron-test-error/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/cron-test/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/endpoint-behind-middleware/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/openai/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/queue-send/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/queues/process-order/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/v3/topic/[...params]/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/component-annotation/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/db-page/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/global-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/isr-test/[product]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/isr-test/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/metrics/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/metrics/route-handler/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/nested-rsc-error/[param]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/non-isr-test/[item]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/pageload-tracing/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/pageload-tracing/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/prefetching/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/prefetching/to-be-prefetched/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/redirect/destination/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/redirect/origin/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/capture-exception/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/capture-message/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/edge/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/error/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/node/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/sri-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/sri-test/target/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/streaming-rsc-error/[param]/client-page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/streaming-rsc-error/[param]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/suspense-error/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/app/third-party-filter/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/eslint.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/instrumentation-client.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/lib/queue.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/next.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/proxy.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/public/file.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/public/globe.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/public/next.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/public/vercel.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/public/window.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/ai-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/ai-test.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/async-params.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/build-output.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/component-annotation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/db-page.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isDevMode.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/metrics.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/middleware.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/nested-rsc-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/node-runtime-metrics.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/openai.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/pageload-tracing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/parameterized-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/prefetch-spans.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/route-handler.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/server-action-redirect.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/server-components.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/sri.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/streaming-rsc-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/suspense-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/third-party-filter.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/turbopack-dev-in-app.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/vercel-cron-monitoring.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/vercel-queue.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-static/vercel.json diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-static/.gitignore new file mode 100644 index 000000000000..dd146b53d966 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/ai-mock-server.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-static/ai-mock-server.mjs new file mode 100644 index 000000000000..b97e0cb162dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/ai-mock-server.mjs @@ -0,0 +1,93 @@ +import { createServer } from 'node:http'; + +// A single mock server standing in for the OpenAI, Anthropic and Google GenAI HTTP APIs, so the real +// SDK clients emit gen_ai spans without any live credentials. Response bodies mirror the mock servers +// in the node-integration tests (suites/tracing/{openai,anthropic,google-genai}). Uses raw `node:http` +// (not express) so the mock doesn't itself get instrumented. + +function readJson(req) { + return new Promise(resolve => { + let body = ''; + req.on('data', chunk => (body += chunk)); + req.on('end', () => { + try { + resolve(JSON.parse(body || '{}')); + } catch { + resolve({}); + } + }); + }); +} + +function sendJson(res, status, obj) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(obj)); +} + +let serverPromise; + +/** Lazily starts the shared mock server and resolves to its port. */ +export function getMockAiPort() { + serverPromise ??= new Promise(resolve => { + const server = createServer(async (req, res) => { + const url = req.url || ''; + + // OpenAI: chat completions + if (req.method === 'POST' && url.endsWith('/openai/chat/completions')) { + const { model } = await readJson(req); + sendJson(res, 200, { + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [ + { index: 0, message: { role: 'assistant', content: 'Hello from OpenAI mock!' }, finish_reason: 'stop' }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }); + return; + } + + // Anthropic: messages + if (req.method === 'POST' && url.endsWith('/anthropic/v1/messages')) { + const { model } = await readJson(req); + sendJson(res, 200, { + id: 'msg_mock123', + type: 'message', + model, + role: 'assistant', + content: [{ type: 'text', text: 'Hello from Anthropic mock!' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 15 }, + }); + return; + } + + // Google GenAI: generateContent (the model name is embedded in the path before `:generateContent`). + // Plain string checks avoid the polynomial-backtracking risk of a `.+` regex on the URL. + if (req.method === 'POST' && url.startsWith('/v1beta/models/') && url.endsWith(':generateContent')) { + await readJson(req); + sendJson(res, 200, { + candidates: [ + { + content: { parts: [{ text: 'Mock response from Google GenAI!' }], role: 'model' }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { promptTokenCount: 8, candidatesTokenCount: 12, totalTokenCount: 20 }, + }); + return; + } + + res.writeHead(404).end(); + }); + + server.listen(0, () => { + resolve(server.address().port); + }); + }); + + return serverPromise; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/layout.tsx new file mode 100644 index 000000000000..ace0c2f086b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

Layout

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx new file mode 100644 index 000000000000..dbdc60adadc2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

DynamicLayout

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/[dynamic]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/[dynamic]/page.tsx new file mode 100644 index 000000000000..3eaddda2a1df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/[dynamic]/page.tsx @@ -0,0 +1,15 @@ +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( +
+

Dynamic Page

+
+ ); +} + +export async function generateMetadata() { + return { + title: 'I am dynamic page generated metadata', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/layout.tsx new file mode 100644 index 000000000000..ace0c2f086b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

Layout

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/page.tsx new file mode 100644 index 000000000000..8077c14d23ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/(nested-layout)/nested-layout/page.tsx @@ -0,0 +1,11 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Hello World!

; +} + +export async function generateMetadata() { + return { + title: 'I am generated metadata', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/ai-error-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/ai-error-test/page.tsx new file mode 100644 index 000000000000..bd75c0062228 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/ai-error-test/page.tsx @@ -0,0 +1,50 @@ +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +// Error trace handling in tool calls +async function runAITest() { + const result = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async args => { + throw new Error('Tool call failed'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); +} + +export default async function Page() { + await Sentry.startSpan({ op: 'function', name: 'ai-error-test' }, async () => { + return await runAITest(); + }); + + return ( +
+

AI Test Results

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/ai-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/ai-test/page.tsx new file mode 100644 index 000000000000..f0dd971fa642 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/ai-test/page.tsx @@ -0,0 +1,98 @@ +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +async function runAITest() { + // First span - telemetry should be enabled automatically but no input/output recorded by default + const result1 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'First span here!', + }), + }), + prompt: 'Where is the first span?', + }); + + // Second span - explicitly enabled telemetry, should record inputs/outputs + const result2 = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Second span here!', + }), + }), + prompt: 'Where is the second span?', + }); + + // Third span - with tool calls and tool results + const result3 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async args => { + return `Weather in ${args.location}: Sunny, 72°F`; + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + + // Fourth span - explicitly disabled telemetry, should not be captured + const result4 = await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Third span here!', + }), + }), + prompt: 'Where is the third span?', + }); + + return { + result1: result1.text, + result2: result2.text, + result3: result3.text, + result4: result4.text, + }; +} + +export default async function Page() { + const results = await Sentry.startSpan({ op: 'function', name: 'ai-test' }, async () => { + return await runAITest(); + }); + + return ( +
+

AI Test Results

+
{JSON.stringify(results, null, 2)}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/cron-test-error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/cron-test-error/route.ts new file mode 100644 index 000000000000..4826ffa16b15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/cron-test-error/route.ts @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export async function GET() { + throw new Error('Cron job error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/cron-test/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/cron-test/route.ts new file mode 100644 index 000000000000..e70938cbe491 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/cron-test/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + // Simulate some work + await new Promise(resolve => setTimeout(resolve, 100)); + return NextResponse.json({ message: 'Cron job executed successfully' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/endpoint-behind-middleware/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/endpoint-behind-middleware/route.ts new file mode 100644 index 000000000000..2733cc918f44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/endpoint-behind-middleware/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ name: 'John Doe' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/openai/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/openai/route.ts new file mode 100644 index 000000000000..9315f9d079ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/openai/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; +import OpenAI from 'openai'; +import { getMockAiPort } from '../../../ai-mock-server.mjs'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const port = await getMockAiPort(); + const client = new OpenAI({ + baseURL: `http://localhost:${port}/openai`, + apiKey: 'mock-api-key', + }); + + await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }); + + return NextResponse.json({ status: 'ok' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/queue-send/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/queue-send/route.ts new file mode 100644 index 000000000000..fe49a990921b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/queue-send/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { send } from '../../../lib/queue'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request) { + const body = await request.json(); + const topic = body.topic ?? 'orders'; + const payload = body.payload ?? body; + + const { messageId } = await send(topic, payload); + + return NextResponse.json({ messageId }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/queues/process-order/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/queues/process-order/route.ts new file mode 100644 index 000000000000..41cec36d5d8a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/queues/process-order/route.ts @@ -0,0 +1,10 @@ +import { handleCallback } from '../../../../lib/queue'; + +export const dynamic = 'force-dynamic'; + +// The @vercel/queue handleCallback return type (CallbackRequestInput) doesn't match +// Next.js's strict route handler type check with webpack builds, so we cast it. +export const POST = handleCallback(async (message, _metadata) => { + // Simulate some async work + await new Promise(resolve => setTimeout(resolve, 50)); +}) as unknown as (req: Request) => Promise; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/v3/topic/[...params]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/v3/topic/[...params]/route.ts new file mode 100644 index 000000000000..51dfa2e656db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/api/v3/topic/[...params]/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server'; + +/** + * Mock Vercel Queues API server. + * + * This route handler simulates the Vercel Queues HTTP API so that the real + * @vercel/queue SDK can be used in E2E tests without Vercel infrastructure. + * + * Handled endpoints: + * POST /api/v3/topic/{topic} → SendMessage + * POST /api/v3/topic/{topic}/consumer/{consumer}/id/{messageId} → ReceiveMessageById + * DELETE /api/v3/topic/{topic}/consumer/{consumer}/lease/{handle} → AcknowledgeMessage + * PATCH /api/v3/topic/{topic}/consumer/{consumer}/lease/{handle} → ExtendLease + */ + +export const dynamic = 'force-dynamic'; + +let messageCounter = 0; + +function generateMessageId(): string { + return `msg_test_${++messageCounter}_${Date.now()}`; +} + +function generateReceiptHandle(): string { + return `rh_test_${Date.now()}_${Math.random().toString(36).slice(2)}`; +} + +// Encode a file path into a consumer-group name, matching the SDK's algorithm. +function filePathToConsumerGroup(filePath: string): string { + let result = ''; + for (const char of filePath) { + if (char === '_') result += '__'; + else if (char === '/') result += '_S'; + else if (char === '.') result += '_D'; + else if (/[A-Za-z0-9-]/.test(char)) result += char; + else result += '_' + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'); + } + return result; +} + +// Topic → consumer route path (mirrors vercel.json experimentalTriggers). +const TOPIC_ROUTES: Record = { + orders: '/api/queues/process-order', +}; + +// The file path key used in vercel.json for each consumer route. +const ROUTE_FILE_PATHS: Record = { + '/api/queues/process-order': 'app/api/queues/process-order/route.ts', +}; + +export async function POST(request: Request, { params }: { params: Promise<{ params: string[] }> }) { + const { params: segments } = await params; + + // POST /api/v3/topic/{topic} → SendMessage + if (segments.length === 1) { + const topic = segments[0]; + const body = await request.arrayBuffer(); + const messageId = generateMessageId(); + const receiptHandle = generateReceiptHandle(); + const now = new Date(); + const createdAt = now.toISOString(); + const expiresAt = new Date(now.getTime() + 86_400_000).toISOString(); + const visibilityDeadline = new Date(now.getTime() + 300_000).toISOString(); + + const consumerRoute = TOPIC_ROUTES[topic]; + if (consumerRoute) { + const filePath = ROUTE_FILE_PATHS[consumerRoute] ?? consumerRoute; + const consumerGroup = filePathToConsumerGroup(filePath); + const port = process.env.PORT || 3030; + + // Simulate Vercel infrastructure pushing the message to the consumer. + // Fire-and-forget so the SendMessage response returns immediately. + void fetch(`http://localhost:${port}${consumerRoute}`, { + method: 'POST', + headers: { + 'ce-type': 'com.vercel.queue.v2beta', + 'ce-vqsqueuename': topic, + 'ce-vqsconsumergroup': consumerGroup, + 'ce-vqsmessageid': messageId, + 'ce-vqsreceipthandle': receiptHandle, + 'ce-vqsdeliverycount': '1', + 'ce-vqscreatedat': createdAt, + 'ce-vqsexpiresat': expiresAt, + 'ce-vqsregion': 'test1', + 'ce-vqsvisibilitydeadline': visibilityDeadline, + 'content-type': request.headers.get('content-type') || 'application/json', + }, + body: Buffer.from(body), + }).catch(err => console.error('[mock-queue] Failed to push to consumer:', err)); + } + + return NextResponse.json({ messageId }, { status: 201, headers: { 'Vqs-Message-Id': messageId } }); + } + + // POST /api/v3/topic/{topic}/consumer/{consumer}/id/{messageId} → ReceiveMessageById + // Not used in binary-mode push flow, but handled for completeness. + if (segments.length >= 4 && segments[1] === 'consumer') { + return new Response(null, { status: 204 }); + } + + return NextResponse.json({ error: 'Unknown endpoint' }, { status: 404 }); +} + +// DELETE /api/v3/topic/{topic}/consumer/{consumer}/lease/{receiptHandle} → AcknowledgeMessage +export async function DELETE() { + return new Response(null, { status: 204 }); +} + +// PATCH /api/v3/topic/{topic}/consumer/{consumer}/lease/{receiptHandle} → ExtendLease +export async function PATCH() { + return NextResponse.json({ success: true }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/component-annotation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/component-annotation/page.tsx new file mode 100644 index 000000000000..8ac6973dc5c8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/component-annotation/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +export default function ComponentAnnotationTestPage() { + return ( +
+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/db-page/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/db-page/page.tsx new file mode 100644 index 000000000000..dd7d53087291 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/db-page/page.tsx @@ -0,0 +1,52 @@ +import Redis from 'ioredis'; +import { Client } from 'pg'; + +export const dynamic = 'force-dynamic'; + +async function queryDatabases(): Promise<{ answer: string; cached: string | null }> { + const client = new Client({ + host: 'localhost', + port: 5432, + user: 'postgres', + password: 'docker', + database: 'postgres', + }); + + await client.connect(); + + let answer: string; + try { + const result = await client.query('SELECT 40 + 2 AS answer'); + answer = String(result.rows[0]?.answer); + } finally { + await client.end(); + } + + const redis = new Redis({ + // Don't keep retrying forever if Redis goes away (e.g. on test teardown) + maxRetriesPerRequest: 1, + retryStrategy: () => null, + }); + + let cached: string | null; + try { + await redis.set('page-key', answer); + cached = await redis.get('page-key'); + } finally { + redis.disconnect(); + } + + return { answer, cached }; +} + +export default async function DbPage() { + const { answer, cached } = await queryDatabases(); + + return ( +
+

DB page

+

answer: {answer}

+

cached: {cached}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/global-error.tsx new file mode 100644 index 000000000000..20c175015b03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/global-error.tsx @@ -0,0 +1,23 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import NextError from 'next/error'; +import { useEffect } from 'react'; + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/isr-test/[product]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/isr-test/[product]/page.tsx new file mode 100644 index 000000000000..cd1e085e2763 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/isr-test/[product]/page.tsx @@ -0,0 +1,17 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams + +export async function generateStaticParams(): Promise> { + return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }]; +} + +export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) { + const { product } = await params; + + return ( +
+

ISR Product: {product}

+
{product}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/isr-test/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/isr-test/static/page.tsx new file mode 100644 index 000000000000..f49605bd9da4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/isr-test/static/page.tsx @@ -0,0 +1,15 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; + +export async function generateStaticParams(): Promise { + return []; +} + +export default function ISRStaticPage() { + return ( +
+

ISR Static Page

+
static-isr
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/metrics/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/metrics/page.tsx new file mode 100644 index 000000000000..fdb7bc0a40a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/metrics/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +export default function Page() { + const handleClick = async () => { + Sentry.metrics.count('test.page.count', 1, { + attributes: { + page: '/metrics', + 'random.attribute': 'Apples', + }, + }); + Sentry.metrics.distribution('test.page.distribution', 100, { + attributes: { + page: '/metrics', + 'random.attribute': 'Manzanas', + }, + }); + Sentry.metrics.gauge('test.page.gauge', 200, { + attributes: { + page: '/metrics', + 'random.attribute': 'Mele', + }, + }); + await fetch('/metrics/route-handler'); + }; + + return ( +
+

Metrics page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/metrics/route-handler/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/metrics/route-handler/route.ts new file mode 100644 index 000000000000..84e81960f9c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/metrics/route-handler/route.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/nextjs'; + +export const GET = async () => { + Sentry.metrics.count('test.route.handler.count', 1, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Potatoes', + }, + }); + Sentry.metrics.distribution('test.route.handler.distribution', 100, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Patatas', + }, + }); + Sentry.metrics.gauge('test.route.handler.gauge', 200, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Patate', + }, + }); + return Response.json({ message: 'Bueno' }); +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/nested-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/nested-rsc-error/[param]/page.tsx new file mode 100644 index 000000000000..675b248026be --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/nested-rsc-error/[param]/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( + Loading...

}> + {/* @ts-ignore */} + ; +
+ ); +} + +async function Crash() { + throw new Error('I am technically uncatchable'); + return

unreachable

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/non-isr-test/[item]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/non-isr-test/[item]/page.tsx new file mode 100644 index 000000000000..e0bafdb24181 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/non-isr-test/[item]/page.tsx @@ -0,0 +1,11 @@ +// No generateStaticParams - this is NOT an ISR page +export default async function NonISRPage({ params }: { params: Promise<{ item: string }> }) { + const { item } = await params; + + return ( +
+

Non-ISR Dynamic Page: {item}

+
{item}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/page.tsx new file mode 100644 index 000000000000..2bc0a407a355 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Next 16 test app

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/pageload-tracing/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/pageload-tracing/layout.tsx new file mode 100644 index 000000000000..1f0cbe478f88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/pageload-tracing/layout.tsx @@ -0,0 +1,8 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default async function Layout({ children }: PropsWithChildren) { + await new Promise(resolve => setTimeout(resolve, 500)); + return <>{children}; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/pageload-tracing/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/pageload-tracing/page.tsx new file mode 100644 index 000000000000..689735d61ddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/pageload-tracing/page.tsx @@ -0,0 +1,14 @@ +export const dynamic = 'force-dynamic'; + +export default async function Page() { + await new Promise(resolve => setTimeout(resolve, 1000)); + return

I am page 2

; +} + +export async function generateMetadata() { + (await fetch('https://example.com/', { cache: 'no-store' })).text(); + + return { + title: 'my title', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..16ef0482d53b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/parameterized/static/page.tsx @@ -0,0 +1,3 @@ +export default function StaticPage() { + return
Static page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/prefetching/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/prefetching/page.tsx new file mode 100644 index 000000000000..4cb811ecf1b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/prefetching/page.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link'; + +export default function Page() { + return ( + + link + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/prefetching/to-be-prefetched/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/prefetching/to-be-prefetched/page.tsx new file mode 100644 index 000000000000..83aac90d65cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/prefetching/to-be-prefetched/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Hello

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/redirect/destination/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/redirect/destination/page.tsx new file mode 100644 index 000000000000..5583d36b04b0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/redirect/destination/page.tsx @@ -0,0 +1,7 @@ +export default function RedirectDestinationPage() { + return ( +
+

Redirect Destination

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/redirect/origin/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/redirect/origin/page.tsx new file mode 100644 index 000000000000..52615e0a054b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/redirect/origin/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from 'next/navigation'; + +async function redirectAction() { + 'use server'; + + redirect('/redirect/destination'); +} + +export default function RedirectOriginPage() { + return ( + <> + {/* @ts-ignore */} +
+ +
+ + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/capture-exception/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/capture-exception/route.ts new file mode 100644 index 000000000000..2f8a8b84d9e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/capture-exception/route.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + Sentry.captureException(new Error('route-handler-capture-exception')); + return NextResponse.json({ message: 'Exception captured' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/capture-message/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/capture-message/route.ts new file mode 100644 index 000000000000..67015ec11b2f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/capture-message/route.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + Sentry.captureMessage('route-handler-message'); + return NextResponse.json({ message: 'Message captured' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/edge/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/edge/route.ts new file mode 100644 index 000000000000..7cd1fc7e332c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/edge/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'edge'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Edge Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/error/route.ts new file mode 100644 index 000000000000..064b9df86854 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/error/route.ts @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export async function GET() { + throw new Error('route-handler-error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/node/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/node/route.ts new file mode 100644 index 000000000000..5bc418f077aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/route-handler/[xoxo]/node/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Node Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/sri-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/sri-test/page.tsx new file mode 100644 index 000000000000..94e17a8bb4ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/sri-test/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +export default function SriTestPage() { + const [count, setCount] = useState(0); + + return ( +
+

SRI Test Page

+ + + Go to target + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/sri-test/target/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/sri-test/target/page.tsx new file mode 100644 index 000000000000..80ea89c506d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/sri-test/target/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +export default function SriTestTargetPage() { + const [clicked, setClicked] = useState(false); + + return ( +
+

SRI Target Page

+ + + Go back + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/streaming-rsc-error/[param]/client-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/streaming-rsc-error/[param]/client-page.tsx new file mode 100644 index 000000000000..7b66c3fbdeef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/streaming-rsc-error/[param]/client-page.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { use } from 'react'; + +export function RenderPromise({ stringPromise }: { stringPromise: Promise }) { + const s = use(stringPromise); + return <>{s}; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/streaming-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/streaming-rsc-error/[param]/page.tsx new file mode 100644 index 000000000000..9531f9a42139 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/streaming-rsc-error/[param]/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from 'react'; +import { RenderPromise } from './client-page'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + const crashingPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('I am a data streaming error')); + }, 100); + }); + + return ( + Loading...

}> + ; +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/suspense-error/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/suspense-error/page.tsx new file mode 100644 index 000000000000..ff49745d405b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/suspense-error/page.tsx @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/nextjs'; +import { use } from 'react'; +export const dynamic = 'force-dynamic'; + +export default async function Page() { + try { + use(fetch('https://example.com/')); + } catch (e) { + Sentry.captureException(e); // This error should not be reported + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for any async event processors to run + await Sentry.flush(); + } + + return

test

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/third-party-filter/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/third-party-filter/page.tsx new file mode 100644 index 000000000000..b6b4bea80def --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/app/third-party-filter/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +function throwFirstPartyError(): void { + throw new Error('first-party-error'); +} + +export default function Page() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nextjs-16-static/docker-compose.yml new file mode 100644 index 000000000000..641f915577f7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/docker-compose.yml @@ -0,0 +1,28 @@ +services: + postgres: + image: postgres:16 + restart: always + container_name: e2e-tests-nextjs-16-static-postgres + ports: + - '5432:5432' + environment: + POSTGRES_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s + + redis: + image: redis:7 + restart: always + container_name: e2e-tests-nextjs-16-static-redis + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-static/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/eslint.config.mjs @@ -0,0 +1,19 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'], + }, +]; + +export default eslintConfig; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-static/global-setup.mjs new file mode 100644 index 000000000000..dc7ec8736faf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start Postgres + Redis via Docker Compose. `--wait` blocks until the + // healthchecks in docker-compose.yml pass, so the app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-static/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/instrumentation-client.ts new file mode 100644 index 000000000000..76b6706566c3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/instrumentation-client.ts @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/nextjs'; +import type { Log } from '@sentry/nextjs'; + +Sentry.init({ + // This app is the static counterpart of `nextjs-16`, which streams spans. + // Do not port it: it exists to keep the static trace lifecycle covered end to end. + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + integrations: [ + Sentry.thirdPartyErrorFilterIntegration({ + filterKeys: ['nextjs-16-static-e2e'], + behaviour: 'apply-tag-if-contains-third-party-frames', + }), + ], + // Verify Log type is available + beforeSendLog(log: Log) { + return log; + }, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/instrumentation.ts new file mode 100644 index 000000000000..34da3b114e1b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/instrumentation.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/nextjs'; +// Regression guard for https://github.com/getsentry/sentry-javascript/issues/21317: +import { pinoIntegration } from '@sentry/nextjs'; + +void pinoIntegration; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/lib/queue.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/lib/queue.ts new file mode 100644 index 000000000000..8dc8ce0ad5ed --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/lib/queue.ts @@ -0,0 +1,12 @@ +import { QueueClient } from '@vercel/queue'; + +// For E2E testing, point the SDK at a local mock server running within Next.js. +// The mock API lives at app/api/v3/topic/[...params]/route.ts +const queue = new QueueClient({ + region: 'test1', + resolveBaseUrl: () => new URL(`http://localhost:${process.env.PORT || 3030}`), + token: 'mock-token', + deploymentId: null, +}); + +export const { send, handleCallback } = queue; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/next.config.ts new file mode 100644 index 000000000000..63fcef137805 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/next.config.ts @@ -0,0 +1,24 @@ +import { withSentryConfig } from '@sentry/nextjs/config'; +import type { NextConfig } from 'next'; + +// Simulate Vercel environment for cron monitoring tests +process.env.VERCEL = '1'; + +const nextConfig: NextConfig = { + experimental: { + sri: { + algorithm: 'sha256', + }, + }, +}; + +export default withSentryConfig(nextConfig, { + silent: true, + applicationKey: 'nextjs-16-static-e2e', + reactComponentAnnotation: { + enabled: true, + }, + _experimental: { + vercelCronsMonitoring: true, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json new file mode 100644 index 000000000000..ed2a7ef09401 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json @@ -0,0 +1,46 @@ +{ + "name": "nextjs-16-static", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "start": "next start", + "lint": "eslint", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "//": "Pin `ioredis` to 5.10.1: it's the last version before the driver publishes its own native diagnostics channels; orchestrion's config covers `ioredis <5.11.0`.", + "dependencies": { + "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@vercel/queue": "^0.1.3", + "ai": "^3.0.0", + "import-in-the-middle": "^2", + "ioredis": "5.10.1", + "next": "16.2.11", + "openai": "5.18.1", + "pg": "^8.13.1", + "react": "19.1.0", + "react-dom": "19.1.0", + "require-in-the-middle": "^8", + "zod": "^3.22.4" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/pg": "^8.11.10", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "^16", + "typescript": "^5" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-static/playwright.config.mjs new file mode 100644 index 000000000000..cbac80f5b7e7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/playwright.config.mjs @@ -0,0 +1,33 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development-webpack') { + return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'development') { + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/proxy.ts new file mode 100644 index 000000000000..60722f329fa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/proxy.ts @@ -0,0 +1,24 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function proxy(request: NextRequest) { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + + if (request.headers.has('x-should-throw')) { + throw new Error('Middleware Error'); + } + + if (request.headers.has('x-should-make-request')) { + await fetch('http://localhost:3030/'); + } + + return NextResponse.next(); +} + +// See "Matching Paths" below to learn more +export const config = { + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/file.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/file.svg new file mode 100644 index 000000000000..004145cddf3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/globe.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/globe.svg new file mode 100644 index 000000000000..567f17b0d7c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/next.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/next.svg new file mode 100644 index 000000000000..5174b28c565c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/vercel.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/vercel.svg new file mode 100644 index 000000000000..77053960334e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/window.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/window.svg new file mode 100644 index 000000000000..b2b2a44f6ebc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/sentry.edge.config.ts new file mode 100644 index 000000000000..d98baaf4651f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/sentry.edge.config.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + // This app is the static counterpart of `nextjs-16`, which streams spans. + // Do not port it: it exists to keep the static trace lifecycle covered end to end. + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + // debug: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/sentry.server.config.ts new file mode 100644 index 000000000000..c2dc1ac56917 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/sentry.server.config.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/nextjs'; +import { Log } from '@sentry/nextjs'; + +Sentry.init({ + // This app is the static counterpart of `nextjs-16`, which streams spans. + // Do not port it: it exists to keep the static trace lifecycle covered end to end. + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + // debug: true, + integrations: [Sentry.vercelAIIntegration(), Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 })], + // Verify Log type is available + beforeSendLog(log: Log) { + return log; + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-static/start-event-proxy.mjs new file mode 100644 index 000000000000..c13c32c146e5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-16-static', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-16-static-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/ai-error.test.ts new file mode 100644 index 000000000000..e87f4ba723a3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/ai-error.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpans, waitForTransaction } from '@sentry-internal/test-utils'; + +// FIXME: This app uses `ai@^3`, which the channel-based Vercel AI integration doesn't instrument +// (it supports v4-v6 via the orchestrion transform and v7 via the native `ai:telemetry` channel). +// With channel-based instrumentation now the default, no gen_ai spans are produced. Re-enable once +// the app is upgraded to `ai@v7` (or v3 support is restored). +test.fixme('should create AI spans with correct attributes and error linking', async ({ page }) => { + const aiTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent.transaction === 'GET /ai-error-test'; + }); + + // gen_ai spans are extracted into a separate span v2 envelope item + const genAiSpansPromise = waitForStreamedSpans('nextjs-16-static', spans => + spans.some(span => getSpanOp(span) === 'gen_ai.invoke_agent'), + ); + + const errorEventPromise = waitForError('nextjs-16-static', async errorEvent => { + return !!errorEvent.exception?.values?.[0]?.value?.includes('Tool call failed'); + }); + + await page.goto('/ai-error-test'); + + const aiTransaction = await aiTransactionPromise; + const genAiSpans = await genAiSpansPromise; + const errorEvent = await errorEventPromise; + + expect(aiTransaction).toBeDefined(); + expect(aiTransaction.transaction).toBe('GET /ai-error-test'); + + // Each generateText call should create 2 spans: one for the pipeline and one for doGenerate + // Plus a span for the tool call + // TODO: For now, this is sadly not fully working - the monkey patching of the ai package is not working + // because of this, only spans that are manually opted-in at call time will be captured + // this may be fixed by https://github.com/vercel/ai/pull/6716 in the future + const aiPipelineSpans = genAiSpans.filter(span => getSpanOp(span) === 'gen_ai.invoke_agent'); + const aiGenerateSpans = genAiSpans.filter(span => getSpanOp(span) === 'gen_ai.generate_content'); + const toolCallSpans = genAiSpans.filter(span => getSpanOp(span) === 'gen_ai.execute_tool'); + + expect(aiPipelineSpans.length).toBeGreaterThanOrEqual(1); + expect(aiGenerateSpans.length).toBeGreaterThanOrEqual(1); + expect(toolCallSpans.length).toBeGreaterThanOrEqual(0); + + expect(errorEvent).toBeDefined(); + + //Verify error is linked to the same trace as the transaction + expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiTransaction.contexts?.trace?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/ai-test.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/ai-test.test.ts new file mode 100644 index 000000000000..adecce61a811 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/ai-test.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpans, waitForTransaction } from '@sentry-internal/test-utils'; + +// FIXME: This app uses `ai@^3`, which the channel-based Vercel AI integration doesn't instrument +// (it supports v4-v6 via the orchestrion transform and v7 via the native `ai:telemetry` channel). +// With channel-based instrumentation now the default, no gen_ai spans are produced. Re-enable once +// the app is upgraded to `ai@v7` (or v3 support is restored). +test.fixme('should create AI spans with correct attributes', async ({ page }) => { + const aiTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent.transaction === 'GET /ai-test'; + }); + + // gen_ai spans are extracted into a separate span v2 envelope item + const genAiSpansPromise = waitForStreamedSpans('nextjs-16-static', spans => + spans.some(span => getSpanOp(span) === 'gen_ai.invoke_agent'), + ); + + await page.goto('/ai-test'); + + const aiTransaction = await aiTransactionPromise; + const genAiSpans = await genAiSpansPromise; + + expect(aiTransaction).toBeDefined(); + expect(aiTransaction.transaction).toBe('GET /ai-test'); + + // We expect spans for the first 3 AI calls (4th is disabled) + // Each generateText call should create 2 spans: one for the pipeline and one for doGenerate + // Plus a span for the tool call + // TODO: For now, this is sadly not fully working - the monkey patching of the ai package is not working + // because of this, only spans that are manually opted-in at call time will be captured + // this may be fixed by https://github.com/vercel/ai/pull/6716 in the future + const aiPipelineSpans = genAiSpans.filter(span => getSpanOp(span) === 'gen_ai.invoke_agent'); + const aiGenerateSpans = genAiSpans.filter(span => getSpanOp(span) === 'gen_ai.generate_content'); + const toolCallSpans = genAiSpans.filter(span => getSpanOp(span) === 'gen_ai.execute_tool'); + + expect(aiPipelineSpans.length).toBeGreaterThanOrEqual(1); + expect(aiGenerateSpans.length).toBeGreaterThanOrEqual(1); + expect(toolCallSpans.length).toBeGreaterThanOrEqual(0); + + // First AI call - should have telemetry enabled and record inputs/outputs by default + /* const firstPipelineSpan = aiPipelineSpans[0]; + expect(firstPipelineSpan?.attributes['vercel.ai.model.id']?.value).toBe('mock-model-id'); + expect(firstPipelineSpan?.attributes['vercel.ai.model.provider']?.value).toBe('mock-provider'); + expect(firstPipelineSpan?.attributes['vercel.ai.prompt']?.value).toContain('Where is the first span?'); + expect(firstPipelineSpan?.attributes['gen_ai.output.messages']?.value).toContain('First span here!'); + expect(firstPipelineSpan?.attributes['gen_ai.usage.input_tokens']?.value).toBe(10); + expect(firstPipelineSpan?.attributes['gen_ai.usage.output_tokens']?.value).toBe(20); */ + + // Second AI call - explicitly enabled telemetry + const secondPipelineSpan = aiPipelineSpans[0]; + expect(secondPipelineSpan?.attributes['vercel.ai.prompt']?.value).toContain('Where is the second span?'); + expect(secondPipelineSpan?.attributes['gen_ai.output.messages']?.value).toContain('Second span here!'); + + // Third AI call - with tool calls + /* const thirdPipelineSpan = aiPipelineSpans[2]; + expect(thirdPipelineSpan?.attributes['vercel.ai.response.finishReason']?.value).toBe('tool-calls'); + expect(thirdPipelineSpan?.attributes['gen_ai.usage.input_tokens']?.value).toBe(15); + expect(thirdPipelineSpan?.attributes['gen_ai.usage.output_tokens']?.value).toBe(25); */ + + // Tool call span + /* const toolSpan = toolCallSpans[0]; + expect(toolSpan?.attributes['vercel.ai.toolCall.name']?.value).toBe('getWeather'); + expect(toolSpan?.attributes['vercel.ai.toolCall.id']?.value).toBe('call-1'); + expect(toolSpan?.attributes['vercel.ai.toolCall.args']?.value).toContain('San Francisco'); + expect(toolSpan?.attributes['vercel.ai.toolCall.result']?.value).toContain('Sunny, 72°F'); */ + + // Verify the fourth call was not captured (telemetry disabled) + const promptsInSpans = genAiSpans + .map(span => span.attributes['vercel.ai.prompt']?.value) + .filter((prompt): prompt is string => typeof prompt === 'string'); + const hasDisabledPrompt = promptsInSpans.some(prompt => prompt.includes('Where is the third span?')); + expect(hasDisabledPrompt).toBe(false); + + // Verify results are displayed on the page + const resultsText = await page.locator('#ai-results').textContent(); + expect(resultsText).toContain('First span here!'); + expect(resultsText).toContain('Second span here!'); + expect(resultsText).toContain('Tool call completed!'); + expect(resultsText).toContain('Third span here!'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/async-params.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/async-params.test.ts new file mode 100644 index 000000000000..e8160d12aded --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/async-params.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from '@playwright/test'; +import fs from 'fs'; +import { isDevMode } from './isDevMode'; + +test('should not print warning for async params', async ({ page }) => { + test.skip(!isDevMode, 'should be skipped for non-dev mode'); + await page.goto('/'); + + // If the server exits with code 1, the test will fail (see instrumentation.ts) + const devStdout = fs.readFileSync('.tmp_dev_server_logs', 'utf-8'); + expect(devStdout).not.toContain('`params` should be awaited before using its properties.'); + + await expect(page.getByText('Next 16 test app')).toBeVisible(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/build-output.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/build-output.test.ts new file mode 100644 index 000000000000..735e04649c32 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/build-output.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test'; +import { findAbsolutePathImports } from '@sentry-internal/test-utils'; +import * as path from 'path'; + +test('emits no absolute-path imports into the server output', () => { + const leaks = findAbsolutePathImports({ outputDir: path.join(process.cwd(), '.next', 'server') }); + + expect(leaks).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/component-annotation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/component-annotation.test.ts new file mode 100644 index 000000000000..a528025b8fe1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/component-annotation.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const isWebpackDev = process.env.TEST_ENV === 'development-webpack'; + +test('React component annotation adds data-sentry-component attributes (Turbopack)', async ({ page }) => { + test.skip(isWebpackDev, 'Only relevant for Turbopack builds'); + + await page.goto('/component-annotation'); + + const button = page.locator('#annotated-btn'); + await expect(button).toBeVisible(); + + // Set up error listener before clicking + const errorPromise = waitForError('nextjs-16-static', errorEvent => { + return errorEvent?.exception?.values?.some(value => value.value === 'component-annotation-test') ?? false; + }); + + await button.click(); + const errorEvent = await errorPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('component-annotation-test'); + + // In production, TEST_ENV=production is shared by both turbopack and webpack variants. + // The component annotation loader only runs in Turbopack builds, so use the independent + // turbopack tag (set by the SDK based on build metadata) to gate assertions rather than + // checking the feature's own output, which would silently pass on regression. + if (errorEvent.tags?.turbopack) { + const annotatedEl = page.locator('[data-sentry-component="ComponentAnnotationTestPage"]'); + await expect(annotatedEl).toBeVisible(); + + const clickBreadcrumb = errorEvent.breadcrumbs?.find(bc => bc.category === 'ui.click'); + expect(clickBreadcrumb?.data?.['ui.component_name']).toBe('ComponentAnnotationTestPage'); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/db-page.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/db-page.test.ts new file mode 100644 index 000000000000..f82a49550158 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/db-page.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Instruments DB calls made during server-side rendering of a page', async ({ page }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-static', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-page'; + }); + + await page.goto('/db-page'); + await expect(page.locator('#answer')).toHaveText('answer: 42'); + await expect(page.locator('#cached')).toHaveText('cached: 42'); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + // One page render produces spans from both injection paths: pg (externalized → runtime module + // hook) and ioredis (bundle-safe allowlisted → build-time loader). + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.postgres', + description: 'SELECT 40 + 2 AS answer', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'postgresql', + 'db.query.text': 'SELECT 40 + 2 AS answer', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db.query', + origin: 'auto.db.redis', + description: 'set page-key [1 other arguments]', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'redis', + 'db.operation.name': 'set', + 'db.query.text': 'set page-key [1 other arguments]', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db.query', + origin: 'auto.db.redis', + description: 'get page-key', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'redis', + 'db.operation.name': 'get', + 'db.query.text': 'get page-key', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isDevMode.ts new file mode 100644 index 000000000000..28f60f6f035c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isDevMode.ts @@ -0,0 +1,2 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); +export const isTurbopackDevMode = process.env.TEST_ENV === 'development'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts new file mode 100644 index 000000000000..4fde597dfeaa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { + // Navigate to ISR page + await page.goto('/isr-test/laptop'); + + // Wait for page to be fully loaded + await expect(page.locator('#isr-product-id')).toHaveText('laptop'); + + // Check that sentry-trace and baggage meta tags are removed for ISR pages + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => { + // Navigate to ISR static page + await page.goto('/isr-test/static'); + + // Wait for page to be fully loaded + await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); + + // Check that sentry-trace and baggage meta tags are removed for ISR pages + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should remove meta tags for different ISR dynamic route values', async ({ page }) => { + // Test with 'phone' (one of the pre-generated static params) + await page.goto('/isr-test/phone'); + await expect(page.locator('#isr-product-id')).toHaveText('phone'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + + // Test with 'tablet' + await page.goto('/isr-test/tablet'); + await expect(page.locator('#isr-product-id')).toHaveText('tablet'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should create unique transactions for ISR pages on each visit', async ({ page }) => { + const traceIds: string[] = []; + + // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed + for (let i = 0; i < 5; i++) { + const transactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return !!( + transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + if (i === 0) { + await page.goto('/isr-test/laptop'); + } else { + await page.reload(); + } + + const transaction = await transactionPromise; + const traceId = transaction.contexts?.trace?.trace_id; + + expect(traceId).toBeDefined(); + expect(traceId).toMatch(/[a-f0-9]{32}/); + traceIds.push(traceId!); + } + + // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags) + const uniqueTraceIds = new Set(traceIds); + expect(uniqueTraceIds.size).toBe(5); +}); + +test('ISR route should be identified correctly in the route manifest', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/isr-test/laptop'); + const transaction = await transactionPromise; + + // Verify the transaction is properly parameterized + expect(transaction).toMatchObject({ + transaction: '/isr-test/:product', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.segment.name.source': 'route', + }, + }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/metrics.test.ts new file mode 100644 index 000000000000..bfc47722c4e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/metrics.test.ts @@ -0,0 +1,133 @@ +import { expect, test } from '@playwright/test'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +test('Should emit metrics from server and client', async ({ request, page }) => { + const clientCountPromise = waitForMetric('nextjs-16-static', async metric => { + return metric.name === 'test.page.count'; + }); + + const clientDistributionPromise = waitForMetric('nextjs-16-static', async metric => { + return metric.name === 'test.page.distribution'; + }); + + const clientGaugePromise = waitForMetric('nextjs-16-static', async metric => { + return metric.name === 'test.page.gauge'; + }); + + const serverCountPromise = waitForMetric('nextjs-16-static', async metric => { + return metric.name === 'test.route.handler.count'; + }); + + const serverDistributionPromise = waitForMetric('nextjs-16-static', async metric => { + return metric.name === 'test.route.handler.distribution'; + }); + + const serverGaugePromise = waitForMetric('nextjs-16-static', async metric => { + return metric.name === 'test.route.handler.gauge'; + }); + + await page.goto('/metrics'); + await page.getByText('Emit').click(); + const clientCount = await clientCountPromise; + const clientDistribution = await clientDistributionPromise; + const clientGauge = await clientGaugePromise; + const serverCount = await serverCountPromise; + const serverDistribution = await serverDistributionPromise; + const serverGauge = await serverGaugePromise; + + expect(clientCount).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + name: 'test.page.count', + type: 'counter', + value: 1, + attributes: { + page: { value: '/metrics', type: 'string' }, + 'random.attribute': { value: 'Apples', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(clientDistribution).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + name: 'test.page.distribution', + type: 'distribution', + value: 100, + attributes: { + page: { value: '/metrics', type: 'string' }, + 'random.attribute': { value: 'Manzanas', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(clientGauge).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + name: 'test.page.gauge', + type: 'gauge', + value: 200, + attributes: { + page: { value: '/metrics', type: 'string' }, + 'random.attribute': { value: 'Mele', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(serverCount).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.route.handler.count', + type: 'counter', + value: 1, + attributes: { + 'server.address': { value: expect.any(String), type: 'string' }, + 'random.attribute': { value: 'Potatoes', type: 'string' }, + endpoint: { value: '/metrics/route-handler', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(serverDistribution).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.route.handler.distribution', + type: 'distribution', + value: 100, + attributes: { + 'server.address': { value: expect.any(String), type: 'string' }, + 'random.attribute': { value: 'Patatas', type: 'string' }, + endpoint: { value: '/metrics/route-handler', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(serverGauge).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.route.handler.gauge', + type: 'gauge', + value: 200, + attributes: { + 'server.address': { value: expect.any(String), type: 'string' }, + 'random.attribute': { value: 'Patate', type: 'string' }, + endpoint: { value: '/metrics/route-handler', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/middleware.test.ts new file mode 100644 index 000000000000..8852e0883eb7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/middleware.test.ts @@ -0,0 +1,155 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; + +test('Should create a transaction for middleware', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + const routeTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'GET /api/endpoint-behind-middleware'; + }); + + const response = await request.get('/api/endpoint-behind-middleware'); + expect(await response.json()).toStrictEqual({ name: 'John Doe' }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); + expect(middlewareTransaction.transaction_info?.source).toBe('route'); + + expect(middlewareTransaction.request?.method).toBe('GET'); + expect(middlewareTransaction.request?.url).toContain('/api/endpoint-behind-middleware'); + + // The `Middleware.execute` OTEL root span is the only `middleware` span. The build-time + // `wrapMiddlewareWithSentry` wrapper used to start a second, redundant one nested inside it. + const nestedMiddlewareSpans = middlewareTransaction.spans?.filter(span => span.op === 'middleware'); + expect(nestedMiddlewareSpans).toHaveLength(0); + + // Assert that isolation scope works properly + expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + + // Tags set in middleware must not leak into other requests' events (e.g. via a shared scope when the middleware + // runs in a detached context - https://github.com/vercel/next.js/pull/95306) + const routeTransaction = await routeTransactionPromise; + expect(routeTransaction.tags?.['my-isolated-tag']).not.toBeDefined(); + expect(routeTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); +}); + +test('Faulty middlewares', async ({ request }) => { + test.skip(isDevMode, 'Throwing crashes the dev server atm'); // https://github.com/vercel/next.js/issues/85261 + const middlewareTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + const errorEventPromise = waitForError('nextjs-16-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { + // Noop + }); + + await test.step('should record transactions', async () => { + const middlewareTransaction = await middlewareTransactionPromise; + expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); + expect(middlewareTransaction.transaction_info?.source).toBe('route'); + }); + + // TODO: proxy errors currently not reported via onRequestError + // await test.step('should record exceptions', async () => { + // const errorEvent = await errorEventPromise; + + // // Assert that isolation scope works properly + // expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + // expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + // expect([ + // 'middleware GET', // non-otel webpack versions + // '/middleware', // middleware file + // '/proxy', // proxy file + // ]).toContain(errorEvent.transaction); + // }); +}); + +test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { + test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm'); + const middlewareTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + // In some builds (especially webpack), fetch spans may end up in a separate transaction instead of as child spans + // This test validates that the fetch is traced either way + const fetchTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return ( + transactionEvent?.transaction === 'GET http://localhost:3030/' || + transactionEvent?.contexts?.trace?.description === 'GET http://localhost:3030/' + ); + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => { + // Noop + }); + + const middlewareTransaction = await middlewareTransactionPromise; + + // Breadcrumbs should always be created for the fetch request + expect(middlewareTransaction.breadcrumbs).toEqual( + expect.arrayContaining([ + { + category: 'http', + data: { 'http.request.method': 'GET', status_code: 200, url: 'http://localhost:3030/' }, + timestamp: expect.any(Number), + type: 'http', + }, + ]), + ); + + // Check if http.client span exists as a child of the middleware transaction + const hasHttpClientSpan = !!middlewareTransaction.spans?.find(span => span.op === 'http.client'); + + if (hasHttpClientSpan) { + // Check if fetch is traced as a child span of the middleware transaction + expect(middlewareTransaction.spans).toContainEqual({ + data: { + 'http.request.method': 'GET', + 'http.request.method_original': 'GET', + 'http.response.status_code': 200, + 'network.peer.address': '::1', + 'network.peer.port': 3030, + 'sentry.kind': 'client', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.node_fetch', + 'server.address': 'localhost', + 'server.port': 3030, + 'url.domain': 'localhost', + 'url.full': 'http://localhost:3030/', + 'url.path': '/', + 'url.scheme': 'http', + 'user_agent.original': 'node', + }, + description: 'GET http://localhost:3030/', + op: 'http.client', + origin: 'auto.http.node_fetch', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + } else { + // Alternatively, fetch is traced as a separate transaction, similar to Dev builds + const fetchTransaction = await fetchTransactionPromise; + + expect(fetchTransaction.contexts?.trace?.op).toBe('http.client'); + expect(fetchTransaction.contexts?.trace?.status).toBe('ok'); + expect(fetchTransaction.contexts?.trace?.data?.['http.request.method']).toBe('GET'); + expect(fetchTransaction.contexts?.trace?.data?.['url.full']).toBe('http://localhost:3030/'); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/nested-rsc-error.test.ts new file mode 100644 index 000000000000..d7bc82203d1b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/nested-rsc-error.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ + page, +}) => { + const errorEventPromise = waitForError('nextjs-16-static', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable'); + }); + + const serverTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'GET /nested-rsc-error/[param]'; + }); + + await page.goto(`/nested-rsc-error/123`); + const errorEvent = await errorEventPromise; + const serverTransactionEvent = await serverTransactionPromise; + + // error event is part of the transaction + expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id); + + expect(errorEvent.request).toMatchObject({ + headers: expect.any(Object), + method: 'GET', + }); + + expect(errorEvent.contexts?.nextjs).toEqual({ + route_type: 'render', + router_kind: 'App Router', + router_path: '/nested-rsc-error/[param]', + request_path: '/nested-rsc-error/123', + }); + + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.nextjs.on_request_error', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/node-runtime-metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/node-runtime-metrics.test.ts new file mode 100644 index 000000000000..f4f1f04317ac --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/node-runtime-metrics.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +const EXPECTED_ATTRIBUTES = { + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.origin': { value: 'auto.node.runtime_metrics', type: 'string' }, +}; + +test('Should emit node runtime memory metrics', async ({ baseURL }) => { + const rssPromise = waitForMetric('nextjs-16-static', metric => { + return metric.name === 'node.runtime.mem.rss'; + }); + + const heapUsedPromise = waitForMetric('nextjs-16-static', metric => { + return metric.name === 'node.runtime.mem.heap_used'; + }); + + const heapTotalPromise = waitForMetric('nextjs-16-static', metric => { + return metric.name === 'node.runtime.mem.heap_total'; + }); + + // Trigger a request to ensure the server is running and metrics start being collected + await fetch(`${baseURL}/`); + + const rss = await rssPromise; + const heapUsed = await heapUsedPromise; + const heapTotal = await heapTotalPromise; + + expect(rss).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.rss', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(heapUsed).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.heap_used', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(heapTotal).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.heap_total', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime CPU utilization metric', async ({ baseURL }) => { + const cpuUtilPromise = waitForMetric('nextjs-16-static', metric => { + return metric.name === 'node.runtime.cpu.utilization'; + }); + + await fetch(`${baseURL}/`); + + const cpuUtil = await cpuUtilPromise; + + expect(cpuUtil).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.cpu.utilization', + type: 'gauge', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime event loop metrics', async ({ baseURL }) => { + const elDelayP50Promise = waitForMetric('nextjs-16-static', metric => { + return metric.name === 'node.runtime.event_loop.delay.p50'; + }); + + const elDelayP99Promise = waitForMetric('nextjs-16-static', metric => { + return metric.name === 'node.runtime.event_loop.delay.p99'; + }); + + const elUtilPromise = waitForMetric('nextjs-16-static', metric => { + return metric.name === 'node.runtime.event_loop.utilization'; + }); + + await fetch(`${baseURL}/`); + + const elDelayP50 = await elDelayP50Promise; + const elDelayP99 = await elDelayP99Promise; + const elUtil = await elUtilPromise; + + expect(elDelayP50).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.delay.p50', + type: 'gauge', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(elDelayP99).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.delay.p99', + type: 'gauge', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(elUtil).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.utilization', + type: 'gauge', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime uptime counter', async ({ baseURL }) => { + const uptimePromise = waitForMetric('nextjs-16-static', metric => { + return metric.name === 'node.runtime.process.uptime'; + }); + + await fetch(`${baseURL}/`); + + const uptime = await uptimePromise; + + expect(uptime).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.process.uptime', + type: 'counter', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/openai.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/openai.test.ts new file mode 100644 index 000000000000..85f503ed8ca4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/openai.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpans } from '@sentry-internal/test-utils'; + +// gen_ai spans are emitted as a separate span-v2 envelope item (not inline on the transaction), so we +// assert on the streamed spans. Attribute values are wrapped as `{ value, type }` in the v2 format. +test('Instruments openai automatically via orchestrion', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('nextjs-16-static', spans => + spans.some(span => span.attributes['sentry.origin']?.value === 'auto.ai.openai'), + ); + + await fetch(`${baseURL}/api/openai`); + + const spans = await spansPromise; + + const chatSpan = spans.find(span => span.name === 'chat gpt-3.5-turbo'); + expect(chatSpan).toBeDefined(); + expect(chatSpan?.attributes['sentry.op']?.value).toBe('gen_ai.chat'); + expect(chatSpan?.attributes['sentry.origin']?.value).toBe('auto.ai.openai'); + expect(chatSpan?.attributes['gen_ai.provider.name']?.value).toBe('openai'); + expect(chatSpan?.attributes['gen_ai.request.model']?.value).toBe('gpt-3.5-turbo'); + expect(chatSpan?.attributes['gen_ai.usage.input_tokens']?.value).toBe(10); + expect(chatSpan?.attributes['gen_ai.usage.output_tokens']?.value).toBe(15); + expect(chatSpan?.attributes['gen_ai.usage.total_tokens']?.value).toBe(25); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/pageload-tracing.test.ts new file mode 100644 index 000000000000..036adea9d4b1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/pageload-tracing.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('App router transactions should be attached to the pageload request span', async ({ page }) => { + const serverTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'GET /pageload-tracing'; + }); + + const pageloadTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === '/pageload-tracing'; + }); + + await page.goto(`/pageload-tracing`); + + const [serverTransaction, pageloadTransaction] = await Promise.all([ + serverTransactionPromise, + pageloadTransactionPromise, + ]); + + const pageloadTraceId = pageloadTransaction.contexts?.trace?.trace_id; + + expect(pageloadTraceId).toBeTruthy(); + expect(serverTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); +}); + +test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { + const serverTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'GET /pageload-tracing'; + }); + + await fetch(`${baseURL}/pageload-tracing`, { + headers: { + 'User-Agent': 'Custom-NextJS-Agent/15.0', + 'Content-Type': 'text/html', + 'X-NextJS-Test': 'nextjs-header-value', + Accept: 'text/html, application/xhtml+xml', + 'X-Framework': 'Next.js', + 'X-Request-ID': 'nextjs-789', + }, + }); + + const serverTransaction = await serverTransactionPromise; + + expect(serverTransaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.request.header.user_agent': 'Custom-NextJS-Agent/15.0', + 'http.request.header.content_type': 'text/html', + 'http.request.header.x_nextjs_test': 'nextjs-header-value', + 'http.request.header.accept': 'text/html, application/xhtml+xml', + 'http.request.header.x_framework': 'Next.js', + 'http.request.header.x_request_id': 'nextjs-789', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/parameterized-routes.test.ts new file mode 100644 index 000000000000..33175e1a1fa1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/parameterized-routes.test.ts @@ -0,0 +1,171 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.segment.name.source': 'route', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/parameterized\/cappuccino$/), + 'url.path': '/parameterized/cappuccino', + 'url.template': '/parameterized/:one', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a transaction named after the static route when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.segment.name.source': 'route', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/parameterized\/static$/), + 'url.path': '/parameterized/static', + 'url.template': '/parameterized/static', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/static$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/static', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.segment.name.source': 'route', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/parameterized\/cappuccino\/beep$/), + 'url.path': '/parameterized/cappuccino/beep', + 'url.template': '/parameterized/:one/beep', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a nested parameterized transaction when the `app` directory is used.', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep/:two' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.segment.name.source': 'route', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/parameterized\/cappuccino\/beep\/espresso$/), + 'url.path': '/parameterized/cappuccino/beep/espresso', + 'url.template': '/parameterized/:one/beep/:two', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep/:two', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/prefetch-spans.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/prefetch-spans.test.ts new file mode 100644 index 000000000000..244e9a890bcc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/prefetch-spans.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; + +test('Prefetch client spans should have a http.request.prefetch attribute', async ({ page }) => { + test.skip(isDevMode, "Prefetch requests don't have the prefetch header in dev mode"); + + const pageloadTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === '/prefetching'; + }); + + await page.goto(`/prefetching`); + + // Make it more likely that nextjs prefetches + await page.hover('#prefetch-link'); + + expect((await pageloadTransactionPromise).spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + 'http.request.prefetch': true, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/route-handler.test.ts new file mode 100644 index 000000000000..9a91fc9c95cd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/route-handler.test.ts @@ -0,0 +1,136 @@ +import test, { expect } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isTurbopackDevMode } from './isDevMode'; + +test('Should create a transaction for node route handlers', async ({ request }) => { + test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for dynamic routes in dev mode'); + + const routehandlerTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/node'; + }); + + const response = await request.get('/route-handler/123/node', { headers: { 'x-charly': 'gomez' } }); + expect(await response.json()).toStrictEqual({ message: 'Hello Node Route Handler' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerTransaction.contexts?.trace?.data?.['http.request.header.x_charly']).toBe('gomez'); +}); + +test('Should create a transaction for edge route handlers', async ({ request }) => { + // This test only works for webpack builds on non-async param extraction + // todo: check if we can set request headers for edge on sdkProcessingMetadata + test.skip(); + const routehandlerTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/edge'; + }); + + const response = await request.get('/route-handler/123/edge', { headers: { 'x-charly': 'gomez' } }); + expect(await response.json()).toStrictEqual({ message: 'Hello Edge Route Handler' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerTransaction.contexts?.trace?.data?.['http.request.header.x_charly']).toBe('gomez'); +}); + +test('Should report an error with a parameterized transaction name for a throwing route handler', async ({ + request, +}) => { + test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for dynamic routes in dev mode'); + + const errorEventPromise = waitForError('nextjs-16-static', errorEvent => { + return errorEvent?.exception?.values?.some(value => value.value === 'route-handler-error') ?? false; + }); + + const transactionEventPromise = waitForTransaction('nextjs-16-static', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /route-handler/[xoxo]/error' && + transactionEvent?.contexts?.trace?.op === 'http.server' + ); + }); + + request.get('/route-handler/456/error').catch(() => {}); + + const errorEvent = await errorEventPromise; + const transactionEvent = await transactionEventPromise; + + // Error event should be part of the same trace as the transaction + expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + + // Error should carry the parameterized transaction name + expect(errorEvent.transaction).toBe('GET /route-handler/[xoxo]/error'); + + // Transaction should have parameterized name and internal_error status + expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/error'); + expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); +}); + +test('Should set a parameterized transaction name on a captureMessage event in a route handler', async ({ + request, +}) => { + test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for dynamic routes in dev mode'); + + const messageEventPromise = waitForError('nextjs-16-static', event => { + return event?.message === 'route-handler-message'; + }); + + const transactionEventPromise = waitForTransaction('nextjs-16-static', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /route-handler/[xoxo]/capture-message' && + transactionEvent?.contexts?.trace?.op === 'http.server' + ); + }); + + const response = await request.get('/route-handler/789/capture-message'); + expect(await response.json()).toStrictEqual({ message: 'Message captured' }); + + const messageEvent = await messageEventPromise; + const transactionEvent = await transactionEventPromise; + + // Message event should be part of the same trace as the transaction + expect(messageEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + + // Message should carry the parameterized transaction name + expect(messageEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-message'); + + // Transaction should have parameterized name and ok status + expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-message'); + expect(transactionEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Should set a parameterized transaction name on a captureException event in a route handler', async ({ + request, +}) => { + test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for dynamic routes in dev mode'); + + const errorEventPromise = waitForError('nextjs-16-static', errorEvent => { + return errorEvent?.exception?.values?.some(value => value.value === 'route-handler-capture-exception') ?? false; + }); + + const transactionEventPromise = waitForTransaction('nextjs-16-static', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /route-handler/[xoxo]/capture-exception' && + transactionEvent?.contexts?.trace?.op === 'http.server' + ); + }); + + const response = await request.get('/route-handler/321/capture-exception'); + expect(await response.json()).toStrictEqual({ message: 'Exception captured' }); + + const errorEvent = await errorEventPromise; + const transactionEvent = await transactionEventPromise; + + // Error event should be part of the same trace as the transaction + expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + + // Manually captured exception should carry the parameterized transaction name + expect(errorEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-exception'); + + // Transaction should have parameterized name and ok status (error was caught, not thrown) + expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-exception'); + expect(transactionEvent.contexts?.trace?.status).toBe('ok'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/server-action-redirect.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/server-action-redirect.test.ts new file mode 100644 index 000000000000..59980ddebb8e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/server-action-redirect.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should handle server action redirect without capturing errors', async ({ page }) => { + // Wait for the initial page load transaction + const pageLoadTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === '/redirect/origin'; + }); + + // Navigate to the origin page + await page.goto('/redirect/origin'); + + const pageLoadTransaction = await pageLoadTransactionPromise; + expect(pageLoadTransaction).toBeDefined(); + + // Wait for the redirect transaction + const redirectTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'GET /redirect/destination'; + }); + + // No error should be captured + const redirectErrorPromise = waitForError('nextjs-16-static', async errorEvent => { + return !!errorEvent; + }); + + // Click the redirect button + await page.click('button[type="submit"]'); + + await redirectTransactionPromise; + + // Verify we got redirected to the destination page + await expect(page).toHaveURL('/redirect/destination'); + + // Wait for potential errors with a 2 second timeout + const errorTimeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('No error captured (timeout)')), 2000), + ); + + // We expect this to timeout since no error should be captured during the redirect + try { + await Promise.race([redirectErrorPromise, errorTimeout]); + throw new Error('Expected no error to be captured, but an error was found'); + } catch (e) { + // If we get a timeout error (as expected), no error was captured + expect((e as Error).message).toBe('No error captured (timeout)'); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/server-components.test.ts new file mode 100644 index 000000000000..2d39db2e4c86 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/server-components.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { isTurbopackDevMode } from './isDevMode'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for nested dynamic routes in dev mode'); + + const serverComponentTransactionPromise = waitForTransaction('nextjs-16-static', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.segment.name.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'sentry.kind': 'server', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); + +test('Will create a transaction with spans for every server component and metadata generation functions when visiting a page', async ({ + page, +}) => { + const serverTransactionEventPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'GET /nested-layout'; + }); + + await page.goto('/nested-layout'); + + const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => { + return span.description; + }); + + expect(spanDescriptions).toContainEqual('render route (app) /nested-layout'); + expect(spanDescriptions).toContainEqual('build component tree'); + expect(spanDescriptions).toContainEqual('resolve root layout server component'); + expect(spanDescriptions).toContainEqual('resolve layout server component "(nested-layout)"'); + expect(spanDescriptions).toContainEqual('resolve layout server component "nested-layout"'); + expect(spanDescriptions).toContainEqual('resolve page server component "/nested-layout"'); + expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); + expect(spanDescriptions).toContainEqual('start response'); +}); + +test('Will create a transaction with spans for every server component and metadata generation functions when visiting a dynamic page', async ({ + page, +}) => { + test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for dynamic routes in dev mode'); + + const serverTransactionEventPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'GET /nested-layout/[dynamic]'; + }); + + await page.goto('/nested-layout/123'); + + const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => { + return span.description; + }); + + expect(spanDescriptions).toContainEqual('resolve page components'); + expect(spanDescriptions).toContainEqual('render route (app) /nested-layout/[dynamic]'); + expect(spanDescriptions).toContainEqual('build component tree'); + expect(spanDescriptions).toContainEqual('resolve root layout server component'); + expect(spanDescriptions).toContainEqual('resolve layout server component "(nested-layout)"'); + expect(spanDescriptions).toContainEqual('resolve layout server component "nested-layout"'); + expect(spanDescriptions).toContainEqual('resolve layout server component "[dynamic]"'); + expect(spanDescriptions).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); + expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); + expect(spanDescriptions).toContainEqual('start response'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/sri.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/sri.test.ts new file mode 100644 index 000000000000..c68d23c21de6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/sri.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; + +const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); + +test.describe('Subresource Integrity (SRI)', () => { + test('page with client components loads correctly with SRI enabled', async ({ page }) => { + // SRI is only relevant for production builds + test.skip(isDevMode, 'SRI only applies to production builds'); + + const consoleErrors: string[] = []; + page.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await page.goto('/sri-test'); + + const heading = page.locator('#sri-test-heading'); + await expect(heading).toBeVisible(); + + // Verify client-side interactivity works (scripts loaded correctly) + const button = page.locator('#counter-button'); + await expect(button).toContainText('Count: 0'); + await button.click(); + await expect(button).toContainText('Count: 1'); + + expect(consoleErrors.filter(e => e.includes('integrity'))).toHaveLength(0); + }); + + test('client-side navigation works with SRI enabled', async ({ page }) => { + test.skip(isDevMode, 'SRI only applies to production builds'); + + const consoleErrors: string[] = []; + page.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await page.goto('/sri-test'); + await expect(page.locator('#sri-test-heading')).toBeVisible(); + + // Navigate to target page via client-side link + await page.locator('#navigate-link').click(); + await expect(page.locator('#sri-target-heading')).toBeVisible(); + + // Verify client-side interactivity on the target page + const targetButton = page.locator('#target-button'); + await expect(targetButton).toContainText('Click me'); + await targetButton.click(); + await expect(targetButton).toContainText('Clicked!'); + + // Navigate back + await page.locator('#back-link').click(); + await expect(page.locator('#sri-test-heading')).toBeVisible(); + + expect(consoleErrors.filter(e => e.includes('integrity'))).toHaveLength(0); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/streaming-rsc-error.test.ts new file mode 100644 index 000000000000..49137e369108 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/streaming-rsc-error.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ + page, +}) => { + const errorEventPromise = waitForError('nextjs-16-static', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'I am a data streaming error'); + }); + + const serverTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'GET /streaming-rsc-error/[param]'; + }); + + // The streaming RSC error can interrupt the HTTP response, causing the navigation to reject + // (e.g. net::ERR_ABORTED) even though the error and transaction are still captured. + await page.goto(`/streaming-rsc-error/123`).catch(() => {}); + const errorEvent = await errorEventPromise; + const serverTransactionEvent = await serverTransactionPromise; + + // error event is part of the transaction + expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id); + + expect(errorEvent.request).toMatchObject({ + headers: expect.any(Object), + method: 'GET', + }); + + expect(errorEvent.contexts?.nextjs).toEqual({ + route_type: 'render', + router_kind: 'App Router', + router_path: '/streaming-rsc-error/[param]', + request_path: '/streaming-rsc-error/123', + }); + + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.nextjs.on_request_error', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/suspense-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/suspense-error.test.ts new file mode 100644 index 000000000000..2e6f7d22cddd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/suspense-error.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('should not capture serverside suspense errors', async ({ page }) => { + const pageServerComponentTransactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { + return transactionEvent?.transaction === 'GET /suspense-error'; + }); + + let errorEvent; + waitForError('nextjs-16-static', async errorEvent => { + return errorEvent?.transaction === 'Page Server Component (/suspense-error)'; + }).then(event => { + errorEvent = event; + }); + + await page.goto(`/suspense-error`); + + // Just to be a little bit more sure + await page.waitForTimeout(5000); + + const pageServerComponentTransaction = await pageServerComponentTransactionPromise; + expect(pageServerComponentTransaction).toBeDefined(); + + expect(errorEvent).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/third-party-filter.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/third-party-filter.test.ts new file mode 100644 index 000000000000..69a0e9a4c363 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/third-party-filter.test.ts @@ -0,0 +1,30 @@ +import test, { expect } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const isWebpackDev = process.env.TEST_ENV === 'development-webpack'; + +test('First-party error with React frames should not be tagged as third-party code', async ({ page }) => { + test.skip(isWebpackDev, 'Only relevant for Turbopack builds'); + + const errorPromise = waitForError('nextjs-16-static', errorEvent => { + return errorEvent?.exception?.values?.some(value => value.value === 'first-party-error') ?? false; + }); + + await page.goto('/third-party-filter'); + await page.locator('#first-party-error-btn').click(); + + const errorEvent = await errorPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('first-party-error'); + + // In production, TEST_ENV=production is shared by both turbopack and webpack variants. + // Only assert when the build is actually turbopack. + if (errorEvent.tags?.turbopack) { + // The integration uses `apply-tag-if-contains-third-party-frames` which tags errors + // if ANY frame is third-party. This error is thrown inside a React onClick handler, + // so the stack trace contains React frames from node_modules. These must NOT be + // treated as third-party — the module metadata injection must cover node_modules too + // (matching the webpack plugin's behavior). + expect(errorEvent.tags?.third_party_code).toBeUndefined(); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/turbopack-dev-in-app.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/turbopack-dev-in-app.test.ts new file mode 100644 index 000000000000..d763462b3524 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/turbopack-dev-in-app.test.ts @@ -0,0 +1,43 @@ +import test, { expect } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { isTurbopackDevMode } from './isDevMode'; + +// Regression test for https://github.com/getsentry/sentry-javascript/issues/23176 +// +// Under `next dev` with Turbopack, server stack frames get their `in_app` classification inverted: +// Turbopack names vendor chunks `node_modules__.js` (underscores, no `node_modules/` +// segment) and prefixes app modules with `[project]/`. `filenameIsInApp` keys off a literal +// `node_modules/` substring and treats bracket-prefixed paths as internal, so vendor code is +// marked `in_app: true` and the real app crash site is marked `in_app: false`. +test('Turbopack dev: server stack frames are classified in_app correctly', async ({ page }) => { + test.skip( + !isTurbopackDevMode, + 'Turbopack chunk naming (node_modules_ / [project]) only occurs in Turbopack dev mode', + ); + + const errorPromise = waitForError('nextjs-16-static', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value?.includes('Tool call failed')); + }); + + await page.goto('/ai-error-test'); + + const errorEvent = await errorPromise; + + const frames = errorEvent.exception?.values?.flatMap(value => value.stacktrace?.frames ?? []) ?? []; + expect(frames.length).toBeGreaterThan(0); + + // The crash site is app code: the `execute` tool callback defined in `app/ai-error-test/page.tsx`. + // It must be `in_app` so the issue leads with the real crash frame instead of vendor code. + const appFrame = frames.find(frame => frame.filename?.includes('ai-error-test')); + expect( + appFrame, + `expected an app frame for the route file, got: ${JSON.stringify(frames.map(frame => frame.filename))}`, + ).toBeDefined(); + expect(appFrame?.in_app).toBe(true); + + // Vendor frames from the `ai` package (Turbopack: `...node_modules_ai_...`) must not be `in_app`. + const vendorFrames = frames.filter(frame => frame.filename?.includes('node_modules')); + for (const vendorFrame of vendorFrames) { + expect(vendorFrame.in_app, `vendor frame ${vendorFrame.filename} should not be in_app`).toBe(false); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/vercel-cron-monitoring.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/vercel-cron-monitoring.test.ts new file mode 100644 index 000000000000..73a5e9203c2b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/vercel-cron-monitoring.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; + +test('Sends cron check-in envelope for successful cron job', async ({ request }) => { + const inProgressEnvelopePromise = waitForEnvelopeItem('nextjs-16-static', envelope => { + return ( + envelope[0].type === 'check_in' && + // @ts-expect-error envelope[1] is untyped + envelope[1]['monitor_slug'] === '/api/cron-test' && + // @ts-expect-error envelope[1] is untyped + envelope[1]['status'] === 'in_progress' + ); + }); + + const okEnvelopePromise = waitForEnvelopeItem('nextjs-16-static', envelope => { + return ( + envelope[0].type === 'check_in' && + // @ts-expect-error envelope[1] is untyped + envelope[1]['monitor_slug'] === '/api/cron-test' && + // @ts-expect-error envelope[1] is untyped + envelope[1]['status'] === 'ok' + ); + }); + + const response = await request.get('/api/cron-test', { + headers: { + 'User-Agent': 'vercel-cron/1.0', + }, + }); + + expect(response.status()).toBe(200); + expect(await response.json()).toStrictEqual({ message: 'Cron job executed successfully' }); + + const inProgressEnvelope = await inProgressEnvelopePromise; + const okEnvelope = await okEnvelopePromise; + + expect(inProgressEnvelope[1]).toEqual( + expect.objectContaining({ + check_in_id: expect.any(String), + monitor_slug: '/api/cron-test', + status: 'in_progress', + monitor_config: { + schedule: { + type: 'crontab', + value: '0 * * * *', + }, + max_runtime: 720, + }, + }), + ); + + expect(okEnvelope[1]).toEqual( + expect.objectContaining({ + check_in_id: expect.any(String), + monitor_slug: '/api/cron-test', + status: 'ok', + duration: expect.any(Number), + }), + ); + + // @ts-expect-error envelope[1] is untyped + expect(okEnvelope[1]['check_in_id']).toBe(inProgressEnvelope[1]['check_in_id']); +}); + +test('Sends cron check-in envelope with error status for failed cron job', async ({ request }) => { + const inProgressEnvelopePromise = waitForEnvelopeItem('nextjs-16-static', envelope => { + return ( + envelope[0].type === 'check_in' && + // @ts-expect-error envelope[1] is untyped + envelope[1]['monitor_slug'] === '/api/cron-test-error' && + // @ts-expect-error envelope[1] is untyped + envelope[1]['status'] === 'in_progress' + ); + }); + + const errorEnvelopePromise = waitForEnvelopeItem('nextjs-16-static', envelope => { + return ( + envelope[0].type === 'check_in' && + // @ts-expect-error envelope[1] is untyped + envelope[1]['monitor_slug'] === '/api/cron-test-error' && + // @ts-expect-error envelope[1] is untyped + envelope[1]['status'] === 'error' + ); + }); + + await request.get('/api/cron-test-error', { + headers: { + 'User-Agent': 'vercel-cron/1.0', + }, + }); + + const inProgressEnvelope = await inProgressEnvelopePromise; + const errorEnvelope = await errorEnvelopePromise; + + expect(inProgressEnvelope[1]).toEqual( + expect.objectContaining({ + check_in_id: expect.any(String), + monitor_slug: '/api/cron-test-error', + status: 'in_progress', + monitor_config: { + schedule: { + type: 'crontab', + value: '30 * * * *', + }, + max_runtime: 720, + }, + }), + ); + + expect(errorEnvelope[1]).toEqual( + expect.objectContaining({ + check_in_id: expect.any(String), + monitor_slug: '/api/cron-test-error', + status: 'error', + duration: expect.any(Number), + }), + ); + + // @ts-expect-error envelope[1] is untyped + expect(errorEnvelope[1]['check_in_id']).toBe(inProgressEnvelope[1]['check_in_id']); +}); + +test('Does not send cron check-in envelope for regular requests without vercel-cron user agent', async ({ + request, +}) => { + let checkInReceived = false; + + waitForEnvelopeItem('nextjs-16-static', envelope => { + if ( + envelope[0].type === 'check_in' && // @ts-expect-error envelope[1] is untyped + envelope[1]['monitor_slug'] === '/api/cron-test' + ) { + checkInReceived = true; + return true; + } + return false; + }); + + const response = await request.get('/api/cron-test'); + + expect(response.status()).toBe(200); + expect(await response.json()).toStrictEqual({ message: 'Cron job executed successfully' }); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + expect(checkInReceived).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/vercel-queue.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/vercel-queue.test.ts new file mode 100644 index 000000000000..664e9be6f216 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/vercel-queue.test.ts @@ -0,0 +1,51 @@ +import test, { expect } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// The queue E2E test only runs in production mode. +// In development mode the @vercel/queue SDK uses an in-memory dispatch that +// bypasses our mock HTTP server, causing duplicate handler invocations. +const isProduction = process.env.TEST_ENV === 'production'; + +test('Should create transactions for queue producer and consumer', async ({ request }) => { + test.skip(!isProduction, 'Vercel Queue test only runs in production mode'); + + // 1. Set up waiters for both the producer and consumer transactions. + const producerTransactionPromise = waitForTransaction('nextjs-16-static', transactionEvent => { + return transactionEvent?.transaction === 'POST /api/queue-send'; + }); + + const consumerTransactionPromise = waitForTransaction('nextjs-16-static', transactionEvent => { + return transactionEvent?.transaction === 'POST /api/queues/process-order'; + }); + + // 2. Hit the producer route to enqueue a message. + const response = await request.post('/api/queue-send', { + data: { topic: 'orders', payload: { orderId: 'e2e-test-123', action: 'fulfill' } }, + headers: { 'Content-Type': 'application/json' }, + }); + + const responseBody = await response.json(); + expect(response.status()).toBe(200); + expect(responseBody.messageId).toBeTruthy(); + + // 3. Wait for the producer transaction. + const producerTransaction = await producerTransactionPromise; + expect(producerTransaction).toBeDefined(); + expect(producerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(producerTransaction.contexts?.trace?.status).toBe('ok'); + + // 4. Wait for the consumer transaction (the mock server pushes the message + // to the consumer route via CloudEvent POST). + const consumerTransaction = await consumerTransactionPromise; + expect(consumerTransaction).toBeDefined(); + expect(consumerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(consumerTransaction.contexts?.trace?.status).toBe('ok'); + + // 5. Verify the consumer span has messaging.* attributes from queue instrumentation. + const consumerSpanData = consumerTransaction.contexts?.trace?.data; + expect(consumerSpanData?.['messaging.system']).toBe('vercel.queue'); + expect(consumerSpanData?.['messaging.operation.name']).toBe('process'); + expect(consumerSpanData?.['messaging.destination.name']).toBe('orders'); + expect(consumerSpanData?.['messaging.message.id']).toBeTruthy(); + expect(consumerSpanData?.['messaging.consumer.group.name']).toBeTruthy(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/vercel.json b/dev-packages/e2e-tests/test-applications/nextjs-16-static/vercel.json new file mode 100644 index 000000000000..58730a0978fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/vercel.json @@ -0,0 +1,17 @@ +{ + "crons": [ + { + "path": "/api/cron-test", + "schedule": "0 * * * *" + }, + { + "path": "/api/cron-test-error", + "schedule": "30 * * * *" + } + ], + "functions": { + "app/api/queues/process-order/route.ts": { + "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "orders" }] + } + } +}