From ae747ad28cc2dc4c2b20d4b94f156cd9bb7565dd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 25 Sep 2025 11:57:36 +0200 Subject: [PATCH 01/17] test(e2e): Pin `import-in-the-middle@1.14.2` due to `@vercel/nft` incompatibility (#17777) This PR unblocks our CI which pas failing because: - Nuxt and SolidStart rely on nitro which relies on on `@vercel/nft` - `import-in-the-middle` received a [bug fix ](https://github.com/nodejs/import-in-the-middle/pull/205/files) which mixes `require` and `import` statements in `hooks.mjs`. This was released [in `1.14.3`](https://github.com/nodejs/import-in-the-middle/releases/tag/import-in-the-middle-v1.14.3) - Apparently, nft can't trace modules that use both, `import` and `require` statements, so the `require`'d `hooks.js` file is not added to the subset of modules nft returns (we're working on a minimal reproduction and bug report for nft) As a result, we have to pin IITM to the version before the bugfix (`1.14.2`) until either IITM reverts the fix or NFT releases a fix. The terrible part about this though is that users have to do the same thing for now and there's nothing we can do besides documenting to add an override for IITM. Being a JS developer is fun they said... --- .../test-applications/nuxt-3-dynamic-import/package.json | 5 +++++ .../e2e-tests/test-applications/nuxt-3-min/package.json | 3 ++- .../test-applications/nuxt-3-top-level-import/package.json | 5 +++++ dev-packages/e2e-tests/test-applications/nuxt-3/package.json | 5 +++++ dev-packages/e2e-tests/test-applications/nuxt-4/package.json | 5 +++++ .../test-applications/solidstart-dynamic-import/package.json | 5 +++++ .../e2e-tests/test-applications/solidstart-spa/package.json | 5 +++++ .../solidstart-top-level-import/package.json | 5 +++++ .../e2e-tests/test-applications/solidstart/package.json | 5 +++++ 9 files changed, 42 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json index b776fa5aee68..7613f56ee9e1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json @@ -23,5 +23,10 @@ }, "volta": { "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "import-in-the-middle": "1.14.2" + } } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json index 77a0459542be..b1a80add9654 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json @@ -29,7 +29,8 @@ "overrides": { "nitropack": "2.10.0", "ofetch": "1.4.0", - "@vercel/nft": "0.29.4" + "@vercel/nft": "0.29.4", + "import-in-the-middle": "1.14.2" } }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json index 61bb3b7f5e11..0e3eac926da5 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json @@ -24,5 +24,10 @@ }, "volta": { "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "import-in-the-middle": "1.14.2" + } } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json index b38943d6e3eb..ed1a12630d11 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -34,5 +34,10 @@ }, "volta": { "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "import-in-the-middle": "1.14.2" + } } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index b16b7ee2b236..983f0108e86d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -34,5 +34,10 @@ "label": "nuxt-4 (canary)" } ] + }, + "pnpm": { + "overrides": { + "import-in-the-middle": "1.14.2" + } } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json index fc1d87a63c71..783438a5420d 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json @@ -34,5 +34,10 @@ }, "volta": { "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "import-in-the-middle": "1.14.2" + } } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json index e3fb382d2387..ff0d4951ec63 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json @@ -34,5 +34,10 @@ }, "volta": { "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "import-in-the-middle": "1.14.2" + } } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json index 8612cd64bfaf..b3089abea44e 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json @@ -34,5 +34,10 @@ }, "volta": { "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "import-in-the-middle": "1.14.2" + } } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index e1d0f8b97017..9afe64a22d17 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -34,5 +34,10 @@ }, "volta": { "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "import-in-the-middle": "1.14.2" + } } } From 4df0621747c061d398276170df334773c79c5e46 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 25 Sep 2025 13:19:11 +0200 Subject: [PATCH 02/17] ref(aws-serverless): Add resolution for `import-in-the-middle` when building the Lambda layer (#17780) this is actually a fix but not a user-facing one, therefore, `ref`. Starting with `1.14.3`, `import-in-the-middle` uses both, `import` and `require` statements in its `hook.mjs` file. This caused `@vercel/nft` in its default config to _not_ trace the required `hook.js` file (+ all of its dependencies). This PR enables `mixedModules: true` to trace both, requires and imports. In contrast to #17777, we therefore don't have to override the IITM version and we also don't have to revert this PR. --- packages/aws-serverless/scripts/buildLambdaLayer.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/aws-serverless/scripts/buildLambdaLayer.ts b/packages/aws-serverless/scripts/buildLambdaLayer.ts index c12d8bd70d77..fb11652712a7 100644 --- a/packages/aws-serverless/scripts/buildLambdaLayer.ts +++ b/packages/aws-serverless/scripts/buildLambdaLayer.ts @@ -79,7 +79,11 @@ async function pruneNodeModules(): Promise { './build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/esm/awslambda-auto.js', ]; - const { fileList } = await nodeFileTrace(entrypoints); + const { fileList } = await nodeFileTrace(entrypoints, { + // import-in-the-middle uses mixed require and import syntax in their `hook.mjs` file. + // So we need to set `mixedModules` to `true` to ensure that all modules are tracked. + mixedModules: true, + }); const allFiles = getAllFiles('./build/aws/dist-serverless/nodejs/node_modules'); From 450d9f5b38e94777ca2132e9d28d3a28983d8975 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 25 Sep 2025 13:41:38 +0200 Subject: [PATCH 03/17] build(aws): Ensure AWS build cache does not keep old files (#17776) This does two things: 1. Ensure the `build/aws` dir is cleared before we re-build it, to ensure no old files remain and keep being cached. 2. Ensure we generate a zip explicitly with the files we care about only, not all files in the `build/aws` dir --- packages/aws-serverless/package.json | 2 +- packages/aws-serverless/scripts/buildLambdaLayer.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index f5361a8428a3..b00fe8929303 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -79,7 +79,7 @@ }, "scripts": { "build": "run-p build:transpile build:types", - "build:layer": "rollup -c rollup.lambda-extension.config.mjs && yarn ts-node scripts/buildLambdaLayer.ts", + "build:layer": "rimraf build/aws && rollup -c rollup.lambda-extension.config.mjs && yarn ts-node scripts/buildLambdaLayer.ts", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.mjs && yarn build:layer", "build:types": "run-s build:types:core build:types:downlevel", diff --git a/packages/aws-serverless/scripts/buildLambdaLayer.ts b/packages/aws-serverless/scripts/buildLambdaLayer.ts index fb11652712a7..241bc864816a 100644 --- a/packages/aws-serverless/scripts/buildLambdaLayer.ts +++ b/packages/aws-serverless/scripts/buildLambdaLayer.ts @@ -51,9 +51,11 @@ async function buildLambdaLayer(): Promise { fs.chmodSync('./build/aws/dist-serverless/sentry-extension/index.mjs', 0o755); const zipFilename = `sentry-node-serverless-${version}.zip`; + // Only include these directories in the zip file + const dirsToZip = ['nodejs', 'extensions', 'sentry-extension']; console.log(`Creating final layer zip file ${zipFilename}.`); // need to preserve the symlink above with -y - run(`zip -r -y ${zipFilename} .`, { cwd: 'build/aws/dist-serverless' }); + run(`zip -r -y ${zipFilename} ${dirsToZip.join(' ')}`, { cwd: 'build/aws/dist-serverless' }); } // eslint-disable-next-line @typescript-eslint/no-floating-promises From f4df972f989464a541f98676dcd4188288af239c Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:46:00 +0200 Subject: [PATCH 04/17] fix(core): Prevent `instrumentAnthropicAiClient` breaking MessageStream api (#17754) Previously, we completely walked over anthropic's SDK and replaced `message.stream` with our own method that returns an async generator. This breaks the SDK as `MessageStream` has further user callable api, such as adding event handlers. This fix proxies `message.stream` instead of replacing it with our own method. Instead of returning an async generator, we now hook into various events to do our instrumentation. Streams requested via `stream: true` are expected to return async generators, so the current approach still holds, the only change is that we proxy instead of overwrite. Fixes: #17734 --- .../suites/tracing/anthropic/scenario.mjs | 73 +++++++ .../suites/tracing/anthropic/test.ts | 48 ++++- packages/core/src/utils/anthropic-ai/index.ts | 184 +++++++++++------- .../core/src/utils/anthropic-ai/streaming.ts | 106 +++++++++- 4 files changed, 337 insertions(+), 74 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs index 577c63dc3d08..7e8e2ecd52f8 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs @@ -29,6 +29,55 @@ function startMockAnthropicServer() { return; } + // Check if streaming is requested + if (req.body.stream === true) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + // Send streaming events + const events = [ + { + type: 'message_start', + message: { + id: 'msg_stream123', + type: 'message', + role: 'assistant', + model, + content: [], + usage: { input_tokens: 10 }, + }, + }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello ' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'from ' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'stream!' } }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 15 }, + }, + { type: 'message_stop' }, + ]; + + events.forEach((event, index) => { + setTimeout(() => { + res.write(`event: ${event.type}\n`); + res.write(`data: ${JSON.stringify(event)}\n\n`); + + if (index === events.length - 1) { + res.end(); + } + }, index * 10); // Small delay between events + }); + + return; + } + + // Non-streaming response res.send({ id: 'msg_mock123', type: 'message', @@ -92,8 +141,32 @@ async function run() { // Fourth test: models.retrieve await client.models.retrieve('claude-3-haiku-20240307'); + + // Fifth test: streaming via messages.create + const stream = await client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + stream: true, + }); + + for await (const _ of stream) { + void _; + } + + // Sixth test: streaming via messages.stream + await client.messages + .stream({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }) + .on('streamEvent', () => { + Sentry.captureMessage('stream event from user-added event listener captured'); + }); }); + // Wait for the stream event handler to finish + await Sentry.flush(2000); + server.close(); } diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 9c14f698bc18..c05db16fc251 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -152,6 +152,30 @@ describe('Anthropic integration', () => { origin: 'auto.ai.anthropic', status: 'ok', }), + // Fifth span - messages.create with stream: true + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'messages', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.stream': true, + }), + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + // Sixth span - messages.stream + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'messages', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.stream': true, + }), + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), ]), }; @@ -189,6 +213,21 @@ describe('Anthropic integration', () => { ]), }; + const EXPECTED_MODEL_ERROR = { + exception: { + values: [ + { + type: 'Error', + value: '404 Model not found', + }, + ], + }, + }; + + const EXPECTED_STREAM_EVENT_HANDLER_MESSAGE = { + message: 'stream event from user-added event listener captured', + }; + createEsmAndCjsTests(__dirname, 'scenario-manual-client.mjs', 'instrument.mjs', (createRunner, test) => { test('creates anthropic related spans when manually insturmenting client', async () => { await createRunner() @@ -202,8 +241,9 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates anthropic related spans with sendDefaultPii: false', async () => { await createRunner() - .ignore('event') + .expect({ event: EXPECTED_MODEL_ERROR }) .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .expect({ event: EXPECTED_STREAM_EVENT_HANDLER_MESSAGE }) .start() .completed(); }); @@ -212,8 +252,9 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates anthropic related spans with sendDefaultPii: true', async () => { await createRunner() - .ignore('event') + .expect({ event: EXPECTED_MODEL_ERROR }) .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .expect({ event: EXPECTED_STREAM_EVENT_HANDLER_MESSAGE }) .start() .completed(); }); @@ -222,8 +263,9 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates anthropic related spans with custom options', async () => { await createRunner() - .ignore('event') + .expect({ event: EXPECTED_MODEL_ERROR }) .expect({ transaction: EXPECTED_TRANSACTION_WITH_OPTIONS }) + .expect({ event: EXPECTED_STREAM_EVENT_HANDLER_MESSAGE }) .start() .completed(); }); diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index 8cc44c188671..8e77dd76b34e 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -25,7 +25,7 @@ import { } from '../ai/gen-ai-attributes'; import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; -import { instrumentStream } from './streaming'; +import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { AnthropicAiInstrumentedMethod, AnthropicAiOptions, @@ -194,6 +194,74 @@ function addResponseAttributes(span: Span, response: AnthropicAiResponse, record addMetadataAttributes(span, response); } +/** + * Handle common error catching and reporting for streaming requests + */ +function handleStreamingError(error: unknown, span: Span, methodPath: string): never { + captureException(error, { + mechanism: { handled: false, type: 'auto.ai.anthropic', data: { function: methodPath } }, + }); + + if (span.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + span.end(); + } + throw error; +} + +/** + * Handle streaming cases with common logic + */ +function handleStreamingRequest( + originalMethod: (...args: T) => Promise, + target: (...args: T) => Promise, + context: unknown, + args: T, + requestAttributes: Record, + operationName: string, + methodPath: string, + params: Record | undefined, + options: AnthropicAiOptions, + isStreamRequested: boolean, +): Promise { + const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; + const spanConfig = { + name: `${operationName} ${model} stream-response`, + op: getSpanOperation(methodPath), + attributes: requestAttributes as Record, + }; + + if (isStreamRequested) { + return startSpanManual(spanConfig, async span => { + try { + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); + } + const result = await originalMethod.apply(context, args); + return instrumentAsyncIterableStream( + result as AsyncIterable, + span, + options.recordOutputs ?? false, + ) as unknown as R; + } catch (error) { + return handleStreamingError(error, span, methodPath); + } + }); + } else { + return startSpanManual(spanConfig, span => { + try { + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); + } + const messageStream = target.apply(context, args); + return instrumentMessageStream(messageStream, span, options.recordOutputs ?? false); + } catch (error) { + return handleStreamingError(error, span, methodPath); + } + }); + } +} + /** * Instrument a method with Sentry spans * Following Sentry AI Agents Manual Instrumentation conventions @@ -205,82 +273,62 @@ function instrumentMethod( context: unknown, options: AnthropicAiOptions, ): (...args: T) => Promise { - return async function instrumentedMethod(...args: T): Promise { - const requestAttributes = extractRequestAttributes(args, methodPath); - const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; - const operationName = getFinalOperationName(methodPath); + return new Proxy(originalMethod, { + apply(target, thisArg, args: T): Promise { + const requestAttributes = extractRequestAttributes(args, methodPath); + const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; + const operationName = getFinalOperationName(methodPath); - const params = typeof args[0] === 'object' ? (args[0] as Record) : undefined; - const isStreamRequested = Boolean(params?.stream); - const isStreamingMethod = methodPath === 'messages.stream'; + const params = typeof args[0] === 'object' ? (args[0] as Record) : undefined; + const isStreamRequested = Boolean(params?.stream); + const isStreamingMethod = methodPath === 'messages.stream'; - if (isStreamRequested || isStreamingMethod) { - return startSpanManual( + if (isStreamRequested || isStreamingMethod) { + return handleStreamingRequest( + originalMethod, + target, + context, + args, + requestAttributes, + operationName, + methodPath, + params, + options, + isStreamRequested, + ); + } + + return startSpan( { - name: `${operationName} ${model} stream-response`, + name: `${operationName} ${model}`, op: getSpanOperation(methodPath), attributes: requestAttributes as Record, }, - async span => { - try { - if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); - } - - const result = await originalMethod.apply(context, args); - return instrumentStream( - result as AsyncIterable, - span, - options.recordOutputs ?? false, - ) as unknown as R; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic', - data: { - function: methodPath, - }, - }, - }); - span.end(); - throw error; + span => { + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); } - }, - ); - } - return startSpan( - { - name: `${operationName} ${model}`, - op: getSpanOperation(methodPath), - attributes: requestAttributes as Record, - }, - span => { - if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); - } - - return handleCallbackErrors( - () => originalMethod.apply(context, args), - error => { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic', - data: { - function: methodPath, + return handleCallbackErrors( + () => target.apply(context, args), + error => { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic', + data: { + function: methodPath, + }, }, - }, - }); - }, - () => {}, - result => addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs), - ); - }, - ); - }; + }); + }, + () => {}, + result => addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs), + ); + }, + ); + }, + }) as (...args: T) => Promise; } /** diff --git a/packages/core/src/utils/anthropic-ai/streaming.ts b/packages/core/src/utils/anthropic-ai/streaming.ts index cd30d99ad09e..b542cbfda75a 100644 --- a/packages/core/src/utils/anthropic-ai/streaming.ts +++ b/packages/core/src/utils/anthropic-ai/streaming.ts @@ -15,7 +15,6 @@ import type { AnthropicAiStreamingEvent } from './types'; /** * State object used to accumulate information from a stream of Anthropic AI events. */ - interface StreamingState { /** Collected response text fragments (for output recording). */ responseTexts: string[]; @@ -183,7 +182,6 @@ function handleContentBlockStop(event: AnthropicAiStreamingEvent, state: Streami * @param recordOutputs - Whether to record outputs * @param span - The span to update */ - function processEvent( event: AnthropicAiStreamingEvent, state: StreamingState, @@ -209,12 +207,66 @@ function processEvent( handleContentBlockStop(event, state); } +/** + * Finalizes span attributes when stream processing completes + */ +function finalizeStreamSpan(state: StreamingState, span: Span, recordOutputs: boolean): void { + if (!span.isRecording()) { + return; + } + + // Set common response attributes if available + if (state.responseId) { + span.setAttributes({ + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: state.responseId, + }); + } + if (state.responseModel) { + span.setAttributes({ + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: state.responseModel, + }); + } + + setTokenUsageAttributes( + span, + state.promptTokens, + state.completionTokens, + state.cacheCreationInputTokens, + state.cacheReadInputTokens, + ); + + span.setAttributes({ + [GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]: true, + }); + + if (state.finishReasons.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(state.finishReasons), + }); + } + + if (recordOutputs && state.responseTexts.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: state.responseTexts.join(''), + }); + } + + // Set tool calls if any were captured + if (recordOutputs && state.toolCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(state.toolCalls), + }); + } + + span.end(); +} + /** * Instruments an async iterable stream of Anthropic events, updates the span with * streaming attributes and (optionally) the aggregated output text, and yields * each event from the input stream unchanged. */ -export async function* instrumentStream( +export async function* instrumentAsyncIterableStream( stream: AsyncIterable, span: Span, recordOutputs: boolean, @@ -284,3 +336,51 @@ export async function* instrumentStream( span.end(); } } + +/** + * Instruments a MessageStream by registering event handlers and preserving the original stream API. + */ +export function instrumentMessageStream void }>( + stream: R, + span: Span, + recordOutputs: boolean, +): R { + const state: StreamingState = { + responseTexts: [], + finishReasons: [], + responseId: '', + responseModel: '', + promptTokens: undefined, + completionTokens: undefined, + cacheCreationInputTokens: undefined, + cacheReadInputTokens: undefined, + toolCalls: [], + activeToolBlocks: {}, + }; + + stream.on('streamEvent', (event: unknown) => { + processEvent(event as AnthropicAiStreamingEvent, state, recordOutputs, span); + }); + + // The event fired when a message is done being streamed by the API. Corresponds to the message_stop SSE event. + // @see https://github.com/anthropics/anthropic-sdk-typescript/blob/d3be31f5a4e6ebb4c0a2f65dbb8f381ae73a9166/helpers.md?plain=1#L42-L44 + stream.on('message', () => { + finalizeStreamSpan(state, span, recordOutputs); + }); + + stream.on('error', (error: unknown) => { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic.stream_error', + }, + }); + + if (span.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'stream_error' }); + span.end(); + } + }); + + return stream; +} From 42604ea1b915e9a00f267a2f03c61d0210c2747f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 25 Sep 2025 16:06:11 +0200 Subject: [PATCH 05/17] feat(react-router): Update loadContext type to be compatible with middleware (#17758) --- packages/react-router/package.json | 2 +- .../src/server/createSentryHandleRequest.tsx | 6 ++-- .../src/server/wrapSentryHandleRequest.ts | 6 ++-- yarn.lock | 31 ++++--------------- 4 files changed, 13 insertions(+), 32 deletions(-) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index b9d339c865db..c56faf7f1662 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -61,7 +61,7 @@ "@react-router/dev": "^7.5.2", "@react-router/node": "^7.5.2", "react": "^18.3.1", - "react-router": "^7.5.2", + "react-router": "^7.9.2", "vite": "^6.1.0" }, "peerDependencies": { diff --git a/packages/react-router/src/server/createSentryHandleRequest.tsx b/packages/react-router/src/server/createSentryHandleRequest.tsx index d7db59be616f..eaf13eb16779 100644 --- a/packages/react-router/src/server/createSentryHandleRequest.tsx +++ b/packages/react-router/src/server/createSentryHandleRequest.tsx @@ -1,7 +1,7 @@ import type { createReadableStreamFromReadable } from '@react-router/node'; import type { ReactNode } from 'react'; import React from 'react'; -import type { AppLoadContext, EntryContext, ServerRouter } from 'react-router'; +import type { AppLoadContext, EntryContext, RouterContextProvider, ServerRouter } from 'react-router'; import { PassThrough } from 'stream'; import { getMetaTagTransformer } from './getMetaTagTransformer'; import { wrapSentryHandleRequest } from './wrapSentryHandleRequest'; @@ -67,7 +67,7 @@ export function createSentryHandleRequest( responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext, + loadContext: AppLoadContext | RouterContextProvider, ) => Promise { const { streamTimeout = 10000, @@ -82,7 +82,7 @@ export function createSentryHandleRequest( responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - _loadContext: AppLoadContext, + _loadContext: AppLoadContext | RouterContextProvider, ): Promise { return new Promise((resolve, reject) => { let shellRendered = false; diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 161b40f9e241..5651ad208a9d 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -8,14 +8,14 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; -import type { AppLoadContext, EntryContext } from 'react-router'; +import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router'; type OriginalHandleRequest = ( request: Request, responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext, + loadContext: AppLoadContext | RouterContextProvider, ) => Promise; /** @@ -30,7 +30,7 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext, + loadContext: AppLoadContext | RouterContextProvider, ) { const parameterizedPath = routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path; diff --git a/yarn.lock b/yarn.lock index 299b1415d89a..6e477bf0a40b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9448,7 +9448,7 @@ postcss "^8.4.47" source-map-js "^1.2.0" -"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.13", "@vue/compiler-sfc@^3.5.4": +"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.4": version "3.5.21" resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz#e48189ef3ffe334c864c2625389ebe3bb4fa41eb" integrity sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ== @@ -14248,9 +14248,6 @@ detective-scss@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-5.0.1.tgz#6a7f792dc9c0e8cfc0d252a50ba26a6df12596a7" integrity sha512-MAyPYRgS6DCiS6n6AoSBJXLGVOydsr9huwXORUlJ37K3YLyiN0vYHpzs3AdJOgHobBfispokoqrEon9rbmKacg== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^7.0.1" detective-stylus@^4.0.0: version "4.0.0" @@ -14285,14 +14282,6 @@ detective-vue2@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/detective-vue2/-/detective-vue2-2.2.0.tgz#35fd1d39e261b064aca9fcaf20e136c76877482a" integrity sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA== - dependencies: - "@dependents/detective-less" "^5.0.1" - "@vue/compiler-sfc" "^3.5.13" - detective-es6 "^5.0.1" - detective-sass "^6.0.1" - detective-scss "^5.0.1" - detective-stylus "^5.0.1" - detective-typescript "^14.0.0" deterministic-object-hash@^1.3.1: version "1.3.1" @@ -16844,9 +16833,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: version "3.2.0" resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== - dependencies: - node-domexception "^1.0.0" - web-streams-polyfill "^3.0.3" fflate@0.8.2, fflate@^0.8.2: version "0.8.2" @@ -23099,11 +23085,6 @@ node-cron@^3.0.3: dependencies: uuid "8.3.2" -node-domexception@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" - integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== - node-fetch-native@^1.4.0, node-fetch-native@^1.6.3, node-fetch-native@^1.6.4, node-fetch-native@^1.6.6: version "1.6.6" resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.6.tgz#ae1d0e537af35c2c0b0de81cbff37eedd410aa37" @@ -26164,10 +26145,10 @@ react-router@6.28.1: dependencies: "@remix-run/router" "1.21.0" -react-router@^7.5.2: - version "7.6.2" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.6.2.tgz#9f48b343bead7d0a94e28342fc4f9ae29131520e" - integrity sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w== +react-router@^7.9.2: + version "7.9.2" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.2.tgz#f424a14f87e4d7b5b268ce3647876e9504e4fca6" + integrity sha512-i2TPp4dgaqrOqiRGLZmqh2WXmbdFknUyiCRmSKs0hf6fWXkTKg5h56b+9F22NbGRAMxjYfqQnpi63egzD2SuZA== dependencies: cookie "^1.0.1" set-cookie-parser "^2.6.0" @@ -31227,7 +31208,7 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== -web-streams-polyfill@^3.0.3, web-streams-polyfill@^3.1.1: +web-streams-polyfill@^3.1.1: version "3.3.3" resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== From 930c1f0e16b0393122fa181c496b28d4e62f2020 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 25 Sep 2025 16:24:49 +0200 Subject: [PATCH 06/17] Revert "test(e2e): Pin `import-in-the-middle@1.14.2` due to `@vercel/nft` incompatibility (#17777)" (#17784) Should be good to revert #17777 since [IITM 1.14.4](https://github.com/nodejs/import-in-the-middle/releases/tag/import-in-the-middle-v1.14.4) was released --- .../test-applications/nuxt-3-dynamic-import/package.json | 5 ----- .../e2e-tests/test-applications/nuxt-3-min/package.json | 3 +-- .../test-applications/nuxt-3-top-level-import/package.json | 5 ----- dev-packages/e2e-tests/test-applications/nuxt-3/package.json | 5 ----- dev-packages/e2e-tests/test-applications/nuxt-4/package.json | 5 ----- .../test-applications/solidstart-dynamic-import/package.json | 5 ----- .../e2e-tests/test-applications/solidstart-spa/package.json | 5 ----- .../solidstart-top-level-import/package.json | 5 ----- .../e2e-tests/test-applications/solidstart/package.json | 5 ----- 9 files changed, 1 insertion(+), 42 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json index 7613f56ee9e1..b776fa5aee68 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json @@ -23,10 +23,5 @@ }, "volta": { "extends": "../../package.json" - }, - "pnpm": { - "overrides": { - "import-in-the-middle": "1.14.2" - } } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json index b1a80add9654..77a0459542be 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json @@ -29,8 +29,7 @@ "overrides": { "nitropack": "2.10.0", "ofetch": "1.4.0", - "@vercel/nft": "0.29.4", - "import-in-the-middle": "1.14.2" + "@vercel/nft": "0.29.4" } }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json index 0e3eac926da5..61bb3b7f5e11 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json @@ -24,10 +24,5 @@ }, "volta": { "extends": "../../package.json" - }, - "pnpm": { - "overrides": { - "import-in-the-middle": "1.14.2" - } } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json index ed1a12630d11..b38943d6e3eb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -34,10 +34,5 @@ }, "volta": { "extends": "../../package.json" - }, - "pnpm": { - "overrides": { - "import-in-the-middle": "1.14.2" - } } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 983f0108e86d..b16b7ee2b236 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -34,10 +34,5 @@ "label": "nuxt-4 (canary)" } ] - }, - "pnpm": { - "overrides": { - "import-in-the-middle": "1.14.2" - } } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json index 783438a5420d..fc1d87a63c71 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json @@ -34,10 +34,5 @@ }, "volta": { "extends": "../../package.json" - }, - "pnpm": { - "overrides": { - "import-in-the-middle": "1.14.2" - } } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json index ff0d4951ec63..e3fb382d2387 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json @@ -34,10 +34,5 @@ }, "volta": { "extends": "../../package.json" - }, - "pnpm": { - "overrides": { - "import-in-the-middle": "1.14.2" - } } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json index b3089abea44e..8612cd64bfaf 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json @@ -34,10 +34,5 @@ }, "volta": { "extends": "../../package.json" - }, - "pnpm": { - "overrides": { - "import-in-the-middle": "1.14.2" - } } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index 9afe64a22d17..e1d0f8b97017 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -34,10 +34,5 @@ }, "volta": { "extends": "../../package.json" - }, - "pnpm": { - "overrides": { - "import-in-the-middle": "1.14.2" - } } } From b29c88020769acc38cf36eaa5bc58dda6be32176 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 25 Sep 2025 16:37:55 +0200 Subject: [PATCH 07/17] ref(aws-serverless): Improve README with better examples (#17787) Fixes: #17774 --- packages/aws-serverless/README.md | 57 ++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/packages/aws-serverless/README.md b/packages/aws-serverless/README.md index 353b702fbcb9..81372f2178d2 100644 --- a/packages/aws-serverless/README.md +++ b/packages/aws-serverless/README.md @@ -15,16 +15,53 @@ This package is a wrapper around `@sentry/node`, with added functionality related to AWS Lambda. All methods available in `@sentry/node` can be imported from `@sentry/aws-serverless`. -To use this SDK, call `Sentry.init(options)` at the very beginning of your JavaScript file. +### Automatic Setup + +To use this SDK with an automatic setup, set the following environment variables in your Lambda function configuration: + +```bash +NODE_OPTIONS="--import @sentry/aws-serverless/awslambda-auto" +SENTRY_DSN="__DSN__" +# Add Tracing by setting tracesSampleRate and adding integration +# Set tracesSampleRate to 1.0 to capture 100% of transactions +# We recommend adjusting this value in production +# Learn more at +# https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate +SENTRY_TRACES_SAMPLE_RATE="1.0" +``` + +### Manual Setup + +Alternatively, to further customize the SDK setup, you can also manually initialize the SDK in your lambda function. The benefit of this installation method is that you can fully customize your Sentry SDK setup in a Sentry.init call. -```javascript +Create a new file, for example `instrument.js` to initialize the SDK: + +```js import * as Sentry from '@sentry/aws-serverless'; Sentry.init({ dsn: '__DSN__', - // ... + // Adds request headers and IP for users, for more info visit: + // https://docs.sentry.io/platforms/javascript/guides/aws-lambda/configuration/options/#sendDefaultPii + sendDefaultPii: true, + // Add Tracing by setting tracesSampleRate and adding integration + // Set tracesSampleRate to 1.0 to capture 100% of transactions + // We recommend adjusting this value in production + // Learn more at + // https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate + tracesSampleRate: 1.0, }); +``` + +And then load the SDK before your function starts by importing the instrument.js file via a NODE_OPTIONS environment variable: + +```bash +NODE_OPTIONS="--import ./instrument.js" +``` + +## Verify +```js // async (recommended) export const handler = async (event, context) => { throw new Error('oh, hello there!'); @@ -36,19 +73,7 @@ export const handler = (event, context, callback) => { }; ``` -If you also want to trace performance of all the incoming requests and also outgoing AWS service requests, just set the -`tracesSampleRate` option. - -```javascript -import * as Sentry from '@sentry/aws-serverless'; - -Sentry.init({ - dsn: '__DSN__', - tracesSampleRate: 1.0, -}); -``` - -#### Integrate Sentry using the Sentry Lambda layer +## Integrate Sentry using the Sentry Lambda layer Another much simpler way to integrate Sentry to your AWS Lambda function is to add the official layer. From 80e26e0a1fb5c0039f6288ed69230dd83604e28b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 26 Sep 2025 10:09:04 +0200 Subject: [PATCH 08/17] feat(replay/logs): Only attach sampled replay Ids to logs (#17750) Adds an option to `getSessionId` and `getSessionId` to only return a value if the replay is sampled. ref https://github.com/getsentry/sentry-javascript/issues/17676 --- packages/core/src/logs/internal.ts | 6 +- packages/core/test/lib/logs/internal.test.ts | 181 ++++++++++++++++++ packages/replay-internal/src/integration.ts | 7 +- packages/replay-internal/src/replay.ts | 11 +- .../test/integration/getReplayId.test.ts | 109 +++++++++++ .../test/unit/getSessionId.test.ts | 123 ++++++++++++ 6 files changed, 431 insertions(+), 6 deletions(-) create mode 100644 packages/replay-internal/test/unit/getSessionId.test.ts diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index adcbf0dfb737..b439d04ec075 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -151,8 +151,10 @@ export function _INTERNAL_captureLog( setLogAttribute(processedLogAttributes, 'sentry.sdk.name', name); setLogAttribute(processedLogAttributes, 'sentry.sdk.version', version); - const replay = client.getIntegrationByName string }>('Replay'); - setLogAttribute(processedLogAttributes, 'sentry.replay_id', replay?.getReplayId()); + const replay = client.getIntegrationByName string }>( + 'Replay', + ); + setLogAttribute(processedLogAttributes, 'sentry.replay_id', replay?.getReplayId(true)); const beforeLogMessage = beforeLog.message; if (isParameterizedString(beforeLogMessage)) { diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index 49339e72b6b1..b2d5569ecfa7 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -411,6 +411,187 @@ describe('_INTERNAL_captureLog', () => { beforeCaptureLogSpy.mockRestore(); }); + describe('replay integration with onlyIfSampled', () => { + it('includes replay ID for sampled sessions', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with sampled session + const mockReplayIntegration = { + getReplayId: vi.fn((onlyIfSampled?: boolean) => { + // Simulate behavior: return ID for sampled sessions + return onlyIfSampled ? 'sampled-replay-id' : 'any-replay-id'; + }), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with sampled replay' }, scope); + + // Verify getReplayId was called with onlyIfSampled=true + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + 'sentry.replay_id': { + value: 'sampled-replay-id', + type: 'string', + }, + }); + }); + + it('excludes replay ID for unsampled sessions when onlyIfSampled=true', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with unsampled session + const mockReplayIntegration = { + getReplayId: vi.fn((onlyIfSampled?: boolean) => { + // Simulate behavior: return undefined for unsampled when onlyIfSampled=true + return onlyIfSampled ? undefined : 'unsampled-replay-id'; + }), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with unsampled replay' }, scope); + + // Verify getReplayId was called with onlyIfSampled=true + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + // Should not include sentry.replay_id attribute + expect(logAttributes).toEqual({}); + }); + + it('includes replay ID for buffer mode sessions', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with buffer mode session + const mockReplayIntegration = { + getReplayId: vi.fn((_onlyIfSampled?: boolean) => { + // Buffer mode should still return ID even with onlyIfSampled=true + return 'buffer-replay-id'; + }), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with buffer replay' }, scope); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + 'sentry.replay_id': { + value: 'buffer-replay-id', + type: 'string', + }, + }); + }); + + it('handles missing replay integration gracefully', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock no replay integration found + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + _INTERNAL_captureLog({ level: 'info', message: 'test log without replay' }, scope); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + // Should not include sentry.replay_id attribute + expect(logAttributes).toEqual({}); + }); + + it('combines replay ID with other log attributes', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + release: '1.0.0', + environment: 'test', + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'test-replay-id'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog( + { + level: 'info', + message: 'test log with replay and other attributes', + attributes: { component: 'auth', action: 'login' }, + }, + scope, + ); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + component: { + value: 'auth', + type: 'string', + }, + action: { + value: 'login', + type: 'string', + }, + 'sentry.release': { + value: '1.0.0', + type: 'string', + }, + 'sentry.environment': { + value: 'test', + type: 'string', + }, + 'sentry.replay_id': { + value: 'test-replay-id', + type: 'string', + }, + }); + }); + + it('does not set replay ID attribute when getReplayId returns null or undefined', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const testCases = [null, undefined]; + + testCases.forEach(returnValue => { + // Clear buffer for each test + _INTERNAL_getLogBuffer(client)?.splice(0); + + const mockReplayIntegration = { + getReplayId: vi.fn(() => returnValue), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: `test log with replay returning ${returnValue}` }, scope); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({}); + expect(logAttributes).not.toHaveProperty('sentry.replay_id'); + }); + }); + }); + describe('user functionality', () => { it('includes user data in log attributes', () => { const options = getDefaultTestClientOptions({ diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index 795562c7f6ce..41c5966b88c5 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -280,13 +280,16 @@ export class Replay implements Integration { /** * Get the current session ID. + * + * @param onlyIfSampled - If true, will only return the session ID if the session is sampled. + * */ - public getReplayId(): string | undefined { + public getReplayId(onlyIfSampled?: boolean): string | undefined { if (!this._replay?.isEnabled()) { return; } - return this._replay.getSessionId(); + return this._replay.getSessionId(onlyIfSampled); } /** diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index ae3aa9589cab..61676f790b4d 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -719,8 +719,15 @@ export class ReplayContainer implements ReplayContainerInterface { this._debouncedFlush.cancel(); } - /** Get the current session (=replay) ID */ - public getSessionId(): string | undefined { + /** Get the current session (=replay) ID + * + * @param onlyIfSampled - If true, will only return the session ID if the session is sampled. + */ + public getSessionId(onlyIfSampled?: boolean): string | undefined { + if (onlyIfSampled && this.session?.sampled === false) { + return undefined; + } + return this.session?.id; } diff --git a/packages/replay-internal/test/integration/getReplayId.test.ts b/packages/replay-internal/test/integration/getReplayId.test.ts index c2f4e765520a..28b8f56ccaab 100644 --- a/packages/replay-internal/test/integration/getReplayId.test.ts +++ b/packages/replay-internal/test/integration/getReplayId.test.ts @@ -30,4 +30,113 @@ describe('Integration | getReplayId', () => { expect(integration.getReplayId()).toBeUndefined(); }); + + describe('onlyIfSampled parameter', () => { + it('returns replay ID for session mode when onlyIfSampled=true', async () => { + const { integration, replay } = await mockSdk({ + replayOptions: { + stickySession: true, + }, + }); + + // Should be in session mode by default with sessionSampleRate: 1.0 + expect(replay.recordingMode).toBe('session'); + expect(replay.session?.sampled).toBe('session'); + + expect(integration.getReplayId(true)).toBeDefined(); + expect(integration.getReplayId(true)).toEqual(replay.session?.id); + }); + + it('returns replay ID for buffer mode when onlyIfSampled=true', async () => { + const { integration, replay } = await mockSdk({ + replayOptions: { + stickySession: true, + }, + sentryOptions: { + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + }, + }); + + // Force buffer mode by manually setting session + if (replay.session) { + replay.session.sampled = 'buffer'; + replay.recordingMode = 'buffer'; + } + + expect(integration.getReplayId(true)).toBeDefined(); + expect(integration.getReplayId(true)).toEqual(replay.session?.id); + }); + + it('returns undefined for unsampled sessions when onlyIfSampled=true', async () => { + const { integration, replay } = await mockSdk({ + replayOptions: { + stickySession: true, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, // Start enabled to create session + replaysOnErrorSampleRate: 0.0, + }, + }); + + // Manually create an unsampled session by overriding the existing one + replay.session = { + id: 'test-unsampled-session', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: false, + }; + + expect(integration.getReplayId(true)).toBeUndefined(); + // But default behavior should still return the ID + expect(integration.getReplayId()).toBe('test-unsampled-session'); + expect(integration.getReplayId(false)).toBe('test-unsampled-session'); + }); + + it('maintains backward compatibility when onlyIfSampled is not provided', async () => { + const { integration, replay } = await mockSdk({ + replayOptions: { + stickySession: true, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, // Start with a session to ensure initialization + replaysOnErrorSampleRate: 0.0, + }, + }); + + const testCases: Array<{ sampled: 'session' | 'buffer' | false; sessionId: string }> = [ + { sampled: 'session', sessionId: 'session-test-id' }, + { sampled: 'buffer', sessionId: 'buffer-test-id' }, + { sampled: false, sessionId: 'unsampled-test-id' }, + ]; + + for (const { sampled, sessionId } of testCases) { + replay.session = { + id: sessionId, + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled, + }; + + // Default behavior should always return the ID + expect(integration.getReplayId()).toBe(sessionId); + } + }); + + it('returns undefined when replay is disabled regardless of onlyIfSampled', async () => { + const { integration } = await mockSdk({ + replayOptions: { + stickySession: true, + }, + }); + + integration.stop(); + + expect(integration.getReplayId()).toBeUndefined(); + expect(integration.getReplayId(true)).toBeUndefined(); + expect(integration.getReplayId(false)).toBeUndefined(); + }); + }); }); diff --git a/packages/replay-internal/test/unit/getSessionId.test.ts b/packages/replay-internal/test/unit/getSessionId.test.ts new file mode 100644 index 000000000000..c9ccde7d07d0 --- /dev/null +++ b/packages/replay-internal/test/unit/getSessionId.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, it } from 'vitest'; +import type { Session } from '../../src/types'; +import { setupReplayContainer } from '../utils/setupReplayContainer'; + +describe('Unit | ReplayContainer | getSessionId', () => { + it('returns session ID when session exists', () => { + const replay = setupReplayContainer(); + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: 'session', + }; + replay.session = mockSession; + + expect(replay.getSessionId()).toBe('test-session-id'); + }); + + it('returns undefined when no session exists', () => { + const replay = setupReplayContainer(); + replay.session = undefined; + + expect(replay.getSessionId()).toBeUndefined(); + }); + + describe('onlyIfSampled parameter', () => { + it('returns session ID for sampled=session when onlyIfSampled=true', () => { + const replay = setupReplayContainer(); + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: 'session', + }; + replay.session = mockSession; + + expect(replay.getSessionId(true)).toBe('test-session-id'); + }); + + it('returns session ID for sampled=buffer when onlyIfSampled=true', () => { + const replay = setupReplayContainer(); + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: 'buffer', + }; + replay.session = mockSession; + + expect(replay.getSessionId(true)).toBe('test-session-id'); + }); + + it('returns undefined for sampled=false when onlyIfSampled=true', () => { + const replay = setupReplayContainer(); + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: false, + }; + replay.session = mockSession; + + expect(replay.getSessionId(true)).toBeUndefined(); + }); + + it('returns session ID for sampled=false when onlyIfSampled=false (default)', () => { + const replay = setupReplayContainer(); + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: false, + }; + replay.session = mockSession; + + expect(replay.getSessionId()).toBe('test-session-id'); + expect(replay.getSessionId(false)).toBe('test-session-id'); + }); + + it('returns undefined when no session exists regardless of onlyIfSampled', () => { + const replay = setupReplayContainer(); + replay.session = undefined; + + expect(replay.getSessionId(true)).toBeUndefined(); + expect(replay.getSessionId(false)).toBeUndefined(); + }); + }); + + describe('backward compatibility', () => { + it('maintains existing behavior when onlyIfSampled is not provided', () => { + const replay = setupReplayContainer(); + + // Test with different sampling states + const testCases: Array<{ sampled: Session['sampled']; expected: string | undefined }> = [ + { sampled: 'session', expected: 'test-session-id' }, + { sampled: 'buffer', expected: 'test-session-id' }, + { sampled: false, expected: 'test-session-id' }, + ]; + + testCases.forEach(({ sampled, expected }) => { + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled, + }; + replay.session = mockSession; + + expect(replay.getSessionId()).toBe(expected); + }); + }); + }); +}); From 48882d29b65474c19b20d7048629cc0a8ab5e6b8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 26 Sep 2025 10:09:54 +0200 Subject: [PATCH 09/17] test(react-router): Test v8 middleware (#17783) --- .../react-router-7-framework/app/context.ts | 8 ++++ .../react-router-7-framework/app/routes.ts | 1 + .../routes/performance/with-middleware.tsx | 38 +++++++++++++++++++ .../react-router-7-framework/package.json | 9 +++++ .../react-router.config.ts | 3 ++ .../performance/middleware.server.test.ts | 38 +++++++++++++++++++ 6 files changed, 97 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework/app/context.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/with-middleware.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/middleware.server.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/context.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/context.ts new file mode 100644 index 000000000000..a15189e5bed8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react-router'; + +export type User = { + id: string; + name: string; +}; + +export const userContext = createContext(null); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts index b412893def52..731081b54f52 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts @@ -17,5 +17,6 @@ export default [ route('static', 'routes/performance/static.tsx'), route('server-loader', 'routes/performance/server-loader.tsx'), route('server-action', 'routes/performance/server-action.tsx'), + route('with-middleware', 'routes/performance/with-middleware.tsx'), ]), ] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/with-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/with-middleware.tsx new file mode 100644 index 000000000000..c86f78e17164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/with-middleware.tsx @@ -0,0 +1,38 @@ +import type { Route } from './+types/with-middleware'; +import type { User } from '../../context'; +import { userContext } from '../../context'; +import * as Sentry from '@sentry/react-router'; + +async function getUser() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { + id: '1', + name: 'Carlos Gomez', + }; +} + +const authMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => { + Sentry.startSpan({ name: 'authMiddleware', op: 'middleware.auth' }, async () => { + const user: User = await getUser(); + context.set(userContext, user); + await next(); + }); +}; + +export const middleware: Route.MiddlewareFunction[] = [authMiddleware]; + +export const loader = async ({ context }: Route.LoaderArgs) => { + const user = context.get(userContext); + return { user }; +}; + +export default function WithMiddlewarePage({ loaderData }: Route.ComponentProps) { + const { user } = loaderData; + + return ( +
+

With Middleware Page

+

User: {user?.name}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json index a6bd5459ae2c..1ec3da8da47a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json @@ -24,6 +24,7 @@ }, "scripts": { "build": "react-router build", + "test:build-latest": "pnpm install && pnpm add react-router@latest && pnpm add @react-router/node@latest && pnpm add @react-router/serve@latest && pnpm build", "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", "proxy": "node start-event-proxy.mjs", @@ -54,5 +55,13 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-latest", + "label": "react-router-7-framework (latest)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts index bb1f96469dd2..72f2eef3b0f5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts @@ -3,4 +3,7 @@ import type { Config } from '@react-router/dev/config'; export default { ssr: true, prerender: ['/performance/static'], + future: { + v8_middleware: true, + }, } satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/middleware.server.test.ts new file mode 100644 index 000000000000..dbce05350ad9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/middleware.server.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server - middleware', () => { + test('should send middleware transaction on pageload', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with-middleware'; + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/with-middleware'; + }); + + const customMiddlewareTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'authMiddleware'; + }); + + await page.goto(`/performance/with-middleware`); + + const serverTx = await serverTxPromise; + const pageloadTx = await pageloadTxPromise; + const customMiddlewareTx = await customMiddlewareTxPromise; + + const traceIds = { + server: serverTx?.contexts?.trace?.trace_id, + pageload: pageloadTx?.contexts?.trace?.trace_id, + customMiddleware: customMiddlewareTx?.contexts?.trace?.trace_id, + }; + + expect(pageloadTx).toBeDefined(); + expect(customMiddlewareTx).toBeDefined(); + + // Assert that all transactions belong to the same trace + expect(traceIds.server).toBe(traceIds.pageload); + expect(traceIds.server).toBe(traceIds.customMiddleware); + }); +}); From 8424fdc51b833242da6be92e3fac7041d8b0018d Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 26 Sep 2025 11:46:29 +0200 Subject: [PATCH 10/17] test(nextjs): Add route handler tests for turbopack (#17515) --- .../app/route-handlers/[param]/error/route.ts | 3 + .../app/route-handlers/[param]/route.ts | 9 +++ .../app/route-handlers/static/route.ts | 5 ++ .../nextjs-turbo/package.json | 2 +- .../tests/app-router/route-handlers.test.ts | 73 +++++++++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/error/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/static/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/error/route.ts new file mode 100644 index 000000000000..dbc0c6193131 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/error/route.ts @@ -0,0 +1,3 @@ +export async function GET(request: Request) { + throw new Error('Dynamic route handler error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/route.ts new file mode 100644 index 000000000000..581a4d68b640 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ name: 'Beep' }); +} + +export async function POST() { + return NextResponse.json({ name: 'Boop' }, { status: 400 }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/static/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/static/route.ts new file mode 100644 index 000000000000..c2407f908b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/static/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + return NextResponse.json({ name: 'Static' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json index 1cfbd8eb6628..e28db5352884 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json @@ -17,7 +17,7 @@ "@types/node": "^18.19.1", "@types/react": "^19", "@types/react-dom": "^19", - "next": "^15.3.5", + "next": "^15.5.4", "react": "^19", "react-dom": "^19", "typescript": "~5.0.0" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts new file mode 100644 index 000000000000..544ba0084167 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should create a transaction for dynamic route handlers', async ({ request }) => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handlers/[param]'; + }); + + const response = await request.get('/route-handlers/foo'); + expect(await response.json()).toStrictEqual({ name: 'Beep' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); +}); + +test('Should create a transaction for static route handlers', async ({ request }) => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handlers/static'; + }); + + const response = await request.get('/route-handlers/static'); + expect(await response.json()).toStrictEqual({ name: 'Static' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); +}); + +test('Should create a transaction for route handlers and correctly set span status depending on http status', async ({ + request, +}) => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return transactionEvent?.transaction === 'POST /route-handlers/[param]'; + }); + + const response = await request.post('/route-handlers/bar'); + expect(await response.json()).toStrictEqual({ name: 'Boop' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('invalid_argument'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); +}); + +test('Should record exceptions and transactions for faulty route handlers', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-turbo', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Dynamic route handler error'; + }); + + const routehandlerTransactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handlers/[param]/error'; + }); + + await request.get('/route-handlers/boop/error').catch(() => {}); + + const routehandlerTransaction = await routehandlerTransactionPromise; + const routehandlerError = await errorEventPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerTransaction.contexts?.trace?.origin).toContain('auto'); + + expect(routehandlerError.exception?.values?.[0].value).toBe('Dynamic route handler error'); + + expect(routehandlerError.request?.method).toBe('GET'); + // todo: make sure url is attached to request object + // expect(routehandlerError.request?.url).toContain('/route-handlers/boop/error'); + + expect(routehandlerError.transaction).toBe('/route-handlers/[param]/error'); +}); From 80d5ff2b0434e78f2b39eb01bb42f1a4a6f982e3 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 26 Sep 2025 13:11:15 +0200 Subject: [PATCH 11/17] feat(logs): Add internal `replay_is_buffering` flag (#17752) --- packages/core/src/logs/internal.ts | 18 +- packages/core/test/lib/logs/internal.test.ts | 197 +++++++++++++++++++ 2 files changed, 211 insertions(+), 4 deletions(-) diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index b439d04ec075..b3bda05d97f7 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -151,10 +151,20 @@ export function _INTERNAL_captureLog( setLogAttribute(processedLogAttributes, 'sentry.sdk.name', name); setLogAttribute(processedLogAttributes, 'sentry.sdk.version', version); - const replay = client.getIntegrationByName string }>( - 'Replay', - ); - setLogAttribute(processedLogAttributes, 'sentry.replay_id', replay?.getReplayId(true)); + const replay = client.getIntegrationByName< + Integration & { + getReplayId: (onlyIfSampled?: boolean) => string; + getRecordingMode: () => 'session' | 'buffer' | undefined; + } + >('Replay'); + + const replayId = replay?.getReplayId(true); + setLogAttribute(processedLogAttributes, 'sentry.replay_id', replayId); + + if (replayId && replay?.getRecordingMode() === 'buffer') { + // We send this so we can identify cases where the replayId is attached but the replay itself might not have been sent to Sentry + setLogAttribute(processedLogAttributes, 'sentry._internal.replay_is_buffering', true); + } const beforeLogMessage = beforeLog.message; if (isParameterizedString(beforeLogMessage)) { diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index b2d5569ecfa7..dbb2966dc076 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -424,6 +424,7 @@ describe('_INTERNAL_captureLog', () => { // Simulate behavior: return ID for sampled sessions return onlyIfSampled ? 'sampled-replay-id' : 'any-replay-id'; }), + getRecordingMode: vi.fn(() => 'session'), }; vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); @@ -480,6 +481,7 @@ describe('_INTERNAL_captureLog', () => { // Buffer mode should still return ID even with onlyIfSampled=true return 'buffer-replay-id'; }), + getRecordingMode: vi.fn(() => 'buffer'), }; vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); @@ -494,6 +496,10 @@ describe('_INTERNAL_captureLog', () => { value: 'buffer-replay-id', type: 'string', }, + 'sentry._internal.replay_is_buffering': { + value: true, + type: 'boolean', + }, }); }); @@ -527,6 +533,7 @@ describe('_INTERNAL_captureLog', () => { // Mock replay integration const mockReplayIntegration = { getReplayId: vi.fn(() => 'test-replay-id'), + getRecordingMode: vi.fn(() => 'session'), }; vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); @@ -590,6 +597,196 @@ describe('_INTERNAL_captureLog', () => { expect(logAttributes).not.toHaveProperty('sentry.replay_id'); }); }); + + it('sets replay_is_buffering attribute when replay is in buffer mode', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with buffer mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'buffer-replay-id'), + getRecordingMode: vi.fn(() => 'buffer'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with buffered replay' }, scope); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled(); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + 'sentry.replay_id': { + value: 'buffer-replay-id', + type: 'string', + }, + 'sentry._internal.replay_is_buffering': { + value: true, + type: 'boolean', + }, + }); + }); + + it('does not set replay_is_buffering attribute when replay is in session mode', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with session mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'session-replay-id'), + getRecordingMode: vi.fn(() => 'session'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with session replay' }, scope); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled(); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + 'sentry.replay_id': { + value: 'session-replay-id', + type: 'string', + }, + }); + expect(logAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering'); + }); + + it('does not set replay_is_buffering attribute when replay is undefined mode', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with undefined mode (replay stopped/disabled) + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'stopped-replay-id'), + getRecordingMode: vi.fn(() => undefined), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with stopped replay' }, scope); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled(); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + 'sentry.replay_id': { + value: 'stopped-replay-id', + type: 'string', + }, + }); + expect(logAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering'); + }); + + it('does not set replay_is_buffering attribute when no replay ID is available', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration that returns no replay ID but has buffer mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => undefined), + getRecordingMode: vi.fn(() => 'buffer'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with buffer mode but no replay ID' }, scope); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + // getRecordingMode should not be called if there's no replay ID + expect(mockReplayIntegration.getRecordingMode).not.toHaveBeenCalled(); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({}); + expect(logAttributes).not.toHaveProperty('sentry.replay_id'); + expect(logAttributes).not.toHaveProperty('sentry.internal.replay_is_buffering'); + }); + + it('does not set replay_is_buffering attribute when replay integration is missing', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock no replay integration found + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + _INTERNAL_captureLog({ level: 'info', message: 'test log without replay integration' }, scope); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({}); + expect(logAttributes).not.toHaveProperty('sentry.replay_id'); + expect(logAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering'); + }); + + it('combines replay_is_buffering with other replay attributes', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + release: '1.0.0', + environment: 'test', + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with buffer mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'buffer-replay-id'), + getRecordingMode: vi.fn(() => 'buffer'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog( + { + level: 'info', + message: 'test log with buffer replay and other attributes', + attributes: { component: 'auth', action: 'login' }, + }, + scope, + ); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + component: { + value: 'auth', + type: 'string', + }, + action: { + value: 'login', + type: 'string', + }, + 'sentry.release': { + value: '1.0.0', + type: 'string', + }, + 'sentry.environment': { + value: 'test', + type: 'string', + }, + 'sentry.replay_id': { + value: 'buffer-replay-id', + type: 'string', + }, + 'sentry._internal.replay_is_buffering': { + value: true, + type: 'boolean', + }, + }); + }); }); describe('user functionality', () => { From 0e196737dbfdd9bb14dd1920b5146f80dadb02f4 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 26 Sep 2025 13:52:04 +0200 Subject: [PATCH 12/17] ref(core): Improve promise buffer (#17788) Extracted this out of https://github.com/getsentry/sentry-javascript/pull/17782, this improved our promise buffer class a bit: 1. Remove the code path without a `limit`, as we never use this (there is a default limit used). There is also really no reason to use this without a limit, the limit is the whole purpose of this class. 2. Use a `Set` instead of an array for the internal buffer handling, this should slightly streamline stuff. 3. For `drain`, we can simplify the implementation without a timeout drastically. We can use `Promise.race()` to handle this more gracefully, which should be supported everywhere. 4. Some slight refactorings, actually improving timing semantics slightly. --- packages/core/src/utils/promisebuffer.ts | 70 +++---- .../core/test/lib/utils/promisebuffer.test.ts | 180 +++++++++++++++--- 2 files changed, 180 insertions(+), 70 deletions(-) diff --git a/packages/core/src/utils/promisebuffer.ts b/packages/core/src/utils/promisebuffer.ts index 2830e8897129..f66077a76fd5 100644 --- a/packages/core/src/utils/promisebuffer.ts +++ b/packages/core/src/utils/promisebuffer.ts @@ -1,9 +1,9 @@ -import { rejectedSyncPromise, resolvedSyncPromise, SyncPromise } from './syncpromise'; +import { rejectedSyncPromise, resolvedSyncPromise } from './syncpromise'; export interface PromiseBuffer { // exposes the internal array so tests can assert on the state of it. // XXX: this really should not be public api. - $: Array>; + $: PromiseLike[]; add(taskProducer: () => PromiseLike): PromiseLike; drain(timeout?: number): PromiseLike; } @@ -14,11 +14,11 @@ export const SENTRY_BUFFER_FULL_ERROR = Symbol.for('SentryBufferFullError'); * Creates an new PromiseBuffer object with the specified limit * @param limit max number of promises that can be stored in the buffer */ -export function makePromiseBuffer(limit?: number): PromiseBuffer { - const buffer: Array> = []; +export function makePromiseBuffer(limit: number = 100): PromiseBuffer { + const buffer: Set> = new Set(); function isReady(): boolean { - return limit === undefined || buffer.length < limit; + return buffer.size < limit; } /** @@ -27,8 +27,8 @@ export function makePromiseBuffer(limit?: number): PromiseBuffer { * @param task Can be any PromiseLike * @returns Removed promise. */ - function remove(task: PromiseLike): PromiseLike { - return buffer.splice(buffer.indexOf(task), 1)[0] || Promise.resolve(undefined); + function remove(task: PromiseLike): void { + buffer.delete(task); } /** @@ -48,19 +48,11 @@ export function makePromiseBuffer(limit?: number): PromiseBuffer { // start the task and add its promise to the queue const task = taskProducer(); - if (buffer.indexOf(task) === -1) { - buffer.push(task); - } - void task - .then(() => remove(task)) - // Use `then(null, rejectionHandler)` rather than `catch(rejectionHandler)` so that we can use `PromiseLike` - // rather than `Promise`. `PromiseLike` doesn't have a `.catch` method, making its polyfill smaller. (ES5 didn't - // have promises, so TS has to polyfill when down-compiling.) - .then(null, () => - remove(task).then(null, () => { - // We have to add another catch here because `remove()` starts a new promise chain. - }), - ); + buffer.add(task); + void task.then( + () => remove(task), + () => remove(task), + ); return task; } @@ -74,34 +66,28 @@ export function makePromiseBuffer(limit?: number): PromiseBuffer { * `false` otherwise */ function drain(timeout?: number): PromiseLike { - return new SyncPromise((resolve, reject) => { - let counter = buffer.length; + if (!buffer.size) { + return resolvedSyncPromise(true); + } - if (!counter) { - return resolve(true); - } + // We want to resolve even if one of the promises rejects + const drainPromise = Promise.allSettled(Array.from(buffer)).then(() => true); + + if (!timeout) { + return drainPromise; + } - // wait for `timeout` ms and then resolve to `false` (if not cancelled first) - const capturedSetTimeout = setTimeout(() => { - if (timeout && timeout > 0) { - resolve(false); - } - }, timeout); + const promises = [drainPromise, new Promise(resolve => setTimeout(() => resolve(false), timeout))]; - // if all promises resolve in time, cancel the timer and resolve to `true` - buffer.forEach(item => { - void resolvedSyncPromise(item).then(() => { - if (!--counter) { - clearTimeout(capturedSetTimeout); - resolve(true); - } - }, reject); - }); - }); + // Promise.race will resolve to the first promise that resolves or rejects + // So if the drainPromise resolves, the timeout promise will be ignored + return Promise.race(promises); } return { - $: buffer, + get $(): PromiseLike[] { + return Array.from(buffer); + }, add, drain, }; diff --git a/packages/core/test/lib/utils/promisebuffer.test.ts b/packages/core/test/lib/utils/promisebuffer.test.ts index 618de06322a0..b1316302e6f6 100644 --- a/packages/core/test/lib/utils/promisebuffer.test.ts +++ b/packages/core/test/lib/utils/promisebuffer.test.ts @@ -1,52 +1,163 @@ import { describe, expect, test, vi } from 'vitest'; import { makePromiseBuffer } from '../../../src/utils/promisebuffer'; -import { SyncPromise } from '../../../src/utils/syncpromise'; +import { rejectedSyncPromise, resolvedSyncPromise } from '../../../src/utils/syncpromise'; describe('PromiseBuffer', () => { describe('add()', () => { - test('no limit', () => { - const buffer = makePromiseBuffer(); - const p = vi.fn(() => new SyncPromise(resolve => setTimeout(resolve))); - void buffer.add(p); - expect(buffer.$.length).toEqual(1); + test('enforces limit of promises', async () => { + const buffer = makePromiseBuffer(5); + + const producer1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const producer2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const producer3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const producer4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const producer5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const producer6 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + + void buffer.add(producer1); + void buffer.add(producer2); + void buffer.add(producer3); + void buffer.add(producer4); + void buffer.add(producer5); + await expect(buffer.add(producer6)).rejects.toThrowError(); + + expect(producer1).toHaveBeenCalledTimes(1); + expect(producer2).toHaveBeenCalledTimes(1); + expect(producer3).toHaveBeenCalledTimes(1); + expect(producer4).toHaveBeenCalledTimes(1); + expect(producer5).toHaveBeenCalledTimes(1); + expect(producer6).not.toHaveBeenCalled(); + + expect(buffer.$.length).toEqual(5); + + await buffer.drain(); + + expect(buffer.$.length).toEqual(0); + + expect(producer1).toHaveBeenCalledTimes(1); + expect(producer2).toHaveBeenCalledTimes(1); + expect(producer3).toHaveBeenCalledTimes(1); + expect(producer4).toHaveBeenCalledTimes(1); + expect(producer5).toHaveBeenCalledTimes(1); + expect(producer6).not.toHaveBeenCalled(); + }); + + test('sync promises', async () => { + const buffer = makePromiseBuffer(1); + let task1; + const producer1 = vi.fn(() => { + task1 = resolvedSyncPromise(); + return task1; + }); + const producer2 = vi.fn(() => resolvedSyncPromise()); + expect(buffer.add(producer1)).toEqual(task1); + const add2 = buffer.add(producer2); + + // This is immediately executed and removed again from the buffer + expect(buffer.$.length).toEqual(0); + + await expect(add2).resolves.toBeUndefined(); + + expect(producer1).toHaveBeenCalled(); + expect(producer2).toHaveBeenCalled(); }); - test('with limit', () => { + test('async promises', async () => { const buffer = makePromiseBuffer(1); let task1; const producer1 = vi.fn(() => { - task1 = new SyncPromise(resolve => setTimeout(resolve)); + task1 = new Promise(resolve => setTimeout(resolve, 1)); return task1; }); - const producer2 = vi.fn(() => new SyncPromise(resolve => setTimeout(resolve))); + const producer2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); expect(buffer.add(producer1)).toEqual(task1); - void expect(buffer.add(producer2)).rejects.toThrowError(); + const add2 = buffer.add(producer2); + expect(buffer.$.length).toEqual(1); + + await expect(add2).rejects.toThrowError(); + expect(producer1).toHaveBeenCalled(); expect(producer2).not.toHaveBeenCalled(); }); + + test('handles multiple equivalent promises', async () => { + const buffer = makePromiseBuffer(10); + + const promise = new Promise(resolve => setTimeout(resolve, 1)); + + const producer = vi.fn(() => promise); + const producer2 = vi.fn(() => promise); + + expect(buffer.add(producer)).toEqual(promise); + expect(buffer.add(producer2)).toEqual(promise); + + expect(buffer.$.length).toEqual(1); + + expect(producer).toHaveBeenCalled(); + expect(producer2).toHaveBeenCalled(); + + await buffer.drain(); + + expect(buffer.$.length).toEqual(0); + }); }); describe('drain()', () => { - test('without timeout', async () => { + test('drains all promises without timeout', async () => { const buffer = makePromiseBuffer(); - for (let i = 0; i < 5; i++) { - void buffer.add(() => new SyncPromise(resolve => setTimeout(resolve))); - } + + const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + + [p1, p2, p3, p4, p5].forEach(p => { + void buffer.add(p); + }); + expect(buffer.$.length).toEqual(5); const result = await buffer.drain(); expect(result).toEqual(true); expect(buffer.$.length).toEqual(0); + + expect(p1).toHaveBeenCalled(); + expect(p2).toHaveBeenCalled(); + expect(p3).toHaveBeenCalled(); + expect(p4).toHaveBeenCalled(); + expect(p5).toHaveBeenCalled(); }); - test('with timeout', async () => { + test('drains all promises with timeout', async () => { const buffer = makePromiseBuffer(); - for (let i = 0; i < 5; i++) { - void buffer.add(() => new SyncPromise(resolve => setTimeout(resolve, 100))); - } + + const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 2))); + const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 4))); + const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 6))); + const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 8))); + const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + + [p1, p2, p3, p4, p5].forEach(p => { + void buffer.add(p); + }); + + expect(p1).toHaveBeenCalled(); + expect(p2).toHaveBeenCalled(); + expect(p3).toHaveBeenCalled(); + expect(p4).toHaveBeenCalled(); + expect(p5).toHaveBeenCalled(); + expect(buffer.$.length).toEqual(5); - const result = await buffer.drain(50); + const result = await buffer.drain(8); expect(result).toEqual(false); + // p5 is still in the buffer + expect(buffer.$.length).toEqual(1); + + // Now drain final item + const result2 = await buffer.drain(); + expect(result2).toEqual(true); + expect(buffer.$.length).toEqual(0); }); test('on empty buffer', async () => { @@ -56,11 +167,26 @@ describe('PromiseBuffer', () => { expect(result).toEqual(true); expect(buffer.$.length).toEqual(0); }); + + test('resolves even if one of the promises rejects', async () => { + const buffer = makePromiseBuffer(); + const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p2 = vi.fn(() => new Promise((_, reject) => setTimeout(() => reject(new Error('whoops')), 1))); + void buffer.add(p1); + void buffer.add(p2); + + const result = await buffer.drain(); + expect(result).toEqual(true); + expect(buffer.$.length).toEqual(0); + + expect(p1).toHaveBeenCalled(); + expect(p2).toHaveBeenCalled(); + }); }); test('resolved promises should not show up in buffer length', async () => { const buffer = makePromiseBuffer(); - const producer = () => new SyncPromise(resolve => setTimeout(resolve)); + const producer = () => new Promise(resolve => setTimeout(resolve, 1)); const task = buffer.add(producer); expect(buffer.$.length).toEqual(1); await task; @@ -69,20 +195,18 @@ describe('PromiseBuffer', () => { test('rejected promises should not show up in buffer length', async () => { const buffer = makePromiseBuffer(); - const producer = () => new SyncPromise((_, reject) => setTimeout(reject)); + const error = new Error('whoops'); + const producer = () => new Promise((_, reject) => setTimeout(() => reject(error), 1)); const task = buffer.add(producer); expect(buffer.$.length).toEqual(1); - try { - await task; - } catch { - // no-empty - } + + await expect(task).rejects.toThrow(error); expect(buffer.$.length).toEqual(0); }); test('resolved task should give an access to the return value', async () => { const buffer = makePromiseBuffer(); - const producer = () => new SyncPromise(resolve => setTimeout(() => resolve('test'))); + const producer = () => resolvedSyncPromise('test'); const task = buffer.add(producer); const result = await task; expect(result).toEqual('test'); @@ -91,7 +215,7 @@ describe('PromiseBuffer', () => { test('rejected task should give an access to the return value', async () => { expect.assertions(1); const buffer = makePromiseBuffer(); - const producer = () => new SyncPromise((_, reject) => setTimeout(() => reject(new Error('whoops')))); + const producer = () => rejectedSyncPromise(new Error('whoops')); const task = buffer.add(producer); try { await task; From 0b4a7b143739f983bdf7243563ff70be11367347 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 29 Sep 2025 11:01:45 +0200 Subject: [PATCH 13/17] chore: Add `publish_release` command (#17797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This will enable just executing `/publish_release` within a cursor chat for creating a new release. https://cursor.com/docs/agent/chat/commands 👀 --- .cursor/commands/publish_release.md | 5 +++++ docs/publishing-a-release.md | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 .cursor/commands/publish_release.md diff --git a/.cursor/commands/publish_release.md b/.cursor/commands/publish_release.md new file mode 100644 index 000000000000..5d1477992dfb --- /dev/null +++ b/.cursor/commands/publish_release.md @@ -0,0 +1,5 @@ +# Release Command + +Execute the standard Sentry JavaScript SDK release process. + +Find the "publishing_release" rule in `.cursor/rules/publishing_release` and follow those complete instructions step by step. diff --git a/docs/publishing-a-release.md b/docs/publishing-a-release.md index 810b180c3ba9..79053f4ef7ee 100644 --- a/docs/publishing-a-release.md +++ b/docs/publishing-a-release.md @@ -4,6 +4,8 @@ _These steps are only relevant to Sentry employees when preparing and publishing These have also been documented via [Cursor Rules](../.cursor/rules/publishing-release.mdc). +You can run a pre-configured command in cursor by just typing `/publish_release` into the chat window to automate the steps below. + **If you want to release a new SDK for the first time, be sure to follow the [New SDK Release Checklist](./new-sdk-release-checklist.md)** From 22f8e5a9ef1e8e02d01bcffb017ef0a5ec78793e Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 29 Sep 2025 11:15:19 +0200 Subject: [PATCH 14/17] fix(react): Do not send additional navigation span on pageload (#17799) The React Router instrumentation created an additional `navigation` span on `pageload`. On initial load, the lazy route has the history action `POP` and state `idle`. This leads to the generation of a `navigation` span. I added a condition to early-return from `handleNavigation` if a page load is going on. Closes issue (in Linear) https://linear.app/getsentry/issue/FE-551/configure-react-router-for-fully-parameterized-routes-with-sentry --- .../tests/transactions.test.ts | 31 +++++++++++++++++++ .../instrumentation.tsx | 7 +++++ 2 files changed, 38 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index 2cfa2935b5bf..34e5105f8f9d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -23,6 +23,37 @@ test('Creates a pageload transaction with parameterized route', async ({ page }) expect(event.contexts?.trace?.op).toBe('pageload'); }); +test('Does not create a navigation transaction on initial load to deep lazy route', async ({ page }) => { + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + await page.goto('/lazy/inner/1/2/3'); + + const pageloadEvent = await pageloadPromise; + + expect(pageloadEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + + const lazyRouteContent = page.locator('id=innermost-lazy-route'); + await expect(lazyRouteContent).toBeVisible(); + + // "Race" between navigation transaction and a timeout to ensure no navigation transaction is created within the timeout period + const result = await Promise.race([ + navigationPromise.then(() => 'navigation'), + new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 1500)), + ]); + + expect(result).toBe('timeout'); +}); + test('Creates a navigation transaction inside a lazy route', async ({ page }) => { const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { return ( diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index a464875a8575..10db32231195 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -567,6 +567,13 @@ export function handleNavigation(opts: { return; } + // Avoid starting a navigation span on initial load when a pageload root span is active. + // This commonly happens when lazy routes resolve during the first render and React Router emits a POP. + const activeRootSpan = getActiveRootSpan(); + if (activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload' && navigationType === 'POP') { + return; + } + if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) { const [name, source] = resolveRouteNameAndSource( location, From 5ce443553f595c314aff21c4cb2f9ae02dc33966 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 29 Sep 2025 11:49:15 +0200 Subject: [PATCH 15/17] fix(nextjs): Don't use chalk in turbopack config file (#17806) We don't know yet why but this config code seems to be evaluated once at runtime for some users when using turbopack. The chalk import breaks with `Failed to load external module @sentry/nextjs: Error [ERR_REQUIRE_ESM]`. Just getting rid of this import for now to not break apps. We're still investigating together with Vercel why this this code gets pulled in. closes https://github.com/getsentry/sentry-javascript/issues/17691 --- .../nextjs/src/config/turbopack/constructTurbopackConfig.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 76d98fda25e8..5c6372d6dec1 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,5 +1,4 @@ import { debug } from '@sentry/core'; -import * as chalk from 'chalk'; import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; import { generateValueInjectionRules } from './generateValueInjectionRules'; @@ -57,9 +56,7 @@ export function safelyAddTurbopackRule( // If the rule already exists, we don't want to mess with it. if (existingRules[matcher]) { debug.log( - `${chalk.cyan( - 'info', - )} - Turbopack rule already exists for ${matcher}. Please remove it from your Next.js config in order for Sentry to work properly.`, + `[@sentry/nextjs] - Turbopack rule already exists for ${matcher}. Please remove it from your Next.js config in order for Sentry to work properly.`, ); return existingRules; } From c94d2a91acd12c7864f4534d78a5bff2d54dbdc1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 29 Sep 2025 11:53:07 +0200 Subject: [PATCH 16/17] fix(browser): Use current start timestamp for CLS span when CLS is 0 (#17800) When there's no layout shift, the standalone CLS span would previously have the same start time as the pageload span (`performance.timeOrigin`). This caused the cls span to be incorrectly interpreted as the trace root in the Sentry UI. We can fix this by setting the span start timestamp as the startTime instead if there's no layout shift. In all other cases, we let the span start at the time the last CLS update occured. --- packages/browser-utils/src/metrics/cls.ts | 12 +- .../browser-utils/test/metrics/cls.test.ts | 231 ++++++++++++++++++ 2 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 packages/browser-utils/test/metrics/cls.test.ts diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index 99f76c610226..d836ff315c06 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -9,6 +9,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + timestampInSeconds, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { addClsInstrumentationHandler } from './instrument'; @@ -42,12 +43,15 @@ export function trackClsAsStandaloneSpan(client: Client): void { }, true); listenForWebVitalReportEvents(client, (reportEvent, pageloadSpanId) => { - sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent); + _sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent); cleanupClsHandler(); }); } -function sendStandaloneClsSpan( +/** + * Exported only for testing! + */ +export function _sendStandaloneClsSpan( clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string, @@ -55,7 +59,7 @@ function sendStandaloneClsSpan( ) { DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`); - const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0)); + const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds(); const routeName = getCurrentScope().getScopeData().transactionName; const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift'; @@ -63,7 +67,7 @@ function sendStandaloneClsSpan( const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.cls', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.webvital.cls', - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration || 0, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, // attach the pageload span id to the CLS span so that we can link them in the UI 'sentry.pageload.span_id': pageloadSpanId, // describes what triggered the web vital to be reported diff --git a/packages/browser-utils/test/metrics/cls.test.ts b/packages/browser-utils/test/metrics/cls.test.ts new file mode 100644 index 000000000000..55550d02f546 --- /dev/null +++ b/packages/browser-utils/test/metrics/cls.test.ts @@ -0,0 +1,231 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _sendStandaloneClsSpan } from '../../src/metrics/cls'; +import * as WebVitalUtils from '../../src/metrics/utils'; + +// Mock all Sentry core dependencies +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + browserPerformanceTimeOrigin: vi.fn(), + timestampInSeconds: vi.fn(), + getCurrentScope: vi.fn(), + htmlTreeAsString: vi.fn(), + }; +}); + +describe('_sendStandaloneClsSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-transaction', + }), + }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); + vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(mockSpan as any); + }); + + it('sends a standalone CLS span with entry data', () => { + const clsValue = 0.1; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 100, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [ + // @ts-expect-error - other properties are irrelevant + { + node: { tagName: 'div' } as Element, + }, + ], + toJSON: vi.fn(), + }; + const pageloadSpanId = '123'; + const reportEvent = 'navigation'; + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, reportEvent); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: '
', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': '123', + 'sentry.report_event': 'navigation', + 'cls.source.1': '
', + }, + startTime: 1.1, // (1000 + 100) / 1000 + }); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', { + 'sentry.measurement_unit': '', + 'sentry.measurement_value': 0.1, + }); + + expect(mockSpan.end).toHaveBeenCalledWith(1.1); + }); + + it('sends a standalone CLS span without entry data', () => { + const clsValue = 0; + const pageloadSpanId = '456'; + const reportEvent = 'pagehide'; + + _sendStandaloneClsSpan(clsValue, undefined, pageloadSpanId, reportEvent); + + expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); + expect(SentryCore.browserPerformanceTimeOrigin).not.toHaveBeenCalled(); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: 'Layout shift', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': pageloadSpanId, + 'sentry.report_event': 'pagehide', + }, + startTime: 1.5, + }); + + expect(mockSpan.end).toHaveBeenCalledWith(1.5); + expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', { + 'sentry.measurement_unit': '', + 'sentry.measurement_value': 0, + }); + }); + + it('handles entry with multiple sources', () => { + const clsValue = 0.15; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 200, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [ + // @ts-expect-error - other properties are irrelevant + { + node: { tagName: 'div' } as Element, + }, + // @ts-expect-error - other properties are irrelevant + { + node: { tagName: 'span' } as Element, + }, + ], + toJSON: vi.fn(), + }; + const pageloadSpanId = '789'; + + vi.mocked(SentryCore.htmlTreeAsString) + .mockReturnValueOnce('
') // for the name + .mockReturnValueOnce('
') // for source 1 + .mockReturnValueOnce(''); // for source 2 + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation'); + + expect(SentryCore.htmlTreeAsString).toHaveBeenCalledTimes(3); + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: '
', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': '789', + 'sentry.report_event': 'navigation', + 'cls.source.1': '
', + 'cls.source.2': '', + }, + startTime: 1.2, // (1000 + 200) / 1000 + }); + }); + + it('handles entry without sources', () => { + const clsValue = 0.05; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 50, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [], + toJSON: vi.fn(), + }; + const pageloadSpanId = '101'; + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation'); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: '
', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': '101', + 'sentry.report_event': 'navigation', + }, + startTime: 1.05, // (1000 + 50) / 1000 + }); + }); + + it('handles when startStandaloneWebVitalSpan returns undefined', () => { + vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(undefined); + + const clsValue = 0.1; + const pageloadSpanId = '123'; + + expect(() => { + _sendStandaloneClsSpan(clsValue, undefined, pageloadSpanId, 'navigation'); + }).not.toThrow(); + + expect(mockSpan.addEvent).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + + it('handles when browserPerformanceTimeOrigin returns null', () => { + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(undefined); + + const clsValue = 0.1; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 200, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [], + toJSON: vi.fn(), + }; + const pageloadSpanId = '123'; + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation'); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith( + expect.objectContaining({ + startTime: 0.2, + }), + ); + }); +}); From 55d8514986590d29be0988c4e7b34cd07ef9df72 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Mon, 29 Sep 2025 12:02:48 +0200 Subject: [PATCH 17/17] meta(changelog): Update changelog for 10.16.0 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5dd9918a40..1989cd3a1f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.16.0 + +- feat(logs): Add internal `replay_is_buffering` flag ([#17752](https://github.com/getsentry/sentry-javascript/pull/17752)) +- feat(react-router): Update loadContext type to be compatible with middleware ([#17758](https://github.com/getsentry/sentry-javascript/pull/17758)) +- feat(replay/logs): Only attach sampled replay Ids to logs ([#17750](https://github.com/getsentry/sentry-javascript/pull/17750)) +- fix(browser): Use current start timestamp for CLS span when CLS is 0 ([#17800](https://github.com/getsentry/sentry-javascript/pull/17800)) +- fix(core): Prevent `instrumentAnthropicAiClient` breaking MessageStream api ([#17754](https://github.com/getsentry/sentry-javascript/pull/17754)) +- fix(nextjs): Don't use chalk in turbopack config file ([#17806](https://github.com/getsentry/sentry-javascript/pull/17806)) +- fix(react): Do not send additional navigation span on pageload ([#17799](https://github.com/getsentry/sentry-javascript/pull/17799)) + +
+ Internal Changes + +- build(aws): Ensure AWS build cache does not keep old files ([#17776](https://github.com/getsentry/sentry-javascript/pull/17776)) +- chore: Add `publish_release` command ([#17797](https://github.com/getsentry/sentry-javascript/pull/17797)) +- ref(aws-serverless): Add resolution for `import-in-the-middle` when building the Lambda layer ([#17780](https://github.com/getsentry/sentry-javascript/pull/17780)) +- ref(aws-serverless): Improve README with better examples ([#17787](https://github.com/getsentry/sentry-javascript/pull/17787)) +- ref(core): Improve promise buffer ([#17788](https://github.com/getsentry/sentry-javascript/pull/17788)) +- Revert "test(e2e): Pin `import-in-the-middle@1.14.2` due to `@vercel/nft` incompatibility ([#17777](https://github.com/getsentry/sentry-javascript/pull/17777))" (#17784) +- test(e2e): Pin `import-in-the-middle@1.14.2` due to `@vercel/nft` incompatibility ([#17777](https://github.com/getsentry/sentry-javascript/pull/17777)) +- test(nextjs): Add route handler tests for turbopack ([#17515](https://github.com/getsentry/sentry-javascript/pull/17515)) +- test(react-router): Test v8 middleware ([#17783](https://github.com/getsentry/sentry-javascript/pull/17783)) + +
+ ## 10.15.0 ### Important Changes