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} +DynamicLayout
+ {children} +Dynamic Page
+Layout
+ {children} +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 ( +{JSON.stringify(results, null, 2)}
+ answer: {answer}
+cached: {cached}
+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 ( +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 }: PropsWithChildrenI 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() { + returnHello
; +} 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 ( +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_