From 1d9d4141c671d16e572756396317900e58dc4649 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:55:05 +0200 Subject: [PATCH 01/30] test(tanstackstart-react): Add source map E2E app Build a TanStack Start app with Nitro against a mock Sentry server and check the upload: the source maps carry the client and server source, their source paths are relative to the project root, every debug ID has an uploaded source map, and no source map stays in the build output. The app is optional in CI because Nitro 3 is in beta. --- .../tanstackstart-react-sourcemaps/.gitignore | 11 ++ .../assert-build.ts | 183 ++++++++++++++++++ .../package.json | 37 ++++ .../src/router.tsx | 4 + .../src/routes/__root.tsx | 20 ++ .../src/routes/index.tsx | 21 ++ .../start-mock-sentry-server.mjs | 3 + .../tsconfig.json | 16 ++ .../vite.config.ts | 21 ++ 9 files changed, 316 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore new file mode 100644 index 000000000000..4187ae735082 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore @@ -0,0 +1,11 @@ +node_modules +.output +.nitro +.tanstack +src/routeTree.gen.ts + +# Mock Sentry server artifacts +.tmp_mock_uploads.json +.tmp_chunks +.tmp_build_stdout +.tmp_build_stderr diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..df57d3303f0b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts @@ -0,0 +1,183 @@ +import * as assert from 'assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + findDebugIdsWithConflictingSourcemaps, + findFilesWithForeignSourcemaps, + findFilesWithMultipleDebugIds, + findInjectedDebugIds, + getArtifactBundles, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemapSources, + getSourcemaps, + loadMockServerResults, + startNodeServer, +} from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ +const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; +const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; + +const DEBUG_ID_PATTERN = /sentry-dbid-([\da-f-]{36})/gi; + +function readDebugIds(file: string): string[] { + return [...fs.readFileSync(file, 'utf8').matchAll(DEBUG_ID_PATTERN)].map(match => (match[1] as string).toLowerCase()); +} + +function emittedJsFiles(dir: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && /\.m?js$/.test(entry.name) && !entry.parentPath.includes('node_modules')) + .map(entry => path.join(entry.parentPath, entry.name)); +} + +/** In debug mode, the Sentry plugin logs each file it deletes after the upload. */ +const deletedFiles = new Set( + [...fs.readFileSync('.tmp_build_stdout', 'utf8').matchAll(/Deleting asset after upload: (.+)$/gm)].map(match => + path.relative(fs.realpathSync('.'), match[1] as string), + ), +); + +/** A chunk without mappable source, such as Nitro's runtime, gets a debug ID but no source map to upload. */ +function debugIdsOfChunksWithoutSourceMap(dir: string): Set { + return new Set( + emittedJsFiles(dir) + .filter(file => !fs.existsSync(`${file}.map`) && !deletedFiles.has(`${file}.map`)) + .flatMap(readDebugIds), + ); +} + +function sourceMapsIn(dir: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.map')) + .map(entry => path.join(entry.parentPath, entry.name)); +} + +// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob +// characters, like Nitro's `_libs/@tanstack/react-router+[...].mjs`. Remove this when the uploader matches paths literally. +function debugIdsOfFilesWithGlobCharacters(dir: string): Set { + return new Set( + emittedJsFiles(dir) + .filter(file => /[[\]{}()*?]/.test(path.relative(dir, file))) + .flatMap(readDebugIds), + ); +} + +const requests = loadMockServerResults(); + +// --- The upload reached Sentry --- + +assert.ok( + getChunkUploadPosts(requests).some(request => request.bodySize > 0), + 'Expected at least one chunk upload with a non-empty body', +); + +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); + +const sourcemaps = getSourcemaps(bundles); +const containsMarker = (marker: string): boolean => + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +// Vite builds the client, and Nitro builds the server again from Vite's SSR output. Each side is pinned to a marker +// only that side's source supplies. +assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded source map carrying the client source'); +assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded source map carrying the server source'); + +// --- Source paths match the files in the project --- + +const unresolvedSources = [...new Set(sourcemaps.flatMap(map => getSourcemapSources(map.sourcemap)))].filter( + source => source.startsWith('../') || path.isAbsolute(source) || /\?ts[rs]-/.test(source), +); +assert.deepEqual(unresolvedSources, [], 'Expected every uploaded source path to be relative to the project root'); + +// --- Server source maps reach the original source --- + +// Nitro bundles the SSR output again. Its maps must lead through that output to the file the server function is in. +const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); +const serverFunctionSources = sourcemaps + .filter(map => map.debugId && serverDebugIds.has(map.debugId)) + .filter(map => map.sourcemap.sourcesContent?.some(source => source?.includes(SERVER_MARKER))) + .flatMap(map => getSourcemapSources(map.sourcemap)); +assert.ok( + serverFunctionSources.includes('src/routes/index.tsx'), + `Expected a server source map to resolve to src/routes/index.tsx, got: ${serverFunctionSources.join(', ')}`, +); + +// --- Debug IDs tie the shipped bundles to the uploaded maps --- + +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); + +for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) { + const injectedDebugIds = findInjectedDebugIds({ outputDir }); + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + + const exemptDebugIds = new Set([ + ...debugIdsOfChunksWithoutSourceMap(outputDir), + ...debugIdsOfFilesWithGlobCharacters(outputDir), + ]); + const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.has(debugId) && !exemptDebugIds.has(debugId)); + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded source map`); + + // A file of Nitro's output can carry the debug ID of the SSR chunk it was built from. + assert.deepEqual( + findFilesWithMultipleDebugIds({ outputDir }), + [], + `Expected at most one debug ID per file in ${outputDir}`, + ); + assert.deepEqual( + findFilesWithForeignSourcemaps({ outputDir }, sourcemaps), + [], + `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, + ); + + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); +} + +assert.deepEqual( + findDebugIdsWithConflictingSourcemaps(sourcemaps), + [], + 'Expected every uploaded debug ID to resolve to exactly one source map', +); + +// --- Sentry deletes the source maps it enabled after the upload --- + +for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) { + assert.deepEqual(sourceMapsIn(outputDir), [], `Expected no source maps in ${outputDir}`); +} + +const clientChunkUrls = emittedJsFiles(CLIENT_OUTPUT).map( + file => `/${path.relative(CLIENT_OUTPUT, file).split(path.sep).join('/')}`, +); +assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`); + +const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 }); + +try { + // Without this, a server that serves no public files at all would pass the 404 checks. + const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status; + assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`); + + // Nitro serves public files from a list it writes into the server bundle. A map deleted after that + // stays on the list, and Nitro answers its request with a 500 instead of a 404. + const responses = await Promise.all( + clientChunkUrls.map(async chunkUrl => ({ + mapUrl: `${chunkUrl}.map`, + status: (await fetch(`${server.baseUrl}${chunkUrl}.map`)).status, + })), + ); + assert.deepEqual( + responses.filter(({ status }) => status !== 404).map(({ mapUrl, status }) => `${mapUrl} → ${status}`), + [], + 'Expected the server to answer each client source map URL with 404', + ); +} finally { + await server.close(); +} + +console.log('All source map assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json new file mode 100644 index 000000000000..e03631c63231 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json @@ -0,0 +1,37 @@ +{ + "name": "tanstackstart-react-sourcemaps", + "description": "E2E test app asserting what the TanStack Start SDK uploads to Sentry when Nitro builds the server.", + "private": true, + "type": "module", + "scripts": { + "build": "node start-mock-sentry-server.mjs & vite build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", + "clean": "npx rimraf node_modules pnpm-lock.yaml .output", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm ts-node --script-mode assert-build.ts" + }, + "dependencies": { + "@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz", + "@tanstack/react-router": "1.170.44", + "@tanstack/react-start": "1.169.1", + "nitro": "latest", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^22.20.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react-swc": "^4.2.0", + "ts-node": "10.9.1", + "typescript": "^5.9.0", + "vite": "^8.3.0" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx new file mode 100644 index 000000000000..741d1aade5d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx @@ -0,0 +1,4 @@ +import { createRouter } from '@tanstack/react-router'; +import { routeTree } from './routeTree.gen'; + +export const getRouter = () => createRouter({ routeTree }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx new file mode 100644 index 000000000000..09432c62c40d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'; + +export const Route = createRootRoute({ + component: RootComponent, +}); + +function RootComponent(): ReactNode { + return ( + + + + + + + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx new file mode 100644 index 000000000000..51012573f928 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; + +// SOURCEMAP_MARKER_CLIENT — a comment, so it is stripped from the emitted bundles but survives in `sourcesContent`. +// `assert-build.ts` uses the markers to check that the original source reached the uploaded maps. +const throwServerError = createServerFn().handler(async () => { + // SOURCEMAP_MARKER_SERVER + throw new Error('Server function error from the TanStack Start source map E2E app'); +}); + +export const Route = createFileRoute('/')({ + component: Home, +}); + +function Home() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..1680a6360165 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ org: 'test-org' }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..02faa5762420 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", ".output"] +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts new file mode 100644 index 000000000000..b43b2441b453 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts @@ -0,0 +1,21 @@ +import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; +import { tanstackStart } from '@tanstack/react-start/plugin/vite'; +import viteReact from '@vitejs/plugin-react-swc'; +import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + tanstackStart(), + nitro(), + viteReact(), + sentryTanstackStart({ + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + debug: true, + }), + ], +}); From 9917d320f3bbd9e0ffe7e5b56186cf95e8c55c11 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:57:54 +0200 Subject: [PATCH 02/30] test(tanstackstart-react): Check source map markers per output side Both markers are in the same route file, which client and server source maps embed completely, so one uploaded map could pass both checks. Check each marker only in the source maps of the files in its output directory, matched by debug ID. --- .../assert-build.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts index df57d3303f0b..2aa44e66f20e 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts @@ -13,6 +13,7 @@ import { getSourcemaps, loadMockServerResults, startNodeServer, + type SourcemapEntry, } from '@sentry-internal/test-utils'; const CLIENT_OUTPUT = path.join('.output', 'public'); @@ -81,13 +82,22 @@ const bundles = getArtifactBundles(requests); assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); const sourcemaps = getSourcemaps(bundles); -const containsMarker = (marker: string): boolean => - sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); -// Vite builds the client, and Nitro builds the server again from Vite's SSR output. Each side is pinned to a marker -// only that side's source supplies. -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded source map carrying the client source'); -assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded source map carrying the server source'); +/** The uploaded source maps of the files in an output directory, matched by debug ID. */ +function sourceMapsOf(outputDir: string): SourcemapEntry[] { + const debugIds = new Set(findInjectedDebugIds({ outputDir })); + return sourcemaps.filter(map => map.debugId && debugIds.has(map.debugId)); +} + +const clientSourceMaps = sourceMapsOf(CLIENT_OUTPUT); +const serverSourceMaps = sourceMapsOf(SERVER_OUTPUT); +const containsMarker = (maps: SourcemapEntry[], marker: string): boolean => + maps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +// Both markers are in the same route file, which client and server source maps embed completely. So each marker only +// proves the upload of its side if the check only looks at the source maps of that side. +assert.ok(containsMarker(clientSourceMaps, CLIENT_MARKER), 'Expected a client source map carrying the client source'); +assert.ok(containsMarker(serverSourceMaps, SERVER_MARKER), 'Expected a server source map carrying the server source'); // --- Source paths match the files in the project --- @@ -99,9 +109,7 @@ assert.deepEqual(unresolvedSources, [], 'Expected every uploaded source path to // --- Server source maps reach the original source --- // Nitro bundles the SSR output again. Its maps must lead through that output to the file the server function is in. -const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); -const serverFunctionSources = sourcemaps - .filter(map => map.debugId && serverDebugIds.has(map.debugId)) +const serverFunctionSources = serverSourceMaps .filter(map => map.sourcemap.sourcesContent?.some(source => source?.includes(SERVER_MARKER))) .flatMap(map => getSourcemapSources(map.sourcemap)); assert.ok( From da7534c6bff58c682685d24e9746f8f73e91144a Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:56:43 +0200 Subject: [PATCH 03/30] chore: Update comments related to span links with latest info (#25135) Small comment change related to this change in Sentry: https://github.com/getsentry/sentry/pull/126702 --- packages/browser/src/tracing/linkedTraces.ts | 4 ++-- packages/cloudflare/src/wrapMethodWithSentry.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/browser/src/tracing/linkedTraces.ts b/packages/browser/src/tracing/linkedTraces.ts index 741212cf2065..e60f3fd59ff1 100644 --- a/packages/browser/src/tracing/linkedTraces.ts +++ b/packages/browser/src/tracing/linkedTraces.ts @@ -191,8 +191,8 @@ export function addPreviousTraceSpanLink( }, }); - // TODO(v12): Remove this once the Sentry trace view finds linked traces via span links. EAP stores - // span links, but the trace view still reads this attribute to navigate to the previous/next trace. + // TODO(v12): Remove once EAP can filter span links by type. The Sentry trace view searches this attribute to find the NEXT trace. + // Sentry 26.9 and older also read it to find the PREVIOUS trace. Newer versions read the span link. span.setAttribute( PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE, `${previousTraceSpanCtx.traceId}-${previousTraceSpanCtx.spanId}-${ diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index c6ff06393d6a..fe1927833e7e 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -217,9 +217,9 @@ export function wrapMethodWithSentry( if (storedContext) { span.addLinks(buildSpanLinks(storedContext)); - // TODO(v12): Remove this once the Sentry trace view finds linked traces via span links. - // EAP stores span links, but the trace view still reads this attribute to navigate to the - // previous/next trace. + + // TODO(v12): Remove once EAP can filter span links by type. The Sentry trace view searches this attribute to find the NEXT trace. + // Sentry 26.9 and older also read it to find the PREVIOUS trace. Newer versions read the span link. const sampledFlag = storedContext.sampled ? '1' : '0'; span.setAttribute( 'sentry.previous_trace', From d885eced5531fdc7e74c3e862e8b261410ea7ef6 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:24:10 +0200 Subject: [PATCH 04/30] feat(nextjs): Add a tunnel route handler that sends only the event payload to Sentry (#25456) Today, `tunnelRoute` rewrites tunnel requests to Sentry as a proxy. This PR adds an opt-in handler mode: if `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`) exists, the SDK rewrites the tunnel route internally to it, and the handler sends only the event payload to Sentry. Without the file, nothing changes. ```ts // app/sentry-tunnel/route.ts export { POST } from '@sentry/nextjs/tunnel'; ``` - When the file exists, but there's no `tunnelRoute` option, a warning is shown. - The rewrite is in `beforeFiles`, so a public file or page at the tunnel path cannot shadow it. - Like TanStack Start, the handler accepts only the server SDK's DSN. We also export `createSentryTunnelRoute({ allowedDsns })` which covers other DSNs. - Fixed: Tunnel request spans were sent with span streaming, in rewrite mode too. They are now dropped. Tested with unit tests, two new `nextjs-16-tunnel` variants (Turbopack/Node.js, webpack/edge) that also check the forwarded headers. Also tested with a manual Vercel deploy on Next.js 14-16. Fixes JS-3927 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 15 ++ .../nextjs-16-tunnel/.gitignore | 3 + .../nextjs-16-tunnel/app/api/flush/route.ts | 7 + .../instrumentation-client.ts | 4 +- .../nextjs-16-tunnel/package.json | 13 ++ .../nextjs-16-tunnel/sentry-dsn.ts | 6 + .../nextjs-16-tunnel/sentry.edge.config.ts | 4 +- .../nextjs-16-tunnel/sentry.server.config.ts | 4 +- .../tests/tunnel-route.test.ts | 80 +++++++++- .../edge/sentry-tunnel/route.ts | 13 ++ .../node/sentry-tunnel/route.ts | 1 + packages/nextjs/package.json | 5 + packages/nextjs/rollup.npm.config.mjs | 1 + packages/nextjs/src/client/tunnelRoute.ts | 11 ++ .../utils/dropMiddlewareTunnelRequests.ts | 53 ++++++- .../src/common/utils/tunnelPathnameMatch.ts | 29 ++++ .../turbopack/constructTurbopackConfig.ts | 10 +- .../turbopack/generateValueInjectionRules.ts | 12 +- packages/nextjs/src/config/types.ts | 11 ++ packages/nextjs/src/config/util.ts | 3 + packages/nextjs/src/config/webpack.ts | 14 +- .../src/config/withSentryConfig/buildTime.ts | 13 +- .../getFinalConfigObjectUtils.ts | 50 +++++- .../src/config/withSentryConfig/tunnel.ts | 87 ++++++++++- packages/nextjs/src/edge/index.ts | 3 +- packages/nextjs/src/server/index.ts | 3 +- packages/nextjs/src/tunnel/index.ts | 76 +++++++++ .../generateValueInjectionRules.test.ts | 27 ++++ .../webpack/constructWebpackConfig.test.ts | 43 +++++- .../config/withSentryConfig/buildTime.test.ts | 21 +++ .../getFinalConfigObjectUtils.test.ts | 123 +++++++++++++++ .../config/withSentryConfig/projectFixture.ts | 23 +++ .../config/withSentryConfig/tunnel.test.ts | 135 ++++++++++++++++ packages/nextjs/test/tunnel/index.test.ts | 146 ++++++++++++++++++ .../dropMiddlewareTunnelRequests.test.ts | 108 ++++++++++++- .../nextjs/test/utils/tunnelRoute.test.ts | 29 ++++ 36 files changed, 1127 insertions(+), 59 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/api/flush/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts create mode 100644 packages/nextjs/src/tunnel/index.ts create mode 100644 packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts create mode 100644 packages/nextjs/test/config/withSentryConfig/projectFixture.ts create mode 100644 packages/nextjs/test/config/withSentryConfig/tunnel.test.ts create mode 100644 packages/nextjs/test/tunnel/index.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a808dcc5947..a25b5bccb34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(nextjs): Add a tunnel route handler that sends only the event payload to Sentry ([#25456](https://github.com/getsentry/sentry-javascript/pull/25456))** + + The `tunnelRoute` option can now send events through a route handler in your app. The handler forwards only the event payload to Sentry, and no request headers. To use it, add this file next to your `tunnelRoute` option: + + ```ts + // app/sentry-tunnel/route.ts (or src/app/sentry-tunnel/route.ts) + export { POST } from '@sentry/nextjs/tunnel'; + ``` + + The handler accepts events for the DSN of your server SDK. To accept other DSNs, use `createSentryTunnelRoute({ allowedDsns })` from the same import. Without the file, the tunnel route works as before. + + Also, the server no longer sends spans of tunnel requests when span streaming is on, because these spans only show the SDK's own traffic. + Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions! ## 11.8.0 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore index dd146b53d966..15bc7628b511 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore @@ -10,6 +10,9 @@ !.yarn/releases !.yarn/versions +# Copied by `test:build-handler*` +/app/sentry-tunnel/ + # testing /coverage diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/api/flush/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/api/flush/route.ts new file mode 100644 index 000000000000..1173242c5d10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/api/flush/route.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/nextjs'; + +// Sends all buffered spans of the server, so a test can wait for them. +export async function GET(): Promise { + await Sentry.flush(); + return new Response(null, { status: 204 }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts index 6709e1cf54a7..84b61b956e4a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nextjs'; +import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + dsn: SENTRY_DSN, // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index 8f63d6f08a43..adbc49ec455c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -15,6 +15,8 @@ "test:dev-webpack": "TEST_ENV=development-webpack _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", "test:build": "pnpm install && pnpm build", "test:build-webpack": "pnpm install && pnpm build-webpack", + "test:build-handler": "pnpm install && cp -R tunnel-handler/node/sentry-tunnel app/ && pnpm build", + "test:build-handler-webpack": "pnpm install && cp -R tunnel-handler/edge/sentry-tunnel app/ && pnpm build-webpack", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", @@ -39,6 +41,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@sentry/conventions": "0.27.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -61,6 +64,16 @@ "build-command": "pnpm test:build", "label": "nextjs-16-tunnel (turbopack)", "assert-command": "pnpm test:assert" + }, + { + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler", + "label": "nextjs-16-tunnel (handler, turbopack)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert" + }, + { + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler-webpack", + "label": "nextjs-16-tunnel (handler, webpack, edge)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert-webpack" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts new file mode 100644 index 000000000000..fa0ad467f9bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts @@ -0,0 +1,6 @@ +// The handler variants send the server events to the event proxy too, so the tests also see server spans. +export const SENTRY_DSN = + process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler' + ? 'http://public@localhost:3031/1337' + : // A fake but properly formatted Sentry SaaS DSN, because the rewrite mode tunnels only SaaS DSNs + 'https://public@o12345.ingest.us.sentry.io/67890'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts index 4184d564044f..0333aa29412b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nextjs'; +import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + dsn: SENTRY_DSN, // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, // debug: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts index 4184d564044f..0333aa29412b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nextjs'; +import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + dsn: SENTRY_DSN, // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, // debug: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts index 3d44c8fc8108..224ed9d7556d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; -import type { SerializedStreamedSpan } from '@sentry/core'; +import { HTTP_REQUEST_METHOD, SERVER_ADDRESS, URL_FULL } from '@sentry/conventions/attributes'; +import { getSpanOp, waitForRequest, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { Event, SerializedStreamedSpan } from '@sentry/core'; function getStringAttribute(span: SerializedStreamedSpan, key: string): string | undefined { const attribute = span.attributes[key]; @@ -106,7 +107,7 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag // We WILL have a middleware span for GET / (the pageload) // But we should NOT have middleware spans for POST requests (tunnel route) const postMiddlewareSpans = middlewareSpans.filter( - span => span.name.includes('POST') || getStringAttribute(span, 'http.request.method') === 'POST', + span => span.name.includes('POST') || getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST', ); expect(postMiddlewareSpans).toHaveLength(0); @@ -114,9 +115,80 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag // We should NOT have any fetch spans to Sentry ingest. Matched on the host attribute rather than a // substring of the full URL, which would also match an arbitrary host with `sentry.io` elsewhere in it. const sentryFetchSpans = allSpans.filter(span => { - const host = getStringAttribute(span, 'server.address') ?? ''; + const host = getStringAttribute(span, SERVER_ADDRESS) ?? ''; return getSpanOp(span) === 'http.client' && (host === 'sentry.io' || host.endsWith('.sentry.io')); }); expect(sentryFetchSpans).toHaveLength(0); }); + +test.describe('Tunnel route handler', () => { + test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'handler', 'Only the handler variants use the handler'); + + test('forwards events without the cookies of the browser', async ({ page, context, baseURL }) => { + await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]); + const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) => + envelope[1].some( + ([itemHeader, item]) => + itemHeader.type === 'event' && + (item as Event).exception?.values?.[0]?.value === 'Tunnel route handler cookie test', + ), + ); + + await page.goto('/'); + await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object'); + const tunnelRequestPromise = page.waitForRequest( + request => + request.method() === 'POST' && + new URL(request.url()).origin === baseURL && + (request.postData() ?? '').includes('Tunnel route handler cookie test'), + ); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Tunnel route handler cookie test'); + }); + }); + + const tunnelRequest = await tunnelRequestPromise; + const proxyRequest = await proxyRequestPromise; + + expect(new URL(tunnelRequest.url()).search).toBe(''); + expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d'); + expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); + expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope'); + }); + + test('does not create server spans for tunnel requests', async ({ page, baseURL }) => { + // One listener, so it gets every span that the proxy received before the barrier span. + const spansBeforeBarrier: SerializedStreamedSpan[] = []; + const barrierSpanPromise = waitForStreamedSpans('nextjs-16-tunnel', spans => { + spansBeforeBarrier.push(...spans); + return spans.some(span => span.is_segment && span.name === 'GET /api/flush'); + }); + const pageloadTunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); + + await page.goto('/'); + await pageloadTunnelResponsePromise; + // Flush the edge client of the edge tunnel route (a 405 on the Node.js one), then the Node.js client twice: + // the first flush sends the tunnel spans, the second one the barrier span of the first flush. + await page.request.get('/sentry-tunnel'); + await page.request.get('/api/flush'); + await page.request.get('/api/flush'); + await barrierSpanPromise; + + const postSpans = spansBeforeBarrier.filter( + span => span.name.startsWith('POST') || getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST', + ); + const ingestSpans = spansBeforeBarrier.filter( + span => + getSpanOp(span) === 'http.client' && !!getStringAttribute(span, URL_FULL)?.startsWith('http://localhost:3031/'), + ); + expect(postSpans).toEqual([]); + expect(ingestSpans).toEqual([]); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts new file mode 100644 index 000000000000..4eeb13efd97f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; +import { createSentryTunnelRoute } from '@sentry/nextjs/tunnel'; +import { SENTRY_DSN } from '@/sentry-dsn'; + +export const { POST } = createSentryTunnelRoute({ allowedDsns: [SENTRY_DSN] }); + +// Each edge route has its own SDK client, so the tests flush this one here. +export async function GET(): Promise { + await Sentry.flush(); + return new Response(null, { status: 204 }); +} + +export const runtime = 'edge'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts new file mode 100644 index 000000000000..23c975fb750d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts @@ -0,0 +1 @@ +export { POST } from '@sentry/nextjs/tunnel'; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 7e7b4154d885..7cea31e746e3 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -55,6 +55,11 @@ "types": "./build/types/config/index.d.ts", "default": "./build/cjs/config/index.js" }, + "./tunnel": { + "types": "./build/types/tunnel/index.d.ts", + "import": "./build/esm/tunnel/index.js", + "require": "./build/cjs/tunnel/index.js" + }, "./async-storage-shim": { "import": { "default": "./build/esm/config/templates/requestAsyncStorageShim.js" diff --git a/packages/nextjs/rollup.npm.config.mjs b/packages/nextjs/rollup.npm.config.mjs index bef1bd0f92d3..925a49507c0f 100644 --- a/packages/nextjs/rollup.npm.config.mjs +++ b/packages/nextjs/rollup.npm.config.mjs @@ -13,6 +13,7 @@ export default [ 'src/edge/index.ts', 'src/cloudflare/index.ts', 'src/config/index.ts', + 'src/tunnel/index.ts', ], // prevent this internal nextjs code from ending up in our built package (this doesn't happen automatically because diff --git a/packages/nextjs/src/client/tunnelRoute.ts b/packages/nextjs/src/client/tunnelRoute.ts index 64025fdd5f9d..ba82260e17df 100644 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ b/packages/nextjs/src/client/tunnelRoute.ts @@ -1,9 +1,11 @@ import { debug, dsnFromString, GLOBAL_OBJ } from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; import { DEBUG_BUILD } from '../common/debug-build'; +import type { TunnelRouteMode } from '../common/utils/tunnelPathnameMatch'; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string; + _sentryTunnelRouteMode?: TunnelRouteMode; }; /** @@ -16,6 +18,15 @@ export function applyTunnelRouteOption(options: BrowserOptions): void { if (!dsnComponents) { return; } + + // The handler reads the DSN from the envelope, so it needs no query and no SaaS DSN. + const tunnelRouteMode = process.env._sentryTunnelRouteMode || globalWithInjectedValues._sentryTunnelRouteMode; + if (tunnelRouteMode === 'handler') { + options.tunnel = tunnelRouteOption; + DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelRouteOption}"`); + return; + } + const sentrySaasDsnMatch = dsnComponents.host.match(/^o(\d+)\.ingest(?:\.([a-z]{2}))?\.sentry\.io$/); if (sentrySaasDsnMatch) { const orgId = sentrySaasDsnMatch[1]; diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 31064377e4ba..f2d4774f3c54 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,12 +1,18 @@ import { HTTP_TARGET, URL_FULL, URL_PATH, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import type { RawAttributes } from '@sentry/core'; -import { getClient, GLOBAL_OBJ, isSentryRequestUrl, type Span } from '@sentry/core'; -import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; -import { isPathnameUnderSentryTunnelRoute } from './tunnelPathnameMatch'; +import type { ClientOptions, RawAttributes } from '@sentry/core'; +import { escapeStringForRegex, getClient, GLOBAL_OBJ, isSentryRequestUrl, type Span } from '@sentry/core'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; +import { + getTunnelRouteRequestPattern, + isPathnameUnderSentryTunnelRoute, + TUNNEL_ROUTE_HANDLER_PATH, + type TunnelRouteMode, +} from './tunnelPathnameMatch'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string; + _sentryTunnelRouteMode?: TunnelRouteMode; }; /** @@ -82,3 +88,42 @@ export function isTunnelRouteSpan(spanAttributes: Record): bool return false; } + +/** + * Returns `ignoreSpans` entries for the spans of incoming tunnel requests. + * + * Span streaming checks `ignoreSpans` at span start, before `dropMiddlewareTunnelRequests` runs. The entries match only + * Next.js request spans, so other spans with the same path are kept. + */ +export function getTunnelRouteIgnoreSpans(): NonNullable { + const tunnelPath = globalWithInjectedValues._sentryRewritesTunnelPath || process.env._sentryRewritesTunnelPath; + if (!tunnelPath) { + return []; + } + + const tunnelRouteMode = + (globalWithInjectedValues._sentryTunnelRouteMode || process.env._sentryTunnelRouteMode) === 'handler' + ? 'handler' + : 'rewrite'; + const tunnelRequestIgnoreSpan = { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: /^(?:BaseServer\.handleRequest|Middleware\.execute)$/, + // Next.js sets only `http.target` at span start. + // eslint-disable-next-line typescript/no-deprecated + [HTTP_TARGET]: getTunnelRouteRequestPattern(tunnelPath, tunnelRouteMode), + }, + }; + + if (tunnelRouteMode !== 'handler') { + return [tunnelRequestIgnoreSpan]; + } + + // On edge, the root span of a route handler has only `next.route`, with a `/route` suffix before Next.js 16. + const tunnelRouteHandlerIgnoreSpan = { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: /^AppRouteRouteHandlers\.runHandler$/, + [ATTR_NEXT_ROUTE]: new RegExp(`^${escapeStringForRegex(TUNNEL_ROUTE_HANDLER_PATH)}(?:/route)?$`), + }, + }; + return [tunnelRequestIgnoreSpan, tunnelRouteHandlerIgnoreSpan]; +} diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts index 9f107d33636c..2c5dc8bb1dd1 100644 --- a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts +++ b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts @@ -1,3 +1,17 @@ +import { escapeStringForRegex } from '@sentry/core'; + +/** + * The path of the tunnel route handler (`app/sentry-tunnel/route.ts`). + * Users create the file at this path, so the value is a public contract. + */ +export const TUNNEL_ROUTE_HANDLER_PATH = '/sentry-tunnel'; + +/** + * `rewrite`: the tunnel route proxies requests to Sentry with all their headers. + * `handler`: the tunnel route goes to the tunnel route handler, which forwards only the envelope. + */ +export type TunnelRouteMode = 'rewrite' | 'handler'; + /** * Returns true when `pathname` is exactly the Sentry tunnel route or a sub-path * (`tunnelPath` + `/...`). A plain `startsWith(tunnelPath)` is unsafe: e.g. tunnel @@ -6,3 +20,18 @@ export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: string): boolean { return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); } + +/** + * Returns a pattern for the request targets (path and query) that the tunnel rewrite serves. + * It must never match more than the rewrite, see GHSA-mchm-gjx6-7h55. + * + * - `handler`: the path, with an optional trailing slash and any query. + * - `rewrite`: also exactly the query that `applyTunnelRouteOption` sends. A looser query match is unsafe, because + * Next.js reads the last value of a repeated param. + */ +export function getTunnelRouteRequestPattern(tunnelPath: string, tunnelRouteMode: TunnelRouteMode): RegExp { + const pathPattern = `^${escapeStringForRegex(tunnelPath)}/?`; + return tunnelRouteMode === 'handler' + ? new RegExp(`${pathPattern}(?:\\?|$)`) + : new RegExp(`${pathPattern}\\?o=\\d+&p=\\d+(?:&r=[a-z]{2})?$`); +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 743f4abc35a8..3eb22ab2263d 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -18,6 +18,7 @@ import type { TurbopackOptions, } from '../types'; import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; +import { getTunnelRouteInjectedValues } from '../withSentryConfig/tunnel'; import { generateValueInjectionRules } from './generateValueInjectionRules'; /** @@ -53,17 +54,10 @@ export function constructTurbopackConfig({ ...(shouldEnableNativeDebugIds ? { debugIds: true } : {}), }; - const tunnelPath = - userSentryOptions?.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` - : undefined; - const valueInjectionRules = generateValueInjectionRules({ routeManifest, nextJsVersion, - tunnelPath, + tunnelRouteValues: getTunnelRouteInjectedValues(userNextConfig, userSentryOptions), vercelCronsConfig, }); diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 8bca01cb2ce4..22c81f00102b 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; import type { JSONValue, TurbopackMatcherWithRule } from '../types'; +import type { TunnelRouteInjectedValues } from '../withSentryConfig/tunnel'; import { getPackageModules, supportsTurbopackRuleCondition } from '../util'; /** @@ -10,16 +11,16 @@ import { getPackageModules, supportsTurbopackRuleCondition } from '../util'; export function generateValueInjectionRules({ routeManifest, nextJsVersion, - tunnelPath, + tunnelRouteValues, vercelCronsConfig, }: { routeManifest?: RouteManifest; nextJsVersion?: string; - tunnelPath?: string; + tunnelRouteValues?: TunnelRouteInjectedValues; vercelCronsConfig?: VercelCronsConfig; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; - const isomorphicValues: Record = {}; + const isomorphicValues: Record = { ...tunnelRouteValues }; let clientValues: Record = {}; let serverValues: Record = {}; @@ -32,11 +33,6 @@ export function generateValueInjectionRules({ clientValues._sentryRouteManifest = JSON.stringify(routeManifest); } - // Inject tunnel route path for both client and server - if (tunnelPath) { - isomorphicValues._sentryRewritesTunnelPath = tunnelPath; - } - // Inject Vercel crons config for server-side cron auto-instrumentation if (vercelCronsConfig) { serverValues._sentryVercelCronsConfig = JSON.stringify(vercelCronsConfig); diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index f1d3a8441bcb..6c9f725a8e49 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -272,6 +272,17 @@ export type SentryBuildOptions = Omit< * - Pass `true` to auto-generate a random, ad-blocker-resistant route for each build * - Pass a string path (e.g., '/monitoring') to use a custom route * + * By default, the tunnel route proxies each request to Sentry with all its headers, cookies included. To forward + * only the event payload, create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route + * group) with: + * + * ```ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` + * + * This handler accepts the DSN of the server SDK, also a self-hosted one. For other DSNs, use + * `createSentryTunnelRoute` from the same import. + * * Tunnel requests go through your middleware (`proxy.ts` / `middleware.ts`) like any other request. If your * middleware redirects or blocks unauthenticated requests, exclude the tunnel route in its `matcher` so events * can reach Sentry. Matchers have to be static, so use a fixed string route in that case rather than `true`. diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 5f0990ac6829..1473b86bd745 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -3,6 +3,9 @@ import * as fs from 'fs'; import { createRequire } from 'module'; import * as path from 'path'; +// Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161 +export const DEFAULT_PAGE_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js']; + /** * Returns the version of Next.js installed in the project, or undefined if it cannot be determined. */ diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 833965b69518..32bd6bee28e7 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -26,8 +26,9 @@ import type { WebpackPluginInstance, } from './types'; import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion/webpack'; -import { getNextjsVersion, getPackageModules } from './util'; +import { DEFAULT_PAGE_EXTENSIONS, getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; +import { getTunnelRouteInjectedValues } from './withSentryConfig/tunnel'; // Next.js runs webpack 3 times, once for the client, the server, and for edge. Because we don't want to print certain // warnings 3 times, we keep track of them here. @@ -72,8 +73,7 @@ export function constructWebpackConfigFunction({ const { isServer, dev: isDev, dir: projectDir } = buildContext; const logger = getBuildLogger(userSentryOptions.silent); const runtime = isServer ? (buildContext.nextRuntime === 'edge' ? 'edge' : 'server') : 'client'; - // Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161 - const pageExtensions = userNextConfig.pageExtensions || ['tsx', 'ts', 'jsx', 'js']; + const pageExtensions = userNextConfig.pageExtensions || DEFAULT_PAGE_EXTENSIONS; const dotPrefixedPageExtensions = pageExtensions.map(ext => `.${ext}`); const pageExtensionRegex = pageExtensions.map(escapeStringForRegex).join('|'); const nextVersion = nextJsVersion || getNextjsVersion(); @@ -719,13 +719,7 @@ function addValueInjectionLoader({ const releaseToInject = releaseName && shouldCreateRelease ? releaseName : undefined; const isomorphicValues = { - // `rewritesTunnel` set by the user in Next.js config - _sentryRewritesTunnelPath: - userSentryOptions.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` - : undefined, + ...getTunnelRouteInjectedValues(userNextConfig, userSentryOptions), // The webpack plugin's release injection breaks the `app` directory so we inject the release manually here instead. // Having a release defined in dev-mode spams releases in Sentry so we only set one in non-dev mode diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index b93d9ea2fc9c..8665b75fc8fb 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { hasOnlyAppRouterPages } from './pagesRouterDetection'; +import { getTunnelRouteInjectedValues } from './tunnel'; /** * Adds Sentry-related build-time variables to `nextConfig.env`. @@ -23,13 +24,6 @@ export function setUpBuildTimeVariables( const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; const basePath = userNextConfig.basePath ?? ''; - const rewritesTunnelPath = - userSentryOptions.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${basePath}${userSentryOptions.tunnelRoute}` - : undefined; - const buildTimeVariables: Record = { // Make sure that if we have a windows path, the backslashes are interpreted as such (rather than as escape // characters) @@ -39,6 +33,7 @@ export function setUpBuildTimeVariables( _sentryRewriteFramesAssetPrefixPath: assetPrefix ? new URL(assetPrefix, 'http://dogs.are.great').pathname.replace(/\/$/, '') : '', + ...getTunnelRouteInjectedValues(userNextConfig, userSentryOptions, projectDir), }; if (userNextConfig.assetPrefix) { @@ -49,10 +44,6 @@ export function setUpBuildTimeVariables( buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; } - if (rewritesTunnelPath) { - buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath; - } - if (basePath) { buildTimeVariables._sentryBasePath = basePath; } diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index c457cb75aaf0..8fa1cd446a25 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -9,9 +9,16 @@ import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { requiresInstrumentationHook } from '../util'; import { getGitRevision, getInstrumentationClientFileContents } from './buildTime'; -import { resolveTunnelRoute, setUpTunnelRewriteRules } from './tunnel'; +import { + findTunnelRouteHandlerFile, + resolveTunnelRoute, + setUpTunnelHandlerRewrite, + setUpTunnelRewriteRules, +} from './tunnel'; let showedExportModeTunnelWarning = false; +let showedUnusedTunnelRouteHandlerWarning = false; +let showedTunnelRouteHandlerModeLog = false; let showedExperimentalBuildModeWarning = false; /** @@ -27,7 +34,7 @@ export function resolveReleaseName(userSentryOptions: SentryBuildOptions): strin } /** - * Applies tunnel-route rewrites, if configured. + * Applies tunnel-route rewrites, if configured. Uses handler mode if the project has a tunnel route handler file. * * Note: this mutates `userSentryOptions` (to store the resolved tunnel route) and `incomingUserNextConfigObject`. */ @@ -35,6 +42,11 @@ export function maybeSetUpTunnelRouteRewriteRules( incomingUserNextConfigObject: NextConfigObject, userSentryOptions: SentryBuildOptions, ): void { + if (userSentryOptions.tunnelRoute === undefined) { + maybeWarnAboutUnusedTunnelRouteHandler(incomingUserNextConfigObject, userSentryOptions.silent); + return; + } + if (!userSentryOptions.tunnelRoute) { return; } @@ -53,7 +65,39 @@ export function maybeSetUpTunnelRouteRewriteRules( const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; - setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); + const tunnelRouteHandlerFile = findTunnelRouteHandlerFile(incomingUserNextConfigObject.pageExtensions); + if (!tunnelRouteHandlerFile) { + setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); + return; + } + + setUpTunnelHandlerRewrite(incomingUserNextConfigObject, resolvedTunnelRoute); + + if (!showedTunnelRouteHandlerModeLog) { + showedTunnelRouteHandlerModeLog = true; + getBuildLogger(userSentryOptions.silent).log( + `[@sentry/nextjs] The tunnel route sends events through ${tunnelRouteHandlerFile}. Only the event payload goes to Sentry.`, + ); + } +} + +function maybeWarnAboutUnusedTunnelRouteHandler( + incomingUserNextConfigObject: NextConfigObject, + silent: boolean | undefined, +): void { + if (showedUnusedTunnelRouteHandlerWarning || incomingUserNextConfigObject.output === 'export') { + return; + } + + const tunnelRouteHandlerFile = findTunnelRouteHandlerFile(incomingUserNextConfigObject.pageExtensions); + if (!tunnelRouteHandlerFile) { + return; + } + + showedUnusedTunnelRouteHandlerWarning = true; + getBuildLogger(silent).warn( + `[@sentry/nextjs] Found ${tunnelRouteHandlerFile}, but the \`tunnelRoute\` option is not set. Set \`tunnelRoute\` in \`withSentryConfig\` to send events through this route.`, + ); } /** diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index 78b050a525bd..d0b45bd6ec8c 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -1,5 +1,14 @@ import { _INTERNAL_safeMathRandom } from '@sentry/core'; -import type { NextConfigObject } from '../types'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TUNNEL_ROUTE_HANDLER_PATH, type TunnelRouteMode } from '../../common/utils/tunnelPathnameMatch'; +import type { NextConfigObject, SentryBuildOptions } from '../types'; +import { DEFAULT_PAGE_EXTENSIONS } from '../util'; + +/** The values that the client, server and edge bundles read to find the tunnel route. */ +export type TunnelRouteInjectedValues = + | { _sentryRewritesTunnelPath: string; _sentryTunnelRouteMode: TunnelRouteMode } + | Record; /** * Generates a random tunnel route path that's less likely to be blocked by ad-blockers @@ -115,3 +124,79 @@ export function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnel } }; } + +/** + * Injects a rewrite rule that sends requests from the `tunnelPath` to the tunnel route handler. + */ +export function setUpTunnelHandlerRewrite(userNextConfig: NextConfigObject, tunnelPath: string): void { + const originalRewrites = userNextConfig.rewrites; + // Next.js adds the `basePath` and an optional trailing slash. + const handlerRewrite = { source: tunnelPath, destination: TUNNEL_ROUTE_HANDLER_PATH }; + + // `beforeFiles`: a `public/` file or a page at the same path wins over an `afterFiles` rewrite. + userNextConfig.rewrites = async () => { + const originalRewritesResult = typeof originalRewrites === 'function' ? await originalRewrites() : []; + + if (Array.isArray(originalRewritesResult)) { + return { beforeFiles: [handlerRewrite], afterFiles: originalRewritesResult }; + } + + return { + ...originalRewritesResult, + beforeFiles: [handlerRewrite, ...(originalRewritesResult.beforeFiles || [])], + }; + }; +} + +/** + * Returns the tunnel route handler file relative to `projectDir` (for example `app/sentry-tunnel/route.ts`), or + * `undefined` if the project has none. + * + * Route groups are not searched: the file must be directly in `app/sentry-tunnel/` or `src/app/sentry-tunnel/`. + */ +export function findTunnelRouteHandlerFile( + pageExtensions: string[] = DEFAULT_PAGE_EXTENSIONS, + projectDir: string = process.cwd(), +): string | undefined { + // Like Next.js, ignore `src/app` if there is a root `app` directory. + const appDir = ['app', 'src/app'].find(dir => getStats(path.join(projectDir, dir))?.isDirectory()); + if (!appDir) { + return undefined; + } + + return pageExtensions + .map(extension => `${appDir}${TUNNEL_ROUTE_HANDLER_PATH}/route.${extension}`) + .find(file => getStats(path.join(projectDir, file))?.isFile()); +} + +function getStats(filePath: string): fs.Stats | undefined { + try { + return fs.statSync(filePath); + } catch { + return undefined; + } +} + +/** + * Returns the tunnel path that the client sends envelopes to, and the tunnel route mode. + * Returns no values if there is no tunnel route, or if it cannot work (static export). + * + * Call it after `maybeSetUpTunnelRouteRewriteRules`, which resolves `tunnelRoute: true` to a path. + */ +export function getTunnelRouteInjectedValues( + userNextConfig: NextConfigObject, + userSentryOptions: SentryBuildOptions | undefined, + projectDir: string = process.cwd(), +): TunnelRouteInjectedValues { + const tunnelRoute = userSentryOptions?.tunnelRoute; + if (typeof tunnelRoute !== 'string' || !tunnelRoute || userNextConfig.output === 'export') { + return {}; + } + + return { + _sentryRewritesTunnelPath: `${userNextConfig.basePath ?? ''}${tunnelRoute}`, + _sentryTunnelRouteMode: findTunnelRouteHandlerFile(userNextConfig.pageExtensions, projectDir) + ? 'handler' + : 'rewrite', + }; +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index b073b4822453..e479fa45bd47 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -25,7 +25,7 @@ import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { backfillHttpResponseStatusCode } from '../common/utils/backfillHttpResponseStatusCode'; import { backfillHttpServerStatus } from '../common/utils/backfillHttpServerStatus'; import { createLiveRootSpanAdapter } from '../common/utils/liveRootSpanAdapter'; -import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; +import { dropMiddlewareTunnelRequests, getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; import { maybeForkIsolationScopeForRootSpan } from '../common/utils/forkIsolationScopeForRootSpan'; import { getNormalizedRequestFromAttributes } from '../common/utils/getNormalizedRequestFromAttributes'; import { isBuild } from '../common/utils/isBuild'; @@ -110,6 +110,7 @@ export function init(options: VercelEdgeOptions = {}): void { const nextjsIgnoreSpans: NonNullable = [ // (set in `dropMiddlewareTunnelRequests` during `spanStart`) { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, + ...getTunnelRouteIgnoreSpans(), ]; opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 6dea488a7d9d..fc1bc51571a2 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -15,6 +15,7 @@ import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sent import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; +import { getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; import { isBuild } from '../common/utils/isBuild'; import { isAsyncContextOwnedByCloudflare, @@ -155,7 +156,7 @@ export function init(options: NodeOptions): NodeClient | undefined { ...cloudflareConfig, }; - opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS]; + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, ...getTunnelRouteIgnoreSpans()]; if (DEBUG_BUILD && opts.debug) { debug.enable(); diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts new file mode 100644 index 000000000000..09a2c7cdcc43 --- /dev/null +++ b/packages/nextjs/src/tunnel/index.ts @@ -0,0 +1,76 @@ +import { dsnToString, getClient, handleTunnelRequest, makeDsn, suppressTracing } from '@sentry/core'; + +export interface CreateSentryTunnelRouteOptions { + /** + * The DSNs that the route accepts. It rejects envelopes for other DSNs with a 403. + * Defaults to the DSN of the server SDK. + */ + allowedDsns?: string[]; +} + +type TunnelRouteHandler = (request: Request) => Promise; + +/** + * Creates a route handler for the `tunnelRoute`, like `POST`, but with your own DSN allowlist. + * + * In most cases, use `export { POST } from '@sentry/nextjs/tunnel'` instead. Use this function only if the route must + * accept DSNs other than the one of the server SDK, for example a different browser DSN. + * + * @example + * ```ts + * // app/sentry-tunnel/route.ts + * import { createSentryTunnelRoute } from '@sentry/nextjs/tunnel'; + * + * export const { POST } = createSentryTunnelRoute({ + * allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + * }); + * ``` + */ +export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions = {}): { POST: TunnelRouteHandler } { + const allowedDsnsFromOptions = options.allowedDsns?.length ? normalizeDsns(options.allowedDsns) : undefined; + + return { + POST: async request => { + // Read on each request, because the client can be set up after this module loads. + const allowedDsns = allowedDsnsFromOptions ?? getServerDsns(); + if (!allowedDsns.length) { + return new Response( + allowedDsnsFromOptions + ? 'Tunnel route has no valid DSN in allowedDsns.' + : 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', + { status: 500 }, + ); + } + + // Like the SDK transports, so that the forward creates no spans, breadcrumbs or trace headers. + return suppressTracing(() => handleTunnelRequest({ request, allowedDsns })); + }, + }; +} + +/** + * Route handler for the `tunnelRoute` of `withSentryConfig`. It forwards Sentry envelopes to Sentry, but not the + * request headers (such as cookies). + * + * It accepts envelopes only for the DSN of the server SDK. For other DSNs, use `createSentryTunnelRoute`. + * + * @example + * ```ts + * // app/sentry-tunnel/route.ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` + */ +export const POST: TunnelRouteHandler = createSentryTunnelRoute().POST; + +function getServerDsns(): string[] { + const dsn = getClient()?.getDsn(); + return dsn ? [dsnToString(dsn)] : []; +} + +// The same form as the DSN in the envelope header, because `handleTunnelRequest` compares exact strings. +function normalizeDsns(dsns: string[]): string[] { + return dsns.flatMap(dsn => { + const dsnComponents = makeDsn(dsn.trim()); + return dsnComponents ? [dsnToString(dsnComponents)] : []; + }); +} diff --git a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts index d4fed2368101..d68c002f298b 100644 --- a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts +++ b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts @@ -305,6 +305,33 @@ describe('generateValueInjectionRules', () => { }); }); + describe('with tunnel route values', () => { + it('injects the tunnel path and mode into the client and server bundles', () => { + const result = generateValueInjectionRules({ + tunnelRouteValues: { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + }); + + const injectedValues = result.map(({ matcher, rule }) => [ + matcher, + (rule as { loaders: Array<{ options: { values: Record } }> }).loaders[0]?.options.values, + ]); + expect(injectedValues).toEqual([ + [ + '**/instrumentation-client.*', + { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + ], + [ + '**/instrumentation.*', + { + _sentryRewritesTunnelPath: '/monitoring', + _sentryTunnelRouteMode: 'handler', + __SENTRY_SERVER_MODULES__: expect.any(Object), + }, + ], + ]); + }); + }); + describe('path resolution', () => { it('should call path.resolve with correct arguments', () => { const pathResolveSpy = vi.spyOn(path, 'resolve'); diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index bab62624c86a..766a05f583c8 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -1,7 +1,10 @@ // mock helper functions not tested directly in this file import '../mocks'; import * as coreServer from '@sentry/core/server'; -import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import type * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import * as getBuildPluginOptionsModule from '../../../src/config/getBuildPluginOptions'; import { CLIENT_SDK_CONFIG_FILE, @@ -15,6 +18,9 @@ import { } from '../fixtures'; import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils'; +const realFs = await vi.importActual('fs'); +const realOs = await vi.importActual('os'); + // Only the plugin factory is stubbed — `resolveOrchestrionRuntimeRequest` must stay real because // the externals handler under test uses it. vi.mock('@sentry/server-utils/orchestrion/webpack', async importOriginal => ({ @@ -822,4 +828,39 @@ describe('constructWebpackConfigFunction()', () => { expect(finalWebpackConfig.externals).toBeUndefined(); }); }); + + describe('tunnel route value injection', () => { + let projectDir: string | undefined; + let cwdSpy: ReturnType | undefined; + + afterEach(() => { + vi.mocked(fs.statSync).mockReset(); + cwdSpy?.mockRestore(); + if (projectDir) { + realFs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('injects the tunnel path and the handler mode', async () => { + projectDir = realFs.mkdtempSync(path.join(realOs.tmpdir(), 'sentry-nextjs-webpack-tunnel-')); + realFs.mkdirSync(path.join(projectDir, 'app/sentry-tunnel'), { recursive: true }); + realFs.writeFileSync(path.join(projectDir, 'app/sentry-tunnel/route.ts'), ''); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + vi.mocked(fs.statSync).mockImplementation(realFs.statSync); + + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig: userNextConfig, + incomingWebpackConfig: clientWebpackConfig, + incomingWebpackBuildContext: clientBuildContext, + sentryBuildTimeOptions: { tunnelRoute: '/monitoring' }, + }); + + const valueInjectionLoader = finalWebpackConfig.module.rules + .flatMap(rule => rule.use ?? []) + .find(loader => String(loader.loader).endsWith('valueInjectionLoader.js')); + expect(valueInjectionLoader?.options).toMatchObject({ + values: { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + }); + }); + }); }); diff --git a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts index b81e3c48ee86..c8bee1a00d39 100644 --- a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts @@ -57,4 +57,25 @@ describe('setUpBuildTimeVariables', () => { expect(nextConfig.env._sentryHasPagesRouter).toBe('true'); }); }); + + describe('tunnel route', () => { + it('adds the tunnel path and the handler mode for a project with a tunnel route handler', () => { + const nextConfig = { basePath: '/docs' }; + + setUpBuildTimeVariables( + nextConfig, + { tunnelRoute: '/monitoring' }, + undefined, + makeProject(['app/sentry-tunnel/route.ts']), + ); + + expect(nextConfig).toEqual({ + basePath: '/docs', + env: expect.objectContaining({ + _sentryRewritesTunnelPath: '/docs/monitoring', + _sentryTunnelRouteMode: 'handler', + }), + }); + }); + }); }); diff --git a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts new file mode 100644 index 000000000000..cfda40eaa28f --- /dev/null +++ b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NextConfigObject } from '../../../src/config/types'; +import { makeProject, removeProjects } from './projectFixture'; + +const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; + +afterEach(() => { + removeProjects(); +}); + +describe('maybeSetUpTunnelRouteRewriteRules', () => { + beforeEach(() => { + vi.restoreAllMocks(); + // A fresh module per test, because the function logs each message only once. + vi.resetModules(); + delete process.env.__SENTRY_TUNNEL_ROUTE__; + }); + + async function setUp(files: string[]) { + vi.spyOn(process, 'cwd').mockReturnValue(makeProject(files)); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { maybeSetUpTunnelRouteRewriteRules } = + await import('../../../src/config/withSentryConfig/getFinalConfigObjectUtils'); + return { maybeSetUpTunnelRouteRewriteRules, logSpy, warnSpy }; + } + + it('sets up the handler rewrite when the project has a tunnel route handler', async () => { + const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/sentry-tunnel/route.ts']); + const nextConfig: NextConfigObject = {}; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [HANDLER_REWRITE], afterFiles: [] }); + }); + + it('logs the tunnel route handler file once', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/sentry-tunnel/route.ts']); + + maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring' }); + maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring' }); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] The tunnel route sends events through app/sentry-tunnel/route.ts. Only the event payload goes to Sentry.', + ); + }); + + it('sets up the external rewrite when the project has no tunnel route handler', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/page.tsx']); + const nextConfig: NextConfigObject = {}; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + + await expect(nextConfig.rewrites?.()).resolves.toEqual([ + { + source: '/monitoring(/?)', + has: [ + { type: 'query', key: 'o', value: '(?\\d*)' }, + { type: 'query', key: 'p', value: '(?\\d*)' }, + { type: 'query', key: 'r', value: '(?[a-z]{2})' }, + ], + destination: 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0', + }, + { + source: '/monitoring(/?)', + has: [ + { type: 'query', key: 'o', value: '(?\\d*)' }, + { type: 'query', key: 'p', value: '(?\\d*)' }, + ], + destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0', + }, + ]); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('does not log with `silent`', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/sentry-tunnel/route.ts']); + + maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring', silent: true }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('warns once when the project has a tunnel route handler but `tunnelRoute` is not set', async () => { + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['src/app/sentry-tunnel/route.tsx']); + const nextConfig: NextConfigObject = {}; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, {}); + maybeSetUpTunnelRouteRewriteRules({}, {}); + + expect(nextConfig.rewrites).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] Found src/app/sentry-tunnel/route.tsx, but the `tunnelRoute` option is not set. Set `tunnelRoute` in `withSentryConfig` to send events through this route.', + ); + }); + + it('sets up no rewrite for a static export with a tunnel route handler', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy, warnSpy } = await setUp(['app/sentry-tunnel/route.ts']); + const nextConfig: NextConfigObject = { output: 'export' }; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + + expect(nextConfig.rewrites).toBeUndefined(); + expect(logSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('will not work in combination with Next.js static exports'), + ); + }); + + it.each([ + ['`tunnelRoute` is `false`', {}, { tunnelRoute: false }], + ['`tunnelRoute` is not set in a static export', { output: 'export' }, {}], + ])('does not warn about the tunnel route handler when %s', async (_description, nextConfig, sentryOptions) => { + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['app/sentry-tunnel/route.ts']); + + maybeSetUpTunnelRouteRewriteRules(nextConfig, sentryOptions); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/test/config/withSentryConfig/projectFixture.ts b/packages/nextjs/test/config/withSentryConfig/projectFixture.ts new file mode 100644 index 000000000000..ececc652755b --- /dev/null +++ b/packages/nextjs/test/config/withSentryConfig/projectFixture.ts @@ -0,0 +1,23 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const projectDirs: string[] = []; + +/** Creates a temporary project directory with empty `files`. `removeProjects` deletes it. */ +export function makeProject(files: string[]): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nextjs-project-')); + projectDirs.push(dir); + for (const file of files) { + const absolute = path.join(dir, file); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, ''); + } + return dir; +} + +export function removeProjects(): void { + for (const dir of projectDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts new file mode 100644 index 000000000000..cc39280aa7d0 --- /dev/null +++ b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { NextConfigObject } from '../../../src/config/types'; +import { + findTunnelRouteHandlerFile, + getTunnelRouteInjectedValues, + setUpTunnelHandlerRewrite, +} from '../../../src/config/withSentryConfig/tunnel'; +import { makeProject, removeProjects } from './projectFixture'; + +const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; + +afterEach(() => { + removeProjects(); +}); + +describe('findTunnelRouteHandlerFile', () => { + it.each([ + ['app/sentry-tunnel/route.ts', ['app/page.tsx', 'app/sentry-tunnel/route.ts']], + ['src/app/sentry-tunnel/route.js', ['src/app/page.tsx', 'src/app/sentry-tunnel/route.js']], + ])('finds %s', (expectedFile, files) => { + expect(findTunnelRouteHandlerFile(undefined, makeProject(files))).toBe(expectedFile); + }); + + // Next.js finds `pages` and `app` separately, so a root `pages` does not hide `src/app`, only `src/pages` + // Next.js 16 rejects this case at build time. + // https://github.com/vercel/next.js/blob/7a7d48862287fd5caecd99da41149ecbddf37320/packages/next/src/lib/find-pages-dir.ts#L4-L13 + it('finds the file in `src/app` next to a root `pages` directory', () => { + const projectDir = makeProject(['pages/index.tsx', 'src/app/sentry-tunnel/route.ts']); + + expect(findTunnelRouteHandlerFile(undefined, projectDir)).toBe('src/app/sentry-tunnel/route.ts'); + }); + + it('ignores `src/app` when there is a root `app` directory', () => { + const projectDir = makeProject(['app/page.tsx', 'src/app/sentry-tunnel/route.ts']); + + expect(findTunnelRouteHandlerFile(undefined, projectDir)).toBeUndefined(); + }); + + it('finds the file only with a whole `pageExtensions` entry', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts', 'app/sentry-tunnel/route.page.ts']); + + expect(findTunnelRouteHandlerFile(['page.ts'], projectDir)).toBe('app/sentry-tunnel/route.page.ts'); + }); + + it('does not find a file that does not match any `pageExtensions` entry', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(findTunnelRouteHandlerFile(['mdx', 'tsx'], projectDir)).toBeUndefined(); + }); + + it.each([ + ['a route group', ['app/(monitoring)/sentry-tunnel/route.ts']], + ['a Pages Router project', ['pages/sentry-tunnel/route.ts']], + ['a directory named like the route file', ['app/sentry-tunnel/route.ts/page.tsx']], + ])('does not find the file in %s', (_description, files) => { + expect(findTunnelRouteHandlerFile(undefined, makeProject(files))).toBeUndefined(); + }); +}); + +describe('getTunnelRouteInjectedValues', () => { + it('returns the handler mode when the project has a tunnel route handler', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(getTunnelRouteInjectedValues({}, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ + _sentryRewritesTunnelPath: '/monitoring', + _sentryTunnelRouteMode: 'handler', + }); + }); + + it('returns the rewrite mode when the project has no tunnel route handler', () => { + const projectDir = makeProject(['app/page.tsx']); + + expect(getTunnelRouteInjectedValues({}, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ + _sentryRewritesTunnelPath: '/monitoring', + _sentryTunnelRouteMode: 'rewrite', + }); + }); + + it('adds the basePath to the tunnel path', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(getTunnelRouteInjectedValues({ basePath: '/docs' }, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ + _sentryRewritesTunnelPath: '/docs/monitoring', + _sentryTunnelRouteMode: 'handler', + }); + }); + + it.each([ + ['`tunnelRoute` is not set', {}, {}], + ['`tunnelRoute` is an empty string', { basePath: '/docs' }, { tunnelRoute: '' }], + ['`tunnelRoute` is still `true`', {}, { tunnelRoute: true }], + ['the app is a static export', { output: 'export' }, { tunnelRoute: '/monitoring' }], + ])('returns no values when %s', (_description, nextConfig, sentryOptions) => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(getTunnelRouteInjectedValues(nextConfig, sentryOptions, projectDir)).toEqual({}); + }); +}); + +describe('setUpTunnelHandlerRewrite', () => { + it('adds the handler rewrite to `beforeFiles` when there are no user rewrites', async () => { + const nextConfig: NextConfigObject = {}; + + setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [HANDLER_REWRITE], afterFiles: [] }); + }); + + it('keeps user rewrites in array form as `afterFiles`', async () => { + const userRewrite = { source: '/blog/:slug', destination: '/news/:slug' }; + const nextConfig: NextConfigObject = { rewrites: async () => [userRewrite] }; + + setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ + beforeFiles: [HANDLER_REWRITE], + afterFiles: [userRewrite], + }); + }); + + it('puts the handler rewrite before the user `beforeFiles` rewrites', async () => { + const userBeforeFilesRewrite = { source: '/old', destination: '/new' }; + const userFallbackRewrite = { source: '/:path*', destination: 'https://legacy.example.com/:path*' }; + const nextConfig: NextConfigObject = { + rewrites: async () => ({ beforeFiles: [userBeforeFilesRewrite], fallback: [userFallbackRewrite] }), + }; + + setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ + beforeFiles: [HANDLER_REWRITE, userBeforeFilesRewrite], + fallback: [userFallbackRewrite], + }); + }); +}); diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts new file mode 100644 index 000000000000..1e86f6925f1f --- /dev/null +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -0,0 +1,146 @@ +import type * as SentryCore from '@sentry/core'; +import { createEnvelope, makeDsn, serializeEnvelope } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSentryTunnelRoute, POST } from '../../src/tunnel'; + +const { mockGetClient } = vi.hoisted(() => ({ mockGetClient: vi.fn() })); + +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + getClient: mockGetClient, +})); + +const SERVER_DSN = 'https://public@o1.ingest.sentry.io/1'; +const BROWSER_DSN = 'https://browser@o1.ingest.sentry.io/2'; + +function createEnvelopeRequest(dsn: string): { request: Request; body: Uint8Array } { + const envelope = serializeEnvelope( + createEnvelope({ dsn, event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', message: 'Checkout failed' }], + ]), + ); + const body = typeof envelope === 'string' ? new TextEncoder().encode(envelope) : envelope; + const request = new Request('https://shop.example.com/monitoring', { + method: 'POST', + headers: { cookie: 'session=8f2c1d', 'content-type': 'text/plain;charset=UTF-8' }, + body, + }); + return { request, body }; +} + +function useClientDsn(dsn: string | undefined): void { + mockGetClient.mockReturnValue(dsn ? { getDsn: () => makeDsn(dsn) } : undefined); +} + +let fetchSpy: ReturnType; + +beforeEach(() => { + vi.restoreAllMocks(); + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 200 })); + useClientDsn(undefined); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('POST', () => { + it('forwards only the envelope to the Sentry host of an allowed DSN', async () => { + useClientDsn(SERVER_DSN); + const { request, body } = createEnvelopeRequest(SERVER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://o1.ingest.sentry.io/api/1/envelope/?sentry_version=7&sentry_key=public', + { + method: 'POST', + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + body, + }, + ); + }); + + it('responds with 500 when the server SDK has no DSN', async () => { + const { request } = createEnvelopeRequest(SERVER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toBe( + 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects an envelope for a DSN that is not the server DSN', async () => { + useClientDsn(SERVER_DSN); + const { request } = createEnvelopeRequest(BROWSER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does not accept DSNs from environment variables', async () => { + useClientDsn(SERVER_DSN); + vi.stubEnv('SENTRY_DSN', BROWSER_DSN); + vi.stubEnv('NEXT_PUBLIC_SENTRY_DSN', BROWSER_DSN); + const { request } = createEnvelopeRequest(BROWSER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(403); + }); +}); + +describe('createSentryTunnelRoute', () => { + it('accepts only the DSNs in `allowedDsns`', async () => { + useClientDsn(SERVER_DSN); + const { POST: handler } = createSentryTunnelRoute({ allowedDsns: [BROWSER_DSN] }); + + const allowedResponse = await handler(createEnvelopeRequest(BROWSER_DSN).request); + const rejectedResponse = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(allowedResponse.status).toBe(200); + expect(rejectedResponse.status).toBe(403); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['omitted', {}], + ['empty', { allowedDsns: [] }], + ])('accepts the server DSN when `allowedDsns` is %s', async (_description, options) => { + useClientDsn(SERVER_DSN); + const { POST: handler } = createSentryTunnelRoute(options); + + const response = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(response.status).toBe(200); + }); + + it('accepts an allowed DSN with a secret and whitespace', async () => { + const { POST: handler } = createSentryTunnelRoute({ + allowedDsns: [' https://public:secret@o1.ingest.sentry.io/1\n'], + }); + + const response = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(response.status).toBe(200); + }); + + it('responds with 500 when no allowed DSN is valid', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + useClientDsn(SERVER_DSN); + const { POST: handler } = createSentryTunnelRoute({ allowedDsns: ['not-a-dsn'] }); + + const response = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toBe('Tunnel route has no valid DSN in allowedDsns.'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts index a635d5d65be5..5fa91525bd68 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -1,9 +1,16 @@ +import { HTTP_TARGET, SERVER_ADDRESS, URL_PATH } from '@sentry/conventions/attributes'; +import { shouldIgnoreSpan } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { dropMiddlewareTunnelRequests } from '../../src/common/utils/dropMiddlewareTunnelRequests'; +import { + dropMiddlewareTunnelRequests, + getTunnelRouteIgnoreSpans, +} from '../../src/common/utils/dropMiddlewareTunnelRequests'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached'; const globalWithInjectedValues = global as typeof global & { _sentryRewritesTunnelPath?: string; + _sentryTunnelRouteMode?: string; }; vi.mock('@sentry/core', async requireActual => { @@ -27,6 +34,7 @@ function createMockSpan(): { setAttribute: ReturnType; attributes: beforeEach(() => { globalWithInjectedValues._sentryRewritesTunnelPath = undefined; + globalWithInjectedValues._sentryTunnelRouteMode = undefined; }); describe('dropMiddlewareTunnelRequests', () => { @@ -152,3 +160,101 @@ describe('dropMiddlewareTunnelRequests', () => { }); }); }); + +describe('getTunnelRouteIgnoreSpans', () => { + function isIgnored(attributes: Record): boolean { + return shouldIgnoreSpan({ description: 'POST', op: 'http.server', attributes }, getTunnelRouteIgnoreSpans()); + } + + // Next.js sets the deprecated `http.target` on its request spans. + function nextRequestSpanAttributes(spanType: string, target: string): Record { + // eslint-disable-next-line typescript/no-deprecated + return { [ATTR_NEXT_SPAN_TYPE]: spanType, [HTTP_TARGET]: target }; + } + + it('returns no entries when there is no tunnel route', () => { + expect(getTunnelRouteIgnoreSpans()).toEqual([]); + }); + + it.each([ + ['BaseServer.handleRequest', '/monitoring?o=123&p=456'], + ['BaseServer.handleRequest', '/monitoring/?o=123&p=456&r=us'], + ['Middleware.execute', '/monitoring?o=123&p=456'], + ])('ignores a %s span for %s in rewrite mode', (spanType, target) => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + + expect(isIgnored(nextRequestSpanAttributes(spanType, target))).toBe(true); + }); + + it.each([ + '/monitoring', + '/monitoring?o=123', + '/monitoring?xo=123&p=456', + '/monitoring?o=123&p=456&o=abc', + '/monitoring?p=456&o=123', + '/monitoring/dashboard?o=123&p=456', + '/monitoring-dashboard?o=123&p=456', + '/api/monitoring?o=123&p=456', + ])('does not ignore a request span for %s in rewrite mode, which the rewrite does not serve', target => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(false); + }); + + it.each(['/monitoring', '/monitoring/', '/monitoring?cache=0'])( + 'ignores a request span for %s in handler mode', + target => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(true); + }, + ); + + it('does not ignore a request span for a sub-path in handler mode', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/monitoring/dashboard'))).toBe(false); + }); + + it('does not ignore an outgoing request with the tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + + const outgoingRequestAttributes = { + [URL_PATH]: '/monitoring', + // eslint-disable-next-line typescript/no-deprecated + [HTTP_TARGET]: '/monitoring', + [SERVER_ADDRESS]: 'api.example.com', + }; + + expect(isIgnored(outgoingRequestAttributes)).toBe(false); + }); + + it.each(['/sentry-tunnel', '/sentry-tunnel/route'])( + 'ignores the route handler span with next.route %s in handler mode', + nextRoute => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + + expect( + isIgnored({ [ATTR_NEXT_SPAN_TYPE]: 'AppRouteRouteHandlers.runHandler', [ATTR_NEXT_ROUTE]: nextRoute }), + ).toBe(true); + }, + ); + + it('does not ignore the route handler span in rewrite mode', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'rewrite'; + + expect( + isIgnored({ [ATTR_NEXT_SPAN_TYPE]: 'AppRouteRouteHandlers.runHandler', [ATTR_NEXT_ROUTE]: '/sentry-tunnel' }), + ).toBe(false); + }); + + it('escapes regex characters in the tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/a.b'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/axb?o=123&p=456'))).toBe(false); + }); +}); diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index 8382e66ca7d4..b901826d3f13 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -4,10 +4,13 @@ import { applyTunnelRouteOption } from '../../src/client/tunnelRoute'; const globalWithInjectedValues = global as typeof global & { _sentryRewritesTunnelPath?: string; + _sentryTunnelRouteMode?: string; }; beforeEach(() => { + vi.restoreAllMocks(); globalWithInjectedValues._sentryRewritesTunnelPath = undefined; + globalWithInjectedValues._sentryTunnelRouteMode = undefined; }); describe('applyTunnelRouteOption()', () => { @@ -118,3 +121,29 @@ describe('Random tunnel route generation', () => { expect(options.tunnel).toBeUndefined(); }); }); + +describe('applyTunnelRouteOption() in handler mode', () => { + it.each([ + ['a SaaS DSN', 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333'], + ['a self-hosted DSN', 'https://11111111111111111111111111111111@sentry.example.com/3333333'], + ])('tunnels %s to the tunnel path with no query', (_description, dsn) => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + const options: BrowserOptions = { dsn }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/my-error-monitoring-route'); + }); + + it("Doesn't apply `tunnelRoute` when DSN is invalid", () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + const options: BrowserOptions = { dsn: 'invalidDsn' }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBeUndefined(); + }); +}); From 13f4909b43a64df09c22136607a0274408b2d438 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:54:09 +0200 Subject: [PATCH 05/30] chore(hono): Remove "ALPHA" from package.json description (#25521) Hono is already stable since a while now. But I discovered this leftover notice in the `package.json`, which should be removed. --- packages/hono/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hono/package.json b/packages/hono/package.json index 8a6b4abe0874..ce561d1101ac 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,7 +1,7 @@ { "name": "@sentry/hono", "version": "11.8.0", - "description": "Official Sentry SDK for Hono (ALPHA)", + "description": "Official Sentry SDK for Hono", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/hono", "author": "Sentry", From 40456d5b44e4d66af905395d97f83bee0f44990d Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 19 Oct 2026 16:59:02 +0200 Subject: [PATCH 06/30] ci(e2e): Stop pnpm run from installing the workspace in E2E jobs (#25514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #25403, E2E jobs no longer restore the root `node_modules`. pnpm then installs all 63 workspace projects before the first `pnpm run test:prepare`, because `verify-deps-before-run` finds the dependencies missing. That's about 26s per E2E job, which cancels out most of what #25403 saved. It also made the background Playwright install flaky: its `npx` ran from the repo root and could find the hoisted `node_modules/playwright` before pnpm had linked its binary (`playwright: not found`). The scripts run with `pnpm run` here are plain `node` scripts, so disabling the check is enough. It's set per job, so local runs keep it. Without the long install, the foreground steps start in quick succession, which exposes a runner bug. The runner sets up the steps of a background composite action from the job's shared `github` context ([`CompositeActionHandler.cs`](https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/CompositeActionHandler.cs) → `GitHubContext.ShallowCopy()`). If a foreground step starts at that moment, this fails with `Collection was modified; enumeration operation may not execute`. The background install now starts right before the app build, so nothing else starts while it sets up. The steps it no longer overlaps with take well under a second. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/build.yml | 46 +++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3267dedc200..ce53bcc737e0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1060,6 +1060,8 @@ jobs: E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests' # The `runtime` of a runtime variant, see dev-packages/e2e-tests/README.md RUNTIME: ${{ matrix.runtime }} + # Without the root node_modules, `pnpm run` would first install the whole workspace. + pnpm_config_verify_deps_before_run: 'false' strategy: fail-fast: false matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix) }} @@ -1119,16 +1121,6 @@ jobs: with: name: build-output - # Installs Playwright while the app builds. - # Started after the artifact downloads: starting next to them made `download-artifact` fail to read the event file. - - name: Install Playwright - id: install-playwright - background: true - uses: ./.github/actions/install-playwright - with: - browsers: chromium - cwd: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }} - - name: Prepare e2e tests run: pnpm run test:prepare working-directory: dev-packages/e2e-tests @@ -1149,6 +1141,17 @@ jobs: }}/dev-packages/e2e-tests/packed working-directory: dev-packages/e2e-tests + # Installs Playwright while the app builds. Keep it right before the build: the runner sets up a background + # composite action's steps from the job's shared `github` context, which fails with "Collection was modified" if + # a foreground step starts at that moment. Starting next to `download-artifact` also made it fail to read the event file. + - name: Install Playwright + id: install-playwright + background: true + uses: ./.github/actions/install-playwright + with: + browsers: chromium + cwd: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }} + - name: Build E2E app working-directory: ${{ runner.temp }}/test-application timeout-minutes: 7 @@ -1206,6 +1209,8 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # The `runtime` of a runtime variant, see dev-packages/e2e-tests/README.md RUNTIME: ${{ matrix.runtime }} + # Without the root node_modules, `pnpm run` would first install the whole workspace. + pnpm_config_verify_deps_before_run: 'false' strategy: fail-fast: false matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix-optional) }} @@ -1246,16 +1251,6 @@ jobs: name: build-test-utils-tarball-output path: dev-packages/test-utils - # Installs Playwright while the app builds. - # Started after the artifact downloads: starting next to them made `download-artifact` fail to read the event file. - - name: Install Playwright - id: install-playwright - background: true - uses: ./.github/actions/install-playwright - with: - browsers: chromium - cwd: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }} - - name: Prepare E2E tests run: pnpm run test:prepare working-directory: dev-packages/e2e-tests @@ -1276,6 +1271,17 @@ jobs: }}/dev-packages/e2e-tests/packed working-directory: dev-packages/e2e-tests + # Installs Playwright while the app builds. Keep it right before the build: the runner sets up a background + # composite action's steps from the job's shared `github` context, which fails with "Collection was modified" if + # a foreground step starts at that moment. Starting next to `download-artifact` also made it fail to read the event file. + - name: Install Playwright + id: install-playwright + background: true + uses: ./.github/actions/install-playwright + with: + browsers: chromium + cwd: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }} + - name: Build E2E app working-directory: ${{ runner.temp }}/test-application timeout-minutes: 7 From e1d4c427359f12a21d52a3929f9d26c43e778a9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 19 Oct 2026 18:07:28 +0200 Subject: [PATCH 07/30] fix(server-utils): Stop the orchestrion Vite plugin from bundling the CJS build of @sentry/server-utils (#25359) closes #25339 closes [JS-3931](https://linear.app/getsentry/issue/JS-3931) ## Description This makes the orchestrion Vite plugin bundle the ESM build of `@sentry/server-utils`, also when an instrumented CommonJS module (for example Express's `router` or `mysql`) `require()`s it. Vite 5 to 7 turned the `require("@sentry/core")` of the CJS build into a default import of the external `@sentry/core`, which crashed the server at startup. This fixes it for every SDK that uses the plugin, not only React Router. On Vite 8, which did not crash, it also removes a second copy of `@sentry/core`. Reproduction: https://github.com/JPeer264/sentry-repros/tree/issue-25339 ## Resolution decisions `resolveId` maps the resolved `build/cjs/index.js` to `build/esm/index.js`. Resolving the `require()` with import conditions also works, but then `@rollup/plugin-commonjs` warns `THIS_RESOLVE_WITHOUT_OPTIONS`. ## Test decisions `react-router-8-framework` now depends on `@sentry/core` directly, so Vite keeps it external, as in a hoisted (npm) install. With pnpm's isolated layout, Vite bundled it and the crash did not happen. The alternative is a new e2e app with a hoisted install. Co-authored-by: Claude Fable 5.1 --- .../react-router-8-framework/package.json | 1 + .../tests/server-build.test.ts | 24 ++++++++++ .../src/orchestrion/bundler/vite.ts | 15 ++++++- .../test/orchestrion/bundler.test.ts | 44 +++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/server-build.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 6ee186f39e49..de5503c7626a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -11,6 +11,7 @@ "@react-router/serve": "^8.0.0", "@sentry/bun": "file:../../packed/sentry-bun-packed.tgz", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", "ioredis": "^5.11.1", diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/server-build.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/server-build.test.ts new file mode 100644 index 000000000000..27dc4f31a025 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/server-build.test.ts @@ -0,0 +1,24 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; +import { bundleReferencesModule } from '@sentry-internal/test-utils'; +import { RUNTIME } from './constants'; + +// `@sentry/core` is a direct dependency of this app so that Vite resolves it from the app root and +// keeps it external, as in an npm install. Bundled into the server build, it hides the case where +// bundled CommonJS `require()`s `@sentry/core`: Vite 5-7 turn that into a default import, and +// `@sentry/core` has no default export, so the server crashes at startup. +test.describe('server build', () => { + test.skip(RUNTIME === 'cloudflare', 'The Cloudflare build bundles all dependencies.'); + + test('imports the external @sentry/core by name only', () => { + const root = path.join(process.cwd(), 'build/server'); + const serverBundle = readdirSync(root, { recursive: true, encoding: 'utf8' }) + .filter(file => file.endsWith('.js')) + .map(file => readFileSync(path.join(root, file), 'utf8')) + .join('\n'); + + expect(bundleReferencesModule(serverBundle, '@sentry/core'), 'Vite must keep @sentry/core external').toBe(true); + expect(serverBundle.match(/\bimport\s+[\w$]+\s*(?:,\s*\{[^}]*\})?\s*from\s*["']@sentry\/[^"']+["']/g)).toBeNull(); + }); +}); diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index d199f7224385..c6a704bb1ca9 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -41,6 +41,11 @@ function ssrOnlyTransform(transform: Plugin['transform'], isDuplicate: () => boo return transform; } +// Maps the CJS entry of `@sentry/server-utils` to its ESM entry; any other id is returned unchanged. +function toEsmEntry(id: string): string { + return id.replace(/([/\\])build[/\\]cjs[/\\]index\.js$/, '$1build$1esm$1index.js'); +} + /** * Vite plugin that runs the orchestrion code transform on the bundled output. * @@ -83,15 +88,21 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { // normal resolution fails, fall back to this package's own resolution so it // gets bundled from its real on-disk path. SSR-gated like the transform: the // specifier only appears in SSR modules. + // + // Always bundle the ESM entry. In a CommonJS module the snippet `require()`s the specifier, + // which resolves to the CJS build. Vite 5-7 turn that build's `require()`s of external + // packages into default imports, and `@sentry/core` has no default export, so the server + // crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`. async resolveId(source, importer, resolveOptions) { if (source !== SNIPPET_IMPORT_SPECIFIER || !resolveOptions?.ssr) { return null; } const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); if (resolved) { - return resolved; + return resolved.external ? resolved : { ...resolved, id: toEsmEntry(resolved.id) }; } - return resolveOrchestrionRuntimeRequest(source) ?? null; + const fallback = resolveOrchestrionRuntimeRequest(source); + return fallback ? toEsmEntry(fallback) : null; }, applyToEnvironment(environment) { // Orchestrion splices `node:diagnostics_channel` calls into instrumented modules, which only diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index 59c74f664ef4..d6cf125df97d 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -402,6 +402,50 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(typeof fallback).toBe('string'); expect(fallback).toContain('server-utils'); }); + + it('resolves the snippet import to the ESM entry, also for require() calls', async () => { + const plugin = vitePlugin(); + const resolveId = plugin.resolveId as ( + this: unknown, + source: string, + importer: string | undefined, + opts?: { ssr?: boolean }, + ) => Promise; + + const resolve = vi.fn().mockResolvedValue(null); + // How Vite 5-7 resolve a `require()` in a CommonJS module. + const requireOptions = { ssr: true, custom: { 'node-resolve': { isRequire: true } } }; + + resolve.mockResolvedValueOnce({ + id: '/app/node_modules/@sentry/server-utils/build/cjs/index.js', + moduleSideEffects: false, + }); + await expect( + resolveId.call({ resolve }, '@sentry/server-utils', '/app/node_modules/mysql/index.js', requireOptions), + ).resolves.toEqual({ + id: '/app/node_modules/@sentry/server-utils/build/esm/index.js', + moduleSideEffects: false, + }); + expect(resolve).toHaveBeenCalledWith('@sentry/server-utils', '/app/node_modules/mysql/index.js', { + ...requireOptions, + skipSelf: true, + }); + + resolve.mockResolvedValueOnce({ id: 'C:\\app\\node_modules\\@sentry\\server-utils\\build\\cjs\\index.js' }); + await expect(resolveId.call({ resolve }, '@sentry/server-utils', '/x.js', requireOptions)).resolves.toEqual({ + id: 'C:\\app\\node_modules\\@sentry\\server-utils\\build\\esm\\index.js', + }); + + resolve.mockResolvedValueOnce({ id: '@sentry/server-utils', external: true }); + await expect(resolveId.call({ resolve }, '@sentry/server-utils', '/x.js', requireOptions)).resolves.toEqual({ + id: '@sentry/server-utils', + external: true, + }); + + const fallback = await resolveId.call({ resolve }, '@sentry/server-utils', '/x.js', requireOptions); + expect(fallback).toMatch(/[/\\]build[/\\]esm[/\\]index\.js$/); + expect(existsSync(fallback as string)).toBe(true); + }); }); describe('sentryOrchestrionPlugin (bun)', () => { From d6c8d820ab0aebffa75798f74f409d172cef692e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 19 Oct 2026 19:01:24 +0200 Subject: [PATCH 08/30] feat(cloudflare): Add `isStreamingResponse` option and detect streamed RSC responses (#24932) closes #22876 closes [JS-3236](https://linear.app/getsentry/issue/JS-3236/provide-a-mechanism-to-override-classifyresponsestreaming-behaviour) ## Description This adds an `isStreamingResponse` option, so users can keep the `http.server` span open until a streamed response has been sent, for example for streamed SSR pages. Spans created while the body streams then stay inside the request. - `text/x-component` (RSC) responses without a `Content-Length` header now count as streamed by default. The classifier is shared, so this also applies to Bun and Deno. - Precompressed bodies with `encodeBody: 'manual'` are no longer compressed a second time. - Workers with a compatibility date before 2022-11-30 send the span again. They ignore the `TransformStream` transformer, so the SDK ends the span at handler return there. ```ts export default defineCloudflareOptions(env => ({ dsn: env.SENTRY_DSN, isStreamingResponse: response => response.headers.get('content-type')?.startsWith('text/html') ? true : undefined, })); ``` ## Detection decisions HTML is not streamed by default, because a buffered `new Response(html)` has no `Content-Length` header either and would also go through the `TransformStream`. The alternative is to treat HTML without a `Content-Length` as streamed, which fixes streamed SSR without configuration. --------- Co-authored-by: Claude Opus 5.5 --- .../legacy-transform-stream/index.ts | 32 +++ .../legacy-transform-stream/test.ts | 20 ++ .../legacy-transform-stream/wrangler.jsonc | 8 + .../precompressed-response/index.ts | 19 ++ .../instrument.server.ts | 6 + .../precompressed-response/test.ts | 18 ++ .../precompressed-response/vite.config.mts | 7 + .../precompressed-response/wrangler.jsonc | 7 + .../streamed-response/index.ts | 43 +++ .../streamed-response/instrument.server.ts | 7 + .../request-handler/streamed-response/test.ts | 35 +++ .../streamed-response/vite.config.mts | 7 + .../streamed-response/wrangler.jsonc | 7 + packages/cloudflare/src/client.ts | 29 +++ .../src/wrapRequestHandlerWithInit.ts | 37 ++- packages/cloudflare/test/options.test-d.ts | 2 + packages/cloudflare/test/request.test.ts | 244 ++++++++++++++++++ packages/core/src/utils/responseStreaming.ts | 7 +- .../test/lib/utils/responseStreaming.test.ts | 5 +- 19 files changed, 525 insertions(+), 15 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts new file mode 100644 index 000000000000..45173ed825e8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts @@ -0,0 +1,32 @@ +import { wrapRequestHandler } from '@sentry/cloudflare/request'; + +interface Env { + SENTRY_DSN: string; +} + +export default { + async fetch(request, env, ctx) { + const contentType = new URL(request.url).searchParams.get('content-type') ?? 'text/x-component'; + + return wrapRequestHandler( + { + options: { + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }, + request, + context: ctx, + }, + () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('
shell
')); + controller.close(); + }, + }); + + return new Response(body, { headers: { 'content-type': contentType } }); + }, + ); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts new file mode 100644 index 000000000000..b675a9ce8714 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +it.for(['text/x-component;charset=utf-8', 'text/event-stream'])( + 'sends the http.server span of a streamed %s response when TransformStream ignores the transformer', + async (contentType, { signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + }) + .start(signal); + + const body = await runner.makeRequest('get', `/?content-type=${encodeURIComponent(contentType)}`); + expect(body).toBe('
shell
'); + + await runner.completed(); + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc new file mode 100644 index 000000000000..10f17305c6f2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "name": "worker-name", + // With this date and only this flag, `new TransformStream()` ignores the transformer. Mini-oxygen + // releases before 4.0.0 run Workers with this setup. + "compatibility_date": "2022-10-31", + "compatibility_flags": ["streams_enable_constructors"], + "main": "index.ts", +} diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts new file mode 100644 index 000000000000..774290a56061 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts @@ -0,0 +1,19 @@ +interface Env { + SENTRY_DSN: string; +} + +const TEXT = 'precompressed'; + +export default { + async fetch(): Promise { + const gzipped = await new Response( + new Blob([TEXT]).stream().pipeThrough(new CompressionStream('gzip')), + ).arrayBuffer(); + + // `text/plain` without a Content-Length is classified as streaming, so the SDK replaces the response. + return new Response(gzipped, { + encodeBody: 'manual', + headers: { 'content-type': 'text/plain;charset=utf-8', 'content-encoding': 'gzip' }, + }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts new file mode 100644 index 000000000000..dd1ba461f2d6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +it('does not compress a response with `encodeBody: manual` a second time', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/' }); + }) + .start(signal); + + const body = await runner.makeRequest('get', '/', { headers: { 'accept-encoding': 'gzip' } }); + expect(body).toBe('precompressed'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc new file mode 100644 index 000000000000..ffc67decd4d2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-request-handler-precompressed-response", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts new file mode 100644 index 000000000000..f983d2cd0faf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +const SHELL = '
shell
'; +const SUSPENDED = '
suspended
'; + +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const contentType = url.searchParams.get('content-type'); + if (!contentType) { + return new Response('Missing content-type', { status: 400 }); + } + + if (url.searchParams.get('body') === 'stream') { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(SHELL)); + // Waits for a later task, so the suspended part renders after the handler has returned. + await new Promise(resolve => setTimeout(resolve, 0)); + Sentry.startSpan({ name: 'render' }, () => { + controller.enqueue(encoder.encode(SUSPENDED)); + }); + controller.close(); + }, + }); + + return new Response(body, { headers: { 'content-type': contentType } }); + } + + const html = Sentry.startSpan({ name: 'render' }, () => SHELL + SUSPENDED); + const headers = new Headers({ 'content-type': contentType }); + if (url.searchParams.get('body') === 'string-with-content-length') { + headers.set('content-length', String(new TextEncoder().encode(html).byteLength)); + } + + return new Response(html, { headers }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts new file mode 100644 index 000000000000..f4c87d6d4b28 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts @@ -0,0 +1,7 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + isStreamingResponse: response => (response.headers.get('content-type')?.startsWith('text/html') ? true : undefined), +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts new file mode 100644 index 000000000000..9bc187460cbe --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts @@ -0,0 +1,35 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp } from '../../../spanUtils'; + +it.for([ + { body: 'stream', contentType: 'text/html;charset=utf-8' }, + { body: 'stream', contentType: 'text/x-component;charset=utf-8' }, + { body: 'string', contentType: 'text/html;charset=utf-8' }, + { body: 'string', contentType: 'text/x-component;charset=utf-8' }, + { body: 'string-with-content-length', contentType: 'text/html;charset=utf-8' }, + { body: 'string-with-content-length', contentType: 'text/x-component;charset=utf-8' }, +])( + 'nests the render span in the http.server span for a $contentType response with a $body body', + async ({ body, contentType }, { signal }) => { + const runner = createRunner(__dirname).start(signal); + + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.some(span => span.is_segment) && spansOfTrace.some(span => span.name === 'render'), + ); + + const responseBody = await runner.makeRequest( + 'get', + `/?body=${body}&content-type=${encodeURIComponent(contentType)}`, + ); + expect(responseBody).toBe('
shell
suspended
'); + + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment); + const renderSpan = spans.find(span => span.name === 'render'); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(renderSpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(segmentSpan!.end_timestamp).toBeGreaterThanOrEqual(renderSpan!.end_timestamp); + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc new file mode 100644 index 000000000000..d41141882a2c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-request-handler-streamed-response", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 751d3d5dac63..c070a104c272 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -478,6 +478,35 @@ interface BaseCloudflareOptions { * @default true */ cacheClient?: boolean; + + /** + * A function that receives the response of a `fetch` handler and the request it answers, and + * returns whether the response body is still being produced after the handler returns. + * + * For `true`, the SDK keeps the `http.server` span open until the body has been sent, so spans + * created while the body streams stay inside the request. It does this by passing the body through + * a `TransformStream`. For `false`, the SDK ends the span when the handler returns and sends the + * response unchanged. For `undefined` or any other value, the SDK decides: Server-Sent Events, + * NDJSON and `application/stream+json` responses, and `text/plain` and `text/x-component` + * responses without a `Content-Length` header count as streamed. HTML does not, so return `true` + * for pages that are rendered with streamed SSR. + * + * The function must be synchronous and must not read the response body. Use the status, the + * headers and the request URL. + * + * @example + * ```ts + * // src/instrument.server.ts + * import { defineCloudflareOptions } from '@sentry/cloudflare'; + * + * export default defineCloudflareOptions(env => ({ + * dsn: env.SENTRY_DSN, + * isStreamingResponse: response => + * response.headers.get('content-type')?.startsWith('text/html') ? true : undefined, + * })); + * ``` + */ + isStreamingResponse?: (response: Response, request: Request) => boolean | undefined; } /** diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 3599b88f4243..71444c9a87e3 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -14,11 +14,13 @@ import { httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, parseStringToURLObject, + safeCallback, setHttpStatus, startSpanManual, winterCGHeadersToDict, } from '@sentry/core'; import { classifyResponseStreaming } from '@sentry/core/server'; +import { DEBUG_BUILD } from './debug-build'; import { captureIncomingRequestBody } from './integrations/httpServer'; import { flushDeferredChannelEvents } from './orchestrion-deferred-channels'; import type { CloudflareClient, CloudflareOptions } from './client'; @@ -180,12 +182,19 @@ export function wrapRequestHandlerWithInit( throw e; } - // Classify response to detect actual streaming - const classification = classifyResponseStreaming(res); + const userClassification = safeCallback( + DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', + () => options.isStreamingResponse?.(res, request), + () => undefined, + ); + // An async callback returns a promise, which would otherwise count as `true`. + const isStreaming = + typeof userClassification === 'boolean' ? userClassification : classifyResponseStreaming(res).isStreaming; - if (classification.isStreaming && res.body) { + if (isStreaming && res.body) { try { let ended = false; + let transformerUsed = false; const endSpanOnce = (): void => { if (ended) return; @@ -195,7 +204,14 @@ export function wrapRequestHandlerWithInit( waitUntil?.(flushAndDispose(client)); }; + // Workers with a compatibility date before 2022-11-30 and without the + // `transformstream_enable_standard_constructor` flag (for example under older mini-oxygen + // releases) ignore the transformer, so `flush` and `cancel` would never end the span. Only a + // used transformer runs `start`. const transform = new TransformStream({ + start() { + transformerUsed = true; + }, flush() { // Source stream completed normally. endSpanOnce(); @@ -208,15 +224,14 @@ export function wrapRequestHandlerWithInit( }, }); - return new Response(res.body.pipeThrough(transform), { - status: res.status, - statusText: res.statusText, - headers: res.headers, - }); + if (transformerUsed) { + // Passing the original response as the init keeps `encodeBody: 'manual'`, which a + // Response does not expose as a property. Without it, workerd compresses a + // pre-compressed body a second time. + return new Response(res.body.pipeThrough(transform), res); + } } catch { - span.end(); - waitUntil?.(flushAndDispose(client)); - return res; + // Falls back to ending the span at handler return. } } diff --git a/packages/cloudflare/test/options.test-d.ts b/packages/cloudflare/test/options.test-d.ts index f93a24bb7497..f47430a14ef7 100644 --- a/packages/cloudflare/test/options.test-d.ts +++ b/packages/cloudflare/test/options.test-d.ts @@ -107,6 +107,8 @@ describe('valid options keep compiling', () => { serverName: 'my-worker', rpcTracePropagationBindings: ['ORDERS', /^SVC_/], durableObjectSqlSpanAllowlist: ['cf_my_table', /^cf_reports_/], + isStreamingResponse: (response, request) => + request.url.endsWith('.data') ? true : response.headers.has('content-length') ? false : undefined, beforeSend: event => event, integrations: [], }), diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index ae30155c3c6a..a8c9a3f735f0 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -961,6 +961,250 @@ describe('flushAndDispose', () => { }); }); + describe('streaming response classification', () => { + const options: CloudflareOptions = { dsn: MOCK_OPTIONS.dsn, cacheClient: false }; + + test('defers teardown until a text/x-component body without Content-Length is consumed', async () => { + const waits: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => waits.push(promise)); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + let releaseLastChunk!: () => void; + const lastChunkGate = new Promise(resolve => { + releaseLastChunk = resolve; + }); + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode('
shell
')); + await lastChunkGate; + controller.enqueue(new TextEncoder().encode('
suspended
')); + controller.close(); + }, + }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => new Response(stream, { headers: { 'content-type': 'text/x-component;charset=utf-8' } }), + ); + + expect(waitUntil).not.toHaveBeenCalled(); + + releaseLastChunk(); + expect(await result.text()).toBe('
shell
suspended
'); + + await Promise.all(waits); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('tears down at handler return for a text/html body without Content-Length', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
page
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test.each(['text/x-component;charset=utf-8', 'text/html;charset=utf-8'])( + 'tears down at handler return for a %s body with a Content-Length', + async contentType => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
prerendered
', { + headers: { 'content-type': contentType, 'content-length': '22' }, + }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }, + ); + + test('tears down at handler return when TransformStream ignores the transformer', async () => { + // workerd without `transformstream_enable_standard_constructor` returns an identity stream. + vi.stubGlobal( + 'TransformStream', + class extends TransformStream { + constructor() { + super(); + } + }, + ); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
shell
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('tears down at handler return when isStreamingResponse returns false', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
buffered
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); + + const result = await wrapRequestHandler( + { + options: { ...options, isStreamingResponse: () => false }, + request: new Request('https://example.com'), + context, + }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('defers teardown when isStreamingResponse returns true for the request route', async () => { + const waits: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => waits.push(promise)); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + let releaseLastChunk!: () => void; + const lastChunkGate = new Promise(resolve => { + releaseLastChunk = resolve; + }); + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode('first')); + await lastChunkGate; + controller.enqueue(new TextEncoder().encode('deferred')); + controller.close(); + }, + }); + + const result = await wrapRequestHandler( + { + options: { + ...options, + isStreamingResponse: (_response, request) => (request.url.endsWith('.data') ? true : undefined), + }, + request: new Request('https://example.com/products.data'), + context, + }, + () => new Response(stream, { headers: { 'content-type': 'text/x-script' } }), + ); + + expect(waitUntil).not.toHaveBeenCalled(); + + releaseLastChunk(); + expect(await result.text()).toBe('firstdeferred'); + + await Promise.all(waits); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('uses the default classification when isStreamingResponse throws', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
shell
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); + + const result = await wrapRequestHandler( + { + options: { + ...options, + isStreamingResponse: () => { + throw new Error('isStreamingResponse failed'); + }, + }, + request: new Request('https://example.com'), + context, + }, + () => response, + ); + + expect(result).not.toBe(response); + expect(waitUntil).not.toHaveBeenCalled(); + expect(await result.text()).toBe('
shell
'); + + flushSpy.mockRestore(); + }); + + test('uses the default classification when isStreamingResponse returns a promise', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
page
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + + const result = await wrapRequestHandler( + { + options: { + ...options, + isStreamingResponse: (async () => true) as unknown as CloudflareOptions['isStreamingResponse'], + }, + request: new Request('https://example.com'), + context, + }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + }); + test('dispose is NOT called for protocol upgrade responses (status 101)', async () => { const context = createMockExecutionContext(); const waits: Promise[] = []; diff --git a/packages/core/src/utils/responseStreaming.ts b/packages/core/src/utils/responseStreaming.ts index fee67cbb9f2a..c87f9142cd68 100644 --- a/packages/core/src/utils/responseStreaming.ts +++ b/packages/core/src/utils/responseStreaming.ts @@ -8,7 +8,7 @@ export type StreamingGuess = { * Heuristics: * - No body → not streaming * - Known streaming Content-Types → streaming (SSE, NDJSON, JSON streaming) - * - text/plain without Content-Length → streaming (some AI APIs) + * - text/plain or text/x-component without Content-Length → streaming (some AI APIs, streamed RSC) * - Otherwise → not streaming (conservative default, including HTML/SSR) * * We avoid probing the stream to prevent blocking on transform streams (like injectTraceMetaTags) @@ -27,11 +27,14 @@ export function classifyResponseStreaming(res: Response): StreamingGuess { // - application/x-ndjson, application/ndjson: Newline-delimited JSON // - application/stream+json: JSON streaming // - text/plain (without Content-Length): Some AI APIs use this for streaming text + // - text/x-component (without Content-Length): Streamed React Server Components payloads. + // Prerendered payloads are sent as one buffer with a Content-Length. + // HTML is left out: a buffered `new Response(html)` has no Content-Length header either. if ( /^text\/event-stream\b/i.test(contentType) || /^application\/(x-)?ndjson\b/i.test(contentType) || /^application\/stream\+json\b/i.test(contentType) || - (/^text\/plain\b/i.test(contentType) && !contentLength) + (/^text\/(plain|x-component)\b/i.test(contentType) && !contentLength) ) { return { isStreaming: true }; } diff --git a/packages/core/test/lib/utils/responseStreaming.test.ts b/packages/core/test/lib/utils/responseStreaming.test.ts index 5ec7779bd7e8..40a23fbfe910 100644 --- a/packages/core/test/lib/utils/responseStreaming.test.ts +++ b/packages/core/test/lib/utils/responseStreaming.test.ts @@ -9,6 +9,7 @@ describe('classifyResponseStreaming', () => { ['application/stream+json', true], ['TEXT/EVENT-STREAM', true], ['text/plain', true], + ['text/x-component;charset=utf-8', true], ['text/html', false], ['application/json', false], ['application/octet-stream', false], @@ -20,8 +21,8 @@ describe('classifyResponseStreaming', () => { expect(response.body?.locked).toBe(false); }); - test('treats plain text with a known length as non-streaming', () => { - const response = new Response('ready', { headers: { 'content-type': 'text/plain', 'content-length': '5' } }); + test.each(['text/plain', 'text/x-component'])('treats %s with a known length as non-streaming', contentType => { + const response = new Response('ready', { headers: { 'content-type': contentType, 'content-length': '5' } }); expect(classifyResponseStreaming(response)).toEqual({ isStreaming: false }); }); From 554668c847b468f5ae8db2a86b77860b585ec75f Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 19 Oct 2026 18:30:34 +0100 Subject: [PATCH 09/30] fix(node): Only read JavaScript and TypeScript source files in ContextLines (#25535) `ContextLines` read context lines from any `filename` in a parsed stack trace. An app that copies external input into `error.stack` before `captureException` let that input name any file on disk, such as `.env`, and the SDK sent its lines to Sentry. The integration now reads only files with a JS or TS source extension, plus `.vue`, `.svelte`, `.astro` and `.mdx`. Query strings and hashes are stripped first so Vite style `index.js?v=abc` frames still pass. The Deno SDK gets the same check. An extension allowlist was chosen over `require.cache` or a project root check: the module cache does not cover ESM, and root checks break monorepos and symlinked installs. --- .../deno/src/integrations/contextlines.ts | 12 +++++- .../node/src/integrations/contextlines.ts | 21 ++++++---- .../test/integrations/contextlines.test.ts | 38 +++++++++++++++++++ 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/deno/src/integrations/contextlines.ts b/packages/deno/src/integrations/contextlines.ts index fe0a3708850a..e367003d8437 100644 --- a/packages/deno/src/integrations/contextlines.ts +++ b/packages/deno/src/integrations/contextlines.ts @@ -1,7 +1,10 @@ import type { Event, IntegrationFn, StackFrame } from '@sentry/core'; -import { addContextToFrame, defineIntegration, LRUMap } from '@sentry/core'; +import { addContextToFrame, defineIntegration, LRUMap, stripUrlQueryAndFragment } from '@sentry/core'; const INTEGRATION_NAME = 'ContextLines' as const; +// Frame filenames come from the parsed `error.stack` string, which application +// code can overwrite with external input, so only read source files. +const SOURCE_FILE_EXTENSION = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|mdx)$/i; const FILE_CONTENT_CACHE = new LRUMap(100); const DEFAULT_LINES_OF_CONTEXT = 5; @@ -91,7 +94,12 @@ async function addSourceContext(event: Event, contextLines: number): Promise { for (const frame of frames) { // Only add context if we have a filename and it hasn't already been added - if (frame.filename && frame.in_app && frame.context_line === undefined) { + if ( + frame.filename && + frame.in_app && + frame.context_line === undefined && + SOURCE_FILE_EXTENSION.test(stripUrlQueryAndFragment(frame.filename)) + ) { const permission = await Deno.permissions.query({ name: 'read', path: frame.filename, diff --git a/packages/node/src/integrations/contextlines.ts b/packages/node/src/integrations/contextlines.ts index 12cc79c90366..9ff0d22cc48f 100644 --- a/packages/node/src/integrations/contextlines.ts +++ b/packages/node/src/integrations/contextlines.ts @@ -1,9 +1,10 @@ import { createReadStream } from 'node:fs'; import { createInterface } from 'node:readline'; import type { Event, IntegrationFn, StackFrame } from '@sentry/core'; -import { debug, defineIntegration, LRUMap, snipLine } from '@sentry/core'; +import { debug, defineIntegration, LRUMap, snipLine, stripUrlQueryAndFragment } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +const SOURCE_FILE_EXTENSION = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|mdx)$/; const LRU_FILE_CONTENTS_CACHE = new LRUMap>(10); const LRU_FILE_CONTENTS_FS_READ_FAILED = new LRUMap(20); const DEFAULT_LINES_OF_CONTEXT = 5; @@ -31,6 +32,7 @@ interface ContextLinesOptions { */ export function resetFileContentCache(): void { LRU_FILE_CONTENTS_CACHE.clear(); + LRU_FILE_CONTENTS_FS_READ_FAILED.clear(); } /** @@ -49,19 +51,22 @@ function emplace, K extends string, V>(map: T, key: K, co /** * Determines if context lines should be skipped for a file. - * - .min.(mjs|cjs|js) files are and not useful since they dont point to the original source * - node: prefixed modules are part of the runtime and cannot be resolved to a file * - data: skip json, wasm and inline js https://nodejs.org/api/esm.html#data-imports + * - .min.(mjs|cjs|js) files are and not useful since they dont point to the original source + * - anything that is not a JavaScript or TypeScript source file. Frame filenames come + * from the parsed `error.stack` string, which application code can overwrite with + * external input, so we must not read arbitrary files off the filesystem. */ function shouldSkipContextLinesForFile(path: string): boolean { - // Test the most common prefix and extension first. These are the ones we - // are most likely to see in user applications and are the ones we can break out of first. if (path.startsWith('node:')) return true; - if (path.endsWith('.min.js')) return true; - if (path.endsWith('.min.cjs')) return true; - if (path.endsWith('.min.mjs')) return true; if (path.startsWith('data:')) return true; - return false; + + const pathname = stripUrlQueryAndFragment(path).toLowerCase(); + if (pathname.endsWith('.min.js')) return true; + if (pathname.endsWith('.min.cjs')) return true; + if (pathname.endsWith('.min.mjs')) return true; + return !SOURCE_FILE_EXTENSION.test(pathname); } /** diff --git a/packages/node/test/integrations/contextlines.test.ts b/packages/node/test/integrations/contextlines.test.ts index af49e1da01cc..c8a5d17ce287 100644 --- a/packages/node/test/integrations/contextlines.test.ts +++ b/packages/node/test/integrations/contextlines.test.ts @@ -69,6 +69,44 @@ describe('ContextLines', () => { }); }); + describe('file filtering', () => { + test.each([ + '/var/task/.env', + '/etc/passwd', + '/home/user/.ssh/id_rsa', + 'file:///var/task/config.json', + 'file:///var/task/index.js.bak', + 'file:///var/task/index.min.js?v=abc', + ])('does not read non-source file %s', async filename => { + expect.assertions(1); + const frames: StackFrame[] = [{ colno: 1, filename, lineno: 1, function: 'fxn1' }]; + + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + await addContext(frames); + expect(readStreamSpy).not.toHaveBeenCalled(); + }); + + test.each([ + 'file:///var/task/index.js', + 'file:///var/task/index.mjs', + 'file:///var/task/index.cjs', + 'file:///var/task/index.ts', + 'file:///var/task/index.tsx', + 'file:///var/task/Index.JS', + 'file:///var/task/index.js?v=abc123', + 'file:///var/task/index.js#hash', + 'file:///var/task/Component.vue', + 'file:///var/task/page.mdx', + ])('reads source file %s', async filename => { + expect.assertions(1); + const frames: StackFrame[] = [{ colno: 1, filename, lineno: 1, function: 'fxn1' }]; + + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + await addContext(frames); + expect(readStreamSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('lru file cache', () => { test('parseStack when file does not exist', async () => { expect.assertions(4); From f71a0ca9e8eed95400a40f64263f89c8d4d7443d Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:19:38 +0200 Subject: [PATCH 10/30] feat(core): Limit the body size of tunnel requests (#25515) The tunnel route handlers (`@sentry/nextjs/tunnel` and TanStack Start's `createSentryTunnelRoute`) now check the request size before they read the body. A request larger than 20 MiB gets a 413, and a request without `Content-Length` gets a 411. So one request can no longer make the server hold more than 20 MiB in memory. SDK envelopes are much smaller than this limit, so normal traffic does not change. Checking the header is enough, because the HTTP server stops reading the body at `Content-Length`. Stacked on #25456. --- packages/core/src/utils/tunnel.ts | 12 ++++ packages/core/test/lib/utils/tunnel.test.ts | 61 +++++++++++++++++++-- packages/nextjs/test/tunnel/index.test.ts | 6 +- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 5645633ff45e..4831d6f48ea8 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -3,6 +3,9 @@ import { debug } from './debug-logger'; import { makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; +// Bounds the memory of each request. SDK envelopes stay far below it. +export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; + export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; @@ -25,6 +28,15 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Tunnel not configured', { status: 500 }); } + // The HTTP server ends the body at `Content-Length`, so the header bounds what `arrayBuffer()` reads. + const contentLength = Number(request.headers.get('content-length')); + if (!contentLength) { + return new Response('Length required', { status: 411 }); + } + if (contentLength > MAX_TUNNEL_BODY_SIZE) { + return new Response('Envelope too large', { status: 413 }); + } + const body = new Uint8Array(await request.arrayBuffer()); let envelopeHeader; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 2b499ce754ae..7e56a5badfca 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -2,14 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; -import { handleTunnelRequest } from '../../../src/utils/tunnel'; +import { handleTunnelRequest, MAX_TUNNEL_BODY_SIZE } from '../../../src/utils/tunnel'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; -function makeEnvelopeRequest(envelopeHeader: Record): Request { +function makeEnvelopeRequest(envelopeHeader: Record, contentLength?: number): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); - return new Request('http://localhost/tunnel', { method: 'POST', body }); + const byteLength = typeof body === 'string' ? new TextEncoder().encode(body).byteLength : body.byteLength; + return new Request('http://localhost/tunnel', { + method: 'POST', + body, + headers: { 'content-length': String(contentLength ?? byteLength) }, + }); } describe('handleTunnelRequest', () => { @@ -69,7 +74,11 @@ describe('handleTunnelRequest', () => { it('returns 400 when the envelope body contains malformed JSON', async () => { const result = await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { method: 'POST', body: 'not valid envelope data{{{' }), + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: 'not valid envelope data{{{', + headers: { 'content-length': '26' }, + }), allowedDsns: [TEST_DSN], }); @@ -133,4 +142,48 @@ describe('handleTunnelRequest', () => { expect(result.status).toBe(500); expect(await result.text()).toBe('Failed to forward envelope to Sentry'); }); + + describe('body size limit', () => { + it('forwards the envelope when Content-Length equals the limit', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(200); + }); + + it('returns 413 when Content-Length is above the limit', async () => { + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE + 1), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(413); + expect(await result.text()).toBe('Envelope too large'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 411 when the request has no Content-Length', async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ dsn: TEST_DSN })}\n`)); + controller.close(); + }, + }); + // Node.js requires `duplex` for a stream body, but the DOM types do not have it yet. + const init: RequestInit & { duplex: 'half' } = { method: 'POST', body, duplex: 'half' }; + + const result = await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', init), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(411); + expect(await result.text()).toBe('Length required'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts index 1e86f6925f1f..0df557baa736 100644 --- a/packages/nextjs/test/tunnel/index.test.ts +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -22,7 +22,11 @@ function createEnvelopeRequest(dsn: string): { request: Request; body: Uint8Arra const body = typeof envelope === 'string' ? new TextEncoder().encode(envelope) : envelope; const request = new Request('https://shop.example.com/monitoring', { method: 'POST', - headers: { cookie: 'session=8f2c1d', 'content-type': 'text/plain;charset=UTF-8' }, + headers: { + cookie: 'session=8f2c1d', + 'content-type': 'text/plain;charset=UTF-8', + 'content-length': String(body.byteLength), + }, body, }); return { request, body }; From 1a2e818322a58f20c0667da9352a9a16749f302e Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 19 Oct 2026 11:49:54 -0700 Subject: [PATCH 11/30] fix(deno): Re-export portable helpers that Cloudflare exposes (#25491) Cloudflare runs on workerd with no Node APIs, but it still exports more than Deno. The missing exports already work in Deno, because they come from `@sentry/core` and `@sentry/server-utils`, which Deno depends on. The manual AI client helpers matter most. Deno does not inject diagnostics channels by default, so `instrumentOpenAiClient` and the other `instrument*` helpers are the documented way to get AI spans there. Deno now re-exports `addIntegration`, `withActiveSpan`, `getSpanDescendants`, `setConversationId`, `createConsolaReporter`, `growthbookIntegration`, `moduleMetadataIntegration`, `trpcMiddleware`, `prismaIntegration`, the OpenAI, Anthropic, Google GenAI, Mistral, TypeSafe, LangChain and LangGraph helpers, and the `FlueOptions` type. Deno also gets `initWithoutDefaultIntegrations` and `getDefaultIntegrationsWithoutPerformance`, to match Node and Bun. The default integration set does not change. Some Cloudflare exports stay out. `instrumentWorkersAiClient` wraps a binding that exists only on Cloudflare. `spotlightIntegration` and `httpServerIntegration` live in the Cloudflare package, not in a shared entry point, so they need more than a re-export. `parameterize` was in the issue, but Cloudflare no longer exports it. Fixes #23896 Fixes JS-3522 Co-authored-by: Claude Opus 5.5 (1M context) --- packages/deno/src/index.ts | 31 +++++++++++++++++++--- packages/deno/src/sdk.ts | 23 +++++++++++++--- packages/deno/test/exports.test.ts | 42 ++++++++++++++++++++++++++++++ packages/deno/test/sdk.test.ts | 38 +++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 packages/deno/test/exports.test.ts diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index badb9ed5a8af..f55fe14de14f 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -26,6 +26,7 @@ export type { DenoOptions } from './types'; export { addEventProcessor, addBreadcrumb, + addIntegration, captureException, captureEvent, captureMessage, @@ -52,6 +53,7 @@ export { setAttribute, setAttributes, setUser, + setConversationId, getSpanStatusFromHttpCode, setHttpStatus, withScope, @@ -69,6 +71,8 @@ export { startNewTrace, bindScopeToEmitter, suppressTracing, + withActiveSpan, + getSpanDescendants, eventFiltersIntegration, linkedErrorsIntegration, functionToStringIntegration, @@ -78,6 +82,7 @@ export { dedupeIntegration, extraErrorDataIntegration, rewriteFramesIntegration, + moduleMetadataIntegration, supabaseIntegration, instrumentSupabaseClient, zodErrorsIntegration, @@ -94,7 +99,9 @@ export { spanToTraceHeader, spanToBaggageHeader, updateSpanName, + createConsolaReporter, featureFlagsIntegration, + growthbookIntegration, metrics, withStaticSpan, // oxlint-disable-next-line typescript/no-deprecated @@ -103,12 +110,30 @@ export { consoleLoggingIntegration, spanStreamingIntegration, } from '@sentry/core'; -export { wrapMcpServerWithSentry } from '@sentry/core/server'; -export { instrumentPostgresJsSql } from '@sentry/server-utils'; +export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { + instrumentPostgresJsSql, + prismaIntegration, + instrumentOpenAiClient, + instrumentAnthropicAiClient, + instrumentGoogleGenAIClient, + instrumentMistralAiClient, + instrumentTypeSafeClient, + createLangChainCallbackHandler, + instrumentLangChainEmbeddings, + instrumentStateGraph, + instrumentCreateReactAgent, +} from '@sentry/server-utils'; +export type { FlueOptions } from '@sentry/server-utils'; export { DenoClient } from './client'; -export { getDefaultIntegrations, init } from './sdk'; +export { + getDefaultIntegrations, + getDefaultIntegrationsWithoutPerformance, + init, + initWithoutDefaultIntegrations, +} from './sdk'; export { denoServeIntegration } from './integrations/deno-serve'; export type { DenoServeIntegrationOptions } from './integrations/deno-serve'; export { fetchIntegration } from './integrations/fetch'; diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 93869d8face1..8b277fb32453 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -29,8 +29,8 @@ import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeFetchTransport } from './transports'; import type { DenoOptions } from './types'; -/** Get the default integrations for the Deno SDK. */ -export function getDefaultIntegrations(options: Options): Integration[] { +/** Get the default integrations for the Deno SDK, excluding performance integrations. */ +export function getDefaultIntegrationsWithoutPerformance(): Integration[] { // We return a copy of the defaultIntegrations here to avoid mutating this return [ // Common @@ -52,10 +52,14 @@ export function getDefaultIntegrations(options: Options): Integration[] { globalHandlersIntegration(), // server-utils integrations ...getErrorIntegrations(), - ...(hasSpansEnabled(options) ? getTracingIntegrations() : []), ]; } +/** Get the default integrations for the Deno SDK. */ +export function getDefaultIntegrations(options: Options): Integration[] { + return [...getDefaultIntegrationsWithoutPerformance(), ...(hasSpansEnabled(options) ? getTracingIntegrations() : [])]; +} + const defaultStackParser: StackParser = createStackParser(nodeStackLineParser()); /** @@ -103,10 +107,21 @@ const defaultStackParser: StackParser = createStackParser(nodeStackLineParser()) * @see {@link DenoOptions} for documentation on configuration options. */ export function init(options: DenoOptions = {}): Client { + return _init(options, getDefaultIntegrations); +} + +/** + * Initialize Sentry for Deno, without any integrations added by default. + */ +export function initWithoutDefaultIntegrations(options: DenoOptions = {}): Client { + return _init(options, () => []); +} + +function _init(options: DenoOptions, getDefaultIntegrationsImpl: (options: Options) => Integration[]): Client { // Computed into a local rather than written back onto `options`: the default set // depends on the tracing options, so caching it on the caller's object would pin the // result of the first `init` for any reused options object. - const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrations(options); + const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(options); const clientOptions: ServerRuntimeClientOptions = { ...options, diff --git a/packages/deno/test/exports.test.ts b/packages/deno/test/exports.test.ts new file mode 100644 index 000000000000..c1468653c566 --- /dev/null +++ b/packages/deno/test/exports.test.ts @@ -0,0 +1,42 @@ +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import * as Sentry from '../build/esm/index.js'; +import type { FlueOptions } from '../build/esm/index.js'; + +// Exports that Cloudflare ships and that need no Node APIs. +const EXPECTED_FUNCTION_EXPORTS = [ + // @sentry/core + 'addIntegration', + 'withActiveSpan', + 'getSpanDescendants', + 'setConversationId', + 'createConsolaReporter', + 'growthbookIntegration', + 'moduleMetadataIntegration', + // @sentry/core/server + 'trpcMiddleware', + // @sentry/server-utils + 'prismaIntegration', + 'instrumentOpenAiClient', + 'instrumentAnthropicAiClient', + 'instrumentGoogleGenAIClient', + 'instrumentMistralAiClient', + 'instrumentTypeSafeClient', + 'createLangChainCallbackHandler', + 'instrumentLangChainEmbeddings', + 'instrumentStateGraph', + 'instrumentCreateReactAgent', + // ./sdk + 'initWithoutDefaultIntegrations', + 'getDefaultIntegrationsWithoutPerformance', +]; + +for (const name of EXPECTED_FUNCTION_EXPORTS) { + Deno.test(`exports ${name}`, () => { + assertEquals(typeof (Sentry as Record)[name], 'function'); + }); +} + +Deno.test('exports the FlueOptions type', () => { + const options: FlueOptions = {}; + assertEquals(typeof options, 'object'); +}); diff --git a/packages/deno/test/sdk.test.ts b/packages/deno/test/sdk.test.ts index 032442ece005..9eebad5e6482 100644 --- a/packages/deno/test/sdk.test.ts +++ b/packages/deno/test/sdk.test.ts @@ -3,7 +3,9 @@ import { assertArrayIncludes } from 'https://deno.land/std@0.212.0/assert/assert import { getCurrentScope, getDefaultIntegrations, + getDefaultIntegrationsWithoutPerformance, init as initSdk, + initWithoutDefaultIntegrations, spanStreamingIntegration, } from '../build/esm/index.js'; import type { DenoOptions } from '../build/esm/index.js'; @@ -82,3 +84,39 @@ Deno.test("init doesn't write the computed default integrations back onto the op const names = second.getOptions().integrations.map(i => i.name); assertArrayIncludes(names, ['Graphql']); }); + +Deno.test('initWithoutDefaultIntegrations sets up only the integrations the user passes', () => { + getCurrentScope().setClient(undefined); + const client = initWithoutDefaultIntegrations({ + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: [spanStreamingIntegration()], + }); + const names = client.getOptions().integrations.map(i => i.name); + assertEquals(names, ['SpanStreaming']); +}); + +Deno.test('initWithoutDefaultIntegrations keeps defaultIntegrations the user passes', () => { + getCurrentScope().setClient(undefined); + const client = initWithoutDefaultIntegrations({ + traceLifecycle: 'static', + defaultIntegrations: getDefaultIntegrationsWithoutPerformance(), + }); + const names = client.getOptions().integrations.map(i => i.name); + assertArrayIncludes(names, ['Breadcrumbs', 'DenoServe', 'Express']); +}); + +Deno.test('getDefaultIntegrationsWithoutPerformance leaves out the tracing set', () => { + const names = getDefaultIntegrationsWithoutPerformance().map(i => i.name); + assertArrayIncludes(names, ['Breadcrumbs', 'DenoServe', 'Express', 'Fastify']); + assert(!names.includes('Graphql')); + assert(!names.includes('Postgres')); + assert(!names.includes('OpenAI')); +}); + +Deno.test('getDefaultIntegrations is getDefaultIntegrationsWithoutPerformance plus the tracing set', () => { + const base = getDefaultIntegrationsWithoutPerformance().map(i => i.name); + const full = getDefaultIntegrations({ tracesSampleRate: 1 }).map(i => i.name); + assertEquals(full.slice(0, base.length), base); + assertArrayIncludes(full.slice(base.length), ['Graphql', 'Postgres', 'OpenAI']); +}); From 4d19e60444c2ce5af1e4827edf5af624f02fbd10 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 19 Oct 2026 13:07:01 -0700 Subject: [PATCH 12/30] feat(deno): Add incoming request options to `denoServeIntegration` (#25493) `Deno.serve` is the server most Deno apps use, but its integration only took `maxRequestBodySize` and the deprecated `ignoreStatusCodes`. A user could not skip body capture for one route, such as a health check, an upload, or a route that receives secrets. `denoHttpIntegration`, Node and Cloudflare all allow this. `denoServeIntegration` now accepts `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`. They use the same names and behavior as `denoHttpIntegration`, but the callbacks receive the web `Request`. The options follow core's `node:http` server code. `urlPath` includes the query string, and the static asset filter applies only to `GET`. When the SDK skips a span, it still continues the incoming trace, attaches request data and the body, and captures errors. For a streamed response, `onSpanEnd` runs when the stream finishes. It does not run when the handler throws, because there is no `Response` to pass. Two defaults change behavior. `ignoreStaticAssets` defaults to `true`, so `GET` requests for files such as `favicon.ico` or `*.js` no longer create spans. `spans` defaults to the client's tracing configuration, so the integration no longer starts spans when tracing is disabled. Request sessions for `Deno.serve` and the same options for `Bun.serve` remain separate work. Fixes #25492 Fixes JS-3955 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 + packages/deno/src/integrations/deno-serve.ts | 52 +- packages/deno/src/utils/streaming.ts | 25 +- .../deno/src/wrap-deno-request-handler.ts | 104 +++- packages/deno/test/deno-serve.test.ts | 493 +++++++++++++++++- packages/deno/test/streaming.test.ts | 43 ++ 6 files changed, 687 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a25b5bccb34e..b6b8060bce4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions! +- **feat(deno): Add incoming request options to `denoServeIntegration`** + + `denoServeIntegration` now accepts `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`, with the same meaning as in `denoHttpIntegration`. The callbacks receive the web `Request`. + + `ignoreStaticAssets` defaults to `true`, so `Deno.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `denoServeIntegration({ ignoreStaticAssets: false })`. `Deno.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. + ## 11.8.0 ### Important Changes diff --git a/packages/deno/src/integrations/deno-serve.ts b/packages/deno/src/integrations/deno-serve.ts index 156fb9fd8559..16d061cbd1f7 100644 --- a/packages/deno/src/integrations/deno-serve.ts +++ b/packages/deno/src/integrations/deno-serve.ts @@ -1,4 +1,4 @@ -import type { Event, IntegrationFn, MaxRequestBodySize } from '@sentry/core'; +import type { Event, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; import { debug, defineIntegration } from '@sentry/core'; import { DEFAULT_IGNORE_STATUS_CODES, processHttpServerTransactionEvent } from '@sentry/core/server'; import type { RequestHandlerWrapperOptions } from '../wrap-deno-request-handler'; @@ -17,6 +17,50 @@ export type DenoServeIntegrationOptions = { */ maxRequestBodySize?: MaxRequestBodySize; + /** + * Do not capture the request body for incoming requests where the given callback returns `true`. + * + * `url` is the full URL of the request, including the query string. `request` is the incoming + * {@link Request}. The callback runs before the SDK reads the body. + */ + ignoreRequestBody?: (url: string, request: Request) => boolean; + + /** + * Do not capture server spans for incoming requests whose URL path makes the given callback return `true`. + * + * `urlPath` is the path of the request, including the query string. `request` is the incoming + * {@link Request}. Errors thrown by the handler are still captured. + */ + ignoreIncomingRequests?: (urlPath: string, request: Request) => boolean; + + /** + * Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating server spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Whether to create spans for incoming requests. + * Errors are still captured and request data is still attached when this is `false`. + * Defaults to the client's tracing configuration (`hasSpansEnabled`). + */ + spans?: boolean; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, before the request handler runs. + */ + onSpanCreated?: (span: Span, request: Request) => void; + + /** + * A hook that can be used to mutate the span one last time before it ends. + * For a streamed response, this runs when the stream finishes. + * It is not called when the request handler throws. + */ + onSpanEnd?: (span: Span, request: Request, response: Response) => void; + /** * Do not capture spans for incoming `Deno.serve` requests with the given status codes. * By default, some 3xx and 4xx status codes are dropped (see @default). @@ -99,6 +143,12 @@ const _denoServeIntegration = ((options: DenoServeIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, maxRequestBodySize: options.maxRequestBodySize, + ignoreRequestBody: options.ignoreRequestBody, + ignoreIncomingRequests: options.ignoreIncomingRequests, + ignoreStaticAssets: options.ignoreStaticAssets ?? true, + spans: options.spans, + onSpanCreated: options.onSpanCreated, + onSpanEnd: options.onSpanEnd, processEvent(event: Event): Event | null { // Gated on this integration's own span origin so it does not filter `node:http` // transactions, which `denoHttpIntegration` owns via its own `ignoreStatusCodes`. diff --git a/packages/deno/src/utils/streaming.ts b/packages/deno/src/utils/streaming.ts index ca6f778b749e..276d2455af3a 100644 --- a/packages/deno/src/utils/streaming.ts +++ b/packages/deno/src/utils/streaming.ts @@ -5,29 +5,32 @@ import { classifyResponseStreaming } from '@sentry/core/server'; * Tee a stream, and end the provided span when the stream ends. * Returns the other side of the tee, which can be used to send the * response to a client. + * + * `onEnd` runs right before the span ends. */ -export async function streamResponse(span: Span, res: Response): Promise { +export async function streamResponse(span: Span, res: Response, onEnd?: () => void): Promise { const classification = classifyResponseStreaming(res); + const endSpan = (): void => { + onEnd?.(); + span.end(); + }; // not streaming, just end the span and return the response if (!classification.isStreaming || !res.body) { - span.end(); + endSpan(); return res; } // Streaming response detected - monitor consumption to keep span alive try { - return new Response( - monitorStream(res.body, () => span.end()), - { - status: res.status, - statusText: res.statusText, - headers: res.headers, - }, - ); + return new Response(monitorStream(res.body, endSpan), { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }); } catch { // tee() failed - handle without streaming - span.end(); + endSpan(); return res; } } diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index 1ab390d3790b..465adeb1ed44 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -6,7 +6,7 @@ import { SENTRY_SEGMENT_NAME_SOURCE, } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; -import type { Integration, MaxRequestBodySize } from '@sentry/core'; +import type { Client, Integration } from '@sentry/core'; import { captureBodyFromWinterCGRequest, captureException, @@ -14,6 +14,7 @@ import { debug, getClient, getHttpSpanDetailsFromUrlObject, + hasSpansEnabled, hasSpanStreamingEnabled, httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, @@ -24,7 +25,8 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; -import { getClientIPAddress } from '@sentry/core/server'; +import { getClientIPAddress, isStaticAssetRequest } from '@sentry/core/server'; +import type { DenoServeIntegrationOptions } from './integrations/deno-serve'; import { streamResponse } from './utils/streaming'; export type RequestHandlerWrapperOptions = { @@ -33,6 +35,45 @@ export type RequestHandlerWrapperOptions = { serveOptions?: Deno.ServeOptions; }; +function captureServeError(e: unknown): void { + captureException(e, { + mechanism: { + handled: false, + type: 'auto.http.deno', + data: { function: 'serve' }, + }, + }); +} + +// A user callback that throws must not fail the request or leave the span open. +function callUserCallback(name: string, callback: () => T, fallback: T): T { + try { + return callback(); + } catch (e) { + debug.error(`Error in the Deno.serve ${name} callback.`, e); + return fallback; + } +} + +function shouldCreateSpan( + client: Client, + integration: DenoServeIntegrationOptions | undefined, + request: Request, + urlPath: string, +): boolean { + if (!(integration?.spans ?? hasSpansEnabled(client.getOptions()))) { + return false; + } + if (integration?.ignoreStaticAssets && request.method === 'GET' && isStaticAssetRequest(urlPath)) { + return false; + } + const ignoreIncomingRequests = integration?.ignoreIncomingRequests; + return !( + ignoreIncomingRequests && + callUserCallback('ignoreIncomingRequests', () => ignoreIncomingRequests(urlPath, request), false) + ); +} + const assignIfSet = , K extends keyof T>( obj: T, key: K, @@ -63,17 +104,13 @@ export const wrapDenoRequestHandler = ( try { return await handler(); } catch (e) { - captureException(e, { - mechanism: { - handled: false, - type: 'auto.http.deno', - data: { function: 'serve' }, - }, - }); + captureServeError(e); throw e; } } + const integration = client.getIntegrationByName('DenoServe'); + const urlObject = parseStringToURLObject(request.url); const [rawName, attributes] = getHttpSpanDetailsFromUrlObject( urlObject, @@ -122,22 +159,45 @@ export const wrapDenoRequestHandler = ( ipAddress: socketIp, }); - const configuredBodySize = client.getIntegrationByName( - 'DenoServe', - )?.maxRequestBodySize; const effectiveBodySize = - configuredBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); - if (request.method !== 'GET' && effectiveBodySize !== 'none') { + integration?.maxRequestBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); + const ignoreRequestBody = integration?.ignoreRequestBody; + if ( + request.method !== 'GET' && + effectiveBodySize !== 'none' && + // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. + !(ignoreRequestBody && callUserCallback('ignoreRequestBody', () => ignoreRequestBody(request.url, request), true)) + ) { await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); } + const urlPath = urlObject ? `${urlObject.pathname}${urlObject.search}` : '/'; + const createSpan = shouldCreateSpan(client, integration, request, urlPath); + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage'), }, - () => { + async () => { + if (!createSpan) { + try { + const res = await handler(); + isolationScope.setContext('response', { + status_code: res.status, + }); + return res; + } catch (e) { + captureServeError(e); + throw e; + } + } + return startSpanManual({ name, attributes }, async span => { + const { onSpanCreated, onSpanEnd } = integration ?? {}; + if (onSpanCreated) { + callUserCallback('onSpanCreated', () => onSpanCreated(span, request), undefined); + } let res: Response; try { @@ -151,17 +211,15 @@ export const wrapDenoRequestHandler = ( ); } catch (e) { span.end(); - captureException(e, { - mechanism: { - handled: false, - type: 'auto.http.deno', - data: { function: 'serve' }, - }, - }); + captureServeError(e); throw e; } - return streamResponse(span, res); + return streamResponse( + span, + res, + onSpanEnd && (() => callUserCallback('onSpanEnd', () => onSpanEnd(span, request, res), undefined)), + ); }); }, ); diff --git a/packages/deno/test/deno-serve.test.ts b/packages/deno/test/deno-serve.test.ts index f85e596e631a..f30318f0fbcd 100644 --- a/packages/deno/test/deno-serve.test.ts +++ b/packages/deno/test/deno-serve.test.ts @@ -1,6 +1,6 @@ // -import type { ErrorEvent, TransactionEvent } from '@sentry/core'; +import type { ErrorEvent, Span, TransactionEvent } from '@sentry/core'; import { getMainCarrier } from '@sentry/core'; import { assertEquals, assertExists, assertMatch, assertNotEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; import type { DenoClient } from '../build/esm/index.js'; @@ -919,3 +919,494 @@ Deno.test('Deno.serve should work when manually capturing exceptions within hand // (unless explicitly set otherwise) assertEquals(errorEvent?.exception?.values?.[0]?.mechanism?.handled, true); }); + +async function serveForTest( + handler: (request: Request) => Response | Promise, +): Promise<{ url: string; close: () => Promise }> { + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve( + { + port: 0, + signal: abortController.signal, + onListen, + onError: error => new Response((error as Error).message, { status: 500 }), + }, + handler, + ); + await p; + return { + url: `http://localhost:${server.addr.port}`, + close: async () => { + abortController.abort(); + await server.finished; + }, + }; +} + +Deno.test('Deno.serve should not capture request bodies that ignoreRequestBody matches', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const ignoreRequestBodyCalls: [string, string][] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + ignoreRequestBody: (url, request) => { + ignoreRequestBodyCalls.push([url, request.method]); + return url.includes('/upload'); + }, + }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const handlerBodies: string[] = []; + const server = await serveForTest(async request => { + handlerBodies.push(await request.text()); + return new Response('OK'); + }); + + await (await fetch(`${server.url}/upload?id=1`, { method: 'POST', body: 'secret' })).text(); + await (await fetch(`${server.url}/other`, { method: 'POST', body: 'public' })).text(); + await server.close(); + + assertEquals(handlerBodies, ['secret', 'public']); + assertEquals(ignoreRequestBodyCalls, [ + [`${server.url}/upload?id=1`, 'POST'], + [`${server.url}/other`, 'POST'], + ]); + assertEquals(transactionEvents.length, 2); + const upload = transactionEvents.find(event => event.request?.url?.includes('/upload')); + const other = transactionEvents.find(event => event.request?.url?.includes('/other')); + assertEquals(upload?.request?.data, undefined); + assertEquals(other?.request?.data, 'public'); +}); + +Deno.test('Deno.serve should not create spans for requests that ignoreIncomingRequests matches', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const errorEvents: ErrorEvent[] = []; + const urlPaths: string[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + ignoreIncomingRequests: (urlPath, request) => { + urlPaths.push(urlPath); + return request.method === 'GET' && urlPath.startsWith('/health'); + }, + }), + ], + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(request => { + if (request.url.includes('fail')) { + throw new Error('health check failed'); + } + return new Response('OK'); + }); + + await (await fetch(`${server.url}/health?ok=1`)).text(); + await (await fetch(`${server.url}/health/fail`)).text(); + await (await fetch(`${server.url}/users`)).text(); + await server.close(); + await client.flush(); + + assertEquals(urlPaths, ['/health?ok=1', '/health/fail', '/users']); + assertEquals( + transactionEvents.map(event => event.transaction), + ['GET /users'], + ); + assertEquals(errorEvents.length, 1); + assertEquals(errorEvents[0]?.exception?.values?.[0]?.value, 'health check failed'); + assertEquals(errorEvents[0]?.exception?.values?.[0]?.mechanism?.type, 'auto.http.deno'); + assertEquals(errorEvents[0]?.request?.url?.includes('/health/fail'), true); +}); + +Deno.test('Deno.serve should not create spans for static assets by default', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/favicon.ico`)).text(); + await (await fetch(`${server.url}/robots.txt`)).text(); + await (await fetch(`${server.url}/assets/app.js?v=2`)).text(); + await (await fetch(`${server.url}/assets/app.js`, { method: 'POST' })).text(); + await (await fetch(`${server.url}/page`)).text(); + await server.close(); + + assertEquals(transactionEvents.map(event => event.transaction).sort(), ['GET /page', 'POST /assets/app.js']); +}); + +Deno.test('Deno.serve should create spans for static assets when ignoreStaticAssets is false', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ ignoreStaticAssets: false }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/favicon.ico`)).text(); + await server.close(); + + assertEquals( + transactionEvents.map(event => event.transaction), + ['GET /favicon.ico'], + ); +}); + +Deno.test('Deno.serve should not create spans when spans is false, but still capture errors', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const errorEvents: ErrorEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ spans: false }), + ], + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(async request => { + if (request.url.includes('error')) { + throw new Error(`failed with ${await request.text()}`); + } + return new Response('OK'); + }); + + await (await fetch(`${server.url}/ok`)).text(); + await (await fetch(`${server.url}/error`, { method: 'POST', body: 'payload' })).text(); + await server.close(); + await client.flush(); + + assertEquals(transactionEvents.length, 0); + assertEquals(errorEvents.length, 1); + assertEquals(errorEvents[0]?.exception?.values?.[0]?.value, 'failed with payload'); + assertEquals(errorEvents[0]?.exception?.values?.[0]?.mechanism?.handled, false); + assertEquals(errorEvents[0]?.request?.url?.includes('/error'), true); + assertEquals(errorEvents[0]?.request?.data, 'payload'); +}); + +Deno.test('Deno.serve should not create spans when tracing is disabled', async () => { + resetGlobals(); + let spanCreatedCalls = 0; + + init({ + dsn: 'https://username@domain/123', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + onSpanCreated: () => { + spanCreatedCalls++; + }, + }), + ], + }) as DenoClient; + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/page`)).text(); + await server.close(); + + assertEquals(spanCreatedCalls, 0); +}); + +Deno.test('Deno.serve should call onSpanCreated and onSpanEnd', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const hookCalls: string[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + onSpanCreated: (span: Span, request: Request) => { + hookCalls.push(`created ${new URL(request.url).pathname}`); + span.setAttribute('test.created', true); + }, + onSpanEnd: (span: Span, request: Request, response: Response) => { + hookCalls.push(`end ${new URL(request.url).pathname} ${response.status}`); + span.setAttribute('test.response_header', response.headers.get('x-test') ?? undefined); + }, + }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => { + hookCalls.push('handler'); + return new Response('OK', { status: 201, headers: { 'x-test': 'yes' } }); + }); + await (await fetch(`${server.url}/hooks`)).text(); + await server.close(); + + assertEquals(hookCalls, ['created /hooks', 'handler', 'end /hooks 201']); + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.contexts?.trace?.data?.['test.created'], true); + assertEquals(transactionEvents[0]?.contexts?.trace?.data?.['test.response_header'], 'yes'); +}); + +Deno.test('Deno.serve should call onSpanEnd when a streamed response finishes', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const hookCalls: string[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + onSpanEnd: (span: Span) => { + hookCalls.push('end'); + span.setAttribute('test.ended', true); + }, + }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => { + const stream = new ReadableStream({ + async pull(controller) { + hookCalls.push('chunk'); + controller.enqueue(new TextEncoder().encode('chunk\n')); + controller.close(); + }, + }); + return new Response(stream, { headers: { 'content-type': 'text/event-stream' } }); + }); + assertEquals(await (await fetch(`${server.url}/stream`)).text(), 'chunk\n'); + await server.close(); + + assertEquals(hookCalls, ['chunk', 'end']); + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.contexts?.trace?.data?.['test.ended'], true); +}); + +Deno.test('Deno.serve should still respond and end the span when onSpanCreated throws', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + onSpanCreated: () => { + throw new Error('onSpanCreated failed'); + }, + }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => new Response('OK')); + const response = await fetch(`${server.url}/created`); + assertEquals(response.status, 200); + assertEquals(await response.text(), 'OK'); + await server.close(); + + assertEquals( + transactionEvents.map(event => event.transaction), + ['GET /created'], + ); +}); + +for (const [label, makeResponse] of [ + ['a response', () => new Response('OK')], + [ + 'a streamed response', + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('OK')); + controller.close(); + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ], +] as const) { + Deno.test(`Deno.serve should still respond and end the span when onSpanEnd throws for ${label}`, async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + onSpanEnd: () => { + throw new Error('onSpanEnd failed'); + }, + }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(makeResponse); + const response = await fetch(`${server.url}/ended`); + assertEquals(response.status, 200); + assertEquals(await response.text(), 'OK'); + await server.close(); + + assertEquals( + transactionEvents.map(event => event.transaction), + ['GET /ended'], + ); + }); +} + +Deno.test('Deno.serve should not capture the body and still respond when ignoreRequestBody throws', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const errorEvents: ErrorEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + ignoreRequestBody: () => { + throw new Error('ignoreRequestBody failed'); + }, + }), + ], + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(async request => new Response(await request.text())); + const response = await fetch(`${server.url}/upload`, { method: 'POST', body: 'secret' }); + assertEquals(response.status, 200); + assertEquals(await response.text(), 'secret'); + await server.close(); + await client.flush(); + + assertEquals(errorEvents.length, 0); + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.request?.data, undefined); +}); + +Deno.test('Deno.serve should create the span and still respond when ignoreIncomingRequests throws', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const errorEvents: ErrorEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + ignoreIncomingRequests: () => { + throw new Error('ignoreIncomingRequests failed'); + }, + }), + ], + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => new Response('OK')); + const response = await fetch(`${server.url}/page`); + assertEquals(response.status, 200); + assertEquals(await response.text(), 'OK'); + await server.close(); + await client.flush(); + + assertEquals(errorEvents.length, 0); + assertEquals( + transactionEvents.map(event => event.transaction), + ['GET /page'], + ); +}); diff --git a/packages/deno/test/streaming.test.ts b/packages/deno/test/streaming.test.ts index cfa040ac01cf..05951459e786 100644 --- a/packages/deno/test/streaming.test.ts +++ b/packages/deno/test/streaming.test.ts @@ -82,3 +82,46 @@ Deno.test('reader.closed.then(f, f) suppresses rejection when releaseLock is cal globalThis.removeEventListener('unhandledrejection', handler); } }); + +Deno.test('calls onEnd before ending the span for a non-streamed response', async () => { + const calls: string[] = []; + const span = { end: () => calls.push('end') } as unknown as Parameters[0]; + + await streamResponse(span, new Response('{}', { headers: { 'content-type': 'application/json' } }), () => + calls.push('onEnd'), + ); + + assertEquals(calls, ['onEnd', 'end']); +}); + +Deno.test('calls onEnd before ending the span when a streamed response finishes', async () => { + const calls: string[] = []; + let resolveEnded: () => void; + const ended = new Promise(resolve => (resolveEnded = resolve)); + const span = { + end: () => { + calls.push('end'); + resolveEnded(); + }, + } as unknown as Parameters[0]; + + let sourceController: ReadableStreamDefaultController | undefined; + const source = new ReadableStream({ + start(controller) { + sourceController = controller; + controller.enqueue(new TextEncoder().encode('chunk')); + }, + }); + const response = await streamResponse( + span, + new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + () => calls.push('onEnd'), + ); + + await new Promise(resolve => setTimeout(resolve, 0)); + assertEquals(calls, []); + sourceController!.close(); + assertEquals(await response.text(), 'chunk'); + await ended; + assertEquals(calls, ['onEnd', 'end']); +}); From d1668707877d1c9dce710e24abf5af229195b825 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 19 Oct 2026 13:07:02 -0700 Subject: [PATCH 13/30] feat(deno): Record request sessions for `Deno.serve` (#25496) `denoHttpIntegration` records a request session for each `node:http` request, but `denoServeIntegration` recorded none. Most Deno apps use `Deno.serve`, so release health showed no request sessions and no crash-free rate for them. The shared helper `recordRequestSession` waits for the `close` event of a `node:http` response, which `Deno.serve` does not have. Core now exports `startRequestSession`, which starts the session and returns an `end()` function. `recordRequestSession` keeps its signature and calls `end()` from the `close` event, so Node does not change. The helper now defaults `sessionFlushingDelayMS` to 60 seconds. Before, only core's `node:http` subscription code set that default, and a caller that left the option out got a timer that fired at once. `denoServeIntegration` accepts `sessions` (default `true`) and `sessionFlushingDelayMS`, as `denoHttpIntegration` does. The session ends when the response finishes. For a streamed response, that is when the stream finishes. When the handler throws, the session ends after the SDK captures the error, so it counts as crashed. Sessions do not depend on tracing, so they are also recorded when the SDK skips the span. To match Node, OPTIONS and HEAD requests also record sessions. Fixes #25495 Fixes JS-3956 Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 + .../http/record-request-session.ts | 32 ++- packages/core/src/server.ts | 2 +- .../http/record-request-session.test.ts | 129 ++++++++++++ packages/deno/src/integrations/deno-serve.ts | 19 ++ packages/deno/src/utils/streaming.ts | 6 +- .../deno/src/wrap-deno-request-handler.ts | 32 ++- packages/deno/test/deno-serve.test.ts | 185 +++++++++++++++++- 8 files changed, 390 insertions(+), 19 deletions(-) create mode 100644 packages/core/test/lib/integrations/http/record-request-session.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b8060bce4e..6d8f555ae436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph `ignoreStaticAssets` defaults to `true`, so `Deno.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `denoServeIntegration({ ignoreStaticAssets: false })`. `Deno.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. +- **feat(deno): Record request sessions for `Deno.serve`** + + `denoServeIntegration` now records a request session for each incoming request, as `denoHttpIntegration` does for `node:http`. This gives release health and crash-free rates for `Deno.serve` apps. Sessions are sent only when a `release` is set. To turn them off, set `denoServeIntegration({ sessions: false })`. `sessionFlushingDelayMS` sets how long the SDK waits before it sends the session aggregate. + ## 11.8.0 ### Important Changes diff --git a/packages/core/src/integrations/http/record-request-session.ts b/packages/core/src/integrations/http/record-request-session.ts index 4ab0eccb88dd..fa608118a5a2 100644 --- a/packages/core/src/integrations/http/record-request-session.ts +++ b/packages/core/src/integrations/http/record-request-session.ts @@ -33,6 +33,29 @@ export function recordRequestSession( sessionFlushingDelayMS?: number; }, ): void { + response.once('close', startRequestSession(client, { requestIsolationScope, sessionFlushingDelayMS })); +} + +/** + * Starts a session and tracks it in the context of a given isolation scope. + * Returns a function to call when the request ends. It puts the session into + * a task, which aggregates it with other sessions in a certain time window + * (sessionFlushingDelayMs, 60 seconds by default). Calls after the first + * one do nothing. + * + * Use this when the server has no `node:http` response to pass to + * {@link recordRequestSession}. + */ +export function startRequestSession( + client: Client, + { + requestIsolationScope, + sessionFlushingDelayMS = 60_000, + }: { + requestIsolationScope: Scope; + sessionFlushingDelayMS?: number; + }, +): () => void { // Errors in this request are counted by the request session aggregate, so they must not also // update a process session inherited from the parent isolation scope. requestIsolationScope.setSession(); @@ -41,7 +64,12 @@ export function recordRequestSession( }); client.emit('startRequestSession'); - response.once('close', () => { + let ended = false; + return () => { + if (ended) { + return; + } + ended = true; const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession; if (client && requestSession) { @@ -89,5 +117,5 @@ export function recordRequestSession( safeUnref(timeout); } } - }); + }; } diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index e44bb9c2df8b..73f0c08e174f 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -22,7 +22,7 @@ export { DEFAULT_IGNORE_STATUS_CODES, processHttpServerTransactionEvent, } from './integrations/http/server-transaction-event'; -export { recordRequestSession } from './integrations/http/record-request-session'; +export { recordRequestSession, startRequestSession } from './integrations/http/record-request-session'; export { getClientIPAddress } from './utils/clientIPAddress'; export { addOutgoingRequestBreadcrumb } from './integrations/http/add-outgoing-request-breadcrumb'; export { diff --git a/packages/core/test/lib/integrations/http/record-request-session.test.ts b/packages/core/test/lib/integrations/http/record-request-session.test.ts new file mode 100644 index 000000000000..2aef541fa81f --- /dev/null +++ b/packages/core/test/lib/integrations/http/record-request-session.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { recordRequestSession, startRequestSession } from '../../../../src/integrations/http/record-request-session'; +import { Scope } from '../../../../src/scope'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +function createTestClient(): TestClient { + return new TestClient(getDefaultTestClientOptions({ release: '1.0.0' })); +} + +describe('startRequestSession()', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('March 19, 1999 06:12:34 UTC')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('counts the session when end() is called', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + const requestIsolationScope = new Scope(); + + const end = startRequestSession(client, { requestIsolationScope }); + vi.runAllTimers(); + expect(sendSessionSpy).not.toHaveBeenCalled(); + + end(); + vi.runAllTimers(); + + expect(sendSessionSpy).toHaveBeenCalledWith({ + aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }], + }); + }); + + it('counts the status set on the request isolation scope before end()', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + const requestIsolationScope = new Scope(); + + const end = startRequestSession(client, { requestIsolationScope }); + requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession!.status = 'crashed'; + end(); + vi.runAllTimers(); + + expect(sendSessionSpy).toHaveBeenCalledWith({ + aggregates: [{ crashed: 1, errored: 0, exited: 0, started: '1999-03-19T06:12:00.000Z' }], + }); + }); + + it('counts a session once when end() is called more than once', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + const requestIsolationScope = new Scope(); + + const end = startRequestSession(client, { requestIsolationScope }); + end(); + end(); + vi.runAllTimers(); + + expect(sendSessionSpy).toHaveBeenCalledTimes(1); + expect(sendSessionSpy).toHaveBeenCalledWith({ + aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }], + }); + }); + + it('emits the "startRequestSession" hook when the session starts', () => { + const client = createTestClient(); + const onStartRequestSession = vi.fn(); + client.on('startRequestSession', onStartRequestSession); + + startRequestSession(client, { requestIsolationScope: new Scope() }); + + expect(onStartRequestSession).toHaveBeenCalledTimes(1); + }); + + it('uses sessionFlushingDelayMS to schedule the aggregate', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + + startRequestSession(client, { requestIsolationScope: new Scope(), sessionFlushingDelayMS: 1_000 })(); + + vi.advanceTimersByTime(999); + expect(sendSessionSpy).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(sendSessionSpy).toHaveBeenCalledTimes(1); + }); + it('sends the aggregate after 60 seconds by default', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + + startRequestSession(client, { requestIsolationScope: new Scope() })(); + + vi.advanceTimersByTime(59_999); + expect(sendSessionSpy).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(sendSessionSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('recordRequestSession()', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('March 19, 1999 06:12:34 UTC')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('counts the session when the response emits close', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + const response = Object.assign(new EventEmitter(), { statusCode: 200, headers: {} }); + + recordRequestSession(client, { requestIsolationScope: new Scope(), response }); + vi.runAllTimers(); + expect(sendSessionSpy).not.toHaveBeenCalled(); + + response.emit('close'); + vi.runAllTimers(); + + expect(sendSessionSpy).toHaveBeenCalledWith({ + aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }], + }); + }); +}); diff --git a/packages/deno/src/integrations/deno-serve.ts b/packages/deno/src/integrations/deno-serve.ts index 16d061cbd1f7..9d20e9084848 100644 --- a/packages/deno/src/integrations/deno-serve.ts +++ b/packages/deno/src/integrations/deno-serve.ts @@ -61,6 +61,23 @@ export type DenoServeIntegrationOptions = { */ onSpanEnd?: (span: Span, request: Request, response: Response) => void; + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for + * incoming requests to track the health and crash-free rate of your releases in Sentry. + * + * For a streamed response, the session ends when the stream finishes. + * + * @default `true` + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions are flushed as a session aggregate. + * + * @default `60000` (60s) + */ + sessionFlushingDelayMS?: number; + /** * Do not capture spans for incoming `Deno.serve` requests with the given status codes. * By default, some 3xx and 4xx status codes are dropped (see @default). @@ -149,6 +166,8 @@ const _denoServeIntegration = ((options: DenoServeIntegrationOptions = {}) => { spans: options.spans, onSpanCreated: options.onSpanCreated, onSpanEnd: options.onSpanEnd, + sessions: options.sessions ?? true, + sessionFlushingDelayMS: options.sessionFlushingDelayMS, processEvent(event: Event): Event | null { // Gated on this integration's own span origin so it does not filter `node:http` // transactions, which `denoHttpIntegration` owns via its own `ignoreStatusCodes`. diff --git a/packages/deno/src/utils/streaming.ts b/packages/deno/src/utils/streaming.ts index 276d2455af3a..0b82c16e4a47 100644 --- a/packages/deno/src/utils/streaming.ts +++ b/packages/deno/src/utils/streaming.ts @@ -2,17 +2,17 @@ import type { Span } from '@sentry/core'; import { classifyResponseStreaming } from '@sentry/core/server'; /** - * Tee a stream, and end the provided span when the stream ends. + * Tee a stream, and end the provided span (if any) when the stream ends. * Returns the other side of the tee, which can be used to send the * response to a client. * * `onEnd` runs right before the span ends. */ -export async function streamResponse(span: Span, res: Response, onEnd?: () => void): Promise { +export async function streamResponse(span: Span | undefined, res: Response, onEnd?: () => void): Promise { const classification = classifyResponseStreaming(res); const endSpan = (): void => { onEnd?.(); - span.end(); + span?.end(); }; // not streaming, just end the span and return the response diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index 465adeb1ed44..ed8d6ae2b77e 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -25,7 +25,7 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; -import { getClientIPAddress, isStaticAssetRequest } from '@sentry/core/server'; +import { getClientIPAddress, isStaticAssetRequest, startRequestSession } from '@sentry/core/server'; import type { DenoServeIntegrationOptions } from './integrations/deno-serve'; import { streamResponse } from './utils/streaming'; @@ -100,17 +100,25 @@ export const wrapDenoRequestHandler = ( } isolationScope.setClient(client); + const integration = client.getIntegrationByName('DenoServe'); + const endSession = integration?.sessions + ? startRequestSession(client, { + requestIsolationScope: isolationScope, + sessionFlushingDelayMS: integration.sessionFlushingDelayMS, + }) + : undefined; + if (request.method === 'OPTIONS' || request.method === 'HEAD') { try { return await handler(); } catch (e) { captureServeError(e); throw e; + } finally { + endSession?.(); } } - const integration = client.getIntegrationByName('DenoServe'); - const urlObject = parseStringToURLObject(request.url); const [rawName, attributes] = getHttpSpanDetailsFromUrlObject( urlObject, @@ -181,16 +189,18 @@ export const wrapDenoRequestHandler = ( }, async () => { if (!createSpan) { + let res: Response; try { - const res = await handler(); + res = await handler(); isolationScope.setContext('response', { status_code: res.status, }); - return res; } catch (e) { captureServeError(e); + endSession?.(); throw e; } + return endSession ? streamResponse(undefined, res, endSession) : res; } return startSpanManual({ name, attributes }, async span => { @@ -212,14 +222,16 @@ export const wrapDenoRequestHandler = ( } catch (e) { span.end(); captureServeError(e); + endSession?.(); throw e; } - return streamResponse( - span, - res, - onSpanEnd && (() => callUserCallback('onSpanEnd', () => onSpanEnd(span, request, res), undefined)), - ); + return streamResponse(span, res, () => { + if (onSpanEnd) { + callUserCallback('onSpanEnd', () => onSpanEnd(span, request, res), undefined); + } + endSession?.(); + }); }); }, ); diff --git a/packages/deno/test/deno-serve.test.ts b/packages/deno/test/deno-serve.test.ts index f30318f0fbcd..90719660ea43 100644 --- a/packages/deno/test/deno-serve.test.ts +++ b/packages/deno/test/deno-serve.test.ts @@ -1,11 +1,13 @@ // -import type { ErrorEvent, Span, TransactionEvent } from '@sentry/core'; -import { getMainCarrier } from '@sentry/core'; +import type { AggregationCounts, Envelope, ErrorEvent, Span, TransactionEvent } from '@sentry/core'; +import { forEachEnvelopeItem, getMainCarrier } from '@sentry/core'; import { assertEquals, assertExists, assertMatch, assertNotEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; -import type { DenoClient } from '../build/esm/index.js'; +import type { DenoClient, DenoOptions } from '../build/esm/index.js'; import { captureException, captureMessage, init, denoServeIntegration, setTag, setUser } from '../build/esm/index.js'; +import { makeTestTransport } from './transport.ts'; + const LOOPBACK_ADDRESS = /^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/; function resetGlobals(): void { @@ -1410,3 +1412,180 @@ Deno.test('Deno.serve should create the span and still respond when ignoreIncomi ['GET /page'], ); }); + +type SessionCounts = Omit; + +function initWithSessionTransport(options: DenoOptions = {}): { + client: DenoClient; + sessions: SessionCounts[]; + firstSession: Promise; +} { + const sessions: SessionCounts[] = []; + let resolveFirstSession: () => void; + const firstSession = new Promise(resolve => (resolveFirstSession = resolve)); + const client = init({ + dsn: 'https://username@domain/123', + release: '1.0.0', + transport: makeTestTransport((envelope: Envelope) => { + forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'sessions') { + for (const { started: _started, ...counts } of (item[1] as { aggregates: AggregationCounts[] }).aggregates) { + sessions.push(counts); + } + resolveFirstSession(); + } + }); + }), + ...options, + }) as DenoClient; + return { client, sessions, firstSession }; +} + +Deno.test('Deno.serve should record an exited session for a successful request', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport(); + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/ok`)).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 1, errored: 0, crashed: 0 }]); +}); + +Deno.test('Deno.serve should record an errored session for a handled exception', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport(); + + const server = await serveForTest(() => { + captureException(new Error('handled')); + return new Response('OK'); + }); + await (await fetch(`${server.url}/handled`)).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 0, errored: 1, crashed: 0 }]); +}); + +Deno.test('Deno.serve should record a crashed session when the handler throws', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport(); + + const server = await serveForTest(() => { + throw new Error('unhandled'); + }); + await (await fetch(`${server.url}/throws`)).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 0, errored: 0, crashed: 1 }]); +}); + +Deno.test('Deno.serve should record sessions for OPTIONS and HEAD requests', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport(); + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/cors`, { method: 'OPTIONS' })).text(); + await (await fetch(`${server.url}/cors`, { method: 'HEAD' })).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 2, errored: 0, crashed: 0 }]); +}); + +Deno.test('Deno.serve should not record sessions when sessions is false', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport({ + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ sessions: false }), + ], + }); + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/ok`)).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, []); +}); + +Deno.test('Deno.serve should send the session aggregate after sessionFlushingDelayMS', async () => { + resetGlobals(); + const { sessions, firstSession } = initWithSessionTransport({ + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ sessionFlushingDelayMS: 50 }), + ], + }); + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/ok`)).text(); + await server.close(); + + // The timeout only bounds a failure. With the default 60s delay, no + // session arrives within it. + let timeoutId: ReturnType | undefined; + const timedOut = new Promise<'timed out'>(resolve => { + timeoutId = setTimeout(() => resolve('timed out'), 10_000); + }); + const result = await Promise.race([firstSession.then(() => 'sent' as const), timedOut]); + clearTimeout(timeoutId); + + assertEquals(result, 'sent'); + assertEquals(sessions, [{ exited: 1, errored: 0, crashed: 0 }]); +}); + +for (const [label, options] of [ + ['when tracing is enabled', { tracesSampleRate: 1, traceLifecycle: 'static' }], + ['when tracing is disabled', {}], +] as const) { + Deno.test(`Deno.serve should record a streamed response session when the stream finishes ${label}`, async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport(options); + + let streamController: ReadableStreamDefaultController | undefined; + const server = await serveForTest(() => { + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue(new TextEncoder().encode('chunk\n')); + }, + }); + return new Response(stream, { headers: { 'content-type': 'text/event-stream' } }); + }); + + const response = await fetch(`${server.url}/stream`); + await client.flush(); + assertEquals(sessions, []); + + streamController!.close(); + assertEquals(await response.text(), 'chunk\n'); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 1, errored: 0, crashed: 0 }]); + }); +} + +Deno.test('Deno.serve should record sessions for requests without a span', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport({ + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ ignoreIncomingRequests: urlPath => urlPath === '/health' }), + ], + }); + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/health`)).text(); + await (await fetch(`${server.url}/favicon.ico`)).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 2, errored: 0, crashed: 0 }]); +}); From 58adba5aed35618e901c35b513fc81a64fd79056 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 20 Oct 2026 09:14:29 +0200 Subject: [PATCH 14/30] test(nestjs): Fix flaky schedule instrumentation error tests (#25447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `@Interval` (and `@Cron`) handlers in `nestjs-basic` threw an identical error on every tick, and the `Dedupe` integration drops consecutive identical errors. Once earlier tests have killed the other erroring cron jobs, nothing interleaves, so only the first interval error is ever sent — often before the test starts listening — and `waitForError` times out. The handlers now include `Date.now()` in the error message so every tick produces a distinct error, and the tests only accept an error thrown after the test started. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../nestjs-basic/src/schedule.service.ts | 5 +++-- .../nestjs-basic/tests/schedule-instrumentation.test.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts index 38b56136ab20..791d9aa83c20 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts @@ -7,15 +7,16 @@ export class ScheduleService { constructor(private schedulerRegistry: SchedulerRegistry) {} // @Cron error test (auto-instrumentation, no @SentryCron) + // The timestamp keeps the Dedupe integration from dropping the error on repeated ticks. @Cron('*/5 * * * * *', { name: 'test-schedule-cron-error' }) handleCronError() { - throw new Error('Test error from schedule cron'); + throw new Error(`Test error from schedule cron ${Date.now()}`); } // @Interval error test @Interval('test-schedule-interval-error', 2000) async handleIntervalError() { - throw new Error('Test error from schedule interval'); + throw new Error(`Test error from schedule interval ${Date.now()}`); } // @Timeout error test diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts index aa8a90c66cd5..b3f5a416f090 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts @@ -2,10 +2,12 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends exceptions to Sentry on error in @Cron decorated method', async ({ baseURL }) => { + const testStart = Date.now(); const errorEventPromise = waitForError('nestjs-basic', event => { + const thrownAt = event.exception?.values?.[0]?.value?.match(/^Test error from schedule cron (\d+)$/)?.[1]; return ( !event.type && - event.exception?.values?.[0]?.value === 'Test error from schedule cron' && + Number(thrownAt) >= testStart && event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.cron' ); }); @@ -23,10 +25,12 @@ test('Sends exceptions to Sentry on error in @Cron decorated method', async ({ b }); test('Sends exceptions to Sentry on error in @Interval decorated method', async ({ baseURL }) => { + const testStart = Date.now(); const errorEventPromise = waitForError('nestjs-basic', event => { + const thrownAt = event.exception?.values?.[0]?.value?.match(/^Test error from schedule interval (\d+)$/)?.[1]; return ( !event.type && - event.exception?.values?.[0]?.value === 'Test error from schedule interval' && + Number(thrownAt) >= testStart && event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.interval' ); }); From 712e9b802a214921d553081418fda0798ab5c7f4 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 20 Oct 2026 09:15:57 +0200 Subject: [PATCH 15/30] ci: Fail when a Playwright version drifts from the pinned one (#25383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a check that every `package.json` declares the same exact `@playwright/test` version as `@sentry-internal/test-utils`. That covers the root, `packages/*`, `dev-packages/*` and every E2E app. It runs in the Lint job's parallel group, so a drifting app fails fast instead of silently testing against different browsers. `@sentry-internal/test-utils` is the reference because E2E apps already depend on it for their Playwright config. The check is an inline `git grep` over tracked `package.json` files, so it needs no installed dependencies and picks up new locations without a list to maintain. Only `@playwright/test` is checked, since `playwright` and `playwright-core` never appear without it. Based on #25404, outside the PR stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/build.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce53bcc737e0..7502ea53b9ac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -346,6 +346,13 @@ jobs: run: pnpm run lint:es-compatibility - name: Lint types run: pnpm run lint:types + - name: Check Playwright versions + run: | + version=$(node -p "require('./dev-packages/test-utils/package.json').devDependencies['@playwright/test']") + if git grep -n '"@playwright/test": "' -- '*package.json' | grep -vF "\"$version\""; then + echo "::error::All @playwright/test dependencies must be pinned to $version (from @sentry-internal/test-utils)." + exit 1 + fi job_check_lockfile: name: Check lockfile From 46f136a1fd2e9ad25b375723527659d6c8a28ff6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:17:05 +0200 Subject: [PATCH 16/30] test(nuxt): Wait for hydration before clicking in error tests (#25520) The Nuxt `errors.client` and `errors.server` E2E tests sometimes time out because they click server-rendered buttons before Vue has hydrated and attached the handlers. The click gets dropped, so no request or error happens. In the failed runs, the server never even logged the `/api/server-error` request. The race exists because the Sentry client config is loaded through a dynamic `import()` in a Nuxt plugin. `page.goto` resolves on `load`, which doesn't wait for that chunk, and Nuxt doesn't mount the app until its plugins finish. The tests now wait until `#__nuxt.__vue_app__.$nuxt.isHydrating === false` before clicking. That is the flag Nuxt clears once the root Suspense resolves after hydration, so it shows the handlers are attached. `typeof window.__SENTRY__ === 'object'` only shows that the SDK loaded, which can happen before mount. The helper replaces the one existing `__SENTRY__` wait in `nuxt-3-dynamic-import`. I checked the race with a minimal Nuxt app whose plugin awaits a delayed dynamic chunk: clicking right after `goto` was a no-op, and clicking after this wait worked. The helper is copied into each Nuxt test app because each app is a standalone package. via **Francesco Gringl-Novy**. -- [View Junior Session](https://junior-prod.sentry.dev/conversations/agent%3Aa39e9eec22d2b41c897347df8de17187) [[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3Aa39e9eec22d2b41c897347df8de17187/?project=4510944073809921) Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Francesco Gringl-Novy --- .../nuxt-3-dynamic-import/tests/errors.client.test.ts | 5 ++++- .../nuxt-3-dynamic-import/tests/errors.server.test.ts | 3 +++ .../nuxt-3-dynamic-import/tests/waitForHydration.ts | 10 ++++++++++ .../nuxt-3-min/tests/errors.client.test.ts | 4 ++++ .../nuxt-3-min/tests/errors.server.test.ts | 3 +++ .../nuxt-3-min/tests/waitForHydration.ts | 10 ++++++++++ .../tests/errors.client.test.ts | 4 ++++ .../tests/errors.server.test.ts | 4 ++++ .../nuxt-3-top-level-import/tests/waitForHydration.ts | 10 ++++++++++ .../nuxt-3/tests/errors.client.test.ts | 5 +++++ .../nuxt-3/tests/errors.server.test.ts | 3 +++ .../test-applications/nuxt-3/tests/waitForHydration.ts | 10 ++++++++++ .../nuxt-4-static/tests/errors.client.test.ts | 5 +++++ .../nuxt-4-static/tests/errors.server.test.ts | 3 +++ .../nuxt-4-static/tests/waitForHydration.ts | 10 ++++++++++ .../nuxt-4/tests/errors.client.test.ts | 5 +++++ .../nuxt-4/tests/errors.server.test.ts | 4 ++++ .../test-applications/nuxt-4/tests/waitForHydration.ts | 10 ++++++++++ .../nuxt-5/tests/errors.client.test.ts | 5 +++++ .../nuxt-5/tests/errors.server.test.ts | 4 ++++ .../test-applications/nuxt-5/tests/waitForHydration.ts | 10 ++++++++++ 21 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/waitForHydration.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/waitForHydration.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/waitForHydration.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/waitForHydration.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/waitForHydration.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/waitForHydration.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/waitForHydration.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.client.test.ts index 0bf456015834..106e26ff68a2 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,7 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); - await page.waitForFunction(() => typeof window.__SENTRY__ === 'object'); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -65,6 +67,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts index 90771e4174ad..64726a13c12a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('server-side errors', async () => { test('captures api fetch error (fetched on click)', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -42,6 +44,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.client.test.ts index e9ef67ef75b6..1ed7de457433 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -64,6 +67,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts index e108a25ea9b1..3edf9e7d8744 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('server-side errors', async () => { test('captures api fetch error (fetched on click)', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -42,6 +44,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.client.test.ts index ff99eb1b192f..a77959867c2b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -64,6 +67,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts index eb8a9647cdd2..336f3966d94c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('server-side errors', async () => { test('captures api fetch error (fetched on click)', async ({ page }) => { @@ -18,6 +19,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const serverSpan = await serverSpanPromise; @@ -66,6 +68,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); await serverSpanPromise; @@ -82,6 +85,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts index 11e4a5a367d4..fc10459b544b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#error-in-error-boundary').click(); const error = await errorPromise; @@ -77,6 +80,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -106,6 +110,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts index a360209ec1ce..cdebac5593e5 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('server-side errors', async () => { test('captures api fetch error (fetched on click)', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -42,6 +44,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts index 1c747e73fe77..64a3f6e7b88d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#error-in-error-boundary').click(); const error = await errorPromise; @@ -77,6 +80,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -106,6 +110,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts index b7dce20b0714..1111253ce495 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('server-side errors', async () => { test('captures api fetch error (fetched on click)', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-routes`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -42,6 +44,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.client.test.ts index 2aed77dbfed6..311c49dba8a9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#error-in-error-boundary').click(); const error = await errorPromise; @@ -77,6 +80,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -106,6 +110,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts index 418c09f16c57..928434c44b27 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { waitForHydration } from './waitForHydration'; IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { test.describe(`server-side errors (${name})`, () => { @@ -10,6 +11,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/fetch-server-routes?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -44,6 +46,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/test-param/1234?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; @@ -81,6 +84,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/fetch-server-routes?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts index 2c6a1be53662..826a578a7e6c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#error-in-error-boundary').click(); const error = await errorPromise; @@ -77,6 +80,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -106,6 +110,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts index 1917990af924..2dcc253a98d1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { waitForHydration } from './waitForHydration'; IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { test.describe(`server-side errors (${name})`, () => { @@ -10,6 +11,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/fetch-server-routes?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -44,6 +46,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/test-param/1234?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; @@ -81,6 +84,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/fetch-server-routes?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} From 11300805516c3316e2900f8eefb8a51c3b1d8774 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 20 Oct 2026 14:00:05 +0200 Subject: [PATCH 17/30] ci: Run the Playwright install's npx outside the repo (#25384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `install-playwright` ran `npx playwright@` from the repo root. With `nodeLinker: hoisted`, npm can find `node_modules/playwright` there before pnpm has linked its binary, so a `pnpm install` running in parallel made it fail with `playwright: not found`. E2E jobs no longer install the workspace since #25514, but the action also runs in other jobs, so it now runs `npx` in `runner.temp`. It also passes the browsers to `install-deps` as separate arguments, which only mattered for the multi-browser default. The original change here, starting the background install before the E2E setup steps, is dropped. #25514 moved it right before the build, because a background composite action crashes the runner with "Collection was modified" when a foreground step starts while it sets up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .github/actions/install-playwright/action.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/actions/install-playwright/action.yml b/.github/actions/install-playwright/action.yml index 30815292dea2..ca6c8a2d9583 100644 --- a/.github/actions/install-playwright/action.yml +++ b/.github/actions/install-playwright/action.yml @@ -37,20 +37,23 @@ runs: key: playwright-${{ runner.os }}-iteration-1-${{ steps.playwright-version.outputs.version }} # We always install all browsers, if uncached + # `npx` runs outside the repo, so it can't pick up a `playwright` that a parallel `pnpm install` is still linking. - name: Install Playwright dependencies (uncached) env: PLAYWRIGHT_VERSION: ${{ steps.playwright-version.outputs.version }} run: npx -y "playwright@$PLAYWRIGHT_VERSION" install chromium webkit firefox --with-deps if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit != 'true' shell: bash + working-directory: ${{ runner.temp }} - name: Install Playwright system dependencies only (cached) env: PLAYWRIGHT_BROWSERS: ${{ inputs.browsers || 'chromium webkit firefox' }} PLAYWRIGHT_VERSION: ${{ steps.playwright-version.outputs.version }} - run: npx -y "playwright@$PLAYWRIGHT_VERSION" install-deps "$PLAYWRIGHT_BROWSERS" + run: npx -y "playwright@$PLAYWRIGHT_VERSION" install-deps $PLAYWRIGHT_BROWSERS if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit == 'true' shell: bash + working-directory: ${{ runner.temp }} # Only store cache on develop branch - name: Store cached playwright binaries From 3d47e12d822ec0093d32a02b46811a0898184b2b Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 20 Oct 2026 13:01:28 +0100 Subject: [PATCH 18/30] fix(bundler-plugins): Inject the release only into chunks that load at startup (#25541) The Rollup, Vite and webpack plugins added the `SENTRY_RELEASE` snippet to every JS chunk. On apps with many lazily loaded chunks that is a lot of repeated output, and the global only needs to exist before `Sentry.init` runs. The release snippet now goes only into entry chunks and the chunks they reach through static imports. Debug ID and module metadata injection stay per chunk. esbuild already injects per entry point and does not change. A plain `chunk.isEntry` check is not enough: Vite multi-page builds drop pure-import HTML facade chunks and put the app code in non-entry shared chunks. The Rollup plugin walks the chunk graph that `renderChunk` receives, and injects everywhere when no graph is available. The webpack plugin uses `chunk.canBeInitial()`. --- packages/bundler-plugins/src/core/index.ts | 52 +++++++++ packages/bundler-plugins/src/rollup/index.ts | 22 ++-- packages/bundler-plugins/src/webpack/index.ts | 16 ++- .../bundler-plugins/test/core/index.test.ts | 27 ++++- .../test/rollup/release-injection.test.ts | 103 ++++++++++++++++++ .../test/webpack/release-injection.test.ts | 66 +++++++++++ 6 files changed, 273 insertions(+), 13 deletions(-) create mode 100644 packages/bundler-plugins/test/rollup/release-injection.test.ts create mode 100644 packages/bundler-plugins/test/webpack/release-injection.test.ts diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index fd645190fb51..4edc5ebbed91 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -37,6 +37,58 @@ export function shouldSkipCodeInjection(code: string, facadeModuleId: string | n return false; } +export interface ChunkGraphNode { + fileName: string; + isEntry?: boolean; + imports?: string[]; +} + +const initialChunksCache = new WeakMap, Set>(); + +function getInitialChunks(chunks: Record): Set { + const cached = initialChunksCache.get(chunks); + if (cached) { + return cached; + } + + const initial = new Set(); + const queue = Object.values(chunks).filter(chunk => chunk.isEntry); + + for (let chunk = queue.shift(); chunk; chunk = queue.shift()) { + if (initial.has(chunk.fileName)) { + continue; + } + initial.add(chunk.fileName); + for (const imported of chunk.imports ?? []) { + const importedChunk = chunks[imported]; + if (importedChunk) { + queue.push(importedChunk); + } + } + } + + initialChunksCache.set(chunks, initial); + return initial; +} + +/** + * Whether a chunk loads at startup: it is an entry chunk, or an entry chunk + * reaches it through static imports. Chunks that only dynamic imports reach + * load later, after startup code already ran. + * + * Without the chunk graph every chunk counts as initial. + */ +export function isInitialChunk( + chunk: Pick, + chunks: Record | undefined, +): boolean { + if (chunk.isEntry || !chunks) { + return true; + } + + return getInitialChunks(chunks).has(chunk.fileName); +} + export { globFiles } from './glob'; export { getCodeInjectionPosition } from './get-code-injection-position'; diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index b3cc6861f55e..227f162eae8f 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -1,9 +1,10 @@ -import type { Options } from '../core'; +import type { ChunkGraphNode, Options } from '../core'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, generateModuleMetadataInjectorCode, isJsFile, + isInitialChunk, shouldSkipCodeInjection, getDebugIdSnippet, createDebugIdUploadFunction, @@ -115,7 +116,8 @@ export function _rollupPluginInternal( const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); const sourcemapsEnabled = options.sourcemaps?.disable !== true; - const staticInjectionCode = new CodeInjection(); + const releaseInjectionCode = new CodeInjection(); + const chunkInjectionCode = new CodeInjection(); if (!options.release.inject) { logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); @@ -124,7 +126,7 @@ export function _rollupPluginInternal( 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', ); } else { - staticInjectionCode.append( + releaseInjectionCode.append( generateReleaseInjectorCode({ release: options.release.name, injectBuildInformation: options._experiments.injectBuildInformation || false, @@ -133,7 +135,7 @@ export function _rollupPluginInternal( } if (Object.keys(bundleMetadata).length > 0) { - staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); } const transformAnnotations = options.reactComponentAnnotation?.enabled @@ -181,9 +183,9 @@ export function _rollupPluginInternal( function renderChunk( this: PluginContext | undefined, code: string, - chunk: { fileName: string; facadeModuleId?: string | null }, + chunk: { fileName: string; facadeModuleId?: string | null; isEntry?: boolean }, _?: unknown, - meta?: { magicString?: MagicString }, + meta?: { magicString?: MagicString; chunks?: Record }, ): { code: string; map?: SourceMap; @@ -197,7 +199,13 @@ export function _rollupPluginInternal( return null; } - const injectCode = staticInjectionCode.clone(); + const injectCode = new CodeInjection(); + + if (isInitialChunk(chunk, meta?.chunks)) { + injectCode.append(releaseInjectionCode); + } + + injectCode.append(chunkInjectionCode); if (sourcemapsEnabled && !hasExistingDebugID(code)) { const debugId = getDebugIdForChunk(code, !!this?.meta?.rolldownVersion); diff --git a/packages/bundler-plugins/src/webpack/index.ts b/packages/bundler-plugins/src/webpack/index.ts index a86f81894821..84600ff47634 100644 --- a/packages/bundler-plugins/src/webpack/index.ts +++ b/packages/bundler-plugins/src/webpack/index.ts @@ -107,6 +107,7 @@ type WebpackCompilation = { files: Iterable; hash?: string; contentHash?: { javascript?: string }; + canBeInitial?: () => boolean; }>; outputOptions: { path?: string; @@ -198,7 +199,8 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) } const sourcemapsEnabled = options.sourcemaps?.disable !== true; - const staticInjectionCode = new CodeInjection(); + const releaseInjectionCode = new CodeInjection(); + const chunkInjectionCode = new CodeInjection(); if (!options.release.inject) { logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); @@ -207,7 +209,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', ); } else { - staticInjectionCode.append( + releaseInjectionCode.append( generateReleaseInjectorCode({ release: options.release.name, injectBuildInformation: options._experiments.injectBuildInformation || false, @@ -216,7 +218,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) } if (Object.keys(bundleMetadata).length > 0) { - staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); } const transformAnnotations = options.reactComponentAnnotation?.enabled @@ -230,7 +232,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) const transformReplace = Object.keys(replacementValues).length > 0; function addCodeInjection(compiler: WebpackCompiler): void { - if (staticInjectionCode.isEmpty() && !sourcemapsEnabled) { + if (releaseInjectionCode.isEmpty() && chunkInjectionCode.isEmpty() && !sourcemapsEnabled) { return; } @@ -267,7 +269,11 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) const sourceContents = source.source(); const code = typeof sourceContents === 'string' ? sourceContents : Buffer.from(sourceContents).toString(); - const codeToInject = staticInjectionCode.clone(); + const codeToInject = new CodeInjection(); + if (chunk.canBeInitial?.() ?? true) { + codeToInject.append(releaseInjectionCode); + } + codeToInject.append(chunkInjectionCode); if (sourcemapsEnabled) { const hash = chunk.contentHash?.javascript ?? chunk.hash; codeToInject.append(getDebugIdSnippet(hash ? stringToUUID(hash) : randomUUID())); diff --git a/packages/bundler-plugins/test/core/index.test.ts b/packages/bundler-plugins/test/core/index.test.ts index d5387fab9da7..3a0593bc4ec7 100644 --- a/packages/bundler-plugins/test/core/index.test.ts +++ b/packages/bundler-plugins/test/core/index.test.ts @@ -1,4 +1,4 @@ -import { createComponentNameAnnotateHooks, getDebugIdSnippet } from '../../src/core'; +import { createComponentNameAnnotateHooks, getDebugIdSnippet, isInitialChunk } from '../../src/core'; import { getOxcParseAstAsync } from '../../src/core/component-annotation-oxc'; import { containsOnlyImports } from '../../src/core/utils'; import { describe, it, expect, vi } from 'vitest'; @@ -63,6 +63,31 @@ describe('getDebugIdSnippet', () => { }); }); +describe('isInitialChunk', () => { + const chunks = { + 'entry.js': { fileName: 'entry.js', isEntry: true, imports: ['shared.js', 'external'] }, + 'shared.js': { fileName: 'shared.js', imports: ['vendor.js'] }, + 'vendor.js': { fileName: 'vendor.js', imports: ['shared.js'] }, + 'route.js': { fileName: 'route.js', imports: ['route-shared.js'] }, + 'route-shared.js': { fileName: 'route-shared.js', imports: [] }, + }; + + it('marks entry chunks and chunks they reach through static imports as initial', () => { + expect(isInitialChunk(chunks['entry.js'], chunks)).toBe(true); + expect(isInitialChunk(chunks['shared.js'], chunks)).toBe(true); + expect(isInitialChunk(chunks['vendor.js'], chunks)).toBe(true); + }); + + it('does not mark chunks that only dynamic imports reach as initial', () => { + expect(isInitialChunk(chunks['route.js'], chunks)).toBe(false); + expect(isInitialChunk(chunks['route-shared.js'], chunks)).toBe(false); + }); + + it('treats every chunk as initial without a chunk graph', () => { + expect(isInitialChunk({ fileName: 'route.js' }, undefined)).toBe(true); + }); +}); + describe('containsOnlyImports', () => { describe('should return true (import-only code)', () => { it.each([ diff --git a/packages/bundler-plugins/test/rollup/release-injection.test.ts b/packages/bundler-plugins/test/rollup/release-injection.test.ts new file mode 100644 index 000000000000..990f3415f65a --- /dev/null +++ b/packages/bundler-plugins/test/rollup/release-injection.test.ts @@ -0,0 +1,103 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { rollup } from 'rollup'; +import { rolldown } from 'rolldown'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sentryRollupPlugin } from '../../src/rollup'; +import { sentryVitePlugin } from '../../src/vite'; + +const RELEASE_SNIPPET = /SENTRY_RELEASE\s*=\s*\{\s*id:\s*"1\.2\.3"\s*\}/; +const DEBUG_ID_MARKER = /sentry-dbid-[0-9a-f-]{36}/; + +function readChunks(outDir: string): Record { + const chunks: Record = {}; + for (const fileName of fs.readdirSync(outDir)) { + if (fileName.endsWith('.js')) { + chunks[fileName] = fs.readFileSync(path.join(outDir, fileName), 'utf8'); + } + } + return chunks; +} + +function findChunk(chunks: Record, content: string): string { + const match = Object.entries(chunks).find(([, code]) => code.includes(content)); + expect(match, `no chunk contains ${content}`).toBeDefined(); + return match?.[1] ?? ''; +} + +describe('release injection', () => { + let tmpDir: string; + let entries: string[]; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-rollup-release-injection-')); + entries = [path.join(tmpDir, 'entry.js'), path.join(tmpDir, 'second.js')]; + fs.writeFileSync( + entries[0] ?? '', + 'import { shared } from "./shared.js";\nconsole.log("entry", shared);\nimport("./route.js");\n', + ); + fs.writeFileSync(entries[1] ?? '', 'import { shared } from "./shared.js";\nconsole.log("second", shared);\n'); + fs.writeFileSync(path.join(tmpDir, 'shared.js'), 'export const shared = "shared";\nconsole.log(shared);\n'); + fs.writeFileSync( + path.join(tmpDir, 'route.js'), + 'import { shared } from "./shared.js";\nconsole.log("route", shared);\n', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('injects the release only into chunks that load at startup (rollup)', async () => { + const outDir = path.join(tmpDir, 'dist'); + const build = await rollup({ + input: entries, + plugins: [sentryRollupPlugin({ telemetry: false, release: { name: '1.2.3', create: false } })], + }); + await build.write({ dir: outDir, chunkFileNames: '[name].js' }); + + const chunks = readChunks(outDir); + expect(Object.keys(chunks)).toHaveLength(4); + + const entryChunk = findChunk(chunks, '"entry"'); + const secondChunk = findChunk(chunks, '"second"'); + const sharedChunk = findChunk(chunks, '"shared"'); + const routeChunk = findChunk(chunks, '"route"'); + + expect(entryChunk).toMatch(RELEASE_SNIPPET); + expect(secondChunk).toMatch(RELEASE_SNIPPET); + expect(sharedChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(entryChunk).toMatch(DEBUG_ID_MARKER); + expect(sharedChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }); + + it('injects the release only into chunks that load at startup (rolldown / vite)', async () => { + const outDir = path.join(tmpDir, 'dist'); + const build = await rolldown({ + input: entries, + plugins: sentryVitePlugin({ telemetry: false, release: { name: '1.2.3', create: false } }), + }); + await build.write({ dir: outDir, chunkFileNames: '[name].js' }); + + const chunks = readChunks(outDir); + expect(Object.keys(chunks)).toHaveLength(4); + + const entryChunk = findChunk(chunks, '"entry"'); + const secondChunk = findChunk(chunks, '"second"'); + const sharedChunk = findChunk(chunks, '"shared"'); + const routeChunk = findChunk(chunks, '"route"'); + + expect(entryChunk).toMatch(RELEASE_SNIPPET); + expect(secondChunk).toMatch(RELEASE_SNIPPET); + expect(sharedChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(entryChunk).toMatch(DEBUG_ID_MARKER); + expect(sharedChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }); +}); diff --git a/packages/bundler-plugins/test/webpack/release-injection.test.ts b/packages/bundler-plugins/test/webpack/release-injection.test.ts new file mode 100644 index 000000000000..55ee52f5a944 --- /dev/null +++ b/packages/bundler-plugins/test/webpack/release-injection.test.ts @@ -0,0 +1,66 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { webpack } from 'webpack'; +import type { Configuration, Stats } from 'webpack'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sentryWebpackPlugin } from '../../src/webpack/index'; + +const RELEASE_SNIPPET = /SENTRY_RELEASE\s*=\s*\{\s*id:\s*"1\.2\.3"\s*\}/; +const DEBUG_ID_MARKER = /sentry-dbid-[0-9a-f-]{36}/; + +function build(config: Configuration): Promise { + return new Promise((resolve, reject) => { + webpack(config, (err, stats) => { + if (err) { + return reject(err); + } + if (!stats || stats.hasErrors()) { + return reject(new Error(stats?.toString() ?? 'no stats')); + } + resolve(stats); + }); + }); +} + +describe('release injection', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-webpack-release-injection-')); + fs.writeFileSync( + path.join(tmpDir, 'entry.js'), + 'import { shared } from "./shared.js";\nconsole.log("entry", shared);\nimport(/* webpackChunkName: "route" */ "./route.js");\n', + ); + fs.writeFileSync(path.join(tmpDir, 'shared.js'), 'export const shared = "shared";\nconsole.log(shared);\n'); + fs.writeFileSync( + path.join(tmpDir, 'route.js'), + 'import { shared } from "./shared.js";\nconsole.log("route", shared);\n', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('injects the release only into initial chunks', async () => { + const outDir = path.join(tmpDir, 'dist'); + + await build({ + mode: 'production', + context: tmpDir, + entry: './entry.js', + output: { path: outDir, filename: '[name].js', chunkFilename: '[name].js' }, + plugins: [sentryWebpackPlugin({ telemetry: false, release: { name: '1.2.3', create: false } })], + }); + + const mainChunk = fs.readFileSync(path.join(outDir, 'main.js'), 'utf8'); + const routeChunk = fs.readFileSync(path.join(outDir, 'route.js'), 'utf8'); + + expect(mainChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(mainChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }); +}); From 782b0e537d6b2998224fd4538bf780fb78369aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 20 Oct 2026 14:52:58 +0200 Subject: [PATCH 19/30] ci: Run GitHub Actions jobs on Namespace runners (#25547) This moves all GitHub Actions jobs to Namespace runners. It keeps node_modules, the pnpm store and the Playwright browsers on Namespace cache volumes instead of the GitHub cache, so every job runs `pnpm install --frozen-lockfile` and there is no dependency cache key anymore. ## Runner decisions Jobs use 3 profiles, sized from the CPU and memory peaks in `nsc instance report`: `-small` (2 vCPU, 4 GB) for bots and checks, the default (4 vCPU, 8 GB) for E2E and light jobs, `-large` (8 vCPU, 16 GB) for build, lint and tests. One profile for all jobs is simpler, but slower or more expensive. ## Cache decisions All `build.yml` jobs share one cache volume through `cache-tag=sentry-javascript`, for all branches (last write wins). Alternative: let only `develop` write to it ("Protect caches from updates"). Co-authored-by: Claude Opus 5.5 --- .../actions/install-dependencies/action.yml | 31 ++-- .github/actions/install-playwright/action.yml | 44 ++--- .github/actions/restore-cache/action.yml | 21 +-- .github/workflows/auto-fix-issue.yml | 2 +- .github/workflows/auto-release.yml | 2 +- .github/workflows/build.yml | 165 ++++++++++-------- .github/workflows/bump-size-limits.yml | 9 +- .github/workflows/canary.yml | 4 +- .github/workflows/ci-metadata.yml | 2 +- .github/workflows/cleanup-e2e-workers.yml | 2 +- .github/workflows/cleanup-pr-caches.yml | 2 +- .github/workflows/clear-cache.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/dependabot-auto-triage.yml | 11 +- .../workflows/enforce-license-compliance.yml | 2 +- .github/workflows/external-contributors.yml | 2 +- .github/workflows/flaky-test-detector.yml | 2 +- .github/workflows/gitflow-merge-conflict.yml | 2 +- .github/workflows/gitflow-sync-develop.yml | 2 +- .github/workflows/issue-package-label.yml | 2 +- .github/workflows/label-external-prs.yml | 2 +- .github/workflows/lighthouse.yml | 2 +- .github/workflows/pr-review-reminder.yml | 2 +- .github/workflows/pr-stale-notifier.yml | 2 +- .github/workflows/release-comment-issues.yml | 2 +- .github/workflows/release-size-info.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/track-framework-updates.yml | 2 +- .github/workflows/validate-pr.yml | 2 +- .github/workflows/weekly-flaky-tests.yml | 2 +- scripts/dependency-hash-key.js | 75 -------- 31 files changed, 148 insertions(+), 258 deletions(-) delete mode 100644 scripts/dependency-hash-key.js diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index 5f51f918c34b..76ca231532f9 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -1,10 +1,5 @@ name: 'Install pnpm dependencies' -description: 'Installs pnpm dependencies and caches them.' - -outputs: - cache_key: - description: 'The dependency cache key' - value: ${{ steps.compute_lockfile_hash.outputs.hash }} +description: 'Installs pnpm dependencies into node_modules folders on the Namespace cache volume.' runs: using: 'composite' @@ -12,19 +7,25 @@ runs: - name: Install pnpm uses: ./.github/actions/install-pnpm - - name: Compute dependency cache key - id: compute_lockfile_hash - run: node ./scripts/dependency-hash-key.js >> "$GITHUB_OUTPUT" + - name: List dependency paths + id: paths + run: | + { + echo 'paths<> "$GITHUB_OUTPUT" shell: bash - - name: Check dependency cache - uses: actions/cache@v6.1.0 - id: cache_dependencies + - name: Mount dependency cache + uses: namespacelabs/nscloud-cache-action@1124a6f3ce44e5cf84cc22111530961f4d2a15f9 # v1.7.0 with: - path: ${{ env.CACHED_DEPENDENCY_PATHS }} - key: ${{ steps.compute_lockfile_hash.outputs.hash }} + cache: pnpm + path: ${{ steps.paths.outputs.paths }} + # The cache volume can hold node_modules of another commit or branch. This is a no-op when they match + # pnpm-lock.yaml. - name: Install dependencies - if: steps.cache_dependencies.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile shell: bash diff --git a/.github/actions/install-playwright/action.yml b/.github/actions/install-playwright/action.yml index ca6c8a2d9583..bd84a13e9107 100644 --- a/.github/actions/install-playwright/action.yml +++ b/.github/actions/install-playwright/action.yml @@ -1,5 +1,5 @@ name: 'Install Playwright dependencies' -description: 'Installs Playwright dependencies and caches them.' +description: 'Installs Playwright browsers and system dependencies, keeping both on the Namespace cache volume.' inputs: browsers: description: 'What browsers to install.' @@ -26,43 +26,21 @@ runs: shell: bash working-directory: ${{ inputs.cwd }} - - name: Restore cached playwright binaries - uses: actions/cache/restore@v6.1.0 - id: playwright-cache + - name: Mount Playwright cache + uses: namespacelabs/nscloud-cache-action@1124a6f3ce44e5cf84cc22111530961f4d2a15f9 # v1.7.0 if: steps.playwright-version.outputs.version != '' with: - path: | - ~/.cache/ms-playwright - # Bump the iteration when bumping runner images to use a new cache - key: playwright-${{ runner.os }}-iteration-1-${{ steps.playwright-version.outputs.version }} + cache: | + playwright + apt - # We always install all browsers, if uncached - # `npx` runs outside the repo, so it can't pick up a `playwright` that a parallel `pnpm install` is still linking. - - name: Install Playwright dependencies (uncached) - env: - PLAYWRIGHT_VERSION: ${{ steps.playwright-version.outputs.version }} - run: npx -y "playwright@$PLAYWRIGHT_VERSION" install chromium webkit firefox --with-deps - if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit != 'true' - shell: bash - working-directory: ${{ runner.temp }} - - - name: Install Playwright system dependencies only (cached) + # Downloads only the browsers and apt packages that are not on the cache volume yet. `npx` runs outside the repo, + # so it can't pick up a `playwright` that a parallel `pnpm install` is still linking. + - name: Install Playwright + if: steps.playwright-version.outputs.version != '' env: PLAYWRIGHT_BROWSERS: ${{ inputs.browsers || 'chromium webkit firefox' }} PLAYWRIGHT_VERSION: ${{ steps.playwright-version.outputs.version }} - run: npx -y "playwright@$PLAYWRIGHT_VERSION" install-deps $PLAYWRIGHT_BROWSERS - if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit == 'true' + run: npx -y "playwright@$PLAYWRIGHT_VERSION" install --with-deps $PLAYWRIGHT_BROWSERS shell: bash working-directory: ${{ runner.temp }} - - # Only store cache on develop branch - - name: Store cached playwright binaries - uses: actions/cache/save@v6.1.0 - if: - steps.playwright-version.outputs.version != '' && github.event_name == 'push' && github.ref == - 'refs/heads/develop' - with: - path: | - ~/.cache/ms-playwright - # Bump the iteration when bumping runner images to use a new cache - key: playwright-${{ runner.os }}-iteration-1-${{ steps.playwright-version.outputs.version }} diff --git a/.github/actions/restore-cache/action.yml b/.github/actions/restore-cache/action.yml index daa5943f9877..c647c74405b9 100644 --- a/.github/actions/restore-cache/action.yml +++ b/.github/actions/restore-cache/action.yml @@ -1,30 +1,13 @@ name: 'Restore dependency & build cache' -description: 'Restore the dependency & build cache.' - -inputs: - dependency_cache_key: - description: 'The dependency cache key' - required: true +description: 'Installs dependencies from the Namespace cache volume and restores the build output.' runs: using: 'composite' steps: - - name: Install pnpm - uses: ./.github/actions/install-pnpm - - - name: Check dependency cache - id: dep-cache - uses: actions/cache/restore@v6.1.0 - with: - path: ${{ env.CACHED_DEPENDENCY_PATHS }} - key: ${{ inputs.dependency_cache_key }} - - name: Restore build artifacts uses: actions/download-artifact@v7 with: name: build-output - name: Install dependencies - if: steps.dep-cache.outputs.cache-hit != 'true' - run: pnpm install --frozen-lockfile - shell: bash + uses: ./.github/actions/install-dependencies diff --git a/.github/workflows/auto-fix-issue.yml b/.github/workflows/auto-fix-issue.yml index 559f255e3e98..8feb478c6edb 100644 --- a/.github/workflows/auto-fix-issue.yml +++ b/.github/workflows/auto-fix-issue.yml @@ -23,7 +23,7 @@ concurrency: jobs: auto-fix-issue: - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript environment: ci-triage permissions: # Required to create a new branch and commit the fix diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 1e79b8667dce..38802d1c0ad6 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -13,7 +13,7 @@ permissions: jobs: release: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small name: 'Prepare a new version' steps: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7502ea53b9ac..59962d0d401f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,14 +29,6 @@ concurrency: env: HEAD_COMMIT: ${{ github.event.inputs.commit || github.sha }} - CACHED_DEPENDENCY_PATHS: | - ${{ github.workspace }}/node_modules - ${{ github.workspace }}/packages/*/node_modules - ${{ github.workspace }}/dev-packages/*/node_modules - ~/.cache/mongodb-binaries/ - - # DEPENDENCY_CACHE_KEY: can't be set here because we don't have access to pnpm-lock.yaml - # build-output artifact paths are computed in job_build (step output from # pnpm run ci:print-build-artifact-paths — Nx merged outputs for build:transpile, and build:types). @@ -60,6 +52,8 @@ env: # This option both controls the `v8-compile-cache-lib` and `v8-compile-cache` packages. DISABLE_V8_COMPILE_CACHE: '1' +# All jobs share one Namespace cache volume through `overrides.cache-tag`. Pull request jobs get their own volume +# (`-pr`), so they cannot change the node_modules that develop and release builds install from. jobs: job_get_metadata: uses: ./.github/workflows/ci-metadata.yml @@ -72,7 +66,9 @@ jobs: job_build: name: Build needs: job_get_metadata - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 15 if: | needs.job_get_metadata.outputs.changed_any_code == 'true' || @@ -98,7 +94,6 @@ jobs: - name: Install Dependencies uses: ./.github/actions/install-dependencies - id: install_dependencies - name: Check for Affected Nx Projects uses: ./.github/actions/nx-affected-list @@ -156,7 +151,6 @@ jobs: working-directory: dev-packages/e2e-tests outputs: - dependency_cache_key: ${{ steps.install_dependencies.outputs.cache_key }} changed_node_integration: ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, '@sentry-internal/node-integration-tests') }} @@ -197,7 +191,9 @@ jobs: needs.job_build.outputs.changed_aws_serverless == 'true' || contains(needs.job_build.outputs.e2e-matrix, 'aws-serverless') || github.event_name != 'pull_request' timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -217,8 +213,6 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Build Lambda layer run: pnpm run build:layer @@ -236,7 +230,9 @@ jobs: name: Build bundles needs: [job_get_metadata, job_build] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -256,8 +252,6 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Build bundles run: pnpm run build:bundle @@ -274,7 +268,9 @@ jobs: job_check_branches: name: Check PR branches needs: job_get_metadata - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-small;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} if: github.event_name == 'pull_request' permissions: pull-requests: write @@ -290,7 +286,9 @@ jobs: name: Size Check needs: [job_get_metadata, job_build, job_build_bundles] timeout-minutes: 15 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} if: github.event_name == 'pull_request' || needs.job_get_metadata.outputs.is_base_branch == 'true' || needs.job_get_metadata.outputs.is_release == 'true' @@ -305,8 +303,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore build bundle artifacts uses: actions/download-artifact@v8 with: @@ -325,7 +321,9 @@ jobs: # inter-package dependencies resolve cleanly. needs: [job_get_metadata, job_build] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -337,8 +335,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - parallel: - name: Lint source files run: pnpm run lint @@ -358,7 +354,9 @@ jobs: name: Check lockfile needs: [job_get_metadata] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} if: | needs.job_get_metadata.outputs.changed_deps == 'true' || needs.job_get_metadata.outputs.is_base_branch == 'true' || @@ -389,7 +387,9 @@ jobs: name: Check file formatting needs: [job_get_metadata] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -403,7 +403,6 @@ jobs: - name: Install Dependencies uses: ./.github/actions/install-dependencies - id: install_dependencies - name: Check file formatting run: pnpm run format:check @@ -412,7 +411,9 @@ jobs: name: Circular Dependency Check needs: [job_get_metadata, job_build] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -424,15 +425,15 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run madge run: pnpm run circularDepCheck job_artifacts: name: Upload Artifacts needs: [job_get_metadata, job_build, job_build_layer, job_build_bundles, job_build_tarballs] - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} # Build artifacts are only needed for releasing workflow. if: needs.job_get_metadata.outputs.is_release == 'true' steps: @@ -446,8 +447,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore tarball artifacts uses: actions/download-artifact@v8 @@ -484,7 +483,9 @@ jobs: name: Browser Unit Tests needs: [job_get_metadata, job_build] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) uses: actions/checkout@v7 @@ -502,8 +503,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run affected tests run: pnpm run test:pr:browser --base=${{ github.event.pull_request.base.sha }} @@ -518,7 +517,9 @@ jobs: needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_bun == 'true' || github.event_name != 'pull_request' timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} strategy: fail-fast: false matrix: @@ -539,8 +540,6 @@ jobs: bun-version: ${{ matrix.bun }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run tests run: | pnpm run test:ci:bun @@ -550,7 +549,9 @@ jobs: needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_deno == 'true' || github.event_name != 'pull_request' timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -566,8 +567,6 @@ jobs: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run tests run: | cd packages/deno @@ -578,7 +577,9 @@ jobs: name: Node (${{ matrix.node }}) Unit Tests needs: [job_get_metadata, job_build] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} strategy: fail-fast: false matrix: @@ -599,8 +600,6 @@ jobs: node-version: ${{ matrix.node }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run affected tests run: pnpm run test:pr:node --base=${{ github.event.pull_request.base.sha }} @@ -620,7 +619,9 @@ jobs: matrix.project) || ''}}${{ matrix.shard && format(' ({0}/{1})', matrix.shard, matrix.shards) || ''}} Tests needs: [job_get_metadata, job_build, job_build_bundles] if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04-large-js + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 25 strategy: fail-fast: false @@ -680,8 +681,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore build bundle artifacts uses: actions/download-artifact@v8 @@ -718,7 +717,9 @@ jobs: name: PW ${{ matrix.bundle }} Tests needs: [job_get_metadata, job_build, job_build_bundles] if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} timeout-minutes: 15 strategy: fail-fast: false @@ -743,8 +744,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore build bundle artifacts uses: actions/download-artifact@v8 @@ -777,7 +776,9 @@ jobs: job_check_for_faulty_dts: name: Check for faulty .d.ts files needs: [job_get_metadata, job_build] - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} timeout-minutes: 5 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) @@ -790,8 +791,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Check for dts files that reference stuff in the temporary build folder run: | if grep -r --include "*.d.ts" --exclude-dir ".nx" 'import("@sentry(-internal)?/[^/]*/build' .; then @@ -805,7 +804,9 @@ jobs: Tests (${{ matrix.shard }}/3) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_node_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 20 strategy: fail-fast: false @@ -849,8 +850,6 @@ jobs: node-version: ${{ matrix.node }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Overwrite typescript version if: matrix.typescript == '5.0' @@ -865,7 +864,9 @@ jobs: name: Cloudflare Integration Tests (${{ matrix.shard }}/2) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_cloudflare_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} timeout-minutes: 15 strategy: fail-fast: false @@ -882,8 +883,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run integration tests working-directory: dev-packages/cloudflare-integration-tests @@ -893,7 +892,9 @@ jobs: name: Bundler Plugin Integration Tests (Node ${{ matrix.node }}) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_bundler_plugin_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 15 strategy: fail-fast: false @@ -910,8 +911,6 @@ jobs: node-version: ${{ matrix.node }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run integration tests working-directory: dev-packages/bundler-plugin-integration-tests @@ -921,7 +920,9 @@ jobs: name: Bun (${{ matrix.bun }}) Integration Tests (${{ matrix.shard }}/2) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_bun_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 15 strategy: fail-fast: false @@ -944,8 +945,6 @@ jobs: bun-version: ${{ matrix.bun }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run integration tests working-directory: dev-packages/bun-integration-tests @@ -955,7 +954,9 @@ jobs: name: Deno Integration Tests (${{ matrix.shard }}/2) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_deno_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 15 strategy: fail-fast: false @@ -976,8 +977,6 @@ jobs: deno-version: 'v2.8.3' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Build @sentry/deno working-directory: packages/deno run: pnpm run build @@ -1000,7 +999,9 @@ jobs: always() && needs.job_build.result == 'success' needs: [job_get_metadata, job_build] - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 15 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) @@ -1021,8 +1022,6 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Build tarballs run: pnpm run build:tarball @@ -1054,7 +1053,9 @@ jobs: if: always() && needs.job_build_tarballs.result == 'success' && needs.job_build.outputs.e2e-matrix !='{"include":[]}' needs: [job_get_metadata, job_build, job_build_layer, job_build_tarballs] - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} timeout-minutes: 15 env: # We just use a dummy DSN here, only send to the tunnel anyhow @@ -1101,6 +1102,11 @@ jobs: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} - name: Install pnpm uses: ./.github/actions/install-pnpm + # Keeps the pnpm store on the cache volume for the `pnpm install` of the test application + - name: Mount pnpm store + uses: namespacelabs/nscloud-cache-action@1124a6f3ce44e5cf84cc22111530961f4d2a15f9 # v1.7.0 + with: + cache: pnpm - name: Restore tarball artifacts uses: actions/download-artifact@v8 @@ -1195,7 +1201,9 @@ jobs: needs.job_build.outputs.e2e-matrix-optional != '{"include":[]}' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]' needs: [job_get_metadata, job_build, job_build_tarballs] - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} timeout-minutes: 15 env: E2E_TEST_AUTH_TOKEN: ${{ secrets.E2E_TEST_AUTH_TOKEN }} @@ -1245,6 +1253,11 @@ jobs: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} - name: Install pnpm uses: ./.github/actions/install-pnpm + # Keeps the pnpm store on the cache volume for the `pnpm install` of the test application + - name: Mount pnpm store + uses: namespacelabs/nscloud-cache-action@1124a6f3ce44e5cf84cc22111530961f4d2a15f9 # v1.7.0 + with: + cache: pnpm - name: Restore tarball artifacts uses: actions/download-artifact@v8 @@ -1330,7 +1343,9 @@ jobs: ] # Always run this, even if a dependent job failed if: always() - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-small;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} permissions: {} steps: - name: Check for failures diff --git a/.github/workflows/bump-size-limits.yml b/.github/workflows/bump-size-limits.yml index e6ee2195ab4b..0e4c3d80d7b7 100644 --- a/.github/workflows/bump-size-limits.yml +++ b/.github/workflows/bump-size-limits.yml @@ -10,13 +10,6 @@ permissions: pull-requests: write issues: write -env: - CACHED_DEPENDENCY_PATHS: | - ${{ github.workspace }}/node_modules - ${{ github.workspace }}/packages/*/node_modules - ${{ github.workspace }}/dev-packages/*/node_modules - ~/.cache/mongodb-binaries/ - concurrency: group: bump-size-limits cancel-in-progress: false @@ -24,7 +17,7 @@ concurrency: jobs: bump: name: Bump size-limit thresholds - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-large timeout-minutes: 25 steps: - name: Generate GitHub App token diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 3db23de94844..45d9191abae5 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -28,7 +28,7 @@ permissions: jobs: job_e2e_prepare: name: Prepare E2E Canary tests - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-large timeout-minutes: 30 steps: - name: Check out current commit @@ -57,7 +57,7 @@ jobs: job_e2e_tests: name: E2E ${{ matrix.label }} Test needs: [job_e2e_prepare] - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript timeout-minutes: 20 env: # We just use a dummy DSN here, only send to the tunnel anyhow diff --git a/.github/workflows/ci-metadata.yml b/.github/workflows/ci-metadata.yml index fde346820f10..ed114f394a19 100644 --- a/.github/workflows/ci-metadata.yml +++ b/.github/workflows/ci-metadata.yml @@ -38,7 +38,7 @@ on: jobs: get_metadata: name: Get Metadata - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small permissions: pull-requests: read steps: diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml index 69aae225e408..917de52e997d 100644 --- a/.github/workflows/cleanup-e2e-workers.yml +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -8,7 +8,7 @@ jobs: cleanup: # The optional E2E job deploys only for PRs from this repository, so forks never have a worker to delete. if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small permissions: {} timeout-minutes: 5 env: diff --git a/.github/workflows/cleanup-pr-caches.yml b/.github/workflows/cleanup-pr-caches.yml index e4543e524c91..4b7dbb4ee476 100644 --- a/.github/workflows/cleanup-pr-caches.yml +++ b/.github/workflows/cleanup-pr-caches.yml @@ -6,7 +6,7 @@ on: jobs: cleanup: - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small permissions: # `actions:write` permission is required to delete caches # See also: https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28#delete-a-github-actions-cache-for-a-repository-using-a-cache-id diff --git a/.github/workflows/clear-cache.yml b/.github/workflows/clear-cache.yml index 750b39305d1f..28dba37b8d7c 100644 --- a/.github/workflows/clear-cache.yml +++ b/.github/workflows/clear-cache.yml @@ -21,7 +21,7 @@ on: jobs: clear-caches: name: Delete all caches - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4e41c7f5d145..79b9bb42a677 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,7 @@ concurrency: jobs: analyze: name: Analyze - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-large # Skip for pushes from dependabot, which is not supported if: github.event_name == 'pull_request' || github.actor != 'dependabot[bot]' diff --git a/.github/workflows/dependabot-auto-triage.yml b/.github/workflows/dependabot-auto-triage.yml index c0298ccee87d..56ebdfe42f2f 100644 --- a/.github/workflows/dependabot-auto-triage.yml +++ b/.github/workflows/dependabot-auto-triage.yml @@ -35,11 +35,6 @@ env: FIX_CAP_DEV: '3' # ⬇️ debatable but let's start off with this PR_REVIEWERS: 'getsentry/team-javascript-sdks-framework' - CACHED_DEPENDENCY_PATHS: | - ${{ github.workspace }}/node_modules - ${{ github.workspace }}/packages/*/node_modules - ${{ github.workspace }}/dev-packages/*/node_modules - ~/.cache/mongodb-binaries/ concurrency: group: dependabot-auto-triage @@ -48,7 +43,7 @@ concurrency: jobs: classify: name: Classify alerts & auto-dismiss noise - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript timeout-minutes: 20 outputs: runtime_alerts: ${{ steps.select.outputs.runtime_alerts }} @@ -129,7 +124,7 @@ jobs: if: (github.event_name == 'schedule' || github.event.inputs.mode == 'full') && needs.classify.outputs.runtime_alerts != '' - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript timeout-minutes: 40 environment: ci-triage steps: @@ -218,7 +213,7 @@ jobs: needs: classify if: (github.event_name == 'schedule' || github.event.inputs.mode == 'full') && needs.classify.outputs.dev_alerts != '' - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript timeout-minutes: 40 environment: ci-triage steps: diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 5942bdb0c355..e748668b92db 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -19,7 +19,7 @@ on: jobs: enforce-license-compliance: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript steps: - name: 'Enforce License Compliance' uses: getsentry/action-enforce-license-compliance@main diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml index 1b131781dc05..6e3a3c5aff8c 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -12,7 +12,7 @@ jobs: permissions: pull-requests: write contents: write - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript if: | github.event.pull_request.merged == true && github.event.pull_request.author_association != 'COLLABORATOR' diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index b95132dbb488..fbde86cf1a0f 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -18,7 +18,7 @@ concurrency: jobs: flaky-detector: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-large timeout-minutes: 60 name: 'Check tests for flakiness' # Also skip if PR is from master -> develop diff --git a/.github/workflows/gitflow-merge-conflict.yml b/.github/workflows/gitflow-merge-conflict.yml index 102d05b6d03b..6195a73350d5 100644 --- a/.github/workflows/gitflow-merge-conflict.yml +++ b/.github/workflows/gitflow-merge-conflict.yml @@ -9,7 +9,7 @@ on: jobs: check-merge-conflicts: name: Detect merge conflicts in gitflow PRs - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small if: | ${{ contains(github.event.pull_request.labels.*.name, 'Dev: Gitflow') }} permissions: diff --git a/.github/workflows/gitflow-sync-develop.yml b/.github/workflows/gitflow-sync-develop.yml index 20f8b335fd0f..21d3c45dc1d0 100644 --- a/.github/workflows/gitflow-sync-develop.yml +++ b/.github/workflows/gitflow-sync-develop.yml @@ -17,7 +17,7 @@ env: jobs: main: name: Create PR master->develop - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small permissions: pull-requests: write contents: write diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index 8ac1c7987801..c855066f318f 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -7,7 +7,7 @@ on: jobs: add_labels: name: Add package label - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small if: ${{ !github.event.issue.pull_request }} steps: - name: Get used package from issue body diff --git a/.github/workflows/label-external-prs.yml b/.github/workflows/label-external-prs.yml index e6a73aaae5a7..6caf6fae7387 100644 --- a/.github/workflows/label-external-prs.yml +++ b/.github/workflows/label-external-prs.yml @@ -9,7 +9,7 @@ jobs: name: Add external label permissions: pull-requests: write - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small if: | github.event.pull_request.author_association != 'COLLABORATOR' && github.event.pull_request.author_association != 'MEMBER' diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index baa9f4838f23..1073b0c17b51 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -11,7 +11,7 @@ on: jobs: build-and-upload: name: Bundle and upload Lighthouse cells - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-large timeout-minutes: 30 env: LIGHTHOUSE_LAB_URL: ${{ secrets.LIGHTHOUSE_LAB_URL }} diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml index 457430fc957c..e601f859d2c4 100644 --- a/.github/workflows/pr-review-reminder.yml +++ b/.github/workflows/pr-review-reminder.yml @@ -22,7 +22,7 @@ jobs: remind-reviewers: # `schedule` has no `repository` on github.event; forks must be skipped only for workflow_dispatch. if: github.event_name == 'schedule' || github.event.repository.fork != true - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small steps: - name: Checkout repository uses: actions/checkout@v7 diff --git a/.github/workflows/pr-stale-notifier.yml b/.github/workflows/pr-stale-notifier.yml index 13bba260a0b0..3c4816dafebe 100644 --- a/.github/workflows/pr-stale-notifier.yml +++ b/.github/workflows/pr-stale-notifier.yml @@ -15,7 +15,7 @@ concurrency: jobs: stale: - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small steps: - name: Warn and close stale PRs uses: actions/stale@v11 diff --git a/.github/workflows/release-comment-issues.yml b/.github/workflows/release-comment-issues.yml index dfb782b1b6d8..88055af892c8 100644 --- a/.github/workflows/release-comment-issues.yml +++ b/.github/workflows/release-comment-issues.yml @@ -12,7 +12,7 @@ on: # This workflow is triggered when a release is published jobs: release-comment-issues: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small name: 'Notify issues' steps: - name: Get version diff --git a/.github/workflows/release-size-info.yml b/.github/workflows/release-size-info.yml index a1f75303d1ff..591a14b6b553 100644 --- a/.github/workflows/release-size-info.yml +++ b/.github/workflows/release-size-info.yml @@ -13,7 +13,7 @@ on: # It fetches the size-limit info from the release branch and adds it to the release jobs: release-size-info: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small name: 'Add size-limit info to release' steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3924c5bf2a2d..e579c239dcfe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ permissions: jobs: release: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small name: 'Release a new version' steps: - name: Get auth token diff --git a/.github/workflows/track-framework-updates.yml b/.github/workflows/track-framework-updates.yml index 0a74bad0b923..3e0172884e7f 100644 --- a/.github/workflows/track-framework-updates.yml +++ b/.github/workflows/track-framework-updates.yml @@ -18,7 +18,7 @@ concurrency: jobs: track-updates: - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small environment: ci-triage permissions: contents: read diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 9e29641264dc..f3c9f955547f 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -6,7 +6,7 @@ on: jobs: validate-pr: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small permissions: pull-requests: write steps: diff --git a/.github/workflows/weekly-flaky-tests.yml b/.github/workflows/weekly-flaky-tests.yml index 5c99a4b5f93f..bb0e5b3866c9 100644 --- a/.github/workflows/weekly-flaky-tests.yml +++ b/.github/workflows/weekly-flaky-tests.yml @@ -18,7 +18,7 @@ concurrency: jobs: report: name: Weekly flaky test report - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small timeout-minutes: 20 steps: - uses: actions/checkout@v7 diff --git a/scripts/dependency-hash-key.js b/scripts/dependency-hash-key.js deleted file mode 100644 index 9af70a756561..000000000000 --- a/scripts/dependency-hash-key.js +++ /dev/null @@ -1,75 +0,0 @@ -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); - -/** - * Build a cache key for the dependencies of the monorepo. - * In addition to the content of the pnpm-lock.yaml and pnpm-workspace.yaml files, we also include - * dependencies of all workspace packages in the cache key. - * This ensures that we get a consistent cache key even if a dependency change does not affect - * the pnpm-lock.yaml file. - */ -function outputDependencyCacheKey() { - const lockfileContent = fs.readFileSync(path.join(process.cwd(), 'pnpm-lock.yaml'), 'utf8'); - - const workspaceContent = fs.readFileSync(path.join(process.cwd(), 'pnpm-workspace.yaml'), 'utf8'); - - const hashParts = [lockfileContent, workspaceContent]; - - const packageJson = require(path.join(process.cwd(), 'package.json')); - - const workspacePackages = packageJson.workspaces || []; - - // Get the package name (e.g. @sentry/browser) of all workspace packages - // we want to ignore their version numbers later - const workspacePackageNames = getWorkspacePackageNames(workspacePackages); - - // Add the dependencies of the workspace itself - hashParts.push(getNormalizedDependencies(packageJson, workspacePackageNames)); - - // Now for each workspace package, add the dependencies - workspacePackages.forEach(workspace => { - const packageJsonPath = path.join(process.cwd(), workspace, 'package.json'); - const packageJson = require(packageJsonPath); - hashParts.push(getNormalizedDependencies(packageJson, workspacePackageNames)); - }); - - const hash = crypto.createHash('md5').update(hashParts.join('\n')).digest('hex'); - // We log the output in a way that the GitHub Actions can append it to the output - // We prefix it with `dependencies-` so it is easier to identify in the logs - // eslint-disable-next-line no-console - console.log(`hash=dependencies-${hash}`); -} - -function getNormalizedDependencies(packageJson, workspacePackageNames) { - const { dependencies, devDependencies } = packageJson; - - const mergedDependencies = { - ...devDependencies, - ...dependencies, - }; - - const normalizedDependencies = {}; - - // Sort the keys to ensure a consistent order - Object.keys(mergedDependencies) - .sort() - .forEach(key => { - // If the dependency is a workspace package, ignore the version - // No need to invalidate a cache after every release - const version = workspacePackageNames.includes(key) ? '**workspace**' : mergedDependencies[key]; - normalizedDependencies[key] = version; - }); - - return JSON.stringify(normalizedDependencies); -} - -function getWorkspacePackageNames(workspacePackages) { - return workspacePackages.map(workspace => { - const packageJsonPath = path.join(process.cwd(), workspace, 'package.json'); - const packageJson = require(packageJsonPath); - return packageJson.name; - }); -} - -outputDependencyCacheKey(); From b151d07337a2000f46d3f4a0d3d22371e846bb33 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:59:38 +0200 Subject: [PATCH 20/30] feat(nextjs): Send tunnel route requests with `credentials: 'omit'` (#25509) The tunnel route is same-origin, so the browser transport includes credentials (cookies, HTTP authentication) with each request, and the tunnel route does not need them. With `tunnelRoute`, `@sentry/nextjs` now sets `credentials: 'omit'` on the transport. Apps with a layer in front that checks credentials (for example Vercel Deployment Protection) can opt back in: ```js // instrumentation-client.ts Sentry.init({ transportOptions: { fetchOptions: { credentials: 'same-origin' } }, }); ``` Fixes JS-3927 --- CHANGELOG.md | 12 +++++ .../tests/tunnel-route.test.ts | 32 +++++++++++++ packages/nextjs/src/client/tunnelRoute.ts | 6 +++ packages/nextjs/src/config/types.ts | 8 +++- .../nextjs/test/utils/tunnelRoute.test.ts | 47 +++++++++++++++++++ 5 files changed, 103 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8f555ae436..6f76928974c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,18 @@ Also, the server no longer sends spans of tunnel requests when span streaming is on, because these spans only show the SDK's own traffic. +- **feat(nextjs): Send tunnel route requests with `credentials: 'omit'` ([#25509](https://github.com/getsentry/sentry-javascript/pull/25509))** + + With `tunnelRoute` and no tunnel route handler, `@sentry/nextjs` now sends events from the browser with `credentials: 'omit'`, because the tunnel route does not need cookies. + If a layer in front of your app checks cookies before it lets requests through (for example Vercel Deployment Protection), you can send them again in your browser `Sentry.init` call: + + ```js + // instrumentation-client.ts + Sentry.init({ + transportOptions: { fetchOptions: { credentials: 'same-origin' } }, + }); + ``` + Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions! - **feat(deno): Add incoming request options to `denoServeIntegration`** diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts index 224ed9d7556d..e0c1b5e273ae 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -122,6 +122,38 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag expect(sentryFetchSpans).toHaveLength(0); }); +test('Tunnel requests are sent without the cookies of the browser', async ({ page, context, baseURL }) => { + test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler', 'The handler variants send the cookies'); + await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]); + const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) => + envelope[1].some( + ([itemHeader, item]) => + itemHeader.type === 'event' && (item as Event).exception?.values?.[0]?.value === 'Tunnel cookie test', + ), + ); + + await page.goto('/'); + await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object'); + const tunnelRequestPromise = page.waitForRequest( + request => + request.method() === 'POST' && + new URL(request.url()).origin === baseURL && + (request.postData() ?? '').includes('Tunnel cookie test'), + ); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Tunnel cookie test'); + }); + }); + + const tunnelRequest = await tunnelRequestPromise; + const proxyRequest = await proxyRequestPromise; + + expect(await page.evaluate(() => document.cookie)).toBe('session=8f2c1d'); + expect((await tunnelRequest.allHeaders()).cookie).toBeUndefined(); + expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); +}); + test.describe('Tunnel route handler', () => { test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'handler', 'Only the handler variants use the handler'); diff --git a/packages/nextjs/src/client/tunnelRoute.ts b/packages/nextjs/src/client/tunnelRoute.ts index ba82260e17df..72f2855e32ae 100644 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ b/packages/nextjs/src/client/tunnelRoute.ts @@ -36,6 +36,12 @@ export function applyTunnelRouteOption(options: BrowserOptions): void { tunnelPath += `&r=${regionCode}`; } options.tunnel = tunnelPath; + // The tunnel route is same-origin, so fetch would include credentials (cookies, HTTP authentication) that the + // tunnel does not need. + options.transportOptions = { + ...options.transportOptions, + fetchOptions: { credentials: 'omit', ...options.transportOptions?.fetchOptions }, + }; DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelPath}"`); } else { DEBUG_BUILD && debug.warn('Provided DSN is not a Sentry SaaS DSN. Will not tunnel events.'); diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 6c9f725a8e49..4e1e6bc2d9e3 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -272,8 +272,8 @@ export type SentryBuildOptions = Omit< * - Pass `true` to auto-generate a random, ad-blocker-resistant route for each build * - Pass a string path (e.g., '/monitoring') to use a custom route * - * By default, the tunnel route proxies each request to Sentry with all its headers, cookies included. To forward - * only the event payload, create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route + * By default, the tunnel route proxies each request to Sentry with its headers. To forward only the event payload, + * create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route * group) with: * * ```ts @@ -287,6 +287,10 @@ export type SentryBuildOptions = Omit< * middleware redirects or blocks unauthenticated requests, exclude the tunnel route in its `matcher` so events * can reach Sentry. Matchers have to be static, so use a fixed string route in that case rather than `true`. * + * Without the route handler, the browser sends tunnel requests with `credentials: 'omit'`, so without cookies or HTTP + * authentication. If a layer in front of your app needs them (for example Vercel Deployment Protection or HTTP Basic authentication), set + * `transportOptions: { fetchOptions: { credentials: 'same-origin' } }` in `Sentry.init` of `instrumentation-client.ts`. + * * NOTE: This feature only works with Next.js 11+ */ tunnelRoute?: string | boolean; diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index b901826d3f13..30dd3f840ba3 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -122,6 +122,53 @@ describe('Random tunnel route generation', () => { }); }); +describe('applyTunnelRouteOption() credentials', () => { + const SAAS_DSN = 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333'; + + beforeEach(() => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; + }); + + it("sends tunnel requests with `credentials: 'omit'`", () => { + const options: BrowserOptions = { dsn: SAAS_DSN }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toEqual({ fetchOptions: { credentials: 'omit' } }); + }); + + it('keeps the fetch options of the user', () => { + const options: BrowserOptions = { + dsn: SAAS_DSN, + transportOptions: { bufferSize: 10, fetchOptions: { credentials: 'same-origin', priority: 'low' } }, + }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toEqual({ + bufferSize: 10, + fetchOptions: { credentials: 'same-origin', priority: 'low' }, + }); + }); + + it('does not change the transport options when the events are not tunneled', () => { + const options: BrowserOptions = { dsn: 'https://11111111111111111111111111111111@example.com/3333333' }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toBeUndefined(); + }); + + it('does not change the transport options in handler mode', () => { + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + const options: BrowserOptions = { dsn: SAAS_DSN }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toBeUndefined(); + }); +}); + describe('applyTunnelRouteOption() in handler mode', () => { it.each([ ['a SaaS DSN', 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333'], From eb607836fe441c44c0f8a9cb14381ae0f2fecb92 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:19:26 +0200 Subject: [PATCH 21/30] fix(solidstart): Read server action route from the request's root span (#25513) This fixes the `E2E solidstart-2` failure on `develop`. Since #25295, the "sends a server action span on client navigation" test times out because the `POST getPrefecture` segment is never sent. _Root cause:_ After #25295, Nitro's h3 route handler opens a `handler` span instead of a nested `http.server` span. `withServerActionInstrumentation` decided whether to rename the segment by reading the attributes of the *active* span. Now that span is the `handler` span, which has no `url.path`, so the `/_server` check never matched and the server action name was never applied. The fix reads the attributes from the root span instead. That is the request's `http.server` span, the one `setHttpServerSpanRouteAttribute` renames, so the check and the rename now target the same span whatever the framework nests in between. Locally, `solidstart-2` E2E fails 1/9 (this test) without the change and passes 9/9 with it. via **Francesco Gringl-Novy**. -- [View Junior Session](https://junior-prod.sentry.dev/conversations/agent-dispatch%3Adispatch_6234d76cd131dcc5f1925dab1ce334ad) [[Sentry]](https://sentry.sentry.io/explore/conversations/agent-dispatch%3Adispatch_6234d76cd131dcc5f1925dab1ce334ad/?project=4510944073809921) Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Francesco Gringl-Novy --- .../server/withServerActionInstrumentation.ts | 6 +++-- .../withServerActionInstrumentation.test.ts | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/solidstart/src/server/withServerActionInstrumentation.ts b/packages/solidstart/src/server/withServerActionInstrumentation.ts index 785ab1432f91..68d4ff5221c7 100644 --- a/packages/solidstart/src/server/withServerActionInstrumentation.ts +++ b/packages/solidstart/src/server/withServerActionInstrumentation.ts @@ -1,6 +1,6 @@ import { handleCallbackErrors, SPAN_STATUS_ERROR } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; -import { captureException, getActiveSpan, spanToJSON, startSpan } from '@sentry/node'; +import { captureException, getActiveSpan, getRootSpan, spanToJSON, startSpan } from '@sentry/node'; import { isRedirect } from './utils'; import { SENTRY_SEGMENT_NAME_SOURCE, @@ -25,7 +25,9 @@ export async function withServerActionInstrumentation { expect(mockSpanSetAttribute).to.toHaveBeenCalledWith(SENTRY_SEGMENT_NAME_SOURCE, 'route'); }); + it('sets a server action name on the root span when the active span is a nested handler span', async () => { + const rootSpan = new SentryCore.SentrySpan({ + attributes: { + 'url.path': '/_server', + [SENTRY_OP]: 'http.server', + }, + }); + const handlerSpan = new SentryCore.SentrySpan({ + attributes: { + [SENTRY_OP]: 'handler', + }, + }); + SentryCore.addChildSpanToSpan(rootSpan, handlerSpan); + mockGetActiveSpan.mockReturnValue(handlerSpan); + const mockRootSpanSetAttribute = vi.spyOn(rootSpan, 'setAttribute'); + + await serverActionGetPrefecture(); + + expect(mockRootSpanSetAttribute).toHaveBeenCalledWith('http.route', 'getPrefecture'); + expect(mockRootSpanSetAttribute).toHaveBeenCalledWith(SENTRY_SEGMENT_NAME_SOURCE, 'route'); + }); + it('does not set a server action name if the active span had a non `/_server` `url.path`', async () => { const span = new SentryCore.SentrySpan(); span.setAttribute('url.path', '/users/5'); From 60c12e82bbf7d22cfe55f049d007b7a9e792f6a5 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:22:01 +0200 Subject: [PATCH 22/30] feat(nextjs): Link `use cache` hits to their fill across instances (#25448) `use cache` hits get a `cache_origin` link to their fill. The SDK remembered fills per process. Previously, deployments on serverless (e.g. Vercel), another instance usually did the fill, so most hits got no link. The SDK now also stores the fill's `sentry-trace` value on the cache entry, so other instances find it. The per-process table stays as the fallback for handlers that drop unknown fields. - In a Vercel test, about 85% of `use cache: remote` hits came from another instance - With the locally built SDK on Vercel, all 148 logged hits linked to their fill (also across instances) Closes https://github.com/getsentry/sentry-javascript/issues/24296 Ref https://github.com/getsentry/sentry-javascript/issues/12888 [JS-3657](https://linear.app/getsentry/issue/JS-3657/decide-the-origin-transport-mechanism-cache-written-to-different) --- .../src/server/useCacheInstrumentation.ts | 93 ++++-- .../server/useCacheInstrumentation.test.ts | 299 ++++++++++++++---- 2 files changed, 307 insertions(+), 85 deletions(-) diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index e51e5e148e05..41901805affa 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { TraceFlags } from '@opentelemetry/api'; import { CACHE_HIT, CACHE_ITEM_AGE, @@ -16,6 +17,7 @@ import { CACHE_OPERATION_NAMES, debug, defineIntegration, + extractTraceparentData, fill, getActiveSpan, getClient, @@ -23,6 +25,7 @@ import { hasSpansEnabled, LRUMap, spanIsSampled, + spanToTraceHeader, startSpan, timestampInSeconds, } from '@sentry/core'; @@ -70,6 +73,11 @@ interface UseCacheEntry { expire?: number; /** `cacheTag()` tags, excluding implicit soft tags */ tags?: unknown[]; + /** + * Set by Sentry, not by Next.js. Holds the `sentry-trace` header of the fill's `cache.put` span. + * Handlers that keep unknown entry properties carry it to hits on other instances. + */ + __sentryCacheOrigin?: unknown; } type GlobalWithCacheHandlers = typeof globalThis & { @@ -171,8 +179,8 @@ function recordCacheEntry(span: Span, entry: unknown): boolean { return hit; } -// Cache origins are scoped per-process (known limitation: no links across server instances — that would require storing metadata on `CacheEntry`). -// Hits on entries filled by another instance get no link. Lives on `globalThis` like the rest of the instrumentation state. +// Fallback for handlers that drop unknown entry properties. Only a hit on the instance that did the +// fill can use it. Lives on `globalThis`, like the rest of the instrumentation state. function getCacheOrigins(): LRUMap { const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers; if (!globalWithCacheHandlers[SENTRY_CACHE_ORIGINS]) { @@ -182,19 +190,58 @@ function getCacheOrigins(): LRUMap { } /** - * Links a cache hit to the fill that wrote this entry revision (matched by timestamp). - * A hit with no matching origin (unsampled or dropped refill, another instance, process restart) - * gets no link instead of a stale one. + * Links a cache hit to the fill that wrote this entry revision. A hit whose origin is unknown gets + * no link, never a stale one. */ function linkCacheOrigin(span: Span, originKey: string, entry: unknown): void { + const context = getPatchedCacheOrigin(entry) ?? getRememberedCacheOrigin(originKey, entry); + if (context) { + span.addLink({ context, attributes: { [SENTRY_LINK_TYPE]: CACHE_ORIGIN_LINK_TYPE } }); + } +} + +/** + * Next.js writes an entry only after it runs the cached function, and it never writes back a hit. + * So a patched origin always belongs to the entry revision that carries it. Links point only at + * sampled traces. + */ +function getPatchedCacheOrigin(entry: unknown): SpanContextData | undefined { + const { __sentryCacheOrigin } = (entry ?? {}) as UseCacheEntry; + const origin = typeof __sentryCacheOrigin === 'string' ? extractTraceparentData(__sentryCacheOrigin) : undefined; + return origin?.traceId && origin.parentSpanId && origin.parentSampled + ? { traceId: origin.traceId, spanId: origin.parentSpanId, traceFlags: TraceFlags.SAMPLED } + : undefined; +} + +/** The table can outlive the entry revision it remembers, so the fill timestamp must match. */ +function getRememberedCacheOrigin(originKey: string, entry: unknown): SpanContextData | undefined { const origin = getCacheOrigins().get(originKey); const { timestamp } = (entry ?? {}) as UseCacheEntry; - if (origin && origin.entryTimestamp === timestamp) { - span.addLink({ - context: origin.context, - attributes: { [SENTRY_LINK_TYPE]: CACHE_ORIGIN_LINK_TYPE }, - }); - } + return origin && origin.entryTimestamp === timestamp ? origin.context : undefined; +} + +/** + * Handlers get the patched promise, not the original one. Some handlers copy the entry in their own + * `then`, which can run before a callback that is added later. + */ +function patchCacheOrigin(pendingEntry: Promise, origin: string): Promise { + const patchedEntry = Promise.resolve(pendingEntry).then(entry => { + if (typeof entry === 'object' && entry !== null) { + try { + // Patch in place, not on a copy. Handlers can replace `entry.value` on the object they get, + // and Next.js keeps a reference to that object. + (entry as UseCacheEntry).__sentryCacheOrigin = origin; + } catch (error) { + // Frozen or proxied entries reject new properties. Hits then fall back to the table. + DEBUG_BUILD && debug.warn('Failed to patch a Next.js cache fill origin', error); + } + } + return entry; + }); + // A handler can fail the write without reading the entry. A failed entry then has no other + // rejection handler, and Sentry must not cause an unhandled rejection. + patchedEntry.catch(() => undefined); + return patchedEntry; } /** @@ -285,17 +332,21 @@ function instrumentHandler(handler: unknown): void { // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this // span covers producing and storing the entry, not just the write. - return startCacheSpan(CACHE_PUT, digest, sourceFile ? { [CODE_FILE_PATH]: sourceFile } : {}, span => - // Only a successful write becomes a fill origin: a failed write leaves no entry or the - // previous one (whose origin still stands). A dropped span (`ignoreSpans`) never - // reaches Sentry, so a link to it would be broken. - Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { - if (span.isRecording()) { - rememberCacheOrigin(originKeyPrefix + digest, span, pendingEntry); - } + return startCacheSpan(CACHE_PUT, digest, sourceFile ? { [CODE_FILE_PATH]: sourceFile } : {}, span => { + // Read `isRecording()` before the write. The span ends with the write, and a handler can + // resolve the write before it reads the entry. A span dropped by `ignoreSpans` never reaches Sentry, so + // a link to it would be broken. + if (!span.isRecording()) { + return originalSet.call(this, cacheKey, pendingEntry); + } + const patchedEntry = patchCacheOrigin(pendingEntry, spanToTraceHeader(span)); + return Promise.resolve(originalSet.call(this, cacheKey, patchedEntry)).then(result => { + // Only a successful write becomes a fill origin. A failed write leaves no entry or the + // previous one, whose origin still applies. + rememberCacheOrigin(originKeyPrefix + digest, span, patchedEntry); return result; - }), - ); + }); + }); }; }); } catch (error) { diff --git a/packages/nextjs/test/server/useCacheInstrumentation.test.ts b/packages/nextjs/test/server/useCacheInstrumentation.test.ts index 587b57bf9820..325e28aef451 100644 --- a/packages/nextjs/test/server/useCacheInstrumentation.test.ts +++ b/packages/nextjs/test/server/useCacheInstrumentation.test.ts @@ -5,15 +5,34 @@ const mocks = vi.hoisted(() => { const setAttribute = vi.fn(); const addLink = vi.fn(); const state = { spanCount: 0, recording: true }; + // Valid hex ids, because the SDK parses the stored origin as a `sentry-trace` header. + const spanContextOf = (n: number) => ({ + traceId: `${n}`.padStart(32, 'a'), + spanId: `${n}`.padStart(16, 'b'), + traceFlags: 1, + }); return { setAttribute, addLink, state, + spanContextOf, startSpan: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => { const n = ++state.spanCount; - const spanContext = { traceId: `trace-${n}`, spanId: `span-${n}`, traceFlags: 1 }; + const spanContext = spanContextOf(n); const recording = state.recording; - return callback({ setAttribute, addLink, spanContext: () => spanContext, isRecording: () => recording }); + let ended = false; + const result = callback({ + setAttribute, + addLink, + spanContext: () => spanContext, + isRecording: () => recording && !ended, + }); + // The span ends when the callback's promise settles, as with `startSpan`. + void Promise.resolve(result).then( + () => (ended = true), + () => (ended = true), + ); + return result; }), activeSpan: undefined as object | undefined, sampled: true, @@ -45,14 +64,53 @@ function createHandler(entry?: unknown) { }; } +/** Keeps the entry object it gets, like Next.js' built-in in-memory handler. */ +function createMemoryHandler() { + let stored: unknown; + return { + get: vi.fn((_key: string) => Promise.resolve(stored)), + set: vi.fn(async (_key: string, pendingEntry: Promise) => { + stored = await pendingEntry; + }), + }; +} + +/** Stores the entry as JSON in a store that all instances share. */ +function createSharedStoreHandler() { + let stored: string | undefined; + return { + get: vi.fn((_key: string) => Promise.resolve(stored === undefined ? undefined : JSON.parse(stored))), + set: vi.fn(async (_key: string, pendingEntry: Promise) => { + stored = JSON.stringify(await pendingEntry); + }), + }; +} + +/** Stores only the entry fields it knows, so the patched origin is lost. */ +function createFieldPickingHandler(entry: { timestamp?: number; expire?: number }) { + return { + get: vi.fn((_key: string) => Promise.resolve({ timestamp: entry.timestamp, expire: entry.expire })), + set: vi.fn((_key: string, pendingEntry: Promise) => pendingEntry.then(() => undefined)), + }; +} + +function installHandlers(handlers: Record): void { + setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map(Object.entries(handlers))); + _instrumentUseCacheHandlers(); +} + +/** A hit served by another instance starts with an empty origin table. */ +function simulateNewServerInstance(): void { + Reflect.deleteProperty(globalThis, SENTRY_CACHE_ORIGINS); +} + function setGlobal(symbol: symbol, value: unknown): void { (globalThis as Record)[symbol] = value; } function installWithDefaultHandler(entry?: unknown) { const handler = createHandler(entry); - setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map([['default', handler]])); - _instrumentUseCacheHandlers(); + installHandlers({ default: handler }); return handler; } @@ -332,6 +390,11 @@ describe('instrumentUseCacheHandlers', () => { }); describe('origin links', () => { + const originLink = (n: number) => ({ + context: mocks.spanContextOf(n), + attributes: { 'sentry.link.type': 'cache_origin' }, + }); + it('links a cache hit to the `cache.put` span of the fill', async () => { const entry = { timestamp: nowMs() }; const handler = installWithDefaultHandler(entry); @@ -339,11 +402,74 @@ describe('instrumentUseCacheHandlers', () => { await handler.set('cache-key', Promise.resolve(entry)); await handler.get('cache-key'); - expect(mocks.addLink).toHaveBeenCalledTimes(1); - expect(mocks.addLink).toHaveBeenCalledWith({ - context: { traceId: 'trace-1', spanId: 'span-1', traceFlags: 1 }, - attributes: { 'sentry.link.type': 'cache_origin' }, - }); + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); + }); + + it('links a hit served by another instance from the origin stored on the entry', async () => { + const handler = createSharedStoreHandler(); + installHandlers({ remote: handler }); + + await handler.set('cache-key', Promise.resolve({ timestamp: nowMs() })); + simulateNewServerInstance(); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); + }); + + it('does not link a hit served by another instance after an unsampled refill', async () => { + const handler = createSharedStoreHandler(); + installHandlers({ remote: handler }); + + await handler.set('cache-key', Promise.resolve({ timestamp: nowMs() - 1_000 })); + mocks.sampled = false; + await handler.set('cache-key', Promise.resolve({ timestamp: nowMs() })); + mocks.sampled = true; + simulateNewServerInstance(); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('patches an entry that resolves after the write resolved', async () => { + // The handler resolves the write at once and stores the entry later, after the + // `cache.put` span has ended. + let stored: object | undefined; + const handler = { + get: vi.fn((_key: string) => Promise.resolve(stored)), + set: vi.fn((_key: string, pendingEntry: Promise) => { + void pendingEntry.then(entry => (stored = { ...entry })); + return Promise.resolve(); + }), + }; + installHandlers({ remote: handler }); + let resolveEntry: (entry: object) => void = () => undefined; + + await handler.set('cache-key', new Promise(resolve => (resolveEntry = resolve))); + resolveEntry({}); + await new Promise(resolve => setImmediate(resolve)); + simulateNewServerInstance(); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); + }); + + it('patches the entry before a handler that copies it in its own `then`', async () => { + let stored: object | undefined; + const handler = { + get: vi.fn((_key: string) => Promise.resolve(stored)), + set: vi.fn((_key: string, pendingEntry: Promise) => + pendingEntry.then(entry => { + stored = { ...entry }; + }), + ), + }; + installHandlers({ default: handler }); + + await handler.set('cache-key', Promise.resolve({})); + simulateNewServerInstance(); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); }); it('links to the most recent fill', async () => { @@ -354,9 +480,7 @@ describe('instrumentUseCacheHandlers', () => { await handler.set('cache-key', Promise.resolve(entry)); await handler.get('cache-key'); - expect(mocks.addLink).toHaveBeenCalledWith( - expect.objectContaining({ context: { traceId: 'trace-2', spanId: 'span-2', traceFlags: 1 } }), - ); + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(2)); }); it('does not link a miss', async () => { @@ -376,34 +500,13 @@ describe('instrumentUseCacheHandlers', () => { expect(mocks.addLink).not.toHaveBeenCalled(); }); - it('does not link a hit to a fill of the same cache key in a different handler', async () => { - const entry = { timestamp: nowMs() }; - const defaultHandler = createHandler(entry); - const remoteHandler = createHandler(entry); - setGlobal( - NEXT_CACHE_HANDLERS_MAP, - new Map([ - ['default', defaultHandler], - ['remote', remoteHandler], - ]), - ); - _instrumentUseCacheHandlers(); - - await defaultHandler.set('cache-key', Promise.resolve(entry)); - await remoteHandler.get('cache-key'); - - expect(mocks.addLink).not.toHaveBeenCalled(); - - await defaultHandler.get('cache-key'); - - expect(mocks.addLink).toHaveBeenCalledTimes(1); - }); - - it('does not link a hit that was filled under a different cache key', async () => { - const entry = { timestamp: nowMs() }; - const handler = installWithDefaultHandler(entry); + it.each([ + { name: 'not a string', storedOrigin: 42 }, + { name: 'malformed', storedOrigin: 'not-a-sentry-trace-header' }, + { name: 'an unsampled fill', storedOrigin: `${'a'.repeat(32)}-${'b'.repeat(16)}-0` }, + ])('does not link a hit whose stored origin is $name', async ({ storedOrigin }) => { + const handler = installWithDefaultHandler({ timestamp: nowMs(), __sentryCacheOrigin: storedOrigin }); - await handler.set('other-key', Promise.resolve(entry)); await handler.get('cache-key'); expect(mocks.addLink).not.toHaveBeenCalled(); @@ -440,7 +543,7 @@ describe('instrumentUseCacheHandlers', () => { expect(mocks.addLink).not.toHaveBeenCalled(); }); - it('does not link a hit on an entry refilled by another server instance', async () => { + it('does not link a hit on an entry refilled by an uninstrumented server instance', async () => { const entryFromOtherInstance = { timestamp: nowMs() }; const handler = installWithDefaultHandler(entryFromOtherInstance); @@ -450,39 +553,107 @@ describe('instrumentUseCacheHandlers', () => { expect(mocks.addLink).not.toHaveBeenCalled(); }); - it('does not link hits on entries that carry no fill timestamp', async () => { - const entry = { expire: 3_600 }; - const handler = installWithDefaultHandler(entry); + it('does not cause an unhandled rejection when the handler fails without reading a failed entry', async () => { + const handler = { get: vi.fn(), set: vi.fn(() => Promise.reject(new Error('store unavailable'))) }; + installHandlers({ default: handler }); + const onUnhandledRejection = vi.fn(); + process.on('unhandledRejection', onUnhandledRejection); - await handler.set('cache-key', Promise.resolve(entry)); - await handler.get('cache-key'); + try { + const failedEntry = Promise.reject(new Error('cached function failed')); + failedEntry.catch(() => undefined); + await expect(handler.set('cache-key', failedEntry)).rejects.toThrow('store unavailable'); + await new Promise(resolve => setImmediate(resolve)); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + } - expect(mocks.addLink).not.toHaveBeenCalled(); + expect(onUnhandledRejection).not.toHaveBeenCalled(); }); - it('does not remember fills whose entry rejected even though the write resolved', async () => { - // Custom handlers can swallow a failed entry and resolve the write anyway. - const handler = installWithDefaultHandler({ timestamp: nowMs() }); + describe('origin table fallback', () => { + it('links a hit on the filling instance when the handler drops the stored origin', async () => { + const entry = { timestamp: nowMs() }; + const handler = createFieldPickingHandler(entry); + installHandlers({ default: handler }); - await handler.set('cache-key', Promise.reject(new Error('entry failed'))); - await handler.get('cache-key'); + await handler.set('cache-key', Promise.resolve(entry)); + await handler.get('cache-key'); - expect(mocks.addLink).not.toHaveBeenCalled(); - }); + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); + }); - it('does not remember fills whose write failed', async () => { - const entry = { timestamp: nowMs() }; - const handler = { - get: vi.fn(() => Promise.resolve(entry)), - set: vi.fn(() => Promise.reject(new Error('write failed'))), - }; - setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map([['default', handler]])); - _instrumentUseCacheHandlers(); + it('still writes and links an entry that cannot be patched', async () => { + const handler = createMemoryHandler(); + installHandlers({ default: handler }); - await expect(handler.set('cache-key', Promise.resolve(entry))).rejects.toThrow('write failed'); - await handler.get('cache-key'); + await handler.set('cache-key', Promise.resolve(Object.freeze({ timestamp: nowMs() }))); + await handler.get('cache-key'); - expect(mocks.addLink).not.toHaveBeenCalled(); + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); + }); + + it('does not link a hit to a fill of the same cache key in a different handler', async () => { + const entry = { timestamp: nowMs() }; + const defaultHandler = createFieldPickingHandler(entry); + const remoteHandler = createFieldPickingHandler(entry); + installHandlers({ default: defaultHandler, remote: remoteHandler }); + + await defaultHandler.set('cache-key', Promise.resolve(entry)); + await remoteHandler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + + await defaultHandler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledTimes(1); + }); + + it('does not link a hit that was filled under a different cache key', async () => { + const entry = { timestamp: nowMs() }; + const handler = createFieldPickingHandler(entry); + installHandlers({ default: handler }); + + await handler.set('other-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose entry carries no fill timestamp', async () => { + const entry = { expire: 3_600 }; + const handler = createFieldPickingHandler(entry); + installHandlers({ default: handler }); + + await handler.set('cache-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose entry rejected even though the write resolved', async () => { + // Custom handlers can swallow a failed entry and resolve the write anyway. + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('cache-key', Promise.reject(new Error('entry failed'))); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose write failed', async () => { + const entry = { timestamp: nowMs() }; + const handler = { + get: vi.fn(() => Promise.resolve({ timestamp: entry.timestamp })), + set: vi.fn(() => Promise.reject(new Error('write failed'))), + }; + installHandlers({ default: handler }); + + await expect(handler.set('cache-key', Promise.resolve(entry))).rejects.toThrow('write failed'); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); }); }); From 62a794d9c92c77b17d885156949ac5f6cc7c0a3b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 20 Oct 2026 09:57:35 -0400 Subject: [PATCH 23/30] test(nuxt): Pin the nuxt-4 e2e MongoDB image to 8.0 (#25557) The nuxt-4 e2e jobs keep failing because the `mongo:8` container goes unhealthy about 10s after it starts, fixed by pinning the image to `mongo:8.0` and printing the container logs when setup fails so the next failure shows why. --- .../test-applications/nuxt-4/docker-compose.yml | 3 ++- .../test-applications/nuxt-4/global-setup.mjs | 13 +++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml index c87cae49aab6..bcb384119acb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml @@ -18,7 +18,8 @@ services: start_period: 10s mongo: - image: mongo:8 + # `mongo:8` floats to the newest 8.x release, which keeps failing its healthcheck on CI. + image: mongo:8.0 restart: always container_name: e2e-tests-nuxt-4-mongo ports: diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs index 4d0386045cb1..0eabcb30c905 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs @@ -7,8 +7,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default async function globalSetup() { // Start MySQL, MongoDB, and 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', - }); + try { + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); + } catch (error) { + execSync('docker compose logs --tail 50', { cwd: __dirname, stdio: 'inherit' }); + throw error; + } } From 2ad05287e310fe63200d7e1303ce196d8dbffb2b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 20 Oct 2026 16:06:44 +0200 Subject: [PATCH 24/30] feat(nextjs): Never emit trace meta tags from a prerender (#25141) Under `cacheComponents` the SDK did not enable `experimental.clientTraceMetadata` (#21141). A shell that Next.js prerenders during one request is cached and served to later visitors, and a `sentry-trace` tag inside it would attach all of them to that one request. ISR pages had the same problem, worked around on the client by removing the tags on routes with `generateStaticParams` (#18192). Next.js computes the trace tags of a document inside the `prerender route (app)` span when it prerenders, and outside of it when it renders for a request, including when it resumes a shell. The server SDK now marks that span on the OpenTelemetry context, and its propagator hands out nothing on a marked context. A prerendered shell never contains a trace, so any tag that reaches the browser belongs to the current request. The mark sits on the context rather than the span because an unsampled request makes the span non-recording, with no attributes to recognize it by, and a `sampled=0` tag in the shell would drop every later pageload. Checked in Next.js 15.0 through 16.4. With that, `clientTraceMetadata` is enabled again under `cacheComponents`, and the client-side ISR workaround and the `isrRoutes` manifest field are removed. The guard covers ISR shells too, including `revalidate` routes the file scan never found. The guard is applied to the tracer provider and propagator the Node SDK registers, and `withSentry` of `@sentry/nextjs/cloudflare` applies it to its own setup, since ISR pages revalidate inside a request of the Worker. A custom OpenTelemetry setup does not get it automatically. If it registers `SentryPropagator`, it can register the exported `NextSentryPropagator` instead and pass its tracer provider to `markPrerendersOnTracerProvider`. Setups with only the OpenTelemetry W3C propagators, which includes `@vercel/otel` by default, write no `sentry-trace` tag, so nothing changes for them. The docs follow in sentry-docs. An ISR page with `revalidate = 1` makes the e2e suite exercise the guard at runtime; the other ISR shells come from the build, where the SDK does not run. On its own this PR keeps the current client behaviour: tags in `` are continued and are now always fresh. The tag of a resumed shell arrives with the resumed part, which the client only reads in #25142. figure-shell Refs #24944, see #24298 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: s1gr1d <32902192+s1gr1d@users.noreply.github.com> --- CHANGELOG.md | 6 + .../nextjs-15/tests/isr-routes.test.ts | 69 +-- .../tests/cacheComponents.spec.ts | 32 -- .../cacheOrigin-pageloadConnection.spec.ts | 5 +- .../nextjs-16-static/tests/isr-routes.test.ts | 69 +-- .../tests/cacheComponents.spec.ts | 34 +- .../cacheOrigin-pageloadConnection.spec.ts | 5 +- .../nextjs-16/app/isr-test/runtime/page.tsx | 11 + .../nextjs-16/tests/isr-routes.test.ts | 97 +++-- packages/nextjs/src/client/index.ts | 7 - .../src/client/routing/isrRoutingTracing.ts | 63 --- .../src/client/routing/parameterization.ts | 1 - packages/nextjs/src/cloudflare/index.ts | 13 +- .../config/manifest/createRouteManifest.ts | 30 +- packages/nextjs/src/config/manifest/types.ts | 5 - .../getFinalConfigObjectUtils.ts | 11 - packages/nextjs/src/server/index.ts | 14 + .../nextjs/src/server/nextSentryPropagator.ts | 38 ++ .../nextjs/src/server/prerenderContext.ts | 109 +++++ .../appRouterRoutingInstrumentation.test.ts | 1 - .../test/client/isrRoutingTracing.test.ts | 398 ------------------ .../nextjs/test/client/routeProvider.test.ts | 1 - packages/nextjs/test/clientSdk.test.ts | 1 - .../nextjs/test/cloudflare/withSentry.test.ts | 36 +- .../excludeRoutesFromManifest.test.ts | 9 - .../suites/base-path/base-path.test.ts | 1 - .../catchall-at-root/catchall-at-root.test.ts | 1 - .../manifest/suites/catchall/catchall.test.ts | 1 - .../manifest/suites/dynamic/dynamic.test.ts | 1 - .../file-extensions/file-extensions.test.ts | 1 - .../app/articles/[category]/[slug]/page.tsx | 2 - .../manifest/suites/isr/app/blog/page.tsx | 2 - .../suites/isr/app/docs/[[...path]]/page.tsx | 2 - .../isr/app/guides/[...segments]/page.tsx | 2 - .../config/manifest/suites/isr/app/page.tsx | 2 - .../suites/isr/app/posts/[slug]/page.tsx | 2 - .../suites/isr/app/products/[id]/page.tsx | 2 - .../manifest/suites/isr/app/regular/page.tsx | 2 - .../isr/app/users/[id]/profile/page.tsx | 2 - .../config/manifest/suites/isr/isr.test.ts | 345 --------------- .../suites/route-groups/route-groups.test.ts | 2 - .../manifest/suites/static/static.test.ts | 1 - .../maybeSetClientTraceMetadataOption.test.ts | 8 +- .../constructTurbopackConfig.test.ts | 8 +- .../test/server/nextSentryPropagator.test.ts | 162 +++++++ packages/nextjs/test/serverSdk.test.ts | 34 ++ 46 files changed, 571 insertions(+), 1077 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/runtime/page.tsx delete mode 100644 packages/nextjs/src/client/routing/isrRoutingTracing.ts create mode 100644 packages/nextjs/src/server/nextSentryPropagator.ts create mode 100644 packages/nextjs/src/server/prerenderContext.ts delete mode 100644 packages/nextjs/test/client/isrRoutingTracing.test.ts delete mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx delete mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx delete mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx delete mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx delete mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/page.tsx delete mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx delete mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx delete mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx delete mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx delete mode 100644 packages/nextjs/test/config/manifest/suites/isr/isr.test.ts create mode 100644 packages/nextjs/test/server/nextSentryPropagator.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f76928974c2..7457cadef6bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ }); ``` +- **feat(nextjs): Never emit trace meta tags from a prerender ([#25141](https://github.com/getsentry/sentry-javascript/pull/25141))** + + Pageloads of apps with `cacheComponents` enabled are connected to their server request again. Pages that Next.js prerenders no longer carry trace information, so a cached page can no longer connect visitors to a request that was not theirs. This also applies to ISR pages. + + If you set up OpenTelemetry yourself and register `SentryPropagator`, register `NextSentryPropagator` instead and pass your tracer provider to `markPrerendersOnTracerProvider`, so that your prerendered pages carry no trace information either. + Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions! - **feat(deno): Add incoming request options to `denoServeIntegration`** diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts index 705b9208675e..8379d544a78e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts @@ -1,50 +1,57 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } 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'); +const isProductionBuild = process.env.TEST_ENV === 'production' || process.env.TEST_ENV === 'prod-turbopack'; - // Wait for page to be fully loaded - await expect(page.locator('#isr-product-id')).toHaveText('laptop'); +test.describe('prerendered ISR pages', () => { + // The dev server renders ISR pages for every request, so there the trace tags are fresh and kept. + test.skip(!isProductionBuild, 'ISR pages are only prerendered in production builds'); - // 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('does not carry trace meta tags on an ISR dynamic route', async ({ page }) => { + // Navigate to ISR page + await page.goto('/isr-test/laptop'); -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-product-id')).toHaveText('laptop'); - // Wait for page to be fully loaded - await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); - // 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('does not carry trace meta tags on an ISR static route', async ({ page }) => { + // Navigate to ISR static page + await page.goto('/isr-test/static'); -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'); + // Wait for page to be fully loaded + await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); + + test('does not carry trace 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'); - // 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); - 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 traces 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 + // Load the same ISR page 5 times: the cached HTML carries no trace tag, so every visit starts its own trace for (let i = 0; i < 5; i++) { const spanPromise = waitForStreamedSpan('nextjs-15', span => { return span.name === '/isr-test/:product' && getSpanOp(span) === 'pageload' && span.is_segment; @@ -64,7 +71,7 @@ test('should create unique traces for ISR pages on each visit', async ({ page }) traceIds.push(traceId!); } - // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags) + // Verify all 5 page loads have unique trace IDs (no trace replayed from the cached HTML) const uniqueTraceIds = new Set(traceIds); expect(uniqueTraceIds.size).toBe(5); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts index 3cb2472c0927..29c90f5bc423 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts @@ -81,38 +81,6 @@ test('Should generate metadata async', async ({ page }) => { await expect(page).toHaveTitle('Product: 1'); }); -test('Prerendered shell does not stitch the pageload onto a stale trace', async ({ page }) => { - const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /pageload-tracing' - ); - }); - - const pageloadTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/pageload-tracing'; - }); - - await page.goto('/pageload-tracing'); - - await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); - - const [serverTx, pageloadTx] = await Promise.all([serverTxPromise, pageloadTxPromise]); - - const serverTraceId = serverTx.contexts?.trace?.trace_id; - const pageloadTraceId = pageloadTx.contexts?.trace?.trace_id; - - // Under Cache Components the shell is prerendered and rendered in a context detached from the - // runtime server request, so a `sentry-trace` meta tag would carry a stale/unrelated trace. The - // SDK therefore does not enable the trace meta tags, and the browser pageload starts a fresh trace - // instead of stitching onto a trace that doesn't match the server request. - expect(pageloadTraceId).toBeTruthy(); - expect(serverTraceId).not.toBe(pageloadTraceId); - - // No trace meta tags should be injected when Cache Components is enabled. - expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); - expect(await page.locator('meta[name="baggage"]').count()).toBe(0); -}); - test('Should prerender a page that captures an exception in generateMetadata', async ({ page }) => { await page.goto('/capture-metadata'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts index c182106f5d10..7de1b6a55a2d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts @@ -1,9 +1,10 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -// TODO: Remove the inverse assertion in cacheComponents.spec.ts ("Prerendered shell does not stitch the pageload onto a stale trace") when this test passes. test('connects the pageload trace to the runtime server request', async ({ page }) => { - test.fail(); + // The resumed part of the document, which carries the tag, arrives after the SDK started. Enabled + // once the pageload instrumentation waits for it. + test.skip(true, 'Needs the deferred pageload start'); // The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`) // runs on the server on every request and produces the `GET /pageload-tracing` transaction. 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 index 4fde597dfeaa..5d363110424d 100644 --- 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 @@ -1,50 +1,57 @@ 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'); +const isProductionBuild = process.env.TEST_ENV === 'production' || process.env.TEST_ENV === 'prod-turbopack'; - // Wait for page to be fully loaded - await expect(page.locator('#isr-product-id')).toHaveText('laptop'); +test.describe('prerendered ISR pages', () => { + // The dev server renders ISR pages for every request, so there the trace tags are fresh and kept. + test.skip(!isProductionBuild, 'ISR pages are only prerendered in production builds'); - // 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('does not carry trace meta tags on an ISR dynamic route', async ({ page }) => { + // Navigate to ISR page + await page.goto('/isr-test/laptop'); -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-product-id')).toHaveText('laptop'); - // Wait for page to be fully loaded - await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); - // 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('does not carry trace meta tags on an ISR static route', async ({ page }) => { + // Navigate to ISR static page + await page.goto('/isr-test/static'); -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'); + // Wait for page to be fully loaded + await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); + + test('does not carry trace 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'); - // 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); - 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 + // Load the same ISR page 5 times: the cached HTML carries no trace tag, so every visit starts its own trace for (let i = 0; i < 5; i++) { const transactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { return !!( @@ -66,7 +73,7 @@ test('should create unique transactions for ISR pages on each visit', async ({ p traceIds.push(traceId!); } - // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags) + // Verify all 5 page loads have unique trace IDs (no trace replayed from the cached HTML) const uniqueTraceIds = new Set(traceIds); expect(uniqueTraceIds.size).toBe(5); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts index 49f2f49cb5b2..35a5c82080f5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpans } from '@sentry-internal/test-utils'; test('Should render cached component', async ({ page }) => { const spansPromise = waitForStreamedSpans('nextjs-16-streaming-cacheComponents', spans => { @@ -93,38 +93,6 @@ test('Should generate metadata async', async ({ page }) => { await expect(page).toHaveTitle('Product: 1'); }); -test('Prerendered shell does not stitch the pageload onto a stale trace', async ({ page }) => { - const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { - return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment; - }); - - const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { - return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; - }); - - await page.goto('/pageload-tracing'); - - await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); - - const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); - - expect(pageloadSpan.attributes).toMatchObject({ - ['sentry.segment.name.source']: { value: 'route', type: 'string' }, - ['url.path']: { value: '/pageload-tracing', type: 'string' }, - ['url.template']: { value: '/pageload-tracing', type: 'string' }, - }); - // Under Cache Components the can be prerendered and rendered in a context detached from the - // runtime server request, so a `sentry-trace` meta tag would carry a stale/unrelated trace. The - // SDK therefore does not enable the trace meta tags, and the browser pageload starts a fresh trace - // instead of stitching onto a trace that doesn't match the server request. - expect(pageloadSpan.trace_id).toBeTruthy(); - expect(serverSpan.trace_id).not.toBe(pageloadSpan.trace_id); - - // No trace meta tags should be injected when Cache Components is enabled. - expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); - expect(await page.locator('meta[name="baggage"]').count()).toBe(0); -}); - test('Should prerender a page that captures an exception in generateMetadata', async ({ page }) => { await page.goto('/capture-metadata'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts index efc35fd92c3d..6782257a59d8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts @@ -1,9 +1,10 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -// TODO: Remove the inverse assertion in cacheComponents.spec.ts ("Prerendered shell does not stitch the pageload onto a stale trace") when this test passes. test('connects the pageload trace to the runtime server request', async ({ page }) => { - test.fail(); + // The resumed part of the document, which carries the tag, arrives after the SDK started. Enabled + // once the pageload instrumentation waits for it. + test.skip(true, 'Needs the deferred pageload start'); // The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`) // runs on the server on every request and produces the `GET /pageload-tracing` root span. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/runtime/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/runtime/page.tsx new file mode 100644 index 000000000000..097ccb54cc68 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/runtime/page.tsx @@ -0,0 +1,11 @@ +// A short revalidation window, so a test can make the server prerender this page again at runtime. +export const revalidate = 1; + +export default function ISRRuntimePage() { + return ( +
+

ISR Runtime Page

+
{Date.now()}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts index ce215ef457e9..5a210e8426c6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts @@ -1,50 +1,83 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } 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'); +const isProductionBuild = process.env.TEST_ENV === 'production' || process.env.TEST_ENV === 'prod-turbopack'; + +test.describe('prerendered ISR pages', () => { + // The dev server and vinext render ISR pages for every request, so there the trace tags are fresh + // and kept. + test.skip( + !isProductionBuild || !!process.env.VINEXT, + 'ISR pages are only prerendered by a production build of Next.js', + ); + + test('does not carry trace meta tags on an ISR dynamic route', 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'); + + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); - // 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('does not carry trace meta tags on an ISR static route', async ({ page }) => { + // Navigate to ISR static page + await page.goto('/isr-test/static'); -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'); - // Wait for page to be fully loaded - await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); - // 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('does not carry trace 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'); -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); - 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'); - // 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); + }); - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + test('does not carry trace meta tags on an ISR page that was prerendered again at runtime', async ({ page }) => { + // The first visit serves the shell from the build. Once its revalidation window passed, a visit serves the stale + // shell and makes the server prerender the page again, inside that request, with the SDK running. The visits after + // that serve the shell from that prerender. + await page.goto('/isr-test/runtime'); + const renderedAtBuild = await page.locator('#isr-runtime-rendered-at').textContent(); + + await expect + .poll( + async () => { + await page.waitForTimeout(1500); + await page.goto('/isr-test/runtime'); + return page.locator('#isr-runtime-rendered-at').textContent(); + }, + { timeout: 30_000 }, + ) + .not.toBe(renderedAtBuild); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); }); test('should create unique traces 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 + // Load the same ISR page 5 times: the cached HTML carries no trace tag, so every visit starts its own trace for (let i = 0; i < 5; i++) { const spanPromise = waitForStreamedSpan('nextjs-16', span => { return span.name === '/isr-test/:product' && getSpanOp(span) === 'pageload' && span.is_segment; @@ -64,7 +97,7 @@ test('should create unique traces for ISR pages on each visit', async ({ page }) traceIds.push(traceId!); } - // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags) + // Verify all 5 page loads have unique trace IDs (no trace replayed from the cached HTML) const uniqueTraceIds = new Set(traceIds); expect(uniqueTraceIds.size).toBe(5); }); diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index d5c7e11a1d06..9f1ed4e4f768 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -11,7 +11,6 @@ import { getClientVercelEnv } from '../common/getVercelEnv'; import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; -import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; import { createNextRouteProvider } from './routing/routeProvider'; import { applyTunnelRouteOption } from './tunnelRoute'; @@ -45,12 +44,6 @@ export function init(options: BrowserOptions): Client | undefined { }); } - // Remove cached trace meta tags for ISR/SSG pages before initializing - // This prevents the browser tracing integration from using stale trace IDs - if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { - removeIsrSsgTraceMetaTags(); - } - const opts = { environment: options.environment || process.env.SENTRY_ENVIRONMENT || getClientVercelEnv() || process.env.NODE_ENV, defaultIntegrations: getDefaultIntegrations(options), diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts deleted file mode 100644 index de5308cbb7ef..000000000000 --- a/packages/nextjs/src/client/routing/isrRoutingTracing.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { LRUMap } from '@sentry/core'; -import { WINDOW } from '@sentry/react'; -import { getManifest, maybeParameterizeRoute } from './parameterization'; - -/** - * Cache for ISR/SSG route checks. Exported for testing purposes. - * @internal - */ -export const IS_ISR_SSG_ROUTE_CACHE = new LRUMap(100); - -/** - * Check if the current page is an ISR/SSG route by checking the route manifest. - * @internal Exported for testing purposes. - */ -export function isIsrSsgRoute(pathname: string): boolean { - // Early parameterization to get the cache key - const parameterizedPath = maybeParameterizeRoute(pathname); - const pathToCheck = parameterizedPath || pathname; - - // Check cache using the parameterized path as the key - const cachedResult = IS_ISR_SSG_ROUTE_CACHE.get(pathToCheck); - if (cachedResult !== undefined) { - return cachedResult; - } - - // Cache miss get the manifest - const manifest = getManifest(); - if (!manifest?.isrRoutes || !Array.isArray(manifest.isrRoutes) || manifest.isrRoutes.length === 0) { - IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, false); - return false; - } - - const isIsrSsgRoute = manifest.isrRoutes.includes(pathToCheck); - IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, isIsrSsgRoute); - - return isIsrSsgRoute; -} - -/** - * Remove sentry-trace and baggage meta tags from the DOM if this is an ISR/SSG page. - * This prevents the browser tracing integration from using stale/cached trace IDs. - */ -export function removeIsrSsgTraceMetaTags(): void { - if (!WINDOW.document || !isIsrSsgRoute(WINDOW.location.pathname)) { - return; - } - - // Helper function to remove a meta tag - function removeMetaTag(metaName: string): void { - try { - const meta = WINDOW.document.querySelector(`meta[name="${metaName}"]`); - if (meta) { - meta.remove(); - } - } catch { - // ignore errors when removing the meta tag - } - } - - // Remove the meta tags so browserTracingIntegration won't pick them up - removeMetaTag('sentry-trace'); - removeMetaTag('baggage'); -} diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index 9b366d705637..619b6d7f8069 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -147,7 +147,6 @@ export function getManifest(): RouteManifest | null { let manifest: RouteManifest = { staticRoutes: [], dynamicRoutes: [], - isrRoutes: [], }; // Shallow check if the manifest is actually what we expect it to be diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index c97b7298a143..9c43cff4b5a3 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -5,21 +5,19 @@ import type { CloudflareOptions } from '@sentry/cloudflare'; import { withSentry as withSentryCloudflare } from '@sentry/cloudflare'; import type { Integration } from '@sentry/core'; import { applySdkMetadata, extractTraceparentData, getRootSpan, GLOBAL_OBJ, spanToJSON } from '@sentry/core'; -import { - registerPrepareSpanScope, - SentryPropagator, - setOpenTelemetryContextAsyncContextStrategy, -} from '@sentry/opentelemetry'; +import { registerPrepareSpanScope, setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd'; +import { NextSentryPropagator } from '../server/nextSentryPropagator'; +import { getGlobalTracerProvider, markPrerendersOnTracerProvider } from '../server/prerenderContext'; import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from '../server/serverSpanHooks'; import { nextjsUseCacheIntegration } from '../server/useCacheInstrumentation'; export * from '@sentry/cloudflare'; -class NextjsCloudflarePropagator extends SentryPropagator { +class NextjsCloudflarePropagator extends NextSentryPropagator { /** @inheritDoc */ public extract(ctx: Context, carrier: unknown, getter: TextMapGetter): Context { // Next.js extracts the request headers again from the root context when it misses its router server context, e.g. @@ -43,6 +41,9 @@ const nextjsIntegration = (): Integration => ({ name: 'Nextjs', setup: client => { if ((client.getOptions() as CloudflareOptions).enableOpenTelemetrySetup) { + // ISR pages revalidate inside a request of the Worker, so the prerender guard has to cover the tracer + // provider `@sentry/cloudflare` registered as well. + markPrerendersOnTracerProvider(getGlobalTracerProvider()); propagation.setGlobalPropagator(new NextjsCloudflarePropagator()); } client.getOptions().release ??= (GLOBAL_OBJ as { _sentryRelease?: string })._sentryRelease; diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index bf1765bfce4f..33f91585b4ad 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -135,20 +135,6 @@ function hasOptionalPrefix(paramNames: string[], localeParamNames: string[]): bo return localeParamNames.includes(firstParam); } -/** - * Check if a page file exports generateStaticParams (ISR/SSG indicator) - */ -function checkForGenerateStaticParams(pageFilePath: string): boolean { - try { - const content = fs.readFileSync(pageFilePath, 'utf8'); - // check for generateStaticParams export - // the regex covers `export function generateStaticParams`, `export async function generateStaticParams`, `export const generateStaticParams` - return /export\s+(async\s+)?function\s+generateStaticParams|export\s+const\s+generateStaticParams/.test(content); - } catch { - return false; - } -} - function scanAppDirectory( dir: string, basePath: string = '', @@ -158,7 +144,6 @@ function scanAppDirectory( ): RouteManifest { const dynamicRoutes: RouteInfo[] = []; const staticRoutes: RouteInfo[] = []; - const isrRoutes: string[] = []; try { const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -169,14 +154,6 @@ function scanAppDirectory( const routePath = includeRouteGroups ? basePath || '/' : normalizeRouteGroupPath(basePath || '/'); const isDynamic = routePath.includes(':'); - // Check if this page has generateStaticParams (ISR/SSG indicator) - const pageFilePath = path.join(dir, pageFile.name); - const hasGenerateStaticParams = checkForGenerateStaticParams(pageFilePath); - - if (hasGenerateStaticParams) { - isrRoutes.push(routePath); - } - if (isDynamic) { const { regex, paramNames, hasOptionalPrefix } = buildRegexForDynamicRoute(routePath, localeParamNames); dynamicRoutes.push({ @@ -217,14 +194,13 @@ function scanAppDirectory( dynamicRoutes.push(...subRoutes.dynamicRoutes); staticRoutes.push(...subRoutes.staticRoutes); - isrRoutes.push(...subRoutes.isrRoutes); } } } catch (error) { getBuildLogger(silent).warn('[@sentry/nextjs] Error building route manifest:', error); } - return { dynamicRoutes, staticRoutes, isrRoutes }; + return { dynamicRoutes, staticRoutes }; } /** @@ -249,7 +225,6 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route if (!targetDir) { return { - isrRoutes: [], dynamicRoutes: [], staticRoutes: [], }; @@ -267,7 +242,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route return manifestCache; } - const { dynamicRoutes, staticRoutes, isrRoutes } = scanAppDirectory( + const { dynamicRoutes, staticRoutes } = scanAppDirectory( targetDir, options?.basePath, options?.includeRouteGroups, @@ -278,7 +253,6 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route const manifest: RouteManifest = { dynamicRoutes, staticRoutes, - isrRoutes, }; // set cache diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts index 99fc42fb27d7..0a0946be70f7 100644 --- a/packages/nextjs/src/config/manifest/types.ts +++ b/packages/nextjs/src/config/manifest/types.ts @@ -34,9 +34,4 @@ export type RouteManifest = { * List of all static routes */ staticRoutes: RouteInfo[]; - - /** - * List of ISR/SSG routes (routes with generateStaticParams) - */ - isrRoutes: string[]; }; diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index 8fa1cd446a25..68520bc1e16d 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -172,7 +172,6 @@ export function filterRouteManifest(manifest: RouteManifest, excludeFilter: Excl return { staticRoutes: manifest.staticRoutes.filter(r => !shouldExclude(r.path)), dynamicRoutes: manifest.dynamicRoutes.filter(r => !shouldExclude(r.path)), - isrRoutes: manifest.isrRoutes.filter(r => !shouldExclude(r)), }; } @@ -184,16 +183,6 @@ export function maybeSetClientTraceMetadataOption( nextJsVersion: string | undefined, silent?: boolean, ): void { - // With Cache Components enabled, the page shell — and therefore the document's `sentry-trace`/ - // `baggage` meta tags — can be prerendered and rendered in an async context detached from the - // runtime `http.server` request. The trace captured into those meta tags is therefore stale and - // unrelated to the actual request. If we enabled `clientTraceMetadata`, the browser pageload would - // continue that stale trace, producing a misleading distributed trace. So we don't enable the meta - // tags here; the browser pageload starts a fresh trace instead. - if (incomingUserNextConfigObject.cacheComponents) { - return; - } - // Add the `clientTraceMetadata` experimental option based on Next.js version. The option got introduced in Next.js version 15.0.0 (actually 14.3.0-canary.64). // Adding the option on lower versions will cause Next.js to print nasty warnings we wouldn't confront our users with. if (nextJsVersion) { diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index fc1bc51571a2..3a1bf1ab9250 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -24,6 +24,7 @@ import { setCloudflareWorkerRelease, } from '../common/utils/responseEnd'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; +import { registerNextSentryPropagator } from './nextSentryPropagator'; import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from './serverSpanHooks'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; import { nextjsUseCacheIntegration } from './useCacheInstrumentation'; @@ -184,6 +185,14 @@ export function init(options: NodeOptions): NodeClient | undefined { setCloudflareWorkerRelease(opts.release); } + // Next.js reads the trace meta tags of a document through the global propagator. Only the setup the + // Node SDK owns gets the Next.js-aware one here; `withSentry` of `@sentry/nextjs/cloudflare` applies it + // to its own setup, and another OpenTelemetry setup (for example `@vercel/otel`) keeps its own tracer + // provider and propagator. + if (client?.traceProvider) { + registerNextSentryPropagator(client.traceProvider); + } + // On the client, not the global scope, so a later `init()` after // `close()` does not stack another copy. In a request of `withSentry` on // Workers, `init` creates no client, so the global scope gets them, which @@ -231,3 +240,8 @@ export function init(options: NodeOptions): NodeClient | undefined { export * from '../common'; export { wrapApiHandlerWithSentry } from '../common/pages-router-instrumentation/wrapApiHandlerWithSentry'; + +// For apps that set up OpenTelemetry themselves (`enableOpenTelemetrySetup: false`), which otherwise +// write the trace meta tags of a prerendered shell with the propagator they registered. +export { NextSentryPropagator } from './nextSentryPropagator'; +export { markPrerendersOnTracerProvider } from './prerenderContext'; diff --git a/packages/nextjs/src/server/nextSentryPropagator.ts b/packages/nextjs/src/server/nextSentryPropagator.ts new file mode 100644 index 000000000000..1fd3c3438307 --- /dev/null +++ b/packages/nextjs/src/server/nextSentryPropagator.ts @@ -0,0 +1,38 @@ +import type { Context, TextMapSetter, TracerProvider } from '@opentelemetry/api'; +import { propagation } from '@opentelemetry/api'; +import { SentryPropagator } from '@sentry/opentelemetry'; +import { isPrerenderContext, markPrerendersOnTracerProvider } from './prerenderContext'; + +/** + * A `SentryPropagator` that does not hand out the trace meta tags of a document while Next.js + * prerenders it. Trace context baked into a prerendered shell would be replayed to every visitor the + * shell is served to, so the browser could continue a trace that never handled its request. + * + * The SDK registers it when it sets up OpenTelemetry. An app that sets up OpenTelemetry itself + * registers it in place of `SentryPropagator` and calls `markPrerendersOnTracerProvider` with its + * tracer provider, which this propagator relies on to recognize a prerender. + */ +export class NextSentryPropagator extends SentryPropagator { + /** @inheritDoc */ + public inject(ctx: Context, carrier: unknown, setter: TextMapSetter): void { + // Next.js collects the meta tag values into an array; every other caller, such as the outgoing + // request instrumentations, passes a headers object and keeps propagating during a prerender. + if (Array.isArray(carrier) && isPrerenderContext(ctx)) { + return; + } + super.inject(ctx, carrier, setter); + } +} + +/** + * Makes the tracer provider the Node SDK registered mark prerenders on the OpenTelemetry context, and + * replaces the propagator it registered with one that hands out no meta tags on a marked context. + */ +export function registerNextSentryPropagator(provider: TracerProvider): void { + markPrerendersOnTracerProvider(provider); + + // The OpenTelemetry API refuses a second global propagator, so the one from the Node SDK setup has + // to be unregistered first. + propagation.disable(); + propagation.setGlobalPropagator(new NextSentryPropagator()); +} diff --git a/packages/nextjs/src/server/prerenderContext.ts b/packages/nextjs/src/server/prerenderContext.ts new file mode 100644 index 000000000000..e5de40bed829 --- /dev/null +++ b/packages/nextjs/src/server/prerenderContext.ts @@ -0,0 +1,109 @@ +import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api'; +import { context, createContextKey, trace } from '@opentelemetry/api'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; + +const APP_RENDER_BODY_SPAN_TYPE = 'AppRender.getBodyResult'; +const PRERENDER_ROUTE_SPAN_NAME_PREFIX = 'prerender route'; + +const PRERENDER_CONTEXT_KEY = createContextKey('sentry.nextjs.prerender'); + +// Prerender spans started with `startSpan`, which the caller activates on a context of its own. +const prerenderSpans = new WeakSet(); + +/** + * Whether these are the start arguments of the span Next.js wraps a prerender in. Next.js computes the + * `sentry-trace` and `baggage` meta tags of a document inside that span, and a prerender produces a + * shell that is cached and served to later visitors. A render for a request, including the resume of + * a prerendered shell, computes the tags outside of it. + * + * @internal + */ +export function isPrerenderSpanStart(name: string, options: SpanOptions | undefined): boolean { + const attributes = options?.attributes; + const spanName = attributes?.[ATTR_NEXT_SPAN_NAME] ?? name; + return ( + attributes?.[ATTR_NEXT_SPAN_TYPE] === APP_RENDER_BODY_SPAN_TYPE && + typeof spanName === 'string' && + spanName.startsWith(PRERENDER_ROUTE_SPAN_NAME_PREFIX) + ); +} + +/** + * Whether the context belongs to a prerender started through a tracer of a marked provider. + * + * @internal + */ +export function isPrerenderContext(ctx: Context): boolean { + if (ctx.getValue(PRERENDER_CONTEXT_KEY) === true) { + return true; + } + const span = trace.getSpan(ctx); + return !!span && prerenderSpans.has(span); +} + +/** + * Marks a prerender on the OpenTelemetry context rather than on the span. The context is inherited by + * everything that runs inside the prerender, and it exists even when the request is not sampled, in + * which case the prerender span is non-recording and carries no attributes to recognize it by. + */ +function wrapTracer(tracer: Tracer): Tracer { + return { + startSpan: (name, options, ctx) => { + const span = tracer.startSpan(name, options, ctx); + if (isPrerenderSpanStart(name, options)) { + prerenderSpans.add(span); + } + return span; + }, + startActiveSpan: (name: string, ...args: unknown[]) => { + const startActiveSpan = tracer.startActiveSpan.bind(tracer) as (name: string, ...args: unknown[]) => unknown; + const options = typeof args[0] === 'function' ? undefined : (args[0] as SpanOptions | undefined); + if (!isPrerenderSpanStart(name, options)) { + return startActiveSpan(name, ...args); + } + + const explicitContext = args.length === 3 ? (args[1] as Context) : undefined; + if (explicitContext) { + return startActiveSpan(name, args[0], explicitContext.setValue(PRERENDER_CONTEXT_KEY, true), args[2]); + } + return context.with(context.active().setValue(PRERENDER_CONTEXT_KEY, true), () => startActiveSpan(name, ...args)); + }, + }; +} + +const markedProviders = new WeakSet(); + +/** + * Makes a tracer provider mark prerenders of Next.js on the OpenTelemetry context, by wrapping the + * tracers it hands out. `NextSentryPropagator` relies on the mark. The SDK calls this for the provider it + * registers; an app that sets up OpenTelemetry itself calls it with its own provider. Safe to call + * repeatedly with the same provider, which the per-request `init` on Cloudflare does. + */ +export function markPrerendersOnTracerProvider(provider: TracerProvider): void { + if (markedProviders.has(provider)) { + return; + } + markedProviders.add(provider); + + const wrappedTracers = new WeakMap(); + const getTracer = provider.getTracer.bind(provider); + provider.getTracer = (...args) => { + const tracer = getTracer(...args); + let wrapped = wrappedTracers.get(tracer); + if (!wrapped) { + wrapped = wrapTracer(tracer); + wrappedTracers.set(tracer, wrapped); + } + return wrapped; + }; +} + +/** + * The tracer provider behind the global OpenTelemetry API, which is a proxy around the registered one. + * + * @internal + */ +export function getGlobalTracerProvider(): TracerProvider { + const provider = trace.getTracerProvider(); + return 'getDelegate' in provider ? (provider as { getDelegate(): TracerProvider }).getDelegate() : provider; +} diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index 0f8a49816253..3fac8e6c2d21 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -48,7 +48,6 @@ const manifest: RouteManifest = { hasOptionalPrefix: false, }, ], - isrRoutes: [], }; function sleep(ms: number): Promise { diff --git a/packages/nextjs/test/client/isrRoutingTracing.test.ts b/packages/nextjs/test/client/isrRoutingTracing.test.ts deleted file mode 100644 index cdca740727dd..000000000000 --- a/packages/nextjs/test/client/isrRoutingTracing.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { WINDOW } from '@sentry/react'; -import { JSDOM } from 'jsdom'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - IS_ISR_SSG_ROUTE_CACHE, - isIsrSsgRoute, - removeIsrSsgTraceMetaTags, -} from '../../src/client/routing/isrRoutingTracing'; -import type { RouteManifest } from '../../src/config/manifest/types'; - -const globalWithInjectedValues = WINDOW as typeof WINDOW & { - _sentryRouteManifest?: string; -}; - -describe('isrRoutingTracing', () => { - let dom: JSDOM; - - beforeEach(() => { - // Set up a fresh DOM environment for each test - dom = new JSDOM('', { - url: 'https://example.com/', - }); - Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); - Object.defineProperty(global, 'location', { value: dom.window.location, writable: true }); - - // Clear the injected manifest - delete globalWithInjectedValues._sentryRouteManifest; - }); - - afterEach(() => { - // Clean up - vi.clearAllMocks(); - }); - - describe('removeIsrSsgTraceMetaTags', () => { - const mockManifest: RouteManifest = { - staticRoutes: [{ path: '/' }, { path: '/blog' }], - dynamicRoutes: [ - { - path: '/products/:id', - regex: '^/products/([^/]+?)(?:/)?$', - paramNames: ['id'], - hasOptionalPrefix: false, - }, - { - path: '/posts/:slug', - regex: '^/posts/([^/]+?)(?:/)?$', - paramNames: ['slug'], - hasOptionalPrefix: false, - }, - ], - isrRoutes: ['/', '/blog', '/products/:id', '/posts/:slug'], - }; - - it('should remove meta tags when on a static ISR route', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest (as stringified JSON, which is how it's injected in production) - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to an ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were removed - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - - it('should remove meta tags when on a dynamic ISR route', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to a dynamic ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/products/123' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were removed - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - - it('should NOT remove meta tags when on a non-ISR route', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to a non-ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/regular-page' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were NOT removed - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).not.toBeNull(); - }); - - it('should handle missing manifest gracefully', () => { - // Clear cache to ensure fresh state - IS_ISR_SSG_ROUTE_CACHE.clear(); - - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - // No manifest set - // globalWithInjectedValues._sentryRouteManifest is undefined - - // Set location - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function (should not throw) - expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); - - // Verify meta tags were NOT removed (no manifest means no ISR detection) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - }); - - it('should handle invalid JSON manifest gracefully', () => { - // Clear cache to ensure fresh state - IS_ISR_SSG_ROUTE_CACHE.clear(); - - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - // Set up invalid manifest - globalWithInjectedValues._sentryRouteManifest = 'invalid json {'; - - // Set location - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function (should not throw) - expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); - - // Verify meta tags were NOT removed (invalid manifest means no ISR detection) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - }); - - it('should handle manifest with no ISR routes', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - // Set up manifest with no ISR routes - const manifestWithNoISR: RouteManifest = { - staticRoutes: [{ path: '/' }], - dynamicRoutes: [], - isrRoutes: [], - }; - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(manifestWithNoISR); - - // Set location - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were NOT removed (no ISR routes in manifest) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - }); - - it('should handle missing meta tags gracefully', () => { - // Set up DOM without meta tags - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to an ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function (should not throw) - expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); - - // Verify no errors and still no meta tags - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - - it('should work with parameterized dynamic routes', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to a different dynamic ISR route value - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/posts/my-awesome-post' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were removed (should match /posts/:slug) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - }); - - describe('isIsrSsgRoute caching', () => { - const mockManifest: RouteManifest = { - staticRoutes: [{ path: '/' }, { path: '/blog' }], - dynamicRoutes: [ - { - path: '/products/:id', - regex: '^/products/([^/]+?)(?:/)?$', - paramNames: ['id'], - hasOptionalPrefix: false, - }, - { - path: '/posts/:slug', - regex: '^/posts/([^/]+?)(?:/)?$', - paramNames: ['slug'], - hasOptionalPrefix: false, - }, - ], - isrRoutes: ['/', '/blog', '/products/:id', '/posts/:slug'], - }; - - beforeEach(() => { - // Clear cache before each test - IS_ISR_SSG_ROUTE_CACHE.clear(); - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - }); - - it('should cache results by parameterized route, not concrete pathname', () => { - // First call with /products/123 - const result1 = isIsrSsgRoute('/products/123'); - expect(result1).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - - // Second call with different concrete path /products/456 - const result2 = isIsrSsgRoute('/products/456'); - expect(result2).toBe(true); - // Cache size should still be 1 - both paths map to same parameterized route - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - - // Third call with yet another path /products/999 - const result3 = isIsrSsgRoute('/products/999'); - expect(result3).toBe(true); - // Still just 1 cache entry - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - }); - - it('should use cached results on subsequent calls with same route pattern', () => { - // Clear cache - IS_ISR_SSG_ROUTE_CACHE.clear(); - - // First call - cache miss, will populate cache - isIsrSsgRoute('/products/1'); - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - - // Second call with different concrete path - cache hit - const result2 = isIsrSsgRoute('/products/2'); - expect(result2).toBe(true); - // Cache size unchanged - using cached result - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - - // Third call - still cache hit - const result3 = isIsrSsgRoute('/products/3'); - expect(result3).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - }); - - it('should cache false results for non-ISR routes', () => { - const result1 = isIsrSsgRoute('/not-an-isr-route'); - expect(result1).toBe(false); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBe(false); - - // Second call should use cache - const result2 = isIsrSsgRoute('/not-an-isr-route'); - expect(result2).toBe(false); - }); - - it('should cache false results when manifest is invalid', () => { - IS_ISR_SSG_ROUTE_CACHE.clear(); - globalWithInjectedValues._sentryRouteManifest = 'invalid json'; - - const result = isIsrSsgRoute('/any-route'); - expect(result).toBe(false); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBe(false); - }); - - it('should cache static routes without parameterization', () => { - const result1 = isIsrSsgRoute('/blog'); - expect(result1).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/blog')).toBeDefined(); - - // Second call should use cache - const result2 = isIsrSsgRoute('/blog'); - expect(result2).toBe(true); - }); - - it('should maintain separate cache entries for different route patterns', () => { - // Check multiple different routes - isIsrSsgRoute('/products/1'); - isIsrSsgRoute('/posts/hello'); - isIsrSsgRoute('/blog'); - isIsrSsgRoute('/'); - - // Should have 4 cache entries (one for each unique route pattern) - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(4); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/posts/:slug')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/blog')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/')).toBeDefined(); - }); - - it('should efficiently handle multiple calls to same dynamic route with different params', () => { - // Simulate real-world scenario with many different product IDs - for (let i = 1; i <= 100; i++) { - isIsrSsgRoute(`/products/${i}`); - } - - // Should only have 1 cache entry despite 100 calls - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - }); - }); -}); diff --git a/packages/nextjs/test/client/routeProvider.test.ts b/packages/nextjs/test/client/routeProvider.test.ts index 90721c503867..91ecce723198 100644 --- a/packages/nextjs/test/client/routeProvider.test.ts +++ b/packages/nextjs/test/client/routeProvider.test.ts @@ -14,7 +14,6 @@ let originalDocument: unknown; const MANIFEST = JSON.stringify({ staticRoutes: [{ path: '/about' }], dynamicRoutes: [{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }], - isrRoutes: [], }); function makeClient(): BrowserClient { diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 03cd83dbe8b6..9f16580546c7 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -193,7 +193,6 @@ describe('Client init()', () => { globalWithManifest._sentryRouteManifest = JSON.stringify({ staticRoutes: [{ path: '/' }], dynamicRoutes: [], - isrRoutes: [], }); init({ dsn: TEST_DSN, tracesSampleRate: 1.0 }); diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts index ff3395f62b4e..d24bb75f3f61 100644 --- a/packages/nextjs/test/cloudflare/withSentry.test.ts +++ b/packages/nextjs/test/cloudflare/withSentry.test.ts @@ -16,7 +16,7 @@ import { } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { withSentry } from '../../src/cloudflare'; -import { ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; import { init as initEdge } from '../../src/edge'; import { init as initServer } from '../../src/server'; import { NEXTJS_SERVER_IGNORE_SPANS } from '../../src/server/serverSpanHooks'; @@ -257,6 +257,40 @@ describe('withSentry', () => { expect(carrier['sentry-trace']).toBe(`${activeSpan?.spanContext().traceId}-${activeSpan?.spanContext().spanId}-1`); }); + it('injects no meta tags while Next.js prerenders a page in a request', async () => { + // Next.js collects the meta tag values with an array carrier. + const carrier: Array<{ key: string; value: string }> = []; + const requestCarrier: Record = {}; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + trace.getTracer('next.js').startActiveSpan( + 'prerender route (app) /isr', + { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult', + [ATTR_NEXT_SPAN_NAME]: 'prerender route (app) /isr', + }, + }, + span => { + propagation.inject(context.active(), carrier, { set: (c, key, value) => c.push({ key, value }) }); + span.end(); + }, + ); + propagation.inject(context.active(), requestCarrier); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(carrier).toEqual([]); + expect(requestCarrier['sentry-trace']).toMatch(/^[0-9a-f]{32}-[0-9a-f]{16}-1$/); + }); + it('does not set the global propagator when the callback turns off the OpenTelemetry setup', async () => { const setGlobalPropagator = vi.spyOn(propagation, 'setGlobalPropagator'); const handler = withSentry(() => ({ dsn: DSN, enableOpenTelemetrySetup: false }), { diff --git a/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts b/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts index a22af530b332..bf0255071375 100644 --- a/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts +++ b/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts @@ -17,7 +17,6 @@ describe('routeManifestInjection.exclude', () => { { path: '/admin/users/:id', regex: '^/admin/users/([^/]+)$', paramNames: ['id'] }, { path: '/secret-feature/:id', regex: '^/secret-feature/([^/]+)$', paramNames: ['id'] }, ], - isrRoutes: ['/blog', '/admin/reports', '/internal/stats'], }; describe('with no filter', () => { @@ -34,7 +33,6 @@ describe('routeManifestInjection.exclude', () => { // All routes containing '/admin' are excluded expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); - expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); }); it('should exclude routes matching multiple string patterns', () => { @@ -47,7 +45,6 @@ describe('routeManifestInjection.exclude', () => { '/internal/secret', '/public/page', ]); - expect(result.isrRoutes).toEqual(['/admin/reports', '/internal/stats']); }); it('should match substrings anywhere in the route', () => { @@ -71,14 +68,12 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); - expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); }); it('should support multiple regex patterns', () => { const result = filterRouteManifest(mockManifest, [/^\/admin/, /^\/internal/]); expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']); - expect(result.isrRoutes).toEqual(['/blog']); }); it('should support partial regex matches', () => { @@ -116,7 +111,6 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); - expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); }); it('should support complex filter logic', () => { @@ -127,7 +121,6 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id']); - expect(result.isrRoutes).toEqual(['/blog']); }); }); @@ -136,7 +129,6 @@ describe('routeManifestInjection.exclude', () => { const emptyManifest: RouteManifest = { staticRoutes: [], dynamicRoutes: [], - isrRoutes: [], }; const result = filterRouteManifest(emptyManifest, [/admin/]); @@ -148,7 +140,6 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes).toEqual([]); expect(result.dynamicRoutes).toEqual([]); - expect(result.isrRoutes).toEqual([]); }); it('should handle filter that excludes nothing', () => { diff --git a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts index 7a9a88033d85..097e3f603693 100644 --- a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts +++ b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts @@ -19,7 +19,6 @@ describe('basePath', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts index 4e8e62199592..8d78f24a0986 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts @@ -16,7 +16,6 @@ describe('catchall', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index 34b9334dba05..d259a1a38223 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -16,7 +16,6 @@ describe('catchall', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index fb0111d941e5..2ea4b4aca5d8 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -34,7 +34,6 @@ describe('dynamic', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts index 79daf2d4d58c..2c898b1e8e96 100644 --- a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -16,7 +16,6 @@ describe('file-extensions', () => { { path: '/typescript' }, ], dynamicRoutes: [], - isrRoutes: [], }); }); }); diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx deleted file mode 100644 index 24b26184353a..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Nested dynamic ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx deleted file mode 100644 index 027e160f91d3..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Static ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx deleted file mode 100644 index f98be769d9c3..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Optional catchall ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx deleted file mode 100644 index b22caba78d2f..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Required catchall ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx deleted file mode 100644 index b4aef2560f50..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Static ISR page at root -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx deleted file mode 100644 index a68fb122c81d..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Dynamic ISR with async function -export const generateStaticParams = async (): Promise => {}; diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx deleted file mode 100644 index f18f341f1f43..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Dynamic ISR page with generateStaticParams -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx deleted file mode 100644 index b46b50a71d44..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Regular page without ISR (no generateStaticParams) -export {}; diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx deleted file mode 100644 index 3774c141885d..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Mixed static-dynamic ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts b/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts deleted file mode 100644 index 56d097df0180..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -import path from 'path'; -import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; - -describe('ISR route detection and matching', () => { - const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); - - describe('ISR detection', () => { - test('should detect static ISR pages with generateStaticParams', () => { - expect(manifest.isrRoutes).toContain('/'); - expect(manifest.isrRoutes).toContain('/blog'); - }); - - test('should detect dynamic ISR pages with generateStaticParams', () => { - expect(manifest.isrRoutes).toContain('/products/:id'); - expect(manifest.isrRoutes).toContain('/posts/:slug'); - }); - - test('should detect nested dynamic ISR pages', () => { - expect(manifest.isrRoutes).toContain('/articles/:category/:slug'); - }); - - test('should detect optional catchall ISR pages', () => { - expect(manifest.isrRoutes).toContain('/docs/:path*?'); - }); - - test('should detect required catchall ISR pages', () => { - expect(manifest.isrRoutes).toContain('/guides/:segments*'); - }); - - test('should detect mixed static-dynamic ISR pages', () => { - expect(manifest.isrRoutes).toContain('/users/:id/profile'); - }); - - test('should NOT detect pages without generateStaticParams as ISR', () => { - expect(manifest.isrRoutes).not.toContain('/regular'); - }); - - test('should detect both function and const generateStaticParams', () => { - // /blog uses function declaration - // /posts/[slug] uses const declaration - expect(manifest.isrRoutes).toContain('/blog'); - expect(manifest.isrRoutes).toContain('/posts/:slug'); - }); - - test('should detect async generateStaticParams', () => { - // Multiple pages use async - this should work - expect(manifest.isrRoutes).toContain('/products/:id'); - expect(manifest.isrRoutes).toContain('/posts/:slug'); - }); - }); - - describe('Route matching against pathnames', () => { - describe('single dynamic segment ISR routes', () => { - test('should match /products/:id against various product IDs', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/products/1')).toBe(true); - expect(regex.test('/products/123')).toBe(true); - expect(regex.test('/products/abc-def')).toBe(true); - expect(regex.test('/products/product-with-dashes')).toBe(true); - expect(regex.test('/products/UPPERCASE')).toBe(true); - - // Should NOT match - expect(regex.test('/products')).toBe(false); - expect(regex.test('/products/')).toBe(false); - expect(regex.test('/products/123/extra')).toBe(false); - expect(regex.test('/product/123')).toBe(false); // typo - }); - - test('should match /posts/:slug against various slugs', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/posts/:slug'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/posts/hello')).toBe(true); - expect(regex.test('/posts/world')).toBe(true); - expect(regex.test('/posts/my-awesome-post')).toBe(true); - expect(regex.test('/posts/post_with_underscores')).toBe(true); - - // Should NOT match - expect(regex.test('/posts')).toBe(false); - expect(regex.test('/posts/')).toBe(false); - expect(regex.test('/posts/hello/world')).toBe(false); - }); - }); - - describe('nested dynamic segments ISR routes', () => { - test('should match /articles/:category/:slug against various paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/articles/:category/:slug'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/articles/tech/nextjs-guide')).toBe(true); - expect(regex.test('/articles/tech/react-tips')).toBe(true); - expect(regex.test('/articles/programming/typescript-advanced')).toBe(true); - expect(regex.test('/articles/news/breaking-news-2024')).toBe(true); - - // Should NOT match - expect(regex.test('/articles')).toBe(false); - expect(regex.test('/articles/tech')).toBe(false); - expect(regex.test('/articles/tech/nextjs-guide/extra')).toBe(false); - - // Extract parameters - const match = '/articles/tech/nextjs-guide'.match(regex); - expect(match).toBeTruthy(); - expect(match?.[1]).toBe('tech'); - expect(match?.[2]).toBe('nextjs-guide'); - }); - }); - - describe('mixed static-dynamic ISR routes', () => { - test('should match /users/:id/profile against user profile paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/users/:id/profile'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/users/user1/profile')).toBe(true); - expect(regex.test('/users/user2/profile')).toBe(true); - expect(regex.test('/users/john-doe/profile')).toBe(true); - expect(regex.test('/users/123/profile')).toBe(true); - - // Should NOT match - expect(regex.test('/users/user1')).toBe(false); - expect(regex.test('/users/user1/profile/edit')).toBe(false); - expect(regex.test('/users/profile')).toBe(false); - expect(regex.test('/user/user1/profile')).toBe(false); // typo - - // Extract parameter - const match = '/users/john-doe/profile'.match(regex); - expect(match).toBeTruthy(); - expect(match?.[1]).toBe('john-doe'); - }); - }); - - describe('optional catchall ISR routes', () => { - test('should match /docs/:path*? against various documentation paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - with paths - expect(regex.test('/docs/getting-started')).toBe(true); - expect(regex.test('/docs/api/reference')).toBe(true); - expect(regex.test('/docs/guides/installation/quick-start')).toBe(true); - expect(regex.test('/docs/a')).toBe(true); - expect(regex.test('/docs/a/b/c/d/e')).toBe(true); - - // Should match - without path (optional catchall) - expect(regex.test('/docs')).toBe(true); - - // Should NOT match - expect(regex.test('/doc')).toBe(false); // typo - expect(regex.test('/')).toBe(false); - expect(regex.test('/documents/test')).toBe(false); - - // Extract parameters - const matchWithPath = '/docs/api/reference'.match(regex); - expect(matchWithPath).toBeTruthy(); - expect(matchWithPath?.[1]).toBe('api/reference'); - - const matchNoPath = '/docs'.match(regex); - expect(matchNoPath).toBeTruthy(); - // Optional catchall without path - expect(matchNoPath?.[1]).toBeUndefined(); - }); - }); - - describe('required catchall ISR routes', () => { - test('should match /guides/:segments* against guide paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/guides/:segments*'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - with paths (required) - expect(regex.test('/guides/intro')).toBe(true); - expect(regex.test('/guides/advanced/topics')).toBe(true); - expect(regex.test('/guides/getting-started/installation/setup')).toBe(true); - - // Should NOT match - without path (required catchall needs at least one segment) - expect(regex.test('/guides')).toBe(false); - expect(regex.test('/guides/')).toBe(false); - - // Should NOT match - wrong path - expect(regex.test('/guide/intro')).toBe(false); // typo - expect(regex.test('/')).toBe(false); - - // Extract parameters - const match = '/guides/advanced/topics'.match(regex); - expect(match).toBeTruthy(); - expect(match?.[1]).toBe('advanced/topics'); - }); - }); - - describe('real-world pathname simulations', () => { - test('should identify ISR pages from window.location.pathname examples', () => { - const testCases = [ - { pathname: '/', isISR: true, matchedRoute: '/' }, - { pathname: '/blog', isISR: true, matchedRoute: '/blog' }, - { pathname: '/products/123', isISR: true, matchedRoute: '/products/:id' }, - { pathname: '/products/gaming-laptop', isISR: true, matchedRoute: '/products/:id' }, - { pathname: '/posts/hello-world', isISR: true, matchedRoute: '/posts/:slug' }, - { pathname: '/articles/tech/nextjs-guide', isISR: true, matchedRoute: '/articles/:category/:slug' }, - { pathname: '/users/john/profile', isISR: true, matchedRoute: '/users/:id/profile' }, - { pathname: '/docs', isISR: true, matchedRoute: '/docs/:path*?' }, - { pathname: '/docs/getting-started', isISR: true, matchedRoute: '/docs/:path*?' }, - { pathname: '/docs/api/reference/advanced', isISR: true, matchedRoute: '/docs/:path*?' }, - { pathname: '/guides/intro', isISR: true, matchedRoute: '/guides/:segments*' }, - { pathname: '/guides/advanced/topics/performance', isISR: true, matchedRoute: '/guides/:segments*' }, - { pathname: '/regular', isISR: false, matchedRoute: null }, - ]; - - testCases.forEach(({ pathname, isISR, matchedRoute }) => { - // Check if pathname matches any ISR route - let foundMatch = false; - let foundRoute = null; - - // Check static ISR routes - if (manifest.isrRoutes.includes(pathname)) { - foundMatch = true; - foundRoute = pathname; - } - - // Check dynamic ISR routes - if (!foundMatch) { - for (const route of manifest.dynamicRoutes) { - if (manifest.isrRoutes.includes(route.path)) { - const regex = new RegExp(route.regex!); - if (regex.test(pathname)) { - foundMatch = true; - foundRoute = route.path; - break; - } - } - } - } - - expect(foundMatch).toBe(isISR); - if (matchedRoute) { - expect(foundRoute).toBe(matchedRoute); - } - }); - }); - }); - - describe('edge cases and special characters', () => { - test('should handle paths with special characters in dynamic segments', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - const regex = new RegExp(route!.regex!); - - expect(regex.test('/products/product-123')).toBe(true); - expect(regex.test('/products/product_456')).toBe(true); - expect(regex.test('/products/PRODUCT-ABC')).toBe(true); - expect(regex.test('/products/2024-new-product')).toBe(true); - }); - - test('should handle deeply nested catchall paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); - const regex = new RegExp(route!.regex!); - - expect(regex.test('/docs/a/b/c/d/e/f/g/h/i/j')).toBe(true); - }); - - test('should not match paths with trailing slashes if route does not have them', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - const regex = new RegExp(route!.regex!); - - // Most Next.js routes don't match trailing slashes - expect(regex.test('/products/123/')).toBe(false); - }); - }); - - describe('parameter extraction for ISR routes', () => { - test('should extract single parameter from ISR route', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - const regex = new RegExp(route!.regex!); - - const match = '/products/gaming-laptop'.match(regex); - expect(match).toBeTruthy(); - expect(route?.paramNames).toEqual(['id']); - expect(match?.[1]).toBe('gaming-laptop'); - }); - - test('should extract multiple parameters from nested ISR route', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/articles/:category/:slug'); - const regex = new RegExp(route!.regex!); - - const match = '/articles/programming/typescript-advanced'.match(regex); - expect(match).toBeTruthy(); - expect(route?.paramNames).toEqual(['category', 'slug']); - expect(match?.[1]).toBe('programming'); - expect(match?.[2]).toBe('typescript-advanced'); - }); - - test('should extract catchall parameter from ISR route', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); - const regex = new RegExp(route!.regex!); - - const match = '/docs/api/reference/advanced'.match(regex); - expect(match).toBeTruthy(); - expect(route?.paramNames).toEqual(['path']); - expect(match?.[1]).toBe('api/reference/advanced'); - }); - }); - }); - - describe('complete manifest structure', () => { - test('should have correct structure with all route types', () => { - expect(manifest).toHaveProperty('staticRoutes'); - expect(manifest).toHaveProperty('dynamicRoutes'); - expect(manifest).toHaveProperty('isrRoutes'); - expect(Array.isArray(manifest.staticRoutes)).toBe(true); - expect(Array.isArray(manifest.dynamicRoutes)).toBe(true); - expect(Array.isArray(manifest.isrRoutes)).toBe(true); - }); - - test('should include both ISR and non-ISR routes in main route lists', () => { - // ISR static routes should be in staticRoutes - expect(manifest.staticRoutes.some(r => r.path === '/')).toBe(true); - expect(manifest.staticRoutes.some(r => r.path === '/blog')).toBe(true); - - // Non-ISR static routes should also be in staticRoutes - expect(manifest.staticRoutes.some(r => r.path === '/regular')).toBe(true); - - // ISR dynamic routes should be in dynamicRoutes - expect(manifest.dynamicRoutes.some(r => r.path === '/products/:id')).toBe(true); - }); - - test('should only include ISR routes in isrRoutes list', () => { - // ISR routes should be in the list - expect(manifest.isrRoutes).toContain('/'); - expect(manifest.isrRoutes).toContain('/blog'); - expect(manifest.isrRoutes).toContain('/products/:id'); - - // Non-ISR routes should NOT be in the list - expect(manifest.isrRoutes).not.toContain('/regular'); - }); - }); -}); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index 32ac315b3571..e34dcde80600 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -29,7 +29,6 @@ describe('route-groups', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); // Verify we have 9 static routes total (including duplicates from special chars) expect(manifest.staticRoutes).toHaveLength(9); @@ -79,7 +78,6 @@ describe('route-groups', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); expect(manifest.staticRoutes).toHaveLength(9); }); diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts index 6701ef0875d4..a6f03f49b6fe 100644 --- a/packages/nextjs/test/config/manifest/suites/static/static.test.ts +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -8,7 +8,6 @@ describe('static', () => { expect(manifest).toEqual({ staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], dynamicRoutes: [], - isrRoutes: [], }); }); }); diff --git a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts index 6d46c20b1e11..6de4a3a697fd 100644 --- a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts +++ b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts @@ -15,12 +15,10 @@ describe('maybeSetClientTraceMetadataOption', () => { expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'my-custom-key']); }); - it('does NOT enable trace meta tags when Cache Components is enabled', () => { - // With Cache Components, the shell is prerendered/detached from the request, so the meta-tag - // trace would be stale. The SDK skips enabling `clientTraceMetadata` so the browser pageload - // starts a fresh trace instead of stitching onto a stale trace. + it('enables trace meta tags when Cache Components is enabled', () => { + // The server withholds the tags while it prerenders, see `nextSentryPropagator.ts`. const config: NextConfigObject = { cacheComponents: true }; maybeSetClientTraceMetadataOption(config, '16.0.0'); - expect(config.experimental?.clientTraceMetadata).toBeUndefined(); + expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace']); }); }); diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index d647c4d78ccf..844ac3f97644 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -32,7 +32,6 @@ describe('constructTurbopackConfig', () => { { path: '/users', regex: '/users' }, { path: '/api/health', regex: '/api/health' }, ], - isrRoutes: [], }; const mockSentryOptions = {}; @@ -285,7 +284,7 @@ describe('constructTurbopackConfig', () => { describe('with edge cases', () => { it('should handle empty route manifest', () => { const userNextConfig: NextConfigObject = {}; - const emptyManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [], isrRoutes: [] }; + const emptyManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [] }; const result = constructTurbopackConfig({ userNextConfig, @@ -325,7 +324,6 @@ describe('constructTurbopackConfig', () => { it('should handle complex route manifest', () => { const userNextConfig: NextConfigObject = {}; const complexManifest: RouteManifest = { - isrRoutes: [], dynamicRoutes: [ { path: '/users/[id]/posts/[postId]', regex: '/users/([^/]+)/posts/([^/]+)', paramNames: ['id', 'postId'] }, { path: '/api/[...params]', regex: '/api/(.+)', paramNames: ['params'] }, @@ -901,7 +899,7 @@ describe('condition field version gating', () => { const result = constructTurbopackConfig({ userNextConfig, - routeManifest: { dynamicRoutes: [], staticRoutes: [], isrRoutes: [] }, + routeManifest: { dynamicRoutes: [], staticRoutes: [] }, nextJsVersion: '17.0.0', }); @@ -1064,7 +1062,6 @@ describe('moduleMetadataInjection with applicationKey', () => { const mockRouteManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [{ path: '/', regex: '/' }], - isrRoutes: [], }; const result = constructTurbopackConfig({ @@ -1264,7 +1261,6 @@ describe('componentAnnotation with turbopackReactComponentAnnotation', () => { const mockRouteManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [{ path: '/', regex: '/' }], - isrRoutes: [], }; const result = constructTurbopackConfig({ diff --git a/packages/nextjs/test/server/nextSentryPropagator.test.ts b/packages/nextjs/test/server/nextSentryPropagator.test.ts new file mode 100644 index 000000000000..03c2e40e4e2d --- /dev/null +++ b/packages/nextjs/test/server/nextSentryPropagator.test.ts @@ -0,0 +1,162 @@ +import { context, propagation, trace } from '@opentelemetry/api'; +import { getCurrentScope, setCurrentClient, startSpan } from '@sentry/core'; +import { NodeClient, getDefaultIntegrations } from '@sentry/node'; +import { SentryPropagator, SentryTracerProvider } from '@sentry/opentelemetry'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { NextSentryPropagator, registerNextSentryPropagator } from '../../src/server/nextSentryPropagator'; +import { isPrerenderContext, isPrerenderSpanStart } from '../../src/server/prerenderContext'; + +const PRERENDER_SPAN_OPTIONS = { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult', + [ATTR_NEXT_SPAN_NAME]: 'prerender route (app) /[id]', + }, +}; + +const RENDER_SPAN_OPTIONS = { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult', + [ATTR_NEXT_SPAN_NAME]: 'render route (app) /blocking', + }, +}; + +// Next.js collects the meta tag values with an array carrier, see `getTracePropagationData` of its tracer. +function inject(): Record { + const entries: Array<{ key: string; value: string }> = []; + propagation.inject(context.active(), entries, { + set: (c, key, value) => (c as typeof entries).push({ key, value }), + }); + return Object.fromEntries(entries.map(({ key, value }) => [key, value])); +} + +function injectHeaders(): Record { + const headers: Record = {}; + propagation.inject(context.active(), headers); + return headers; +} + +function setup(tracesSampleRate: number): void { + const client = new NodeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate, + enableOpenTelemetrySetup: true, + integrations: getDefaultIntegrations({}), + stackParser: () => [], + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); + setCurrentClient(client); + client.init(); + + const provider = new SentryTracerProvider(); + trace.setGlobalTracerProvider(provider); + propagation.setGlobalPropagator(new SentryPropagator()); + registerNextSentryPropagator(provider); +} + +describe('NextSentryPropagator', () => { + beforeEach(() => setup(1)); + + afterEach(() => { + getCurrentScope().setClient(undefined); + trace.disable(); + context.disable(); + propagation.disable(); + vi.restoreAllMocks(); + }); + + it('replaces the registered global propagator', () => { + const injectSpy = vi.spyOn(NextSentryPropagator.prototype, 'inject'); + inject(); + expect(injectSpy).toHaveBeenCalledTimes(1); + }); + + it('hands out trace context while a route is rendered for a request', () => { + trace.getTracer('next.js').startActiveSpan('render route (app) /blocking', RENDER_SPAN_OPTIONS, span => { + expect(isPrerenderContext(context.active())).toBe(false); + expect(inject()).toEqual({ + 'sentry-trace': `${span.spanContext().traceId}-${span.spanContext().spanId}-1`, + baggage: expect.stringContaining('sentry-trace_id='), + }); + span.end(); + }); + }); + + it('hands out nothing while a route is prerendered', () => { + trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { + expect(isPrerenderContext(context.active())).toBe(true); + expect(inject()).toEqual({}); + span.end(); + }); + }); + + it('keeps propagating to outgoing requests while a route is prerendered', () => { + trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { + expect(inject()).toEqual({}); + expect(injectHeaders()['sentry-trace']).toBe(`${span.spanContext().traceId}-${span.spanContext().spanId}-1`); + span.end(); + }); + }); + + it('hands out nothing inside a span nested in a prerender', () => { + trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { + startSpan({ name: 'server component' }, () => { + expect(inject()).toEqual({}); + }); + span.end(); + }); + }); + + it('hands out nothing while a route is prerendered in an unsampled trace', () => { + setup(0); + + trace.getTracer('next.js').startActiveSpan('GET /[id]', span => { + expect(span.isRecording()).toBe(false); + expect(inject()['sentry-trace']).toMatch(/-0$/); + + trace + .getTracer('next.js') + .startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, prerenderSpan => { + expect(prerenderSpan.isRecording()).toBe(false); + expect(inject()).toEqual({}); + prerenderSpan.end(); + }); + span.end(); + }); + }); + + it('recognizes a prerender span that was started inactive and activated by the caller', () => { + const span = trace.getTracer('next.js').startSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS); + context.with(trace.setSpan(context.active(), span), () => { + expect(isPrerenderContext(context.active())).toBe(true); + expect(inject()).toEqual({}); + }); + span.end(); + + const renderSpan = trace.getTracer('next.js').startSpan('render route (app) /blocking', RENDER_SPAN_OPTIONS); + context.with(trace.setSpan(context.active(), renderSpan), () => { + expect(inject()['sentry-trace']).toBeDefined(); + }); + renderSpan.end(); + }); + + it('marks an explicitly passed context', () => { + trace + .getTracer('next.js') + .startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, context.active(), span => { + expect(isPrerenderContext(context.active())).toBe(true); + expect(inject()).toEqual({}); + span.end(); + }); + }); + + it('does not mistake other Next.js spans for a prerender', () => { + expect( + isPrerenderSpanStart('prerender route (app) /x', { + attributes: { [ATTR_NEXT_SPAN_TYPE]: 'NextNodeServer.getLayoutOrPageModule' }, + }), + ).toBe(false); + expect(isPrerenderSpanStart('prerender route (app) /x', undefined)).toBe(false); + expect(isPrerenderSpanStart('prerender route (app) /x', PRERENDER_SPAN_OPTIONS)).toBe(true); + }); +}); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 61d15736a3d0..7ef42977be47 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -6,6 +6,13 @@ import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/server'; +import type * as NextSentryPropagatorModule from '../src/server/nextSentryPropagator'; +import { registerNextSentryPropagator } from '../src/server/nextSentryPropagator'; + +vi.mock('../src/server/nextSentryPropagator', async importOriginal => ({ + ...(await importOriginal()), + registerNextSentryPropagator: vi.fn(), +})); // normally this is set as part of the build process, so mock it here (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir: string })._sentryRewriteFramesDistDir = '.next'; @@ -63,6 +70,33 @@ describe('Server init()', () => { ); }); + describe('trace propagator', () => { + it('replaces the propagator when the Node SDK set up OpenTelemetry itself', () => { + const traceProvider = {}; + nodeInit.mockReturnValueOnce({ + on: vi.fn(), + addEventProcessor: vi.fn(), + traceProvider, + } as unknown as ReturnType); + + init({}); + + expect(registerNextSentryPropagator).toHaveBeenCalledWith(traceProvider); + }); + + it('leaves a foreign OpenTelemetry setup alone', () => { + nodeInit.mockReturnValueOnce({ + on: vi.fn(), + addEventProcessor: vi.fn(), + traceProvider: undefined, + } as unknown as ReturnType); + + init({}); + + expect(registerNextSentryPropagator).not.toHaveBeenCalled(); + }); + }); + it("doesn't reinitialize the node SDK if already initialized", () => { expect(nodeInit).toHaveBeenCalledTimes(0); init({}); From 63808731fc7209f0342d156d3e458d67cb84c9fb Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:20:43 +0200 Subject: [PATCH 25/30] test(nuxt): Pin the nuxt-5 e2e MongoDB image to 8.0 (#25564) Same fix as #25557, applied to nuxt-5. It failed the same way on #25552 (`container e2e-tests-nuxt-5-mongo is unhealthy`) The floating `mongo:8` tag moved to 8.3 on 2026-10-17. On CI the container goes unhealthy some seconds after start. That is much earlier than the healthcheck retries allow, so the server process exits, and Docker then reports the container as unhealthy. If setup fails again, the container logs now print, so we can see why mongod exits. --- .../test-applications/nuxt-5/docker-compose.yml | 3 ++- .../test-applications/nuxt-5/global-setup.mjs | 13 +++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml index a392ebd433af..804b73fcee00 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml @@ -1,6 +1,7 @@ services: mongo: - image: mongo:8 + # `mongo:8` floats to the newest 8.x release, whose server sometimes exits right after start on CI. + image: mongo:8.0 restart: always container_name: e2e-tests-nuxt-5-mongo ports: diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs index fbc228dc4e55..826a78375d74 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs @@ -7,8 +7,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default async function globalSetup() { // Start MongoDB via Docker Compose. `--wait` blocks until the // healthcheck in docker-compose.yml passes, so the app can connect immediately. - execSync('docker compose up -d --wait', { - cwd: __dirname, - stdio: 'inherit', - }); + try { + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); + } catch (error) { + execSync('docker compose logs --tail 50', { cwd: __dirname, stdio: 'inherit' }); + throw error; + } } From ebe8fdc6777cf0f880e15ca7af074394f9aebc37 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:26:47 +0200 Subject: [PATCH 26/30] fix(server-utils): Bundle the CommonJS dependencies of instrumented packages on Vite 8 (#25523) We put pg into ssr.noExternal and that bundles `pg` and changes its CJS imports. In the second bundling step, Rolldown in Vite 8 emits `__require("pg-types")` for the dependency imports of pg (see [their migration guide](https://vite.dev/guide/migration#require-calls-for-externalized-modules) mentioning `esmExternalRequirePlugin` - also check the linked rolldown docs). As `__require` comes from `module.createRequire`, the bundlers think it's a normal function variable, and not an import. Therefore, a sub-dependency like `pg-types` cannot get into the final bundle. The plugin now bundles exactly these `require()` calls. The user's `ssr.external` entries and optional dependencies stay external. We didn't use `esmExternalRequirePlugin`, because it breaks function-exported CommonJS on Node < 23. Stacked on #25359. Fixes #25470 --- **Same problem: `__require()` of an external package stays in bundled CommonJS** - https://github.com/nitrojs/nitro/issues/4171 - https://github.com/rolldown/rolldown/issues/10643 - https://github.com/rolldown/rolldown/issues/9407 **Workarounds and fixes in other tools** - https://github.com/nitrojs/nitro/pull/4365 - https://github.com/vitejs/vite/issues/22618 - https://github.com/vitejs/vite/pull/23759 - https://github.com/vitejs/vite/issues/21969 **Existing Sentry issues** - https://github.com/getsentry/sentry-javascript/issues/24775 - https://github.com/getsentry/sentry-javascript/issues/25339 --- .../tanstackstart-react/package.json | 5 + .../tests/server-build.test.ts | 22 +++ .../orchestrion/bundler/externalRequire.ts | 125 +++++++++++++ .../src/orchestrion/bundler/vite.ts | 7 +- .../test/orchestrion/bundler.test.ts | 21 +++ .../test/orchestrion/externalRequire.test.ts | 173 ++++++++++++++++++ 6 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/server-build.test.ts create mode 100644 packages/server-utils/src/orchestrion/bundler/externalRequire.ts create mode 100644 packages/server-utils/test/orchestrion/externalRequire.test.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index d080dc4e25fd..ed739895d0d9 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -14,6 +14,7 @@ "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:hoisted": "pnpm install --config.node-linker=hoisted && pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", "test:assert": "pnpm test:assert:proxy", @@ -51,6 +52,10 @@ }, "sentryTest": { "variants": [ + { + "label": "tanstackstart-react (hoisted)", + "build-command": "pnpm test:build:hoisted" + }, { "label": "tanstackstart-react (tunnel-generated)", "build-command": "pnpm test:build:tunnel-generated", diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/server-build.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/server-build.test.ts new file mode 100644 index 000000000000..57bf54397164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/server-build.test.ts @@ -0,0 +1,22 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; +import { bundleReferencesModule } from '@sentry-internal/test-utils'; + +// Nitro can't follow the `__require()` calls that Vite 8 emits for external requires in bundled +// CommonJS, so the deployed server fails with `Cannot find module`. Only flat installs (the hoisted +// variant) keep the drivers' dependencies external. +// https://vite.dev/guide/migration#require-calls-for-externalized-modules +test('bundles the CommonJS dependencies of the instrumented drivers', () => { + const root = path.join(process.cwd(), '.output/server'); + const serverBundle = readdirSync(root, { recursive: true, encoding: 'utf8' }) + .filter(file => /\.m?js$/.test(file) && !file.startsWith('node_modules')) + .map(file => readFileSync(path.join(root, file), 'utf8')) + .join('\n'); + const driverDependencies = ['sqlstring', 'bignumber.js', 'denque', 'cluster-key-slot', 'standard-as-callback']; + + const externalRequires = [...serverBundle.matchAll(/__require\(["']([^"']+)["']\)/g)].map(match => match[1]); + + expect(bundleReferencesModule(serverBundle, 'mysql'), 'mysql must be bundled to be instrumented').toBe(false); + expect(externalRequires.filter(name => driverDependencies.includes(name))).toEqual([]); +}); diff --git a/packages/server-utils/src/orchestrion/bundler/externalRequire.ts b/packages/server-utils/src/orchestrion/bundler/externalRequire.ts new file mode 100644 index 000000000000..971f19c322d6 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/externalRequire.ts @@ -0,0 +1,125 @@ +import { isPlainObject } from '@sentry/core'; +import { readFileSync } from 'node:fs'; +import { createRequire, isBuiltin } from 'node:module'; +import { isAbsolute } from 'node:path'; +import type { Plugin } from 'vite'; + +type ResolveIdHandler = Extract, (...args: never[]) => unknown>; +type ResolveIdParameters = Parameters; + +const NODE_MODULES = '/node_modules/'; + +/** + * Creates a `resolveId` handler that bundles the external packages that bundled CommonJS `require()`s. + * + * Rolldown keeps these calls as `__require()`, which a second bundler over the server output can't follow: + * - https://vite.dev/guide/migration#require-calls-for-externalized-modules + * - https://rolldown.rs/in-depth/bundling-cjs#require-external-modules + * + * Only handles requires from packages bundled for orchestrion, and from packages bundled because of them. + */ +export function createExternalRequireResolver( + moduleNames: readonly string[], +): (this: ThisParameterType, ...args: ResolveIdParameters) => Promise { + const bundledPackagesByEnvironment = new Map>(); + + return async function resolveExternalRequire(source, importer, options) { + if ( + !this.meta.rolldownVersion || + this.environment.mode !== 'build' || + options.kind !== 'require-call' || + !importer || + !isPackageSpecifier(source) + ) { + return null; + } + + const bundledPackages = bundledPackagesByEnvironment.get(this.environment.name) ?? new Set(moduleNames); + bundledPackagesByEnvironment.set(this.environment.name, bundledPackages); + + const owner = ownerPackage(importer); + const name = packageName(source); + if ( + !owner || + !bundledPackages.has(owner.name) || + isUserExternal(this.environment.config.resolve.external, source, name) || + isOptionalDependency(owner.dir, name) + ) { + return null; + } + + const resolved = await this.resolve(source, importer, { ...options, skipSelf: true }); + if (!resolved) { + return null; + } + // Vite bundles it anyway (e.g. under pnpm), so its own requires count too. + if (!resolved.external) { + bundledPackages.add(name); + return null; + } + + const file = resolveWithNode(source, importer); + if (file) { + bundledPackages.add(name); + } + return file ?? null; + }; +} + +function isPackageSpecifier(source: string): boolean { + return !isBuiltin(source) && !isAbsolute(source) && !/^[.\0]/.test(source); +} + +// `pg-types/lib/x` → `pg-types`, `@scope/pkg/x` → `@scope/pkg`. +function packageName(specifier: string): string { + const [first = '', second] = specifier.split('/'); + return first.startsWith('@') && second ? `${first}/${second}` : first; +} + +// Uses the last `node_modules` segment, so nested and pnpm store paths work. +function ownerPackage(file: string): { name: string; dir: string } | undefined { + const path = file.replace(/\\/g, '/'); + const start = path.lastIndexOf(NODE_MODULES); + if (start === -1) { + return undefined; + } + const name = packageName(path.slice(start + NODE_MODULES.length)); + return { name, dir: `${path.slice(0, start)}${NODE_MODULES}${name}` }; +} + +// Matches like Vite. `external: true` is Vite's SSR default, not a choice for this package. +function isUserExternal(external: readonly string[] | true, source: string, name: string): boolean { + return Array.isArray(external) && external.some(entry => entry === source || entry === name); +} + +// These can be missing or native, like `pg-native`. +function isOptionalDependency(packageDir: string, name: string): boolean { + const manifest = readManifest(`${packageDir}/package.json`); + if (!manifest) { + return false; + } + const { peerDependenciesMeta, optionalDependencies } = manifest; + const peerMeta = isPlainObject(peerDependenciesMeta) ? peerDependenciesMeta[name] : undefined; + return ( + (isPlainObject(peerMeta) && peerMeta.optional === true) || + (isPlainObject(optionalDependencies) && name in optionalDependencies) + ); +} + +// The file that `require()` loads at runtime. +function resolveWithNode(source: string, importer: string): string | undefined { + try { + return createRequire(importer).resolve(source); + } catch { + return undefined; + } +} + +function readManifest(file: string): Record | undefined { + try { + const manifest: unknown = JSON.parse(readFileSync(file, 'utf8')); + return isPlainObject(manifest) ? manifest : undefined; + } catch { + return undefined; + } +} diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index c6a704bb1ca9..16e642dc76fd 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -5,6 +5,7 @@ export type { Plugin as VitePlugin } from 'vite'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options'; +import { createExternalRequireResolver } from './externalRequire'; import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './resolve'; type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown; @@ -73,6 +74,7 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { ...instrumentedModuleNames(options.instrumentations), '@sentry/server-utils', ]; + const resolveExternalRequire = createExternalRequireResolver(noExternalModules()); const instance = {}; // `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone. @@ -94,7 +96,10 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { // packages into default imports, and `@sentry/core` has no default export, so the server // crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`. async resolveId(source, importer, resolveOptions) { - if (source !== SNIPPET_IMPORT_SPECIFIER || !resolveOptions?.ssr) { + if (source !== SNIPPET_IMPORT_SPECIFIER) { + return resolveExternalRequire.call(this, source, importer, resolveOptions); + } + if (!resolveOptions?.ssr) { return null; } const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index d6cf125df97d..b0690ab74903 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -446,6 +446,27 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(fallback).toMatch(/[/\\]build[/\\]esm[/\\]index\.js$/); expect(existsSync(fallback as string)).toBe(true); }); + + it('resolves the snippet require() to the ESM entry before it handles other requires on Rolldown', async () => { + const resolveId = vitePlugin().resolveId as ( + this: unknown, + source: string, + importer: string, + opts: { ssr: boolean; kind: string }, + ) => Promise; + const context = { + meta: { rolldownVersion: '1.2.14' }, + environment: { name: 'ssr', mode: 'build', config: { resolve: { external: [] } } }, + resolve: vi.fn().mockResolvedValue({ id: '/app/node_modules/@sentry/server-utils/build/cjs/index.js' }), + }; + + const resolved = await resolveId.call(context, '@sentry/server-utils', '/app/node_modules/pg/lib/client.js', { + ssr: true, + kind: 'require-call', + }); + + expect(resolved).toEqual({ id: '/app/node_modules/@sentry/server-utils/build/esm/index.js' }); + }); }); describe('sentryOrchestrionPlugin (bun)', () => { diff --git a/packages/server-utils/test/orchestrion/externalRequire.test.ts b/packages/server-utils/test/orchestrion/externalRequire.test.ts new file mode 100644 index 000000000000..73951a3b971e --- /dev/null +++ b/packages/server-utils/test/orchestrion/externalRequire.test.ts @@ -0,0 +1,173 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createExternalRequireResolver } from '../../src/orchestrion/bundler/externalRequire'; + +interface ContextOptions { + environment?: string; + meta?: Record; + mode?: string; + external?: string[] | true; + resolved?: { id: string; external?: boolean } | null; +} + +describe('createExternalRequireResolver', () => { + let root: string; + let pgFile: string; + + function installPackage(name: string, manifest: Record = {}, parentDir = root): string { + const dir = join(parentDir, 'node_modules', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, main: 'index.js', ...manifest })); + writeFileSync(join(dir, 'index.js'), ''); + return join(dir, 'index.js'); + } + + // By default, a Rolldown build in which Vite keeps the required package external. + function context({ + environment = 'ssr', + meta = { rolldownVersion: '1.2.14' }, + mode = 'build', + external = [], + resolved = { id: 'external', external: true }, + }: ContextOptions = {}): never { + return { + meta, + environment: { name: environment, mode, config: { resolve: { external } } }, + resolve: vi.fn().mockResolvedValue(resolved), + } as never; + } + + function resolveRequire( + resolver: ReturnType, + source: string, + importer: string, + contextOptions?: ContextOptions, + kind = 'require-call', + ): Promise { + return resolver.call(context(contextOptions), source, importer, { kind, isEntry: false } as never); + } + + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orch-external-require-'))); + pgFile = join(root, 'node_modules/pg/lib/client.js'); + installPackage('pg'); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('bundles an external package that an instrumented package requires', async () => { + const pgTypes = installPackage('pg-types'); + + await expect(resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile)).resolves.toBe(pgTypes); + }); + + it.each([ + ['it bundled', { id: 'external', external: true }], + ['Vite bundles itself', { id: 'pg-types/index.js' }], + ])('follows the requires of a package that %s', async (_, resolved) => { + const pgTypes = installPackage('pg-types'); + const postgresArray = installPackage('postgres-array'); + const resolver = createExternalRequireResolver(['pg']); + + await resolveRequire(resolver, 'pg-types', pgFile, { resolved }); + + await expect(resolveRequire(resolver, 'postgres-array', pgTypes)).resolves.toBe(postgresArray); + }); + + // Every require() here resolves to a real file, so only the filter can return `null`. + it.each([ + ['app code', 'pg-types', 'src/db.js'], + ['a package that orchestrion does not bundle', 'pg-types', 'node_modules/other/index.js'], + ['a builtin', 'node:net', 'node_modules/pg/lib/client.js'], + ['a relative path', './connection', 'node_modules/pg/lib/client.js'], + ])('ignores a require() of %s', async (_, source, importer) => { + installPackage('pg-types'); + installPackage('other'); + mkdirSync(join(root, 'node_modules/pg/lib'), { recursive: true }); + writeFileSync(join(root, 'node_modules/pg/lib/connection.js'), ''); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), source, join(root, importer)), + ).resolves.toBeNull(); + }); + + it('bundles a require() from a pnpm store path', async () => { + const store = join(root, 'node_modules/.pnpm/pg@8.23.1'); + installPackage('pg', {}, store); + const pgTypes = installPackage('pg-types', {}, store); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', join(store, 'node_modules/pg/lib/client.js')), + ).resolves.toBe(pgTypes); + }); + + it('tracks bundled packages for each environment separately', async () => { + const pgTypes = installPackage('pg-types'); + installPackage('postgres-array'); + const resolver = createExternalRequireResolver(['pg']); + + await resolveRequire(resolver, 'pg-types', pgFile, { environment: 'ssr' }); + + await expect(resolveRequire(resolver, 'postgres-array', pgTypes, { environment: 'nitro' })).resolves.toBeNull(); + }); + + it.each([ + ['on Rollup', { meta: { rollupVersion: '4.0.0' } }, 'require-call'], + ['on the dev server', { mode: 'dev' }, 'require-call'], + ['for an import', {}, 'import-statement'], + ])('leaves resolution to the bundler %s', async (_, contextOptions, kind) => { + installPackage('pg-types'); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile, contextOptions, kind), + ).resolves.toBeNull(); + }); + + it.each([ + ['an optional peer', { peerDependenciesMeta: { 'pg-native': { optional: true } } }], + ['an optional dependency', { optionalDependencies: { 'pg-native': '^3' } }], + ])('keeps %s external', async (_, manifest) => { + installPackage('pg', manifest); + installPackage('pg-native'); + + await expect(resolveRequire(createExternalRequireResolver(['pg']), 'pg-native', pgFile)).resolves.toBeNull(); + }); + + it('keeps a scoped subpath external when the user lists its package', async () => { + installPackage('@scope/pkg'); + writeFileSync(join(root, 'node_modules/@scope/pkg/sub.js'), ''); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), '@scope/pkg/sub', pgFile, { external: ['@scope/pkg'] }), + ).resolves.toBeNull(); + }); + + it('keeps a package external that the user lists in `external`', async () => { + installPackage('pg-types'); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile, { external: ['pg-types'] }), + ).resolves.toBeNull(); + }); + + it.each([ + ['only a subpath of the package', ['pg-types/lib/arrays']], + ['`true`, the SSR default', true], + ] as const)('bundles a package despite an external entry that names %s', async (_, external) => { + const pgTypes = installPackage('pg-types'); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile, { + external: external === true ? true : [...external], + }), + ).resolves.toBe(pgTypes); + }); + + it('keeps a package external that is not installed', async () => { + await expect(resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile)).resolves.toBeNull(); + }); +}); From d28566e5674c3cc12bebbb14798691e238bfc698 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 20 Oct 2026 17:37:01 +0200 Subject: [PATCH 27/30] feat(nextjs): Wait for the resumed part of a document before starting the pageload (#25142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #25141. When a document resumes a prerendered shell, its `sentry-trace` tag sits at the start of the resumed part, which arrives after the SDK has started. In a local demo six of seven visits found no tag at init, so the pageload could only link the server trace instead of joining it. The app router pageload now continues whatever tag is in the document, last one wins. In apps with `cacheComponents` enabled, if there is no tag, no `Server-Timing` trace and the document is still streaming, it waits for the tag before creating the pageload span. Other apps keep the immediate start: without `cacheComponents` a tag is either in `` from the start or never comes, so waiting only held the pageload back. The wait ends at `DOMContentLoaded` or after 10 seconds. The span still starts at the time origin and gets its children from the performance timeline, so it looks the same as before, only inside the server's trace. No tag by the end of parsing means a fresh trace, which is the fully static case. A client-side navigation during the wait starts the pageload with a fresh trace first, so the navigation ends it like any pageload it interrupts, as today. Started later, the pageload would end the navigation span instead. The pageload keeps the URL the document was loaded with, even if the navigation already changed it. Waiting was chosen over moving a started span to another trace. That needs core changes and does not work with span streaming once children are flushed. The cost: fetches, errors and clicks between SDK start and the resumed part are not attached to the pageload. The `Server-Timing` follow-ups (JS-3864, JS-3866) remove that window where the header can be set. figure-ppr Fixes #24944 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: s1gr1d <32902192+s1gr1d@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../app/blocking/page.tsx | 10 + .../app/static-shell/page.tsx | 4 + .../cacheOrigin-pageloadConnection.spec.ts | 96 +++++++- .../app/blocking/page.tsx | 10 + .../app/slow-hole/page.tsx | 24 ++ .../app/static-shell/page.tsx | 4 + .../cacheOrigin-pageloadConnection.spec.ts | 124 +++++++++- .../src/client/browserTracingIntegration.ts | 8 + .../appRouterRoutingInstrumentation.ts | 78 ++++++- .../src/client/routing/traceMetaTags.ts | 152 ++++++++++++ .../src/config/withSentryConfig/buildTime.ts | 5 + .../appRouterRoutingInstrumentation.test.ts | 72 ++++++ .../client/browserTracingIntegration.test.ts | 59 +++++ .../nextjs/test/client/traceMetaTags.test.ts | 221 ++++++++++++++++++ .../config/withSentryConfig/buildTime.test.ts | 14 ++ 16 files changed, 865 insertions(+), 18 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/blocking/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/static-shell/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/blocking/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/slow-hole/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/static-shell/page.tsx create mode 100644 packages/nextjs/src/client/routing/traceMetaTags.ts create mode 100644 packages/nextjs/test/client/browserTracingIntegration.test.ts create mode 100644 packages/nextjs/test/client/traceMetaTags.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7457cadef6bb..e82be6c72cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ - **feat(nextjs): Never emit trace meta tags from a prerender ([#25141](https://github.com/getsentry/sentry-javascript/pull/25141))** - Pageloads of apps with `cacheComponents` enabled are connected to their server request again. Pages that Next.js prerenders no longer carry trace information, so a cached page can no longer connect visitors to a request that was not theirs. This also applies to ISR pages. + Pageloads of apps with `cacheComponents` enabled are connected to their server request again. Pages that Next.js prerenders no longer carry trace information, so a cached page can no longer connect visitors to a request that was not theirs. This also applies to ISR pages. The pageload waits for the trace information when it arrives with the part of the page that streams in after a cached shell. If you set up OpenTelemetry yourself and register `SentryPropagator`, register `NextSentryPropagator` instead and pass your tracer provider to `markPrerendersOnTracerProvider`, so that your prerendered pages carry no trace information either. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/blocking/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/blocking/page.tsx new file mode 100644 index 000000000000..babed4b8590c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/blocking/page.tsx @@ -0,0 +1,10 @@ +import { headers } from 'next/headers'; + +// Opts out of the static shell: the whole page renders on the server for every request, so the +// trace meta tags land in like in a classic dynamic render. +export const instant = false; + +export default async function Page() { + const requestHeaders = await headers(); + return

Blocking render for {requestHeaders.get('user-agent') ? 'a browser' : 'nobody'}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/static-shell/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/static-shell/page.tsx new file mode 100644 index 000000000000..3ebb9903b2e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/static-shell/page.tsx @@ -0,0 +1,4 @@ +// No request data anywhere, so the whole page is a static shell and nothing renders per request. +export default function Page() { + return

Static shell

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts index 7de1b6a55a2d..900ce43679e9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts @@ -1,21 +1,24 @@ +import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -test('connects the pageload trace to the runtime server request', async ({ page }) => { - // The resumed part of the document, which carries the tag, arrives after the SDK started. Enabled - // once the pageload instrumentation waits for it. - test.skip(true, 'Needs the deferred pageload start'); +const APP = 'nextjs-16-cacheComponents'; + +// The server only writes `sentry-trace` meta tags for a render that happened for the current request, +// never while it prerenders a shell. So a tag in the document can always be continued; the only +// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started, +// which the pageload instrumentation waits for. +test('connects the pageload trace to the runtime server request', async ({ page }) => { // The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`) // runs on the server on every request and produces the `GET /pageload-tracing` transaction. - // A fully prerendered page would produce no server transaction. - const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + const serverTxPromise = waitForTransaction(APP, transactionEvent => { return ( transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /pageload-tracing' ); }); - const pageloadTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/pageload-tracing'; }); @@ -27,4 +30,83 @@ test('connects the pageload trace to the runtime server request', async ({ page expect(serverTx.contexts?.trace?.trace_id).toBeTruthy(); expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id); + expect(pageloadTx.contexts?.trace?.links).toBeUndefined(); +}); + +test('connects the pageload trace to a blocking render', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP, transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /blocking'; + }); + + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/blocking'; + }); + + await page.goto('/blocking'); + + await expect(page.locator('#blocking')).toHaveText('Blocking render for a browser'); + + const [serverTx, pageloadTx] = await Promise.all([serverTxPromise, pageloadTxPromise]); + + // The whole page renders per request, so the tag in is this request's and is continued. + expect(serverTx.contexts?.trace?.trace_id).toBeTruthy(); + expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id); + expect(pageloadTx.contexts?.trace?.links).toBeUndefined(); +}); + +test('does not share a trace between visitors of a shell generated at request time', async ({ browser }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds'); + + // `[id]` only lists `test` in `generateStaticParams`, so the first visit prerenders and caches the + // shell for this id. The prerender writes no trace tag, so every visitor starts a fresh trace. + const id = `shell-${Date.now()}`; + + const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => { + const context = await browser.newContext(); + const page: Page = await context.newPage(); + + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.contexts?.trace?.data?.['url.path'] === `/${id}` + ); + }); + + await page.goto(`/${id}`); + await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + const pageloadTx = await pageloadTxPromise; + await context.close(); + + return { traceId: pageloadTx.contexts?.trace?.trace_id, links: pageloadTx.contexts?.trace?.links }; + }; + + const first = await visit(); + const second = await visit(); + + expect(first.traceId).toBeTruthy(); + expect(second.traceId).toBeTruthy(); + expect(second.traceId).not.toBe(first.traceId); + expect(first.links).toBeUndefined(); + expect(second.links).toBeUndefined(); +}); + +test('starts a fresh trace on a fully static page', async ({ page }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Pages are only prerendered in production builds'); + + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/static-shell'; + }); + + await page.goto('/static-shell'); + + await expect(page.locator('#static-shell')).toHaveText('Static shell'); + + const pageloadTx = await pageloadTxPromise; + + // A build-time shell carries no trace context at all. + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + expect(pageloadTx.contexts?.trace?.trace_id).toBeTruthy(); + expect(pageloadTx.contexts?.trace?.links).toBeUndefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/blocking/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/blocking/page.tsx new file mode 100644 index 000000000000..babed4b8590c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/blocking/page.tsx @@ -0,0 +1,10 @@ +import { headers } from 'next/headers'; + +// Opts out of the static shell: the whole page renders on the server for every request, so the +// trace meta tags land in like in a classic dynamic render. +export const instant = false; + +export default async function Page() { + const requestHeaders = await headers(); + return

Blocking render for {requestHeaders.get('user-agent') ? 'a browser' : 'nobody'}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/slow-hole/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/slow-hole/page.tsx new file mode 100644 index 000000000000..201e4102cb0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/slow-hole/page.tsx @@ -0,0 +1,24 @@ +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { Suspense } from 'react'; + +// The dynamic part, and with it the trace meta tag, streams in seconds after the shell, so a test +// can navigate while the pageload still waits for the tag. +async function SlowContent() { + await headers(); + await new Promise(resolve => setTimeout(resolve, 4000)); + return

Slow content

; +} + +export default function Page() { + return ( + <> + + Static shell + + Loading...

}> + +
+ + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/static-shell/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/static-shell/page.tsx new file mode 100644 index 000000000000..3ebb9903b2e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/static-shell/page.tsx @@ -0,0 +1,4 @@ +// No request data anywhere, so the whole page is a static shell and nothing renders per request. +export default function Page() { + return

Static shell

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts index 6782257a59d8..89f9dc32d26c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts @@ -1,19 +1,22 @@ +import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('connects the pageload trace to the runtime server request', async ({ page }) => { - // The resumed part of the document, which carries the tag, arrives after the SDK started. Enabled - // once the pageload instrumentation waits for it. - test.skip(true, 'Needs the deferred pageload start'); +const APP = 'nextjs-16-streaming-cacheComponents'; + +// The server only writes `sentry-trace` meta tags for a render that happened for the current request, +// never while it prerenders a shell. So a tag in the document can always be continued; the only +// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started, +// which the pageload instrumentation waits for. +test('connects the pageload trace to the runtime server request', async ({ page }) => { // The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`) // runs on the server on every request and produces the `GET /pageload-tracing` root span. - // A fully prerendered page would produce no server span. - const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { + const serverSpanPromise = waitForStreamedSpan(APP, span => { return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment; }); - const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; }); @@ -25,4 +28,111 @@ test('connects the pageload trace to the runtime server request', async ({ page expect(serverSpan.trace_id).toBeTruthy(); expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id); + expect(pageloadSpan.links).toBeUndefined(); +}); + +test('connects the pageload trace to a blocking render', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === 'GET /blocking' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === '/blocking' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/blocking'); + + await expect(page.locator('#blocking')).toHaveText('Blocking render for a browser'); + + const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); + + // The whole page renders per request, so the tag in is this request's and is continued. + expect(serverSpan.trace_id).toBeTruthy(); + expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id); + expect(pageloadSpan.links).toBeUndefined(); +}); + +test('does not share a trace between visitors of a shell generated at request time', async ({ browser }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds'); + + // `[id]` only lists `test` in `generateStaticParams`, so the first visit prerenders and caches the + // shell for this id. The prerender writes no trace tag, so every visitor starts a fresh trace. + const id = `shell-${Date.now()}`; + + const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => { + const context = await browser.newContext(); + const page: Page = await context.newPage(); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.attributes['url.path']?.value === `/${id}`; + }); + + await page.goto(`/${id}`); + await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + const pageloadSpan = await pageloadSpanPromise; + await context.close(); + + return { traceId: pageloadSpan.trace_id, links: pageloadSpan.links }; + }; + + const first = await visit(); + const second = await visit(); + + expect(first.traceId).toBeTruthy(); + expect(second.traceId).toBeTruthy(); + expect(second.traceId).not.toBe(first.traceId); + expect(first.links).toBeUndefined(); + expect(second.links).toBeUndefined(); +}); + +test('starts a fresh trace on a fully static page', async ({ page }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Pages are only prerendered in production builds'); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === '/static-shell' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/static-shell'); + + await expect(page.locator('#static-shell')).toHaveText('Static shell'); + + const pageloadSpan = await pageloadSpanPromise; + + // A build-time shell carries no trace context at all. + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + expect(pageloadSpan.trace_id).toBeTruthy(); + expect(pageloadSpan.links).toBeUndefined(); +}); + +test('keeps a navigation that starts while the pageload waits for the trace meta tag', async ({ page }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Only a prerendered shell makes the trace meta tag arrive late'); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === '/slow-hole' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === '/static-shell' && getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/slow-hole', { waitUntil: 'commit' }); + await page.waitForFunction(() => !!(window as { next?: { router?: unknown } }).next?.router); + + // The resumed part, which carries the tag, has not arrived yet, so the pageload is still waiting. + await expect(page.locator('#slow-fallback')).toBeVisible(); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + await page.locator('#navigate').click(); + await expect(page.locator('#static-shell')).toHaveText('Static shell'); + + const [pageloadSpan, navigationSpan] = await Promise.all([pageloadSpanPromise, navigationSpanPromise]); + + // The navigation ends the pageload like any pageload it interrupts, and runs to its own idle timeout. + expect(pageloadSpan.attributes['sentry.idle_span_finish_reason']).toEqual({ type: 'string', value: 'cancelled' }); + expect(navigationSpan.attributes['sentry.idle_span_finish_reason']).toEqual({ + type: 'string', + value: 'idleTimeout', + }); }); diff --git a/packages/nextjs/src/client/browserTracingIntegration.ts b/packages/nextjs/src/client/browserTracingIntegration.ts index cd957d1d62b5..acd961a77c8e 100644 --- a/packages/nextjs/src/client/browserTracingIntegration.ts +++ b/packages/nextjs/src/client/browserTracingIntegration.ts @@ -1,5 +1,6 @@ import type { Integration } from '@sentry/core'; import { browserTracingIntegration as originalBrowserTracingIntegration, isBotUserAgent } from '@sentry/react'; +import { settlePendingPageloadWait } from './routing/appRouterRoutingInstrumentation'; import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation'; /** @@ -41,6 +42,13 @@ export function browserTracingIntegration( nextRouterInstrumentNavigation(client); } + // A pageload that is still waiting for its trace meta tag has to exist before the navigation span, + // so the navigation ends it like any pageload it interrupts. Started later, the pageload would end + // the navigation span instead. + if (instrumentPageLoad) { + client.on('beforeStartNavigationSpan', settlePendingPageloadWait); + } + browserTracingIntegrationInstance.afterAllSetup(client); if (instrumentPageLoad) { diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 4b7e9b1c7ae5..108637e5de2e 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -1,5 +1,6 @@ -import type { Client, Span } from '@sentry/core'; +import type { Client, Span, StartSpanOptions } from '@sentry/core'; import { + getCurrentScope, GLOBAL_OBJ, hasSpanStreamingEnabled, isObjectLike, @@ -17,6 +18,8 @@ import { resolveRoute, } from '@sentry/react'; import { stripTrailingSlash } from './parameterization'; +import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags'; +import { addTraceMetaTagLink, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag } from './traceMetaTags'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -95,11 +98,48 @@ let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-pa const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined }; +/** + * How long the pageload start may wait for the resumed part of a Cache Components document. The wait + * normally ends with `DOMContentLoaded`; the cap only matters for a response the server keeps open. + */ +const PAGELOAD_TRACE_META_TAG_TIMEOUT_MS = 10_000; + +let pendingPageloadWait: TraceMetaTagWait | undefined; + +/** + * Starts a pageload that is still waiting for its trace meta tag with a fresh trace. Called when a + * navigation starts, before the navigation span is created, so the navigation ends the pageload like + * any pageload it interrupts, and the pageload is still recorded. + */ +export function settlePendingPageloadWait(): void { + pendingPageloadWait?.giveUp(); +} + +/** + * Starts the pageload span with the request data of the document. The span may start after a + * navigation already changed the location, so the browser SDK would otherwise read the new one. + */ +function startPageloadSpan( + client: Client, + spanOptions: StartSpanOptions, + documentUrl: string, + traceMetaTags?: TraceMetaTagValues, +): Span | undefined { + const span = startBrowserTracingPageLoadSpan(client, spanOptions, traceMetaTags); + const scope = getCurrentScope(); + const { normalizedRequest } = scope.getScopeData().sdkProcessingMetadata; + if (normalizedRequest) { + scope.setSDKProcessingMetadata({ normalizedRequest: { ...normalizedRequest, url: documentUrl } }); + } + return span; +} + /** Instruments the Next.js app router for pageloads. */ export function appRouterInstrumentPageLoad(client: Client): void { + const documentUrl = filterCollectedUrl(WINDOW.location.href, client); const pathname = stripTrailingSlash(WINDOW.location.pathname); const parameterizedPathname = resolveCurrentRoute(client); - startBrowserTracingPageLoadSpan(client, { + const spanOptions: StartSpanOptions = { // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname), // pageload should always start at timeOrigin (and needs to be in s, not ms) @@ -107,9 +147,41 @@ export function appRouterInstrumentPageLoad(client: Client): void { [SENTRY_OP]: PAGELOAD, [SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation', [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url', + [URL_PATH]: WINDOW.location.pathname, + [URL_FULL]: documentUrl, ...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }), }, - }); + }; + + // A tag that is already in the document was rendered for this request and can be continued. A + // Cache Components document that resumes a prerendered shell gets its tag with the resumed part, + // which usually arrives after the SDK started. Waiting for it lets the pageload continue the server + // trace instead of linking it; the span's start is backdated to the time origin either way. + const traceMetaTags = readTraceMetaTags(); + if (traceMetaTags || !canWaitForTraceMetaTag()) { + startPageloadSpan(client, spanOptions, documentUrl, traceMetaTags); + return; + } + + pendingPageloadWait = waitForTraceMetaTag( + values => { + pendingPageloadWait = undefined; + startPageloadSpan(client, spanOptions, documentUrl, values); + }, + () => { + pendingPageloadWait = undefined; + const span = startPageloadSpan(client, spanOptions, documentUrl); + // Only reachable through the cap or a navigation while the document still streams: a tag that + // shows up after this can no longer be continued, but the server request can still be linked. + if (span) { + waitForTraceMetaTag( + values => span.isRecording() && addTraceMetaTagLink(span, values), + () => undefined, + ); + } + }, + PAGELOAD_TRACE_META_TAG_TIMEOUT_MS, + ); } interface NavigationSpanRef { diff --git a/packages/nextjs/src/client/routing/traceMetaTags.ts b/packages/nextjs/src/client/routing/traceMetaTags.ts new file mode 100644 index 000000000000..8e1138000ec5 --- /dev/null +++ b/packages/nextjs/src/client/routing/traceMetaTags.ts @@ -0,0 +1,152 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; +import type { Span } from '@sentry/core'; +import { extractTraceparentData } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; +import { WINDOW } from '@sentry/react'; + +// `@opentelemetry/api` `TraceFlags`, inlined because core does not export its copies. +const TRACE_FLAG_SAMPLED = 0x1; +const TRACE_FLAG_NONE = 0x0; + +const SERVER_REQUEST_LINK_TYPE = 'server_request'; + +export interface TraceMetaTagValues { + sentryTrace: string; + baggage: string | undefined; +} + +function getDocument(): Document | undefined { + return WINDOW.document; +} + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryCacheComponents?: string }; + +/** Whether the build enabled `cacheComponents`, so a document can resume a prerendered shell. */ +function hasCacheComponents(): boolean { + return (process.env._sentryCacheComponents ?? globalWithInjectedValues._sentryCacheComponents) === 'true'; +} + +function lastMetaContent(document: Document, name: string): string | undefined { + const tags = document.querySelectorAll(`meta[name="${name}"]`); + return tags[tags.length - 1]?.getAttribute('content') || undefined; +} + +/** + * Reads the `sentry-trace` and `baggage` meta tags of the document. The server only writes them for + * a render that happened for this request (see `nextSentryPropagator.ts`), so whichever tag is + * present may be continued. The last one wins: a document stitched from a shell and a resumed part + * carries the resumed part's tag last. + */ +export function readTraceMetaTags(): TraceMetaTagValues | undefined { + const document = getDocument(); + const sentryTrace = document && lastMetaContent(document, 'sentry-trace'); + return document && sentryTrace ? { sentryTrace, baggage: lastMetaContent(document, 'baggage') } : undefined; +} + +/** + * Whether the response carried the trace in a `Server-Timing` header. The generic browser tracing + * integration continues it when it starts the pageload, so there is nothing to wait for. + */ +export function hasServerTimingTraceContext(): boolean { + // The cast is required for the declaration build, which resolves `getEntriesByType('navigation')` + // to `PerformanceEntry[]` without `serverTiming`. + const navigation = WINDOW.performance?.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined; + return !!navigation?.serverTiming?.some(entry => entry.name === 'sentry-trace'); +} + +/** + * Whether the SDK can still expect a `sentry-trace` meta tag to arrive: the app has `cacheComponents` + * enabled, so a document can resume a prerendered shell and get its tag with the resumed part, the + * document is still streaming, and nothing else already carries the trace. Without `cacheComponents` + * a tag is either in `` from the start or never comes, so waiting would only hold the pageload + * back. + */ +export function canWaitForTraceMetaTag(): boolean { + return ( + hasCacheComponents() && + getDocument()?.readyState === 'loading' && + typeof MutationObserver !== 'undefined' && + !hasServerTimingTraceContext() + ); +} + +export interface TraceMetaTagWait { + /** Stops waiting and reports that no tag will be used, unless the wait already settled. */ + giveUp(): void; +} + +/** + * Waits for a `sentry-trace` meta tag to show up while the document is still streaming. The resumed + * part of a Cache Components page arrives after the SDK started whenever the dynamic hole takes + * longer than loading the SDK, which is the common case. + * + * Calls `onTag` once with the tag, or `onGiveUp` once when the document finished parsing without + * one, `timeoutMs` passed, or `giveUp()` was called, whichever comes first. Settles with `onGiveUp` + * right away when `canWaitForTraceMetaTag()` is false. + */ +export function waitForTraceMetaTag( + onTag: (values: TraceMetaTagValues) => void, + onGiveUp: () => void, + timeoutMs?: number, +): TraceMetaTagWait { + const document = getDocument(); + if (!document || !canWaitForTraceMetaTag()) { + onGiveUp(); + return { giveUp: () => undefined }; + } + + let settled = false; + const settle = (callback: () => void): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + observer.disconnect(); + document.removeEventListener('DOMContentLoaded', onDocumentParsed); + callback(); + }; + + const checkForTag = (): boolean => { + const values = readTraceMetaTags(); + if (values) { + settle(() => onTag(values)); + } + return !!values; + }; + + // The tag can sit in the last chunk, whose mutation records may not have been delivered yet. + const onDocumentParsed = (): void => { + if (!checkForTag()) { + settle(onGiveUp); + } + }; + + const observer = new MutationObserver(checkForTag); + const timeout = timeoutMs === undefined ? undefined : setTimeout(() => settle(onGiveUp), timeoutMs); + + observer.observe(document.documentElement, { childList: true, subtree: true }); + document.addEventListener('DOMContentLoaded', onDocumentParsed); + + return { giveUp: () => settle(onGiveUp) }; +} + +/** + * Adds a span link to the span described by a `sentry-trace` value. Used when the tag arrives after + * the pageload span exists, which can only link the server request, not continue it. + */ +export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagValues): void { + const traceparent = extractTraceparentData(sentryTrace); + if (!traceparent?.traceId || !traceparent.parentSpanId) { + return; + } + + span.addLink({ + context: { + traceId: traceparent.traceId, + spanId: traceparent.parentSpanId, + traceFlags: traceparent.parentSampled ? TRACE_FLAG_SAMPLED : TRACE_FLAG_NONE, + }, + attributes: { [SENTRY_LINK_TYPE]: SERVER_REQUEST_LINK_TYPE }, + }); +} diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index 8665b75fc8fb..836a3829ea5d 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -60,6 +60,11 @@ export function setUpBuildTimeVariables( buildTimeVariables._sentryRelease = releaseName; } + // The client only waits for a late trace meta tag when a document can resume a prerendered shell. + if (userNextConfig.cacheComponents) { + buildTimeVariables._sentryCacheComponents = 'true'; + } + // See `client/routing/nextRoutingInstrumentation.ts`. if (hasOnlyAppRouterPages(projectDir)) { buildTimeVariables._sentryHasPagesRouter = 'false'; diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index 3fac8e6c2d21..0d8bdded0e0d 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -1,4 +1,6 @@ // @vitest-environment jsdom +import { SENTRY_OP, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { PAGELOAD } from '@sentry/conventions/op'; import type { Client } from '@sentry/core'; import type * as SentryCore from '@sentry/core'; import type * as SentryReact from '@sentry/react'; @@ -264,3 +266,73 @@ describe('appRouterInstrumentNavigation with basePath', () => { expect(core.spanToJSON(span!).name).toBe('/my-app/navigation/:param/router-push'); }); }); + +describe('appRouterInstrumentPageLoad', () => { + const originalReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState'); + + beforeEach(() => { + globalWithNext._sentryRouteManifest = JSON.stringify(manifest); + window.history.replaceState({}, '', '/navigation?from=document'); + Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + vi.stubEnv('_sentryCacheComponents', 'true'); + }); + + afterEach(() => { + delete globalWithNext.next; + delete globalWithNext._sentryRouteManifest; + // @ts-expect-error deleting the instance override restores the prototype getter + delete document.readyState; + if (originalReadyState) { + Object.defineProperty(Document.prototype, 'readyState', originalReadyState); + } + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('records the URL of the document when the pageload starts after a navigation changed it', async () => { + const { core, instrumentation } = await setup('static'); + + instrumentation.appRouterInstrumentPageLoad(core.getClient()!); + const spanBeforeSettling = core.getActiveSpan(); + + window.history.replaceState({}, '', '/navigation/42/router-back'); + instrumentation.settlePendingPageloadWait(); + + const span = core.getActiveSpan(); + expect(span).toBeDefined(); + expect(span).not.toBe(spanBeforeSettling); + const spanJson = core.spanToJSON(span!); + expect(spanJson.name).toBe('/navigation'); + expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD); + expect(spanJson.attributes).toEqual( + expect.objectContaining({ + [URL_PATH]: '/navigation', + [URL_FULL]: 'http://localhost:3000/navigation?from=document', + }), + ); + expect(core.getCurrentScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url).toBe( + 'http://localhost:3000/navigation?from=document', + ); + }); + + // `settlePendingPageloadWait` is a no-op when the pageload did not wait, so both cases run the same steps. + it.each([ + { start: 'at once', cacheComponents: 'false' }, + { start: 'after the wait', cacheComponents: 'true' }, + ])( + 'filters sensitive query parameters from the document URL of a pageload that starts $start', + async ({ cacheComponents }) => { + vi.stubEnv('_sentryCacheComponents', cacheComponents); + window.history.replaceState({}, '', '/navigation?token=2c4f9e1b&from=document'); + const { core, instrumentation } = await setup('static'); + + instrumentation.appRouterInstrumentPageLoad(core.getClient()!); + instrumentation.settlePendingPageloadWait(); + + const filteredUrl = 'http://localhost:3000/navigation?token=[Filtered]&from=document'; + expect(core.spanToJSON(core.getActiveSpan()!).attributes?.[URL_FULL]).toBe(filteredUrl); + expect(core.getCurrentScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url).toBe(filteredUrl); + }, + ); +}); diff --git a/packages/nextjs/test/client/browserTracingIntegration.test.ts b/packages/nextjs/test/client/browserTracingIntegration.test.ts new file mode 100644 index 000000000000..28e523b2a580 --- /dev/null +++ b/packages/nextjs/test/client/browserTracingIntegration.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op'; +import type { Span } from '@sentry/core'; +import { + createTransport, + getActiveSpan, + getCurrentScope, + resolvedSyncPromise, + setCurrentClient, + spanToJSON, +} from '@sentry/core'; +import { BrowserClient, startBrowserTracingNavigationSpan } from '@sentry/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { browserTracingIntegration } from '../../src/client/browserTracingIntegration'; + +describe('browserTracingIntegration', () => { + const originalReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState'); + + beforeEach(() => { + window.history.replaceState({}, '', '/products'); + Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + vi.stubEnv('_sentryCacheComponents', 'true'); + }); + + afterEach(() => { + getCurrentScope().setClient(undefined); + // @ts-expect-error deleting the instance override restores the prototype getter + delete document.readyState; + if (originalReadyState) { + Object.defineProperty(Document.prototype, 'readyState', originalReadyState); + } + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('ends a pageload that waits for its trace meta tag when a navigation starts, and keeps the navigation', () => { + const client = new BrowserClient({ + dsn: 'http://examplePublicKey@localhost/0', + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + stackParser: () => [], + tracesSampleRate: 1, + integrations: [browserTracingIntegration({ instrumentNavigation: false })], + }); + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + setCurrentClient(client); + client.init(); + + const navigationSpan = startBrowserTracingNavigationSpan(client, { name: '/products/42' }); + + expect(endedSpans).toHaveLength(1); + expect(spanToJSON(endedSpans[0]!).attributes?.[SENTRY_OP]).toBe(PAGELOAD); + expect(getActiveSpan()).toBe(navigationSpan); + expect(navigationSpan!.isRecording()).toBe(true); + expect(spanToJSON(navigationSpan!).attributes?.[SENTRY_OP]).toBe(NAVIGATION); + }); +}); diff --git a/packages/nextjs/test/client/traceMetaTags.test.ts b/packages/nextjs/test/client/traceMetaTags.test.ts new file mode 100644 index 000000000000..4b9fde700e92 --- /dev/null +++ b/packages/nextjs/test/client/traceMetaTags.test.ts @@ -0,0 +1,221 @@ +// @vitest-environment jsdom +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; +import type { Span } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + addTraceMetaTagLink, + canWaitForTraceMetaTag, + readTraceMetaTags, + waitForTraceMetaTag, +} from '../../src/client/routing/traceMetaTags'; + +const HEAD_TRACE = '11111111111111111111111111111111-1111111111111111-1'; +const BODY_TRACE = '22222222222222222222222222222222-2222222222222222-1'; + +function appendMeta(parent: HTMLElement, name: string, content: string): HTMLMetaElement { + const meta = document.createElement('meta'); + meta.setAttribute('name', name); + meta.setAttribute('content', content); + parent.appendChild(meta); + return meta; +} + +const originalReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState'); + +function setReadyState(value: DocumentReadyState): void { + Object.defineProperty(document, 'readyState', { value, configurable: true }); +} + +beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + vi.stubEnv('_sentryCacheComponents', 'true'); +}); + +afterEach(() => { + // @ts-expect-error deleting the instance override restores the prototype getter + delete document.readyState; + if (originalReadyState) { + Object.defineProperty(Document.prototype, 'readyState', originalReadyState); + } + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.useRealTimers(); +}); + +describe('readTraceMetaTags', () => { + it('returns nothing for a document without trace meta tags', () => { + expect(readTraceMetaTags()).toBeUndefined(); + }); + + it('reads a tag from ', () => { + appendMeta(document.head, 'sentry-trace', HEAD_TRACE); + appendMeta(document.head, 'baggage', 'sentry-trace_id=1111'); + + expect(readTraceMetaTags()).toEqual({ sentryTrace: HEAD_TRACE, baggage: 'sentry-trace_id=1111' }); + expect(document.querySelectorAll('meta[name="sentry-trace"]')).toHaveLength(1); + }); + + it('reads a tag from ', () => { + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + + expect(readTraceMetaTags()).toEqual({ sentryTrace: BODY_TRACE, baggage: undefined }); + }); + + it('prefers the last tag in document order', () => { + appendMeta(document.head, 'sentry-trace', HEAD_TRACE); + appendMeta(document.head, 'baggage', 'sentry-trace_id=1111'); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + appendMeta(document.body, 'baggage', 'sentry-trace_id=2222'); + + expect(readTraceMetaTags()).toEqual({ sentryTrace: BODY_TRACE, baggage: 'sentry-trace_id=2222' }); + }); +}); + +describe('canWaitForTraceMetaTag', () => { + it('is true while the document streams and no Server-Timing trace exists', () => { + setReadyState('loading'); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + + expect(canWaitForTraceMetaTag()).toBe(true); + }); + + it('is false when the build did not enable cacheComponents', () => { + vi.stubEnv('_sentryCacheComponents', ''); + setReadyState('loading'); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + + expect(canWaitForTraceMetaTag()).toBe(false); + }); + + it('is false once the document finished parsing', () => { + setReadyState('interactive'); + + expect(canWaitForTraceMetaTag()).toBe(false); + }); + + it('is false when the response carried the trace in Server-Timing', () => { + setReadyState('loading'); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([ + { serverTiming: [{ name: 'sentry-trace', description: BODY_TRACE, duration: 0 }] } as unknown as PerformanceEntry, + ]); + + expect(canWaitForTraceMetaTag()).toBe(false); + }); +}); + +describe('waitForTraceMetaTag', () => { + beforeEach(() => { + setReadyState('loading'); + vi.useFakeTimers(); + }); + + it('reports a tag that is streamed in after the SDK started, once', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + appendMeta(document.body, 'baggage', 'sentry-trace_id=2222'); + await vi.advanceTimersByTimeAsync(0); + appendMeta(document.body, 'sentry-trace', HEAD_TRACE); + document.dispatchEvent(new Event('DOMContentLoaded')); + await vi.advanceTimersByTimeAsync(2000); + + expect(onTag).toHaveBeenCalledTimes(1); + expect(onTag).toHaveBeenCalledWith({ sentryTrace: BODY_TRACE, baggage: 'sentry-trace_id=2222' }); + expect(onGiveUp).not.toHaveBeenCalled(); + }); + + it('gives up when the document finished parsing without a tag', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + document.dispatchEvent(new Event('DOMContentLoaded')); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + await vi.advanceTimersByTimeAsync(2000); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); + + it('still finds a tag from the last chunk when the document finished parsing', () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + document.dispatchEvent(new Event('DOMContentLoaded')); + + expect(onTag).toHaveBeenCalledWith({ sentryTrace: BODY_TRACE, baggage: undefined }); + expect(onGiveUp).not.toHaveBeenCalled(); + }); + + it('gives up after the timeout while the document is still streaming', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + await vi.advanceTimersByTimeAsync(1000); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + await vi.advanceTimersByTimeAsync(0); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); + + it('gives up early when asked, once', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + const wait = waitForTraceMetaTag(onTag, onGiveUp, 1000); + wait.giveUp(); + wait.giveUp(); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + await vi.advanceTimersByTimeAsync(2000); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); + + it('gives up right away when the document already finished parsing', async () => { + setReadyState('complete'); + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); +}); + +describe('addTraceMetaTagLink', () => { + it('links the server request described by the sentry-trace value and carries the sampled flag', () => { + const span = { addLink: vi.fn() } as unknown as Span; + + addTraceMetaTagLink(span, { sentryTrace: HEAD_TRACE, baggage: undefined }); + addTraceMetaTagLink(span, { + sentryTrace: '33333333333333333333333333333333-3333333333333333-0', + baggage: undefined, + }); + + expect(span.addLink).toHaveBeenNthCalledWith(1, { + context: { traceId: '11111111111111111111111111111111', spanId: '1111111111111111', traceFlags: 1 }, + attributes: { [SENTRY_LINK_TYPE]: 'server_request' }, + }); + expect(span.addLink).toHaveBeenNthCalledWith(2, { + context: { traceId: '33333333333333333333333333333333', spanId: '3333333333333333', traceFlags: 0 }, + attributes: { [SENTRY_LINK_TYPE]: 'server_request' }, + }); + }); + + it('ignores values that are not a sentry-trace header', () => { + const span = { addLink: vi.fn() } as unknown as Span; + + addTraceMetaTagLink(span, { sentryTrace: 'not-a-trace', baggage: undefined }); + + expect(span.addLink).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts index c8bee1a00d39..b30f6f708855 100644 --- a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts @@ -79,3 +79,17 @@ describe('setUpBuildTimeVariables', () => { }); }); }); + +describe('_sentryCacheComponents', () => { + it('is set when the Next.js config enables cacheComponents', () => { + const config: NextConfigObject = { cacheComponents: true }; + setUpBuildTimeVariables(config, {}, undefined); + expect(config.env?._sentryCacheComponents).toBe('true'); + }); + + it('is left unset otherwise', () => { + const config: NextConfigObject = {}; + setUpBuildTimeVariables(config, {}, undefined); + expect(config.env?._sentryCacheComponents).toBeUndefined(); + }); +}); From 42f1ac1f28e8be57a9851299e9e43f700861da53 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:39:58 +0200 Subject: [PATCH 28/30] fix(nuxt): Delete Sentry-enabled source maps from Nitro output directories (#25450) When Sentry enables `'hidden'` source maps, it deleted them with folder-name globs like `.*/**/public/**/*.map`. As discussed in [#25362](https://github.com/getsentry/sentry-javascript/pull/25362#issuecomment-6170792749), a static glob cannot know where a preset writes its output. - Reads `nitro.options.output.publicDir` and `serverDir` instead (available since nitropack 2.6.1, Nuxt 3.7.0). - Fixes client maps leaking in `dist` on Netlify and Cloudflare Pages, and keeps the server maps in `dist/_worker.js`. - Keeps server maps enabled with `nitro.rollupConfig.output.sourcemap`. Tested with unit tests on real files, and with new `netlify` and `cloudflare-pages` E2E variants. --- .../nuxt-4-sourcemaps/.gitignore | 1 + .../nuxt-4-sourcemaps/assert-build.ts | 44 +++++-- .../nuxt-4-sourcemaps/nuxt.config.ts | 10 +- .../nuxt-4-sourcemaps/package.json | 16 ++- packages/nuxt/src/vite/sourceMapDeletion.ts | 40 ++++++ packages/nuxt/src/vite/sourceMaps.ts | 28 +++-- .../nuxt/test/vite/sourceMapDeletion.test.ts | 119 +++++++++++++++++- .../test/vite/sourceMaps-nuxtHooks.test.ts | 70 ++++++++--- packages/nuxt/test/vite/sourceMaps.test.ts | 22 +++- 9 files changed, 306 insertions(+), 44 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore index 1dca2550f7e3..e52212a76a51 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore @@ -5,6 +5,7 @@ .nitro .cache dist +.netlify # Node dependencies node_modules diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts index 671ec4526a7a..8a9ee339b1e6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts @@ -24,10 +24,22 @@ const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; /** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */ const isStaticBuild = process.env.NUXT_COMMAND === 'generate'; -const CLIENT_OUTPUT = path.join('.output', 'public'); +const nitroPreset = process.env.NITRO_PRESET ?? 'node-server'; + +/** If Nitro moves one of these directories, the debug ID checks below fail. */ +const OUTPUT_DIRS: Record = { + 'node-server': { client: path.join('.output', 'public'), server: path.join('.output', 'server') }, + netlify: { client: 'dist', server: path.join('.netlify', 'functions-internal', 'server') }, + 'cloudflare-pages': { client: 'dist', server: path.join('dist', '_worker.js') }, +}; + +const outputDirs = OUTPUT_DIRS[nitroPreset]; +assert.ok(outputDirs, `Expected output directories for the Nitro preset ${nitroPreset}`); + +const CLIENT_OUTPUT = outputDirs.client; /** Vite's own output directory. Nitro copies it to `CLIENT_OUTPUT`, and Sentry deletes no maps here. */ const CLIENT_BUILD_OUTPUT = path.join('.nuxt', 'dist', 'client'); -const SERVER_OUTPUT = path.join('.output', 'server'); +const SERVER_OUTPUT = outputDirs.server; /** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; @@ -35,6 +47,12 @@ const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; +/** Some presets write the server into the client output, e.g. Cloudflare Pages. */ +function isClientFile(file: string): boolean { + const relativePath = path.relative(SERVER_OUTPUT, file); + return relativePath.startsWith('..') || path.isAbsolute(relativePath); +} + function filesContaining(dir: string, needle: string): string[] { return fs .readdirSync(dir, { recursive: true, withFileTypes: true }) @@ -64,7 +82,7 @@ function debugIdsOfChunksWithoutSourceMap(dir: string): Set { } console.log( - `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + + `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, Nitro preset ${nitroPreset}, ` + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, ); @@ -218,7 +236,7 @@ console.log(''); // --- What the build leaves behind in the client output --- -const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }); +const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }).filter(isClientFile); if (keepClientSourceMaps) { assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`); @@ -228,20 +246,30 @@ if (keepClientSourceMaps) { assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`); // The maps are gone, so a surviving reference only 404s in devtools and leaks where they were. - const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }); + const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }).filter(isClientFile); assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`); // Catches source maps inlined as `data:` URIs, which the reference check above skips by design. - const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER); + const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER).filter(isClientFile); assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`); console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`); } -// --- What the built server answers for the client source maps --- +// --- What the build leaves behind in the server output --- -// `nuxt generate` emits no server, and a static host serves only the files that exist. +// Nuxt enables the server maps by default, so they are the user's and Sentry must keep them. if (!isStaticBuild) { + const serverSourceMaps = findSourceMapFiles({ outputDir: SERVER_OUTPUT }); + assert.ok(serverSourceMaps.length > 0, `Expected Sentry to keep the server source maps in ${SERVER_OUTPUT}`); + console.log(` ${serverSourceMaps.length} source map(s) kept in ${SERVER_OUTPUT}, as Nuxt enables them\n`); +} + +// --- What the built server answers for the client source maps --- + +// `nuxt generate` emits no server, and a static host serves only the files that exist. The other presets build a +// server for their platform, which Node cannot start. +if (!isStaticBuild && nitroPreset === 'node-server') { const clientChunkUrls = fs .readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true }) .filter(entry => entry.isFile() && entry.name.endsWith('.js')) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts index ae2e95194fa7..db827c5e9c32 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts @@ -3,11 +3,17 @@ // own warning tells users to set, which makes it the setup worth regression-testing. const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; +// With `sourcemap.client` unset, Sentry enables the client maps and finds their output directory itself, which +// differs per Nitro preset. +const nitroPreset = process.env.NITRO_PRESET; + export default defineNuxtConfig({ compatibilityDate: '2025-06-06', imports: { autoImport: false }, - sourcemap: { client: 'hidden' }, + sourcemap: { client: nitroPreset ? undefined : 'hidden' }, + + nitro: { preset: nitroPreset }, modules: ['@sentry/nuxt/module'], @@ -27,7 +33,7 @@ export default defineNuxtConfig({ release: { name: 'test-release' }, // Dropping `filesToDeleteAfterUpload` is the whole point of the "kept" variant: Sentry should // upload the maps and leave the emitted files alone. - sourcemaps: keepClientSourceMaps ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, + sourcemaps: keepClientSourceMaps || nitroPreset ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, debug: true, }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json index c6237579668a..fed9ff583515 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json @@ -11,7 +11,11 @@ "test:build:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:build", "test:assert:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:assert", "test:build:static": "NUXT_COMMAND=generate pnpm test:build", - "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert" + "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert", + "test:build:netlify": "NITRO_PRESET=netlify pnpm test:build", + "test:assert:netlify": "NITRO_PRESET=netlify pnpm test:assert", + "test:build:cloudflare-pages": "NITRO_PRESET=cloudflare-pages pnpm test:build", + "test:assert:cloudflare-pages": "NITRO_PRESET=cloudflare-pages pnpm test:assert" }, "dependencies": { "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", @@ -38,6 +42,16 @@ "build-command": "pnpm test:build:static", "assert-command": "pnpm test:assert:static", "label": "nuxt-4-sourcemaps (static / nuxt generate)" + }, + { + "build-command": "pnpm test:build:netlify", + "assert-command": "pnpm test:assert:netlify", + "label": "nuxt-4-sourcemaps (netlify preset)" + }, + { + "build-command": "pnpm test:build:cloudflare-pages", + "assert-command": "pnpm test:assert:cloudflare-pages", + "label": "nuxt-4-sourcemaps (cloudflare-pages preset)" } ] } diff --git a/packages/nuxt/src/vite/sourceMapDeletion.ts b/packages/nuxt/src/vite/sourceMapDeletion.ts index 01b4d21daecf..7f9784e3bd37 100644 --- a/packages/nuxt/src/vite/sourceMapDeletion.ts +++ b/packages/nuxt/src/vite/sourceMapDeletion.ts @@ -2,6 +2,46 @@ import { createSentryBuildPluginManager, globFiles, type Options } from '@sentry import * as fs from 'node:fs'; import * as path from 'node:path'; +/** Nitro's output directories, which depend on the Nitro preset, e.g. `dist` for the public files on Netlify. */ +type NitroOutputDirs = { publicDir: string; serverDir: string }; + +/** The sides whose source maps only exist because Sentry enabled them, and where Nitro writes them once it is set up. */ +export type DeletionFallback = { client: boolean; server: boolean; outputDirs?: NitroOutputDirs }; + +/** + * Globs for the source maps in Nitro's output directories of the sides whose source maps only exist because Sentry + * enabled them. Empty until Nitro is set up. + */ +export function getFallbackFilesToDelete(fallback: DeletionFallback | undefined): string[] { + if (!fallback?.outputDirs) { + return []; + } + + const { client, server, outputDirs } = fallback; + const { publicDir, serverDir } = outputDirs; + return [ + ...(client ? getPublicSourceMapGlobs(publicDir, server ? undefined : serverDir) : []), + ...(server ? [`${toGlobPath(serverDir)}/**/*.map`] : []), + ]; +} + +/** Some presets write the server into the public dir, e.g. Cloudflare Pages into `dist/_worker.js`. */ +function getPublicSourceMapGlobs(publicDir: string, keptServerDir: string | undefined): string[] { + const publicGlob = toGlobPath(publicDir); + + if (keptServerDir === undefined || !isInDirectory(keptServerDir, publicDir)) { + return [`${publicGlob}/**/*.map`]; + } + + const [serverDirName] = path.relative(publicDir, keptServerDir).split(path.sep); + return [`${publicGlob}/*.map`, `${publicGlob}/!(${serverDirName})/**/*.map`]; +} + +/** `filesToDeleteAfterUpload` is matched from the working directory and needs forward slashes, also on Windows. */ +function toGlobPath(directory: string): string { + return path.relative(process.cwd(), directory).split(path.sep).join('/') || '.'; +} + export function withoutSourceMapDeletion(options: Options): Options { return { ...options, diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index 94cae5a3f506..891a81602aac 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -5,7 +5,13 @@ import { warnOnRemovedBuildOptions } from '@sentry/core'; import type { NitroConfig } from 'nitropack'; import type { Plugin } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; -import { deletePublicSourceMaps, deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion'; +import { + deletePublicSourceMaps, + deleteSourceMapsAfterBuild, + type DeletionFallback, + getFallbackFilesToDelete, + withoutSourceMapDeletion, +} from './sourceMapDeletion'; import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin'; /** @@ -33,7 +39,7 @@ export function setupSourceMaps( const sourceMapsEnabled = moduleOptions.sourcemaps?.disable !== true; // In case we overwrite the source map settings, we default to deleting the files - const shouldDeleteFilesFallback = { client: true, server: true }; + const shouldDeleteFilesFallback: DeletionFallback = { client: true, server: true }; nuxt.hook('modules:done', () => { if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) { @@ -99,6 +105,9 @@ export function setupSourceMaps( validateNitroSourceMapSettings(nuxt, nitroConfig, moduleOptions); + // `rollupConfig.output.sourcemap` overrides `sourcemap.server`, so the server source maps are the user's. + shouldDeleteFilesFallback.server &&= nitroConfig.rollupConfig.output?.sourcemap === undefined; + if (isDebug) { // eslint-disable-next-line no-console console.log('[Sentry] Adding Sentry Rollup plugin to the server runtime.'); @@ -115,6 +124,7 @@ export function setupSourceMaps( nuxt.hook('nitro:init', nitro => { if (sourceMapsEnabled && !nitro.options.dev && !nuxt.options?._prepare) { const publicDir = nitro.options.output.publicDir; + shouldDeleteFilesFallback.outputDirs = { publicDir, serverDir: nitro.options.output.serverDir }; const rollupConfig = (nitro.options.rollupConfig ??= { output: {} }); rollupConfig.plugins = [ @@ -151,7 +161,7 @@ function normalizePath(path: string): string { */ export function getPluginOptions( moduleOptions: SentryNuxtModuleOptions, - shouldDeleteFilesFallback?: { client: boolean; server: boolean }, + shouldDeleteFilesFallback?: DeletionFallback, ): SentryVitePluginOptions | SentryRollupPluginOptions { const sourcemapsOptions = moduleOptions.sourcemaps || {}; const filesToDeleteAfterUpload = resolveFilesToDeleteAfterUpload(moduleOptions, shouldDeleteFilesFallback); @@ -200,19 +210,15 @@ export function getPluginOptions( * (client/server) whose `sourcemap` setting resolved to `undefined`, where Sentry stepped in and * enabled `'hidden'`. Given Nuxt's default of `{ server: true, client: false }`, this isn't the * default case: it only kicks in when a side is left `undefined` (whole config or a single sub-key). + * Sentry deletes them in Nitro's output directories, which are only known once Nitro is set up. * @see https://nuxt.com/docs/4.x/api/nuxt-config#sourcemap */ function resolveFilesToDeleteAfterUpload( moduleOptions: SentryNuxtModuleOptions, - shouldDeleteFilesFallback?: { client: boolean; server: boolean }, + shouldDeleteFilesFallback?: DeletionFallback, ): string | Array | undefined { - const shouldDeleteFilesAfterUpload = shouldDeleteFilesFallback?.client || shouldDeleteFilesFallback?.server; - const fallbackFilesToDelete = [ - ...(shouldDeleteFilesFallback?.client ? ['.*/**/public/**/*.map'] : []), - ...(shouldDeleteFilesFallback?.server - ? ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'] - : []), - ]; + const fallbackFilesToDelete = getFallbackFilesToDelete(shouldDeleteFilesFallback); + const shouldDeleteFilesAfterUpload = fallbackFilesToDelete.length > 0; const filesToDeleteAfterUpload = moduleOptions.sourcemaps?.filesToDeleteAfterUpload; diff --git a/packages/nuxt/test/vite/sourceMapDeletion.test.ts b/packages/nuxt/test/vite/sourceMapDeletion.test.ts index 1599aebcdf9f..4bc4427a4569 100644 --- a/packages/nuxt/test/vite/sourceMapDeletion.test.ts +++ b/packages/nuxt/test/vite/sourceMapDeletion.test.ts @@ -1,8 +1,9 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { deletePublicSourceMaps } from '../../src/vite/sourceMapDeletion'; +import { globFiles } from '@sentry/bundler-plugins/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { deletePublicSourceMaps, getFallbackFilesToDelete } from '../../src/vite/sourceMapDeletion'; const BUILD_FILES = [ '.output/public/_nuxt/entry.DxB3kLmA.js', @@ -93,3 +94,117 @@ describe('deletePublicSourceMaps', () => { expect(remainingFiles()).toEqual(BUILD_FILES); }); }); + +/** Output directories of Nitro presets and the source maps a build writes there, relative to the Nuxt root. */ +const PRESETS = { + 'node-server': { + outputDirs: { publicDir: '.output/public', serverDir: '.output/server' }, + clientMaps: ['.output/public/_nuxt/entry.DxB3kLmA.js.map'], + serverMaps: ['.output/server/chunks/nitro/nitro.mjs.map'], + }, + netlify: { + outputDirs: { publicDir: 'dist', serverDir: '.netlify/functions-internal/server' }, + clientMaps: ['dist/_nuxt/entry.DxB3kLmA.js.map'], + serverMaps: ['.netlify/functions-internal/server/chunks/nitro/nitro.mjs.map'], + }, + vercel: { + outputDirs: { publicDir: '.vercel/output/static', serverDir: '.vercel/output/functions/__fallback.func' }, + clientMaps: ['.vercel/output/static/_nuxt/entry.DxB3kLmA.js.map'], + serverMaps: ['.vercel/output/functions/__fallback.func/chunks/nitro/nitro.mjs.map'], + }, + 'cloudflare-pages': { + outputDirs: { publicDir: 'dist', serverDir: 'dist/_worker.js' }, + clientMaps: ['dist/_nuxt/entry.DxB3kLmA.js.map', 'dist/sw.js.map'], + serverMaps: ['dist/_worker.js/chunks/nitro/nitro.mjs.map'], + }, +}; + +type Preset = keyof typeof PRESETS; + +/** Never deployed, so Sentry keeps them. */ +const UNDEPLOYED_MAPS = [ + '.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map', + 'node_modules/vue/dist/vue.runtime.esm-bundler.js.map', +]; + +describe('getFallbackFilesToDelete', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nuxt-fallback-deletion-')); + vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + function writeFiles(files: string[], nuxtRoot = projectDir): void { + for (const file of files) { + fs.mkdirSync(path.dirname(path.join(nuxtRoot, file)), { recursive: true }); + fs.writeFileSync(path.join(nuxtRoot, file), ''); + } + } + + /** Writes the build output of a preset and returns the files that the fallback globs match, relative to the root. */ + async function matchFallbackFiles( + preset: Preset, + sides: { client: boolean; server: boolean }, + nuxtRoot = projectDir, + ): Promise { + const { outputDirs, clientMaps, serverMaps } = PRESETS[preset]; + writeFiles([...clientMaps, ...serverMaps, ...UNDEPLOYED_MAPS], nuxtRoot); + + const globs = getFallbackFilesToDelete({ + ...sides, + outputDirs: { + publicDir: path.join(nuxtRoot, outputDirs.publicDir), + serverDir: path.join(nuxtRoot, outputDirs.serverDir), + }, + }); + const files = globs.length > 0 ? await globFiles(globs) : []; + return files.map(file => path.relative(nuxtRoot, file).split(path.sep).join('/')).sort(); + } + + it.each(Object.keys(PRESETS) as Preset[])( + 'matches the client and server source maps in the output of the %s preset', + async preset => { + const { clientMaps, serverMaps } = PRESETS[preset]; + + await expect(matchFallbackFiles(preset, { client: true, server: true })).resolves.toEqual( + [...clientMaps, ...serverMaps].sort(), + ); + }, + ); + + it.each(Object.keys(PRESETS) as Preset[])( + 'matches only the client source maps of the %s preset when Sentry enabled only those', + async preset => { + await expect(matchFallbackFiles(preset, { client: true, server: false })).resolves.toEqual( + [...PRESETS[preset].clientMaps].sort(), + ); + }, + ); + + it.each(Object.keys(PRESETS) as Preset[])( + 'matches only the server source maps of the %s preset when Sentry enabled only those', + async preset => { + await expect(matchFallbackFiles(preset, { client: false, server: true })).resolves.toEqual( + PRESETS[preset].serverMaps, + ); + }, + ); + + it('matches the output of a Nuxt app in a subdirectory of the working directory', async () => { + const nuxtRoot = path.join(projectDir, 'apps', 'shop'); + + await expect(matchFallbackFiles('node-server', { client: true, server: false }, nuxtRoot)).resolves.toEqual( + PRESETS['node-server'].clientMaps, + ); + }); + + it('returns no globs before Nitro reported its output directories', () => { + expect(getFallbackFilesToDelete({ client: true, server: true })).toEqual([]); + }); +}); diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index 2059462d1490..b6bc0b5109ba 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -42,6 +42,23 @@ function createMockAddVitePlugin() { type HookCallback = (...args: unknown[]) => void | Promise; +/** Nitro's output directories of the `node-server` preset, relative to the working directory. */ +const NODE_SERVER_OUTPUT = { publicDir: '.output/public', serverDir: '.output/server' }; + +function createMockNitro(options: { dev?: boolean; output?: { publicDir: string; serverDir: string } } = {}) { + const output = options.output ?? NODE_SERVER_OUTPUT; + return { + options: { + dev: options.dev ?? false, + output: { + publicDir: path.join(process.cwd(), output.publicDir), + serverDir: path.join(process.cwd(), output.serverDir), + }, + rollupConfig: { plugins: [{ name: 'sentry-rollup-plugin' }] as unknown }, + }, + }; +} + function createMockNuxt(options: { _prepare?: boolean; dev?: boolean; @@ -70,12 +87,7 @@ function createMockNuxt(options: { } describe('setupSourceMaps hooks', () => { - const defaultFilesToDeleteAfterUpload = [ - '.*/**/public/**/*.map', - '.*/**/server/**/*.map', - '.*/**/output/**/*.map', - '.*/**/function/**/*.map', - ]; + const defaultFilesToDeleteAfterUpload = ['.output/public/**/*.map', '.output/server/**/*.map']; const consoleLogSpy = vi.spyOn(console, 'log'); const consoleWarnSpy = vi.spyOn(console, 'warn'); @@ -273,6 +285,7 @@ describe('setupSourceMaps hooks', () => { setupSourceMaps({ debug: false }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:init', createMockNitro()); await mockNuxt.triggerHook('close'); expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( @@ -284,6 +297,24 @@ describe('setupSourceMaps hooks', () => { expect(mockDeleteArtifacts).toHaveBeenCalledTimes(1); }); + it('keeps the server source maps that `nitro.rollupConfig.output.sourcemap` enables', async () => { + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: undefined } }); + const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: { sourcemap: true } }, dev: false }; + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + await mockNuxt.triggerHook('nitro:init', createMockNitro()); + await mockNuxt.triggerHook('close'); + + expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload: ['.output/public/**/*.map'] }), + }), + { buildTool: 'nuxt', loggerPrefix: '[Sentry Nuxt]' }, + ); + }); + it('uses user-provided deletion patterns after the build', async () => { const mockNuxt = createMockNuxt({ _prepare: false, @@ -397,16 +428,6 @@ describe('setupSourceMaps hooks', () => { fs.rmSync(rootDir, { recursive: true, force: true }); }); - function createMockNitro(options: { dev?: boolean } = {}) { - return { - options: { - dev: options.dev ?? false, - output: { publicDir: path.join(rootDir, '.output', 'public') }, - rollupConfig: { plugins: [{ name: 'sentry-rollup-plugin' }] as unknown }, - }, - }; - } - function findDeletionPlugin(nitro: ReturnType) { return [nitro.options.rollupConfig.plugins] .flat(2) @@ -454,6 +475,23 @@ describe('setupSourceMaps hooks', () => { expect(remainingFiles()).toEqual([PUBLIC_CHUNK, SSR_BUILD_MAP]); }); + it('deletes the public source maps of a preset that writes the public files outside of `.output`', async () => { + const netlifyPublicMap = 'dist/_nuxt/entry.DxB3kLmA.js.map'; + fs.mkdirSync(path.dirname(path.join(rootDir, netlifyPublicMap)), { recursive: true }); + fs.writeFileSync(path.join(rootDir, netlifyPublicMap), ''); + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: true } }); + const nitro = createMockNitro({ + output: { publicDir: 'dist', serverDir: '.netlify/functions-internal/server' }, + }); + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:init', nitro); + await findDeletionPlugin(nitro)?.buildStart(); + + expect(fs.existsSync(path.join(rootDir, netlifyPublicMap))).toBe(false); + }); + it.each([ { label: 'prepare mode', nuxtOptions: { _prepare: true }, nitroOptions: {} }, { label: 'dev mode', nuxtOptions: { dev: true }, nitroOptions: { dev: true } }, diff --git a/packages/nuxt/test/vite/sourceMaps.test.ts b/packages/nuxt/test/vite/sourceMaps.test.ts index db00688a14d1..b767aa19d458 100644 --- a/packages/nuxt/test/vite/sourceMaps.test.ts +++ b/packages/nuxt/test/vite/sourceMaps.test.ts @@ -1,4 +1,5 @@ import type { Nuxt } from '@nuxt/schema'; +import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SentryNuxtModuleOptions } from '../../src/common/types'; import type { SourceMapSetting } from '../../src/vite/sourceMaps'; @@ -19,6 +20,11 @@ vi.mock('@sentry/core', async importOriginal => { }; }); +const NODE_SERVER_OUTPUT_DIRS = { + publicDir: path.join(process.cwd(), '.output', 'public'), + serverDir: path.join(process.cwd(), '.output', 'server'), +}; + describe('getPluginOptions', () => { beforeEach(() => { vi.resetModules(); @@ -213,19 +219,26 @@ describe('getPluginOptions', () => { name: 'both client and server fallback are true', clientFallback: true, serverFallback: true, - expected: ['.*/**/public/**/*.map', '.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], + expected: ['.output/public/**/*.map', '.output/server/**/*.map'], }, { name: 'only client fallback is true', clientFallback: true, serverFallback: false, - expected: ['.*/**/public/**/*.map'], + expected: ['.output/public/**/*.map'], }, { name: 'only server fallback is true', clientFallback: false, serverFallback: true, - expected: ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], + expected: ['.output/server/**/*.map'], + }, + { + name: 'both fallbacks are true, but Nitro is not set up yet', + clientFallback: true, + serverFallback: true, + outputDirs: null, + expected: undefined, }, { name: 'no fallback, but custom filesToDeleteAfterUpload is provided', @@ -245,10 +258,11 @@ describe('getPluginOptions', () => { }, ])( 'sets filesToDeleteAfterUpload correctly when $name', - ({ clientFallback, serverFallback, customOptions = {}, expected }) => { + ({ clientFallback, serverFallback, customOptions = {}, outputDirs = NODE_SERVER_OUTPUT_DIRS, expected }) => { const options = getPluginOptions(customOptions as SentryNuxtModuleOptions, { client: clientFallback, server: serverFallback, + outputDirs: outputDirs ?? undefined, }); expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expected); From 9180b23ec1c7bd8a01608feda23bfbe41616c767 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:48:29 +0200 Subject: [PATCH 29/30] test(tanstackstart-react): Update source map E2E app for pnpm 12 Install test-utils from the tarball, pin Playwright to 1.64.1 and add the app's pnpm-workspace.yaml. --- .../tanstackstart-react-sourcemaps/package.json | 4 ++-- .../tanstackstart-react-sourcemaps/pnpm-workspace.yaml | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json index e03631c63231..865f7ace7a17 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json @@ -18,8 +18,8 @@ "react-dom": "^19.2.0" }, "devDependencies": { - "@playwright/test": "~1.63.0", - "@sentry-internal/test-utils": "link:../../../test-utils", + "@playwright/test": "1.64.1", + "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@types/node": "^22.20.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml new file mode 100644 index 000000000000..4210a62a804d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@sentry/cli': true +minimumReleaseAge: 0 From 326632b29777d43d3a7e25ff7f614dbf09ce92bc Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:56:27 +0200 Subject: [PATCH 30/30] test(tanstackstart-react): Allow the @swc/core build script in source map E2E app --- .../tanstackstart-react-sourcemaps/pnpm-workspace.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml index 4210a62a804d..4969718e7f8b 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml @@ -1,3 +1,3 @@ allowBuilds: - '@sentry/cli': true + '@swc/core': true minimumReleaseAge: 0