');
+
+ 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
}>
+
+
+ >
+ );
+}
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