From 45fa8101cc71b0d6f60493ec0a639e82bb873511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 9 Apr 2026 20:24:28 +0200 Subject: [PATCH 01/73] feat(core): Export a reusable function to add tracing headers (#20076) This PR is an extraction of #19991 It basically exports `getTracingHeadersForFetchRequest`, which was previously only exported for testing, but offers a great functionality if you want to add tracing headers to a request. I renamed it as `addTracingHeadersToFetchRequest` sounded a little misleading, as it didn't really add headers to the request, as it returned the extracted headers from the request (or init, if there are any). ### Open question I added `@hidden` and `@internal` to it, not sure if this is an approach we follow. I'm ok to remove it from the jsdoc --- packages/core/src/fetch.ts | 55 +++++++++++++------- packages/core/src/index.ts | 2 +- packages/core/test/lib/fetch.test.ts | 75 +++++++++++++++++----------- 3 files changed, 84 insertions(+), 48 deletions(-) diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 4a26a5f06d80..0de9685528c3 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -19,8 +19,9 @@ import { } from './utils/url'; type PolymorphicRequestHeaders = - | Record - | Array<[string, string]> + | Record + | Array<[string, unknown]> + | Iterable> // the below is not precisely the Header type used in Request, but it'll pass duck-typing | { append: (key: string, value: string) => void; @@ -124,7 +125,7 @@ export function instrumentFetchRequest( // Examples: users re-using same options object for multiple fetch calls, frozen objects const options: { [key: string]: unknown } = { ...(handlerData.args[1] || {}) }; - const headers = _addTracingHeadersToFetchRequest( + const headers = _INTERNAL_getTracingHeadersForFetchRequest( request, options, // If performance is disabled (TWP) or there's no active root span (pageload/navigation/interaction), @@ -176,17 +177,21 @@ export function _callOnRequestSpanEnd( } /** - * Adds sentry-trace and baggage headers to the various forms of fetch headers. - * exported only for testing purposes + * Builds merged fetch headers that include `sentry-trace` and `baggage` (and optionally `traceparent`) + * for the given request and init, without mutating the original request or options. + * Returns `undefined` when there is no `sentry-trace` value to attach. * - * When we determine if we should add a baggage header, there are 3 cases: - * 1. No previous baggage header -> add baggage - * 2. Previous baggage header has no sentry baggage values -> add our baggage - * 3. Previous baggage header has sentry baggage values -> do nothing (might have been added manually by users) + * @internal Exported for cross-package instrumentation (for example Cloudflare Workers fetcher bindings) + * and unit tests + * + * Baggage handling: + * 1. No previous baggage header → include Sentry baggage + * 2. Previous baggage has no Sentry entries → merge Sentry baggage in + * 3. Previous baggage already has Sentry entries → leave as-is (may be user-defined) */ // eslint-disable-next-line complexity -- yup it's this complicated :( -export function _addTracingHeadersToFetchRequest( - request: string | Request, +export function _INTERNAL_getTracingHeadersForFetchRequest( + request: string | URL | Request, fetchOptionsObj: { headers?: | { @@ -234,19 +239,20 @@ export function _addTracingHeadersToFetchRequest( } return newHeaders; - } else if (Array.isArray(originalHeaders)) { + } else if (isHeadersInitTupleArray(originalHeaders)) { const newHeaders = [...originalHeaders]; - if (!originalHeaders.find(header => header[0] === 'sentry-trace')) { + if (!newHeaders.find(header => header[0] === 'sentry-trace')) { newHeaders.push(['sentry-trace', sentryTrace]); } - if (propagateTraceparent && traceparent && !originalHeaders.find(header => header[0] === 'traceparent')) { + if (propagateTraceparent && traceparent && !newHeaders.find(header => header[0] === 'traceparent')) { newHeaders.push(['traceparent', traceparent]); } const prevBaggageHeaderWithSentryValues = originalHeaders.find( - header => header[0] === 'baggage' && baggageHeaderHasSentryBaggageValues(header[1]), + header => + header[0] === 'baggage' && typeof header[1] === 'string' && baggageHeaderHasSentryBaggageValues(header[1]), ); if (baggage && !prevBaggageHeaderWithSentryValues) { @@ -255,7 +261,7 @@ export function _addTracingHeadersToFetchRequest( newHeaders.push(['baggage', baggage]); } - return newHeaders as PolymorphicRequestHeaders; + return newHeaders; } else { const existingSentryTraceHeader = 'sentry-trace' in originalHeaders ? originalHeaders['sentry-trace'] : undefined; const existingTraceparentHeader = 'traceparent' in originalHeaders ? originalHeaders.traceparent : undefined; @@ -313,7 +319,11 @@ function endSpan(span: Span, handlerData: HandlerDataFetch): void { span.end(); } -function baggageHeaderHasSentryBaggageValues(baggageHeader: string): boolean { +function baggageHeaderHasSentryBaggageValues(baggageHeader: unknown): boolean { + if (typeof baggageHeader !== 'string') { + return false; + } + return baggageHeader.split(',').some(baggageEntry => baggageEntry.trim().startsWith(SENTRY_BAGGAGE_KEY_PREFIX)); } @@ -321,6 +331,17 @@ function isHeaders(headers: unknown): headers is Headers { return typeof Headers !== 'undefined' && isInstanceOf(headers, Headers); } +/** `HeadersInit` array form: each entry is a [name, value] pair of strings. */ +function isHeadersInitTupleArray(headers: unknown): headers is [string, unknown][] { + if (!Array.isArray(headers)) { + return false; + } + + return headers.every( + (item): item is [string, unknown] => Array.isArray(item) && item.length === 2 && typeof item[0] === 'string', + ); +} + function getSpanStartOptions( url: string, method: string, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7d531e43b26e..5bc834862395 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -147,7 +147,7 @@ export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) // Therefore: // eslint-disable-next-line deprecation/deprecation -export { instrumentFetchRequest } from './fetch'; +export { instrumentFetchRequest, _INTERNAL_getTracingHeadersForFetchRequest } from './fetch'; export { trpcMiddleware } from './trpc'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; export { captureFeedback } from './feedback'; diff --git a/packages/core/test/lib/fetch.test.ts b/packages/core/test/lib/fetch.test.ts index 86087bcd167b..d0274b8efe82 100644 --- a/packages/core/test/lib/fetch.test.ts +++ b/packages/core/test/lib/fetch.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { HandlerDataFetch } from '../../src'; -import { _addTracingHeadersToFetchRequest, instrumentFetchRequest } from '../../src/fetch'; +import { _INTERNAL_getTracingHeadersForFetchRequest, instrumentFetchRequest } from '../../src/fetch'; import type { Span } from '../../src/types-hoist/span'; const { DEFAULT_SENTRY_TRACE, DEFAULT_BAGGAGE, hasSpansEnabled } = vi.hoisted(() => ({ @@ -31,7 +31,7 @@ vi.mock('../../src/utils/hasSpansEnabled', () => { }; }); -describe('_addTracingHeadersToFetchRequest', () => { +describe('_INTERNAL_getTracingHeadersForFetchRequest', () => { beforeEach(() => { vi.clearAllMocks(); hasSpansEnabled.mockReturnValue(false); @@ -47,7 +47,7 @@ describe('_addTracingHeadersToFetchRequest', () => { options: { headers: {} }, }, ])('attaches sentry headers (options: $options)', ({ options }) => { - expect(_addTracingHeadersToFetchRequest('/api/test', options)).toEqual({ + expect(_INTERNAL_getTracingHeadersForFetchRequest('/api/test', options)).toEqual({ 'sentry-trace': DEFAULT_SENTRY_TRACE, baggage: DEFAULT_BAGGAGE, }); @@ -56,17 +56,17 @@ describe('_addTracingHeadersToFetchRequest', () => { describe('and request headers are set in options', () => { it('attaches sentry headers to headers object', () => { - expect(_addTracingHeadersToFetchRequest('/api/test', { headers: { 'custom-header': 'custom-value' } })).toEqual( - { - 'sentry-trace': DEFAULT_SENTRY_TRACE, - baggage: DEFAULT_BAGGAGE, - 'custom-header': 'custom-value', - }, - ); + expect( + _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { headers: { 'custom-header': 'custom-value' } }), + ).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: DEFAULT_BAGGAGE, + 'custom-header': 'custom-value', + }); }); it('attaches sentry headers to a Headers instance', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { headers: new Headers({ 'custom-header': 'custom-value' }), }); @@ -81,7 +81,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }); it('attaches sentry headers to headers array', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { headers: [['custom-header', 'custom-value']], }); @@ -92,11 +92,26 @@ describe('_addTracingHeadersToFetchRequest', () => { ['baggage', DEFAULT_BAGGAGE], ]); }); + + it('treats array with non-tuple items as headers object', () => { + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { + headers: ['not-a-tuple', 'also-not-a-tuple'], + }); + + // Falls through to the else branch (headers object handling) + // since the array items are not [string, string] tuples + expect(returnedHeaders).toEqual({ + '0': 'not-a-tuple', + '1': 'also-not-a-tuple', + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: DEFAULT_BAGGAGE, + }); + }); }); describe('and 3rd party baggage header is set', () => { it('adds additional sentry baggage values to Headers instance', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { headers: new Headers({ baggage: 'custom-baggage=1,someVal=bar', }), @@ -112,7 +127,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }); it('adds additional sentry baggage values to headers array', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { headers: [['baggage', 'custom-baggage=1,someVal=bar']], }); @@ -126,7 +141,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }); it('adds additional sentry baggage values to headers object', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { headers: { baggage: 'custom-baggage=1,someVal=bar', }, @@ -141,7 +156,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }); it('adds additional sentry baggage values to headers object with arrays', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { headers: { baggage: ['custom-baggage=1,someVal=bar', 'other-vendor-key=value'], }, @@ -158,7 +173,7 @@ describe('_addTracingHeadersToFetchRequest', () => { describe('and Sentry values are already set', () => { it('does not override them (Headers instance)', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { headers: new Headers({ 'sentry-trace': CUSTOM_SENTRY_TRACE, baggage: CUSTOM_BAGGAGE, @@ -177,7 +192,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }); it('does not override them (headers array)', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { headers: [ ['sentry-trace', CUSTOM_SENTRY_TRACE], ['baggage', CUSTOM_BAGGAGE], @@ -195,7 +210,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }); it('does not override them (headers object)', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { headers: { 'sentry-trace': CUSTOM_SENTRY_TRACE, baggage: CUSTOM_BAGGAGE, @@ -218,7 +233,7 @@ describe('_addTracingHeadersToFetchRequest', () => { describe('and no request headers are set', () => { it('attaches sentry headers', () => { const request = new Request('http://locahlost:3000/api/test'); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -236,7 +251,7 @@ describe('_addTracingHeadersToFetchRequest', () => { headers: new Headers({ 'custom-header': 'custom-value' }), }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -253,7 +268,7 @@ describe('_addTracingHeadersToFetchRequest', () => { headers: { 'custom-header': 'custom-value' }, }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -270,7 +285,7 @@ describe('_addTracingHeadersToFetchRequest', () => { headers: [['custom-header', 'custom-value']], }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -292,7 +307,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }), }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -309,7 +324,7 @@ describe('_addTracingHeadersToFetchRequest', () => { headers: [['baggage', 'custom-baggage=1,someVal=bar']], }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -327,7 +342,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }, }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -345,7 +360,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }, }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -367,7 +382,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }), }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -388,7 +403,7 @@ describe('_addTracingHeadersToFetchRequest', () => { ], }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -409,7 +424,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }, }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); From 86dc30a3230e176c460dbf8140b5468486a77c04 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:34:19 +0900 Subject: [PATCH 02/73] feat(core): Add `enableTruncation` option to OpenAI integration (#20167) This PR adds an `enableTruncation` option to the OpenAI integration that allows users to disable input message truncation. It defaults to `true` to preserve existing behavior. Closes: #20135 --- .../openai/instrument-no-truncation.mjs | 23 ++++++ .../tracing/openai/scenario-no-truncation.mjs | 81 +++++++++++++++++++ .../suites/tracing/openai/test.ts | 37 +++++++++ packages/core/src/tracing/ai/utils.ts | 11 +++ packages/core/src/tracing/openai/index.ts | 18 +++-- packages/core/src/tracing/openai/types.ts | 5 ++ 6 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs new file mode 100644 index 000000000000..0dd039762f1f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.openAIIntegration({ + recordInputs: true, + recordOutputs: true, + enableTruncation: false, + }), + ], + beforeSendTransaction: event => { + if (event.transaction.includes('/openai/')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs new file mode 100644 index 000000000000..f19345653c07 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs @@ -0,0 +1,81 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json({ limit: '10mb' })); + + app.post('/openai/chat/completions', (req, res) => { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: req.body.model, + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + }); + + app.post('/openai/responses', (req, res) => { + res.send({ + id: 'resp_mock456', + object: 'response', + created_at: 1677652290, + model: req.body.model, + output: [ + { + type: 'message', + id: 'msg_mock_output_1', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: 'Response text', annotations: [] }], + }, + ], + output_text: 'Response text', + status: 'completed', + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + // Chat completion with long content (would normally be truncated) + const longContent = 'A'.repeat(50_000); + await client.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: longContent }], + }); + + // Responses API with long string input (would normally be truncated) + const longStringInput = 'B'.repeat(50_000); + await client.responses.create({ + model: 'gpt-4', + input: longStringInput, + }); + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index ae7715e9852c..d3bdc0a6a80c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -345,6 +345,43 @@ describe('OpenAI integration', () => { }); }); + const longContent = 'A'.repeat(50_000); + + const EXPECTED_TRANSACTION_NO_TRUNCATION = { + transaction: 'main', + spans: expect.arrayContaining([ + // Chat completion with long content should not be truncated + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([{ role: 'user', content: longContent }]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 1, + }), + }), + // Responses API long string input should not be truncated or wrapped in quotes + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: 'B'.repeat(50_000), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 1, + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) + .start() + .completed(); + }); + }, + ); + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS = { transaction: 'main', spans: expect.arrayContaining([ diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index 601807cc194d..d3cce644dbc1 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -169,6 +169,17 @@ export function endStreamSpan(span: Span, state: StreamResponseState, recordOutp span.end(); } +/** + * Serialize a value to a JSON string without truncation. + * Strings are returned as-is, arrays and objects are JSON-stringified. + */ +export function getJsonString(value: T | T[]): string { + if (typeof value === 'string') { + return value; + } + return JSON.stringify(value); +} + /** * Get the truncated JSON string for a string or array of strings. * diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index dc728cbe806f..f1c4d3a06516 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -19,6 +19,7 @@ import type { InstrumentedMethodEntry } from '../ai/utils'; import { buildMethodPath, extractSystemInstructions, + getJsonString, getTruncatedJsonString, resolveAIRecordingOptions, wrapPromiseWithMethods, @@ -78,7 +79,12 @@ function extractRequestAttributes(args: unknown[], operationName: string): Recor } // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. -function addRequestAttributes(span: Span, params: Record, operationName: string): void { +function addRequestAttributes( + span: Span, + params: Record, + operationName: string, + enableTruncation: boolean, +): void { // Store embeddings input on a separate attribute and do not truncate it if (operationName === 'embeddings' && 'input' in params) { const input = params.input; @@ -119,8 +125,10 @@ function addRequestAttributes(span: Span, params: Record, opera span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, systemInstructions); } - const truncatedInput = getTruncatedJsonString(filteredMessages); - span.setAttribute(GEN_AI_INPUT_MESSAGES_ATTRIBUTE, truncatedInput); + span.setAttribute( + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + enableTruncation ? getTruncatedJsonString(filteredMessages) : getJsonString(filteredMessages), + ); if (Array.isArray(filteredMessages)) { span.setAttribute(GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, filteredMessages.length); @@ -162,7 +170,7 @@ function instrumentMethod( originalResult = originalMethod.apply(context, args); if (options.recordInputs && params) { - addRequestAttributes(span, params, operationName); + addRequestAttributes(span, params, operationName, options.enableTruncation ?? true); } // Return async processing @@ -200,7 +208,7 @@ function instrumentMethod( originalResult = originalMethod.apply(context, args); if (options.recordInputs && params) { - addRequestAttributes(span, params, operationName); + addRequestAttributes(span, params, operationName, options.enableTruncation ?? true); } return originalResult.then( diff --git a/packages/core/src/tracing/openai/types.ts b/packages/core/src/tracing/openai/types.ts index dd6872bb691b..794c7ca49f8a 100644 --- a/packages/core/src/tracing/openai/types.ts +++ b/packages/core/src/tracing/openai/types.ts @@ -22,6 +22,11 @@ export interface OpenAiOptions { * Enable or disable output recording. */ recordOutputs?: boolean; + /** + * Enable or disable truncation of recorded input messages. + * Defaults to `true`. + */ + enableTruncation?: boolean; } export interface OpenAiClient { From b845cfa09c4778cdd2b0ba5f31bf99724b94e066 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:18:07 +0000 Subject: [PATCH 03/73] feat(deps): bump defu from 6.1.4 to 6.1.6 (#20104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [defu](https://github.com/unjs/defu) from 6.1.4 to 6.1.6.
Release notes

Sourced from defu's releases.

v6.1.6

compare changes

📦 Build

v6.1.5

compare changes

🩹 Fixes

  • Prevent prototype pollution via __proto__ in defaults (#156)
  • Ignore inherited enumerable properties (11ba022)

✅ Tests

  • Add more tests for plain objects (b65f603)

❤️ Contributors

Changelog

Sourced from defu's changelog.

v6.1.6

compare changes

📦 Build

❤️ Contributors

v6.1.5

compare changes

🩹 Fixes

  • Prevent prototype pollution via __proto__ in defaults (#156)
  • Ignore inherited enumerable properties (11ba022)

🏡 Chore

✅ Tests

  • Add more tests for plain objects (b65f603)

🤖 CI

❤️ Contributors

Commits
  • 001c290 chore(release): v6.1.6
  • 407b516 build: fix mixed types
  • 23e59e6 chore(release): v6.1.5
  • 11ba022 fix: ignore inherited enumerable properties
  • 3942bfb fix: prevent prototype pollution via __proto__ in defaults (#156)
  • d3ef16d chore(deps): update actions/checkout action to v6 (#151)
  • 869a053 chore(deps): update actions/setup-node action to v6 (#149)
  • a97310c chore(deps): update codecov/codecov-action action to v6 (#154)
  • 89df6bb chore: fix typecheck
  • 9237d9c ci: bump node
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=defu&package-manager=npm_and_yarn&previous-version=6.1.4&new-version=6.1.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 112148abcbac..c1a28d46e642 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14680,9 +14680,9 @@ define-property@^2.0.2: isobject "^3.0.1" defu@^6.1.2, defu@^6.1.4: - version "6.1.4" - resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479" - integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg== + version "6.1.6" + resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.6.tgz#20970cc978d9be90ba6c792184a89c92db656e53" + integrity sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug== delay@^5.0.0: version "5.0.0" From 499f042573c474ca0f5aee7a9887b4c8444ba05c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 10 Apr 2026 10:18:46 +0200 Subject: [PATCH 04/73] chore(size-limit): Bump failing size limit scenario (#20186) Please feel free to merge as soon as CI passes --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index 1e6e8d951464..4100751f2c40 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -276,7 +276,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '250 KB', + limit: '251 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', From 5a7de44755cf5c4a3a64a8938d7492dc243726da Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 10 Apr 2026 12:08:59 +0200 Subject: [PATCH 05/73] chore(bugbot): Add rules to flag test-flake-provoking patterns (#20192) We need to get a grip on our test flake situation again. Currently, CI flakes on almost every initial run, which is especially painful when cutting releases. This PR adds a few rules for bug bot to look out for anti patterns that are likely to introduce new test flakes. --- .cursor/BUGBOT.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 0ac58c1503ec..f4b4bd287271 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -58,6 +58,11 @@ Do not flag the issues below if they appear in tests. - Flag usage of `expect.objectContaining` and other relaxed assertions, when a test expects something NOT to be included in a payload but there's no respective assertion. - Flag usage of conditionals in one test and recommend splitting up the test for the different paths. - Flag usage of loops testing multiple scenarios in one test and recommend using `(it)|(test).each` instead. +- Flag tests that are likely to introduce flakes. In our case this usually means we wait for some telemetry requests sent from an SDK. Patterns to look out for: + - Only waiting for a request, after an action is performed. Instead, start waiting, perform action, await request promise. + - Race conditions when waiting on multiple requests. Ensure that waiting checks are unique enough and don't depend on a hard order when there's a chance that telemetry can be sent in arbitrary order. + - Timeouts or sleeps in tests. Instead suggest concrete events or other signals to wait on. +- Flag usage of `getFirstEnvelope*`, `getMultipleEnvelope*` or related test helpers. These are NOT reliable anymore. Instead suggest helpers like `waitForTransaction`, `waitForError`, `waitForSpans`, etc. ## Platform-safe code From d190d26885fa429838f7c1edae66473e1d9d894d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:23:08 +0200 Subject: [PATCH 06/73] chore(deps): Bump hono from 4.12.7 to 4.12.12 (#20118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [hono](https://github.com/honojs/hono) from 4.12.7 to 4.12.12.
Release notes

Sourced from hono's releases.

v4.12.12

Security fixes

This release includes fixes for the following security issues:

Middleware bypass via repeated slashes in serveStatic

Affects: Serve Static middleware. Fixes a path normalization inconsistency where repeated slashes (//) could bypass route-based middleware protections and allow access to protected static files. GHSA-wmmm-f939-6g9c

Path traversal in toSSG() allows writing files outside the output directory

Affects: toSSG() for Static Site Generation. Fixes a path traversal issue where crafted ssgParams values could write files outside the configured output directory. GHSA-xf4j-xp2r-rqqx

Incorrect IP matching in ipRestriction() for IPv4-mapped IPv6 addresses

Affects: IP Restriction Middleware. Fixes improper handling of IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) that could cause allow/deny rules to be bypassed. GHSA-xpcf-pg52-r92g

Missing validation of cookie name on write path in setCookie()

Affects: setCookie(), serialize(), and serializeSigned() from hono/cookie. Fixes missing validation of cookie names on the write path, preventing inconsistent handling between parsing and serialization. GHSA-26pp-8wgv-hjvm

Non-breaking space prefix bypass in cookie name handling in getCookie()

Affects: getCookie() from hono/cookie. Fixes a discrepancy in cookie name handling that could allow attacker-controlled cookies to override legitimate ones and bypass prefix protections. GHSA-r5rp-j6wh-rvv4


Users who use Serve Static, Static Site Generation, Cookie utilities, or IP restriction middleware are strongly encouraged to upgrade to this version.

v4.12.11

What's Changed

New Contributors

Full Changelog: https://github.com/honojs/hono/compare/v4.12.10...v4.12.11

v4.12.10

What's Changed

New Contributors

... (truncated)

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/cloudflare-integration-tests/package.json | 2 +- dev-packages/node-integration-tests/package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 105634d818cb..b5f0eeb7de32 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -16,7 +16,7 @@ "@langchain/langgraph": "^1.0.1", "@sentry/cloudflare": "10.48.0", "@sentry/hono": "10.48.0", - "hono": "^4.12.7" + "hono": "^4.12.12" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250922.0", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 5960e95ef018..0d9f63ca1974 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -58,7 +58,7 @@ "generic-pool": "^3.9.0", "graphql": "^16.11.0", "graphql-tag": "^2.12.6", - "hono": "^4.12.7", + "hono": "^4.12.12", "http-terminator": "^3.2.0", "ioredis": "^5.4.1", "kafkajs": "2.2.4", diff --git a/yarn.lock b/yarn.lock index c1a28d46e642..17e2c001080d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18932,10 +18932,10 @@ homedir-polyfill@^1.0.1: dependencies: parse-passwd "^1.0.0" -hono@^4.12.7: - version "4.12.7" - resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.7.tgz#ca000956e965c2b3d791e43540498e616d6c6442" - integrity sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw== +hono@^4.12.12: + version "4.12.12" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.12.tgz#1f14b0ffb47c386ff50d457d66e706d9c9a7f09c" + integrity sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q== hookable@^5.5.3: version "5.5.3" From 45d624f2324068b26231ce6edd3d6660708bc0ff Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:50:49 +0200 Subject: [PATCH 07/73] fix(e2e): Add op check to waitForTransaction in React Router e2e tests (#20193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [x] Analyze the flaky test issue: `waitForTransaction` in pageload tests only checks `transactionEvent.transaction === '/performance'` without verifying `op === 'pageload'`, so it can match navigation transactions in race conditions - [x] Fix `react-router-7-framework-spa/tests/performance/pageload.client.test.ts` - add `op === 'pageload'` check to all `waitForTransaction` callbacks - [x] Fix `react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts` - same fix - [x] Fix `react-router-7-framework/tests/performance/pageload.client.test.ts` - same fix - [x] Fix `react-router-7-framework-custom/tests/performance/pageload.client.test.ts` - same fix - [x] Fix `react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts` - same fix - [x] Fix navigation tests in the same apps to add `op === 'navigation'` check where missing (prevents symmetric confusion) - [x] Run validation (Code Review ✅, CodeQL ✅) - [x] Fix formatting issues with `yarn format` --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> --- .../tests/performance/navigation.client.test.ts | 9 +++++++-- .../tests/performance/pageload.client.test.ts | 11 ++++++++--- .../tests/performance/navigation.client.test.ts | 9 +++++++-- .../tests/performance/pageload.client.test.ts | 11 ++++++++--- .../tests/performance/navigation.client.test.ts | 5 ++++- .../tests/performance/pageload.client.test.ts | 7 +++++-- .../tests/performance/navigation.client.test.ts | 5 ++++- .../tests/performance/pageload.client.test.ts | 7 +++++-- .../tests/performance/navigation.client.test.ts | 14 +++++++++++--- .../tests/performance/pageload.client.test.ts | 11 ++++++++--- 10 files changed, 67 insertions(+), 22 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts index 9e9891bd9306..3e44c612b462 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts @@ -5,7 +5,9 @@ import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { test('should create navigation transaction', async ({ page }) => { const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/ssr'; + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload @@ -56,7 +58,10 @@ test.describe('client - navigation performance', () => { test('should update navigation transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts index b374c0ce4642..3095f720eb71 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance'; + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto(`/performance`); @@ -55,7 +55,10 @@ test.describe('client - pageload performance', () => { test('should update pageload transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/with/sentry`); @@ -105,7 +108,9 @@ test.describe('client - pageload performance', () => { test('should send pageload transaction for prerendered pages', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/static'; + return ( + transactionEvent.transaction === '/performance/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/static`); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts index 9e9891bd9306..3e44c612b462 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts @@ -5,7 +5,9 @@ import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { test('should create navigation transaction', async ({ page }) => { const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/ssr'; + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload @@ -56,7 +58,10 @@ test.describe('client - navigation performance', () => { test('should update navigation transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts index b374c0ce4642..3095f720eb71 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance'; + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto(`/performance`); @@ -55,7 +55,10 @@ test.describe('client - pageload performance', () => { test('should update pageload transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/with/sentry`); @@ -105,7 +108,9 @@ test.describe('client - pageload performance', () => { test('should send pageload transaction for prerendered pages', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/static'; + return ( + transactionEvent.transaction === '/performance/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/static`); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/navigation.client.test.ts index c30be5a32564..eccddaf77f04 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/navigation.client.test.ts @@ -5,7 +5,10 @@ import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { test('should update navigation transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts index 224a466ece66..d32fd24c75a6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance'; + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto(`/performance`); @@ -55,7 +55,10 @@ test.describe('client - pageload performance', () => { test('should update pageload transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/with/sentry`); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts index c30be5a32564..eccddaf77f04 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts @@ -5,7 +5,10 @@ import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { test('should update navigation transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts index 224a466ece66..d32fd24c75a6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance'; + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto(`/performance`); @@ -55,7 +55,10 @@ test.describe('client - pageload performance', () => { test('should update pageload transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/with/sentry`); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts index c273b5b55195..a31d716f7120 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts @@ -5,7 +5,9 @@ import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { test('should create navigation transaction', async ({ page }) => { const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/ssr'; + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload @@ -56,7 +58,10 @@ test.describe('client - navigation performance', () => { test('should create navigation transaction when navigating with object `to` prop', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload @@ -106,7 +111,10 @@ test.describe('client - navigation performance', () => { test('should update navigation transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts index b374c0ce4642..3095f720eb71 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance'; + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto(`/performance`); @@ -55,7 +55,10 @@ test.describe('client - pageload performance', () => { test('should update pageload transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/with/sentry`); @@ -105,7 +108,9 @@ test.describe('client - pageload performance', () => { test('should send pageload transaction for prerendered pages', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/static'; + return ( + transactionEvent.transaction === '/performance/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/static`); From 5a85b6c5530047138c42da41f2a2030948e7070d Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 10 Apr 2026 12:51:54 +0200 Subject: [PATCH 08/73] enableTruncation for vercel --- .../vercelai/instrument-no-truncation.mjs | 17 ++++++++++ .../vercelai/scenario-no-truncation.mjs | 28 ++++++++++++++++ .../suites/tracing/vercelai/test.ts | 33 +++++++++++++++++++ packages/core/src/tracing/vercel-ai/index.ts | 13 ++++---- packages/core/src/tracing/vercel-ai/utils.ts | 20 ++++++----- .../integrations/tracing/vercelai/index.ts | 5 +-- .../integrations/tracing/vercelai/types.ts | 6 ++++ 7 files changed, 106 insertions(+), 16 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-no-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-no-truncation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-no-truncation.mjs new file mode 100644 index 000000000000..0593d975c8d7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-no-truncation.mjs @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [ + Sentry.vercelAIIntegration({ + recordInputs: true, + recordOutputs: true, + enableTruncation: false, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-no-truncation.mjs new file mode 100644 index 000000000000..415c13ef9acf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-no-truncation.mjs @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + // Multiple messages with long content (would normally be truncated and popped to last message only) + const longContent = 'A'.repeat(50_000); + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 5 }, + text: 'Response', + }), + }), + messages: [ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ], + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index 673887737ee6..d75a1faf8ea0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -950,4 +950,37 @@ describe('Vercel AI integration', () => { .completed(); }); }); + + const longContent = 'A'.repeat(50_000); + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + // Multiple messages should all be preserved (no popping to last message only) + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 3, + }), + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 5458ace456c5..c7afa47c10e2 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -94,7 +94,7 @@ function mapVercelAiOperationName(operationName: string): string { * Post-process spans emitted by the Vercel AI SDK. * This is supposed to be used in `client.on('spanStart', ...) */ -function onVercelAiSpanStart(span: Span): void { +function onVercelAiSpanStart(span: Span, enableTruncation: boolean): void { const { data: attributes, description: name } = spanToJSON(span); if (!name) { @@ -114,7 +114,7 @@ function onVercelAiSpanStart(span: Span): void { return; } - processGenerateSpan(span, name, attributes); + processGenerateSpan(span, name, attributes, enableTruncation); } function vercelAiEventProcessor(event: Event): Event { @@ -396,7 +396,7 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void { } } -function processGenerateSpan(span: Span, name: string, attributes: SpanAttributes): void { +function processGenerateSpan(span: Span, name: string, attributes: SpanAttributes, enableTruncation: boolean): void { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.vercelai.otel'); const nameWthoutAi = name.replace('ai.', ''); @@ -408,7 +408,7 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute span.setAttribute('gen_ai.function_id', functionId); } - requestMessagesFromPrompt(span, attributes); + requestMessagesFromPrompt(span, attributes, enableTruncation); if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) { span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]); @@ -444,8 +444,9 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute /** * Add event processors to the given client to process Vercel AI spans. */ -export function addVercelAiProcessors(client: Client): void { - client.on('spanStart', onVercelAiSpanStart); +export function addVercelAiProcessors(client: Client, options?: { enableTruncation?: boolean }): void { + const enableTruncation = options?.enableTruncation ?? true; + client.on('spanStart', span => onVercelAiSpanStart(span, enableTruncation)); // Note: We cannot do this on `spanEnd`, because the span cannot be mutated anymore at this point client.addEventProcessor(Object.assign(vercelAiEventProcessor, { id: 'VercelAiEventProcessor' })); } diff --git a/packages/core/src/tracing/vercel-ai/utils.ts b/packages/core/src/tracing/vercel-ai/utils.ts index 2a715deab764..600cb8d2528a 100644 --- a/packages/core/src/tracing/vercel-ai/utils.ts +++ b/packages/core/src/tracing/vercel-ai/utils.ts @@ -16,7 +16,7 @@ import { GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils'; +import { extractSystemInstructions, getJsonString, getTruncatedJsonString } from '../ai/utils'; import { toolCallSpanContextMap } from './constants'; import type { TokenSummary, ToolCallSpanContext } from './types'; import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes'; @@ -227,7 +227,7 @@ export function convertUserInputToMessagesFormat(userInput: string): { role: str * Generate a request.messages JSON array from the prompt field in the * invoke_agent op */ -export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes): void { +export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes, enableTruncation: boolean): void { if ( typeof attributes[AI_PROMPT_ATTRIBUTE] === 'string' && !attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE] && @@ -247,11 +247,13 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes } const filteredLength = Array.isArray(filteredMessages) ? filteredMessages.length : 0; - const truncatedMessages = getTruncatedJsonString(filteredMessages); + const messagesJson = enableTruncation + ? getTruncatedJsonString(filteredMessages) + : getJsonString(filteredMessages); span.setAttributes({ - [AI_PROMPT_ATTRIBUTE]: truncatedMessages, - [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: truncatedMessages, + [AI_PROMPT_ATTRIBUTE]: messagesJson, + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: messagesJson, [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: filteredLength, }); } @@ -268,11 +270,13 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes } const filteredLength = Array.isArray(filteredMessages) ? filteredMessages.length : 0; - const truncatedMessages = getTruncatedJsonString(filteredMessages); + const messagesJson = enableTruncation + ? getTruncatedJsonString(filteredMessages) + : getJsonString(filteredMessages); span.setAttributes({ - [AI_PROMPT_MESSAGES_ATTRIBUTE]: truncatedMessages, - [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: truncatedMessages, + [AI_PROMPT_MESSAGES_ATTRIBUTE]: messagesJson, + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: messagesJson, [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: filteredLength, }); } diff --git a/packages/node/src/integrations/tracing/vercelai/index.ts b/packages/node/src/integrations/tracing/vercelai/index.ts index a0b3f3126d01..6ff5be83d670 100644 --- a/packages/node/src/integrations/tracing/vercelai/index.ts +++ b/packages/node/src/integrations/tracing/vercelai/index.ts @@ -30,10 +30,11 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { // Note that this can only be detected if the 'Modules' integration is available, and running in CJS mode const shouldForce = options.force ?? shouldForceIntegration(client); + const processorOptions = { enableTruncation: options.enableTruncation }; if (shouldForce) { - addVercelAiProcessors(client); + addVercelAiProcessors(client, processorOptions); } else { - instrumentation?.callWhenPatched(() => addVercelAiProcessors(client)); + instrumentation?.callWhenPatched(() => addVercelAiProcessors(client, processorOptions)); } }, }; diff --git a/packages/node/src/integrations/tracing/vercelai/types.ts b/packages/node/src/integrations/tracing/vercelai/types.ts index 35cfeb33a112..624212f9f7bd 100644 --- a/packages/node/src/integrations/tracing/vercelai/types.ts +++ b/packages/node/src/integrations/tracing/vercelai/types.ts @@ -62,6 +62,12 @@ export interface VercelAiOptions { * If you want to register the span processors even when the ai package usage cannot be detected, you can set `force` to `true`. */ force?: boolean; + + /** + * Enable or disable truncation of recorded input messages. + * Defaults to `true`. + */ + enableTruncation?: boolean; } export interface VercelAiIntegration extends Integration { From 01a04ab834f0fd4e6ec526904f724b9602a1c41c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 10 Apr 2026 12:58:32 +0200 Subject: [PATCH 09/73] fix(deno): Avoid inferring invalid span op from Deno tracer (#20128) fix(deno): Avoid inferring invalid span op from Deno tracer --- .../test-applications/deno/tests/ai.test.ts | 24 +++++++++++++++---- .../deno/tests/transactions.test.ts | 13 +++++++--- packages/deno/src/opentelemetry/tracer.ts | 8 +++---- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts index d0b824bf9b2f..9d849a33224b 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts @@ -28,13 +28,27 @@ test('should create AI pipeline spans with Vercel AI SDK', async ({ baseURL }) = // Due to the AI SDK monkey-patching limitation (https://github.com/vercel/ai/pull/6716), // only explicitly opted-in calls produce telemetry spans. // The explicitly enabled call (experimental_telemetry: { isEnabled: true }) should produce spans. - const aiSpans = spans.filter( - (span: any) => + const aiSpans = spans.filter((span: any) => { + if ( span.op === 'gen_ai.invoke_agent' || span.op === 'gen_ai.generate_content' || - span.op === 'otel.span' || - span.description?.includes('ai.generateText'), - ); + span.op === 'gen_ai.execute_tool' + ) { + return true; + } + // Processed Vercel AI spans (incl. cases where OTel kind no longer maps to a generic `op`) + if (span.origin === 'auto.vercelai.otel') { + return true; + } + // Raw Vercel AI OTel span names / attributes before or without full Sentry mapping + if (typeof span.description === 'string' && span.description.startsWith('ai.')) { + return true; + } + if (span.data?.['ai.operationId'] != null || span.data?.['ai.pipeline.name'] != null) { + return true; + } + return false; + }); // We expect at least one AI-related span from the explicitly enabled call expect(aiSpans.length).toBeGreaterThanOrEqual(1); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts index 3cd0892cebdc..19077bb76b75 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts @@ -33,11 +33,15 @@ test('Sends transaction with OTel tracer.startSpan despite pre-existing provider expect.arrayContaining([ expect.objectContaining({ description: 'test-otel-span', - op: 'otel.span', origin: 'manual', }), ]), ); + + const otelSpan = transaction.spans!.find((s: any) => s.description === 'test-otel-span'); + expect(otelSpan).toBeDefined(); + // INTERNAL (and other unmapped) kinds must not get a synthetic `otel.span` op + expect(otelSpan!.op).toBeUndefined(); }); test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) => { @@ -53,11 +57,14 @@ test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) = expect.arrayContaining([ expect.objectContaining({ description: 'test-otel-active-span', - op: 'otel.span', origin: 'manual', }), ]), ); + + const otelSpan = transaction.spans!.find((s: any) => s.description === 'test-otel-active-span'); + expect(otelSpan).toBeDefined(); + expect(otelSpan!.op).toBeUndefined(); }); test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => { @@ -77,7 +84,6 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) }), expect.objectContaining({ description: 'otel-child', - op: 'otel.span', origin: 'manual', }), ]), @@ -87,4 +93,5 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) const sentrySpan = transaction.spans!.find((s: any) => s.description === 'sentry-parent'); const otelSpan = transaction.spans!.find((s: any) => s.description === 'otel-child'); expect(otelSpan!.parent_span_id).toBe(sentrySpan!.span_id); + expect(otelSpan!.op).toBeUndefined(); }); diff --git a/packages/deno/src/opentelemetry/tracer.ts b/packages/deno/src/opentelemetry/tracer.ts index 7bc704446d37..bdd86bde6a8d 100644 --- a/packages/deno/src/opentelemetry/tracer.ts +++ b/packages/deno/src/opentelemetry/tracer.ts @@ -43,7 +43,7 @@ class SentryDenoTracer implements Tracer { attributes: { ...options?.attributes, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + ...(op ? { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op } : {}), 'sentry.deno_tracer': true, }, }); @@ -77,7 +77,7 @@ class SentryDenoTracer implements Tracer { attributes: { ...opts.attributes, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + ...(op ? { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op } : {}), 'sentry.deno_tracer': true, }, }; @@ -96,7 +96,7 @@ class SentryDenoTracer implements Tracer { return startSpanManual(spanOpts, callback) as ReturnType; } - private _mapSpanKindToOp(kind?: SpanKind): string { + private _mapSpanKindToOp(kind?: SpanKind): string | undefined { switch (kind) { case SpanKind.CLIENT: return 'http.client'; @@ -107,7 +107,7 @@ class SentryDenoTracer implements Tracer { case SpanKind.CONSUMER: return 'message.consume'; default: - return 'otel.span'; + return undefined; } } } From 8a1f1bdfef90aa985e6f760d291941f9db01b38e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:11:49 +0200 Subject: [PATCH 10/73] chore(deps): Bump axios from 1.13.5 to 1.15.0 in /dev-packages/e2e-tests/test-applications/nestjs-basic (#20179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [axios](https://github.com/axios/axios) from 1.13.5 to 1.15.0.
Release notes

Sourced from axios's releases.

v1.15.0

This release delivers two critical security patches, adds runtime support for Deno and Bun, and includes significant CI hardening, documentation improvements, and routine dependency updates.

⚠️ Important Changes

  • Deprecation: url.parse() usage has been replaced to address Node.js deprecation warnings. If you are on a recent version of Node.js, this resolves console warnings you may have been seeing. (#10625)

🔒 Security Fixes

  • Proxy Handling: Fixed a no_proxy hostname normalisation bypass that could lead to Server-Side Request Forgery (SSRF). (#10661)
  • Header Injection: Fixed an unrestricted cloud metadata exfiltration vulnerability via a header injection chain. (#10660)

🚀 New Features

  • Runtime Support: Added compatibility checks and documentation for Deno and Bun environments. (#10652, #10653)

🔧 Maintenance & Chores

  • CI Security: Hardened workflow permissions to least privilege, added the zizmor security scanner, pinned action versions, and gated npm publishing with OIDC and environment protection. (#10618, #10619, #10627, #10637, #10666)
  • Dependencies: Bumped serialize-javascript, handlebars, picomatch, vite, and denoland/setup-deno to latest versions. Added a 7-day Dependabot cooldown period. (#10574, #10572, #10568, #10663, #10664, #10665, #10669, #10670, #10616)
  • Documentation: Unified docs, improved beforeRedirect credential leakage example, clarified withCredentials/withXSRFToken behaviour, HTTP/2 support notes, async/await timeout error handling, header case preservation, and various typo fixes. (#10649, #10624, #7452, #7471, #10654, #10644, #10589)
  • Housekeeping: Removed stale files, regenerated lockfile, and updated sponsor scripts and blocks. (#10584, #10650, #10582, #10640, #10659, #10668)
  • Tests: Added regression coverage for urlencoded Content-Type casing. (#10573)

🌟 New Contributors

We are thrilled to welcome our new contributors. Thank you for helping improve Axios:

v1.14.0

This release focuses on compatibility fixes, adapter stability improvements, and test/tooling modernisation.

⚠️ Important Changes

  • Breaking Changes: None identified in this release.
  • Action Required: If you rely on env-based proxy behaviour or CJS resolution edge-cases, validate your integration after upgrade (notably proxy-from-env v2 alignment and main entry compatibility fix).

🚀 New Features

  • Runtime Features: No new end-user features were introduced in this release.
  • Test Coverage Expansion: Added broader smoke/module test coverage for CJS and ESM package usage. (#7510)

🐛 Bug Fixes

  • Headers: Trim trailing CRLF in normalised header values. (#7456)
  • HTTP/2: Close detached HTTP/2 sessions on timeout to avoid lingering sessions. (#7457)
  • Fetch Adapter: Cancel ReadableStream created during request-stream capability probing to prevent async resource leaks. (#7515)
  • Proxy Handling: Fixed env proxy behavior with proxy-from-env v2 usage. (#7499)

... (truncated)

Changelog

Sourced from axios's changelog.

Changelog

1.13.3 (2026-01-20)

Bug Fixes

  • http2: Use port 443 for HTTPS connections by default. (#7256) (d7e6065)
  • interceptor: handle the error in the same interceptor (#6269) (5945e40)
  • main field in package.json should correspond to cjs artifacts (#5756) (7373fbf)
  • package.json: add 'bun' package.json 'exports' condition. Load the Node.js build in Bun instead of the browser build (#5754) (b89217e)
  • silentJSONParsing=false should throw on invalid JSON (#7253) (#7257) (7d19335)
  • turn AxiosError into a native error (#5394) (#5558) (1c6a86d)
  • types: add handlers to AxiosInterceptorManager interface (#5551) (8d1271b)
  • types: restore AxiosError.cause type from unknown to Error (#7327) (d8233d9)
  • unclear error message is thrown when specifying an empty proxy authorization (#6314) (6ef867e)

Features

Reverts

  • Revert "fix: silentJSONParsing=false should throw on invalid JSON (#7253) (#7…" (#7298) (a4230f5), closes #7253 #7 #7298
  • deps: bump peter-evans/create-pull-request from 7 to 8 in the github-actions group (#7334) (2d6ad5e)

Contributors to this release

... (truncated)

Commits
  • 772a4e5 chore(release): prepare release 1.15.0 (#10671)
  • 4b07137 chore(deps-dev): bump vite from 8.0.0 to 8.0.5 in /tests/smoke/esm (#10663)
  • 51e57b3 chore(deps-dev): bump vite from 8.0.2 to 8.0.5 (#10664)
  • fba1a77 chore(deps-dev): bump vite from 8.0.2 to 8.0.5 in /tests/module/esm (#10665)
  • 0bf6e28 chore(deps): bump denoland/setup-deno in the github-actions group (#10669)
  • 8107157 chore(deps-dev): bump the development_dependencies group with 4 updates (#10670)
  • e66530e ci: require npm-publish environment for releases (#10666)
  • 49f23cb chore(sponsor): update sponsor block (#10668)
  • 3631854 fix: unrestricted cloud metadata exfiltration via header injection chain (#10...
  • fb3befb fix: no_proxy hostname normalization bypass leads to ssrf (#10661)
  • Additional commits viewable in compare view
Install script changes

This version modifies prepare script that runs during installation. Review the package contents before updating.


Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../e2e-tests/test-applications/nestjs-basic/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json b/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json index 46beea570042..6917e546a383 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json @@ -21,7 +21,7 @@ "@nestjs/platform-express": "^10.0.0", "@sentry/nestjs": "latest || *", "reflect-metadata": "^0.2.0", - "axios": "1.13.5", + "axios": "1.15.0", "rxjs": "^7.8.1" }, "devDependencies": { From b9de89331ad16ad3210bea6116d2a96ab6db6377 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 10 Apr 2026 13:23:36 +0200 Subject: [PATCH 11/73] . --- packages/core/src/tracing/vercel-ai/index.ts | 10 ++++++---- .../node/src/integrations/tracing/vercelai/index.ts | 5 ++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index c7afa47c10e2..faf844f33eea 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -94,7 +94,7 @@ function mapVercelAiOperationName(operationName: string): string { * Post-process spans emitted by the Vercel AI SDK. * This is supposed to be used in `client.on('spanStart', ...) */ -function onVercelAiSpanStart(span: Span, enableTruncation: boolean): void { +function onVercelAiSpanStart(span: Span, client: Client): void { const { data: attributes, description: name } = spanToJSON(span); if (!name) { @@ -114,6 +114,9 @@ function onVercelAiSpanStart(span: Span, enableTruncation: boolean): void { return; } + const integration = client.getIntegrationByName('VercelAI') as { options?: { enableTruncation?: boolean } } | undefined; + const enableTruncation = integration?.options?.enableTruncation ?? true; + processGenerateSpan(span, name, attributes, enableTruncation); } @@ -444,9 +447,8 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute /** * Add event processors to the given client to process Vercel AI spans. */ -export function addVercelAiProcessors(client: Client, options?: { enableTruncation?: boolean }): void { - const enableTruncation = options?.enableTruncation ?? true; - client.on('spanStart', span => onVercelAiSpanStart(span, enableTruncation)); +export function addVercelAiProcessors(client: Client): void { + client.on('spanStart', span => onVercelAiSpanStart(span, client)); // Note: We cannot do this on `spanEnd`, because the span cannot be mutated anymore at this point client.addEventProcessor(Object.assign(vercelAiEventProcessor, { id: 'VercelAiEventProcessor' })); } diff --git a/packages/node/src/integrations/tracing/vercelai/index.ts b/packages/node/src/integrations/tracing/vercelai/index.ts index 6ff5be83d670..a0b3f3126d01 100644 --- a/packages/node/src/integrations/tracing/vercelai/index.ts +++ b/packages/node/src/integrations/tracing/vercelai/index.ts @@ -30,11 +30,10 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { // Note that this can only be detected if the 'Modules' integration is available, and running in CJS mode const shouldForce = options.force ?? shouldForceIntegration(client); - const processorOptions = { enableTruncation: options.enableTruncation }; if (shouldForce) { - addVercelAiProcessors(client, processorOptions); + addVercelAiProcessors(client); } else { - instrumentation?.callWhenPatched(() => addVercelAiProcessors(client, processorOptions)); + instrumentation?.callWhenPatched(() => addVercelAiProcessors(client)); } }, }; From d1760534c33b69a4441b4755cd2ce006b9d8f4eb Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 10 Apr 2026 13:26:51 +0200 Subject: [PATCH 12/73] . --- packages/core/src/tracing/vercel-ai/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index faf844f33eea..90fb1b16fc65 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import type { Client } from '../../client'; +import { getClient } from '../../currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import type { Event } from '../../types-hoist/event'; import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON } from '../../types-hoist/span'; @@ -94,7 +95,7 @@ function mapVercelAiOperationName(operationName: string): string { * Post-process spans emitted by the Vercel AI SDK. * This is supposed to be used in `client.on('spanStart', ...) */ -function onVercelAiSpanStart(span: Span, client: Client): void { +function onVercelAiSpanStart(span: Span): void { const { data: attributes, description: name } = spanToJSON(span); if (!name) { @@ -114,7 +115,10 @@ function onVercelAiSpanStart(span: Span, client: Client): void { return; } - const integration = client.getIntegrationByName('VercelAI') as { options?: { enableTruncation?: boolean } } | undefined; + const client = getClient(); + const integration = client?.getIntegrationByName('VercelAI') as + | { options?: { enableTruncation?: boolean } } + | undefined; const enableTruncation = integration?.options?.enableTruncation ?? true; processGenerateSpan(span, name, attributes, enableTruncation); @@ -448,7 +452,7 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute * Add event processors to the given client to process Vercel AI spans. */ export function addVercelAiProcessors(client: Client): void { - client.on('spanStart', span => onVercelAiSpanStart(span, client)); + client.on('spanStart', onVercelAiSpanStart); // Note: We cannot do this on `spanEnd`, because the span cannot be mutated anymore at this point client.addEventProcessor(Object.assign(vercelAiEventProcessor, { id: 'VercelAiEventProcessor' })); } From bf21665d3e318a71b90104d7f967edd8b2c28f19 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:32:43 +0900 Subject: [PATCH 13/73] feat(core): Add `enableTruncation` option to LangChain integration (#20182) This PR adds an `enableTruncation` option to the LangChain integration that allows users to disable input message truncation. It defaults to `true` to preserve existing behavior. Also fixes missing truncation for LLM string prompts in extractLLMRequestAttributes and refactors to use the shared getTruncatedJsonString/getJsonString utilities. Closes: #20138 --------- Co-authored-by: Nicolas Hrubec --- .../langchain/instrument-no-truncation.mjs | 24 ++++++++ .../langchain/scenario-no-truncation.mjs | 56 +++++++++++++++++++ .../suites/tracing/langchain/test.ts | 33 +++++++++++ packages/core/.oxlintrc.json | 6 ++ packages/core/src/tracing/langchain/index.ts | 3 + packages/core/src/tracing/langchain/types.ts | 6 ++ packages/core/src/tracing/langchain/utils.ts | 18 ++++-- .../test/lib/tracing/langchain-utils.test.ts | 2 +- 8 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/instrument-no-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/scenario-no-truncation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-no-truncation.mjs new file mode 100644 index 000000000000..027299eeacad --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-no-truncation.mjs @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [ + Sentry.langChainIntegration({ + enableTruncation: false, + recordInputs: true, + recordOutputs: true, + }), + ], + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1/messages') || event.transaction.includes('/v1/embeddings')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-no-truncation.mjs new file mode 100644 index 000000000000..bb8f5fc35325 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-no-truncation.mjs @@ -0,0 +1,56 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json({ limit: '10mb' })); + + app.post('/v1/messages', (req, res) => { + res.json({ + id: 'msg_no_truncation_test', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Response' }], + model: req.body.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 5 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const model = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + // Long content that would normally be truncated + const longContent = 'A'.repeat(50_000); + await model.invoke([ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ]); + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts index 39127c7e3055..434001c92965 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -549,4 +549,37 @@ describe('LangChain integration', () => { .completed(); }); }); + + const longContent = 'A'.repeat(50_000); + + const EXPECTED_TRANSACTION_NO_TRUNCATION = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 3, + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/.oxlintrc.json b/packages/core/.oxlintrc.json index 05b86efa950b..df7465d97fa2 100644 --- a/packages/core/.oxlintrc.json +++ b/packages/core/.oxlintrc.json @@ -16,6 +16,12 @@ "rules": { "sdk/no-unsafe-random-apis": "off" } + }, + { + "files": ["src/tracing/langchain/utils.ts"], + "rules": { + "max-lines": "off" + } } ], "ignorePatterns": ["rollup.npm.config.mjs"] diff --git a/packages/core/src/tracing/langchain/index.ts b/packages/core/src/tracing/langchain/index.ts index 16257acebbd7..7acc35400c99 100644 --- a/packages/core/src/tracing/langchain/index.ts +++ b/packages/core/src/tracing/langchain/index.ts @@ -34,6 +34,7 @@ import { */ export function createLangChainCallbackHandler(options: LangChainOptions = {}): LangChainCallbackHandler { const { recordInputs, recordOutputs } = resolveAIRecordingOptions(options); + const enableTruncation = options.enableTruncation ?? true; // Internal state - single instance tracks all spans const spanMap = new Map(); @@ -89,6 +90,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): llm as LangChainSerialized, prompts, recordInputs, + enableTruncation, invocationParams, metadata, ); @@ -127,6 +129,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): llm as LangChainSerialized, messages as LangChainMessage[][], recordInputs, + enableTruncation, invocationParams, metadata, ); diff --git a/packages/core/src/tracing/langchain/types.ts b/packages/core/src/tracing/langchain/types.ts index 7379de764817..1c066269aba5 100644 --- a/packages/core/src/tracing/langchain/types.ts +++ b/packages/core/src/tracing/langchain/types.ts @@ -13,6 +13,12 @@ export interface LangChainOptions { * @default false (respects sendDefaultPii option) */ recordOutputs?: boolean; + + /** + * Enable or disable truncation of recorded input messages. + * Defaults to `true`. + */ + enableTruncation?: boolean; } /** diff --git a/packages/core/src/tracing/langchain/utils.ts b/packages/core/src/tracing/langchain/utils.ts index 924739485948..1227889f210d 100644 --- a/packages/core/src/tracing/langchain/utils.ts +++ b/packages/core/src/tracing/langchain/utils.ts @@ -26,8 +26,7 @@ import { GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { isContentMedia, stripInlineMediaFromSingleMessage } from '../ai/mediaStripping'; -import { truncateGenAiMessages } from '../ai/messageTruncation'; -import { extractSystemInstructions } from '../ai/utils'; +import { extractSystemInstructions, getJsonString, getTruncatedJsonString } from '../ai/utils'; import { LANGCHAIN_ORIGIN, ROLE_MAP } from './constants'; import type { LangChainLLMResult, LangChainMessage, LangChainSerialized } from './types'; @@ -284,6 +283,7 @@ export function extractLLMRequestAttributes( llm: LangChainSerialized, prompts: string[], recordInputs: boolean, + enableTruncation: boolean, invocationParams?: Record, langSmithMetadata?: Record, ): Record { @@ -295,7 +295,11 @@ export function extractLLMRequestAttributes( if (recordInputs && Array.isArray(prompts) && prompts.length > 0) { setIfDefined(attrs, GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, prompts.length); const messages = prompts.map(p => ({ role: 'user', content: p })); - setIfDefined(attrs, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, asString(messages)); + setIfDefined( + attrs, + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + enableTruncation ? getTruncatedJsonString(messages) : getJsonString(messages), + ); } return attrs; @@ -314,6 +318,7 @@ export function extractChatModelRequestAttributes( llm: LangChainSerialized, langChainMessages: LangChainMessage[][], recordInputs: boolean, + enableTruncation: boolean, invocationParams?: Record, langSmithMetadata?: Record, ): Record { @@ -334,8 +339,11 @@ export function extractChatModelRequestAttributes( const filteredLength = Array.isArray(filteredMessages) ? filteredMessages.length : 0; setIfDefined(attrs, GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, filteredLength); - const truncated = truncateGenAiMessages(filteredMessages as unknown[]); - setIfDefined(attrs, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, asString(truncated)); + setIfDefined( + attrs, + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + enableTruncation ? getTruncatedJsonString(filteredMessages) : getJsonString(filteredMessages), + ); } return attrs; diff --git a/packages/core/test/lib/tracing/langchain-utils.test.ts b/packages/core/test/lib/tracing/langchain-utils.test.ts index 98724c8902d4..18807631c404 100644 --- a/packages/core/test/lib/tracing/langchain-utils.test.ts +++ b/packages/core/test/lib/tracing/langchain-utils.test.ts @@ -237,7 +237,7 @@ describe('extractChatModelRequestAttributes with multimodal content', () => { ], ]; - const attrs = extractChatModelRequestAttributes(serialized, messages, true); + const attrs = extractChatModelRequestAttributes(serialized, messages, true, true); const inputMessages = attrs[GEN_AI_INPUT_MESSAGES_ATTRIBUTE] as string | undefined; expect(inputMessages).toBeDefined(); From c9782aedfccad1584b9689842dcb2b292fe3ffa0 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:33:19 +0900 Subject: [PATCH 14/73] feat(core): Add `enableTruncation` option to LangGraph integration (#20183) This PR adds an `enableTruncation` option to the LangGraph integration that allows users to disable input message truncation. It defaults to `true` to preserve existing behavior. Also refactors to use the shared getTruncatedJsonString/getJsonString utilities. Closes: #20139 --------- Co-authored-by: Nicolas Hrubec --- .../langgraph/instrument-no-truncation.mjs | 17 +++++++ .../langgraph/scenario-no-truncation.mjs | 46 +++++++++++++++++++ .../suites/tracing/langgraph/test.ts | 34 ++++++++++++++ packages/core/src/tracing/langgraph/index.ts | 14 ++++-- packages/core/src/tracing/langgraph/types.ts | 5 ++ 5 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-no-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-no-truncation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-no-truncation.mjs new file mode 100644 index 000000000000..91b4e4b1bae5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-no-truncation.mjs @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [ + Sentry.langGraphIntegration({ + recordInputs: true, + recordOutputs: true, + enableTruncation: false, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-no-truncation.mjs new file mode 100644 index 000000000000..982e7a69de53 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-no-truncation.mjs @@ -0,0 +1,46 @@ +import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; +import * as Sentry from '@sentry/node'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'langgraph-test' }, async () => { + const mockLlm = () => { + return { + messages: [ + { + role: 'assistant', + content: 'Mock LLM response', + response_metadata: { + model_name: 'mock-model', + finish_reason: 'stop', + tokenUsage: { + promptTokens: 20, + completionTokens: 10, + totalTokens: 30, + }, + }, + }, + ], + }; + }; + + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', mockLlm) + .addEdge(START, 'agent') + .addEdge('agent', END) + .compile({ name: 'weather_assistant' }); + + // Multiple messages with long content (would normally be truncated and popped to last message only) + const longContent = 'A'.repeat(50_000); + await graph.invoke({ + messages: [ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ], + }); + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 0b03e59bbfbf..329cb914851a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -4,6 +4,7 @@ import { GEN_AI_AGENT_NAME_ATTRIBUTE, GEN_AI_CONVERSATION_ID_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_PIPELINE_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, @@ -364,4 +365,37 @@ describe('LangGraph integration', () => { await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_RESUME }).start().completed(); }); }); + + const longContent = 'A'.repeat(50_000); + + const EXPECTED_TRANSACTION_NO_TRUNCATION = { + transaction: 'langgraph-test', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 3, + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index c010520d10cc..5230b43bb54d 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -12,8 +12,12 @@ import { GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { truncateGenAiMessages } from '../ai/messageTruncation'; -import { extractSystemInstructions, resolveAIRecordingOptions } from '../ai/utils'; +import { + extractSystemInstructions, + getJsonString, + getTruncatedJsonString, + resolveAIRecordingOptions, +} from '../ai/utils'; import type { LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; import { startSpan } from '../trace'; @@ -146,10 +150,12 @@ function instrumentCompiledGraphInvoke( span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, systemInstructions); } - const truncatedMessages = truncateGenAiMessages(filteredMessages as unknown[]); + const enableTruncation = options.enableTruncation ?? true; const filteredLength = Array.isArray(filteredMessages) ? filteredMessages.length : 0; span.setAttributes({ - [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify(truncatedMessages), + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: enableTruncation + ? getTruncatedJsonString(filteredMessages) + : getJsonString(filteredMessages), [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: filteredLength, }); } diff --git a/packages/core/src/tracing/langgraph/types.ts b/packages/core/src/tracing/langgraph/types.ts index b16f9718c69e..021099f369b1 100644 --- a/packages/core/src/tracing/langgraph/types.ts +++ b/packages/core/src/tracing/langgraph/types.ts @@ -7,6 +7,11 @@ export interface LangGraphOptions { * Enable or disable output recording. */ recordOutputs?: boolean; + /** + * Enable or disable truncation of recorded input messages. + * Defaults to `true`. + */ + enableTruncation?: boolean; } /** From 468e03872e5353aad8bd2ea013431c43633f3eb0 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:33:43 +0900 Subject: [PATCH 15/73] feat(core): Add `enableTruncation` option to Anthropic AI integration (#20181) This PR adds an `enableTruncation` option to the Anthropic AI integration that allows users to disable input message truncation. It defaults to `true` to preserve existing behavior. Closes: #20136 --------- Co-authored-by: Nicolas Hrubec Co-authored-by: Nicolas Hrubec --- .../anthropic/instrument-no-truncation.mjs | 24 +++++++++ .../anthropic/scenario-no-truncation.mjs | 54 +++++++++++++++++++ .../suites/tracing/anthropic/test.ts | 42 +++++++++++++++ .../core/src/tracing/anthropic-ai/index.ts | 10 ++-- .../core/src/tracing/anthropic-ai/types.ts | 5 ++ .../core/src/tracing/anthropic-ai/utils.ts | 8 +-- .../test/lib/utils/anthropic-utils.test.ts | 6 +-- 7 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-no-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-no-truncation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-no-truncation.mjs new file mode 100644 index 000000000000..ce15aad4e8e1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-no-truncation.mjs @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.anthropicAIIntegration({ + recordInputs: true, + recordOutputs: true, + enableTruncation: false, + }), + ], + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/anthropic/v1/')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-no-truncation.mjs new file mode 100644 index 000000000000..36f2ffe8c35c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-no-truncation.mjs @@ -0,0 +1,54 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + this.messages = { + create: this._messagesCreate.bind(this), + }; + } + + async _messagesCreate(params) { + await new Promise(resolve => setTimeout(resolve, 10)); + return { + id: 'msg-no-truncation-test', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Response' }], + model: params.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 5 }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ apiKey: 'mock-api-key' }); + const client = instrumentAnthropicAiClient(mockClient, { enableTruncation: false, recordInputs: true }); + + // Multiple messages with long content (would normally be truncated and popped to last message only) + const longContent = 'A'.repeat(50_000); + await client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + messages: [ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ], + }); + + // Long string input (messagesFromParams wraps it in an array) + const longStringInput = 'B'.repeat(50_000); + await client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + input: longStringInput, + }); + }); +} + +run(); 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 3241adfc161d..7dcc7f8743f9 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -802,4 +802,46 @@ describe('Anthropic integration', () => { }); }, ); + + const longContent = 'A'.repeat(50_000); + const longStringInput = 'B'.repeat(50_000); + + const EXPECTED_TRANSACTION_NO_TRUNCATION = { + transaction: 'main', + spans: expect.arrayContaining([ + // Multiple messages should all be preserved (no popping to last message only) + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 3, + }), + }), + // Long string input should not be truncated (messagesFromParams wraps it in an array) + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([longStringInput]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 1, + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index 14cc44d6d6be..323c7feb2bb2 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -72,9 +72,9 @@ function extractRequestAttributes(args: unknown[], methodPath: string, operation * Add private request attributes to spans. * This is only recorded if recordInputs is true. */ -function addPrivateRequestAttributes(span: Span, params: Record): void { +function addPrivateRequestAttributes(span: Span, params: Record, enableTruncation: boolean): void { const messages = messagesFromParams(params); - setMessagesAttribute(span, messages); + setMessagesAttribute(span, messages, enableTruncation); if ('prompt' in params) { span.setAttributes({ [GEN_AI_PROMPT_ATTRIBUTE]: JSON.stringify(params.prompt) }); @@ -206,7 +206,7 @@ function handleStreamingRequest( originalResult = originalMethod.apply(context, args) as Promise; if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); + addPrivateRequestAttributes(span, params, options.enableTruncation ?? true); } return (async () => { @@ -228,7 +228,7 @@ function handleStreamingRequest( return startSpanManual(spanConfig, span => { try { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); + addPrivateRequestAttributes(span, params, options.enableTruncation ?? true); } const messageStream = target.apply(context, args); return instrumentMessageStream(messageStream, span, options.recordOutputs ?? false); @@ -289,7 +289,7 @@ function instrumentMethod( originalResult = target.apply(context, args) as Promise; if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); + addPrivateRequestAttributes(span, params, options.enableTruncation ?? true); } return originalResult.then( diff --git a/packages/core/src/tracing/anthropic-ai/types.ts b/packages/core/src/tracing/anthropic-ai/types.ts index ba281ef82a0d..dd61ff63e264 100644 --- a/packages/core/src/tracing/anthropic-ai/types.ts +++ b/packages/core/src/tracing/anthropic-ai/types.ts @@ -9,6 +9,11 @@ export interface AnthropicAiOptions { * Enable or disable output recording. */ recordOutputs?: boolean; + /** + * Enable or disable truncation of recorded input messages. + * Defaults to `true`. + */ + enableTruncation?: boolean; } export type Message = { diff --git a/packages/core/src/tracing/anthropic-ai/utils.ts b/packages/core/src/tracing/anthropic-ai/utils.ts index b70d9adcfa67..20e2ff15bafb 100644 --- a/packages/core/src/tracing/anthropic-ai/utils.ts +++ b/packages/core/src/tracing/anthropic-ai/utils.ts @@ -7,14 +7,14 @@ import { GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils'; +import { extractSystemInstructions, getJsonString, getTruncatedJsonString } from '../ai/utils'; import type { AnthropicAiResponse } from './types'; /** * Set the messages and messages original length attributes. * Extracts system instructions before truncation. */ -export function setMessagesAttribute(span: Span, messages: unknown): void { +export function setMessagesAttribute(span: Span, messages: unknown, enableTruncation: boolean): void { if (Array.isArray(messages) && messages.length === 0) { return; } @@ -29,7 +29,9 @@ export function setMessagesAttribute(span: Span, messages: unknown): void { const filteredLength = Array.isArray(filteredMessages) ? filteredMessages.length : 1; span.setAttributes({ - [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(filteredMessages), + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: enableTruncation + ? getTruncatedJsonString(filteredMessages) + : getJsonString(filteredMessages), [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: filteredLength, }); } diff --git a/packages/core/test/lib/utils/anthropic-utils.test.ts b/packages/core/test/lib/utils/anthropic-utils.test.ts index 012ff9e6ccb6..8712d1f96407 100644 --- a/packages/core/test/lib/utils/anthropic-utils.test.ts +++ b/packages/core/test/lib/utils/anthropic-utils.test.ts @@ -98,7 +98,7 @@ describe('anthropic-ai-utils', () => { it('sets length along with truncated value', () => { const content = 'A'.repeat(200_000); - setMessagesAttribute(span, [{ role: 'user', content }]); + setMessagesAttribute(span, [{ role: 'user', content }], true); const result = [{ role: 'user', content: 'A'.repeat(19970) }]; expect(mock.attributes).toStrictEqual({ 'sentry.sdk_meta.gen_ai.input.messages.original_length': 1, @@ -107,7 +107,7 @@ describe('anthropic-ai-utils', () => { }); it('sets length to 1 for non-array input', () => { - setMessagesAttribute(span, { content: 'hello, world' }); + setMessagesAttribute(span, { content: 'hello, world' }, true); expect(mock.attributes).toStrictEqual({ 'sentry.sdk_meta.gen_ai.input.messages.original_length': 1, 'gen_ai.input.messages': '{"content":"hello, world"}', @@ -115,7 +115,7 @@ describe('anthropic-ai-utils', () => { }); it('ignores empty array', () => { - setMessagesAttribute(span, []); + setMessagesAttribute(span, [], true); expect(mock.attributes).toStrictEqual({ 'sentry.sdk_meta.gen_ai.input.messages.original_length': 1, 'gen_ai.input.messages': '{"content":"hello, world"}', From 855d550f159ea283713ce70d76dab7bd2cd80f0e Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:34:12 +0900 Subject: [PATCH 16/73] feat(core): Add enableTruncation option to Google GenAI integration (#20184) This PR adds an `enableTruncation` option to the Google GenAI integration that allows users to disable input message truncation. It defaults to `true` to preserve existing behavior. Also refactors the truncation to use the shared `getTruncatedJsonString`/`getJsonString` utilities instead of calling `truncateGenAiMessages` directly. Closes: #20137 --------- Co-authored-by: Nicolas Hrubec Co-authored-by: Nicolas Hrubec --- .../google-genai/instrument-no-truncation.mjs | 17 +++++++ .../google-genai/scenario-no-truncation.mjs | 47 +++++++++++++++++++ .../suites/tracing/google-genai/test.ts | 33 +++++++++++++ .../core/src/tracing/google-genai/index.ts | 24 +++++++--- .../core/src/tracing/google-genai/types.ts | 5 ++ 5 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-no-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-no-truncation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-no-truncation.mjs new file mode 100644 index 000000000000..be5288b429d6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-no-truncation.mjs @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.googleGenAIIntegration({ + recordInputs: true, + recordOutputs: true, + enableTruncation: false, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-no-truncation.mjs new file mode 100644 index 000000000000..13b271a23878 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-no-truncation.mjs @@ -0,0 +1,47 @@ +import { instrumentGoogleGenAIClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockGoogleGenerativeAI { + constructor(config) { + this.apiKey = config.apiKey; + this.models = { + generateContent: this._generateContent.bind(this), + }; + } + + async _generateContent() { + await new Promise(resolve => setTimeout(resolve, 10)); + return { + response: { + text: () => 'Response', + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 }, + candidates: [ + { + content: { parts: [{ text: 'Response' }], role: 'model' }, + finishReason: 'STOP', + }, + ], + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockGoogleGenerativeAI({ apiKey: 'mock-api-key' }); + const client = instrumentGoogleGenAIClient(mockClient, { enableTruncation: false, recordInputs: true }); + + // Long content that would normally be truncated + const longContent = 'A'.repeat(50_000); + await client.models.generateContent({ + model: 'gemini-1.5-flash', + contents: [ + { role: 'user', parts: [{ text: longContent }] }, + { role: 'model', parts: [{ text: 'Some reply' }] }, + { role: 'user', parts: [{ text: 'Follow-up question' }] }, + ], + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 5d79cdf94202..b6271a03f4fc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -653,4 +653,37 @@ describe('Google GenAI integration', () => { .completed(); }); }); + + const longContent = 'A'.repeat(50_000); + + const EXPECTED_TRANSACTION_NO_TRUNCATION = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([ + { role: 'user', parts: [{ text: longContent }] }, + { role: 'model', parts: [{ text: 'Some reply' }] }, + { role: 'user', parts: [{ text: 'Follow-up question' }] }, + ]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 3, + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index ac06d39a5784..51ca11f612fa 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -27,9 +27,14 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { truncateGenAiMessages } from '../ai/messageTruncation'; import type { InstrumentedMethodEntry } from '../ai/utils'; -import { buildMethodPath, extractSystemInstructions, resolveAIRecordingOptions } from '../ai/utils'; +import { + buildMethodPath, + extractSystemInstructions, + getJsonString, + getTruncatedJsonString, + resolveAIRecordingOptions, +} from '../ai/utils'; import { GOOGLE_GENAI_METHOD_REGISTRY, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { Candidate, ContentPart, GoogleGenAIOptions, GoogleGenAIResponse } from './types'; @@ -134,7 +139,12 @@ function extractRequestAttributes( * This is only recorded if recordInputs is true. * Handles different parameter formats for different Google GenAI methods. */ -function addPrivateRequestAttributes(span: Span, params: Record, isEmbeddings: boolean): void { +function addPrivateRequestAttributes( + span: Span, + params: Record, + isEmbeddings: boolean, + enableTruncation: boolean, +): void { if (isEmbeddings) { const contents = params.contents; if (contents != null) { @@ -184,7 +194,9 @@ function addPrivateRequestAttributes(span: Span, params: Record const filteredLength = Array.isArray(filteredMessages) ? filteredMessages.length : 0; span.setAttributes({ [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: filteredLength, - [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify(truncateGenAiMessages(filteredMessages as unknown[])), + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: enableTruncation + ? getTruncatedJsonString(filteredMessages) + : getJsonString(filteredMessages), }); } } @@ -285,7 +297,7 @@ function instrumentMethod( async (span: Span) => { try { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params, isEmbeddings); + addPrivateRequestAttributes(span, params, isEmbeddings, options.enableTruncation ?? true); } const stream = await target.apply(context, args); return instrumentStream(stream, span, Boolean(options.recordOutputs)) as R; @@ -313,7 +325,7 @@ function instrumentMethod( }, (span: Span) => { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params, isEmbeddings); + addPrivateRequestAttributes(span, params, isEmbeddings, options.enableTruncation ?? true); } return handleCallbackErrors( diff --git a/packages/core/src/tracing/google-genai/types.ts b/packages/core/src/tracing/google-genai/types.ts index abfb8141ce31..69f1e279fbd0 100644 --- a/packages/core/src/tracing/google-genai/types.ts +++ b/packages/core/src/tracing/google-genai/types.ts @@ -9,6 +9,11 @@ export interface GoogleGenAIOptions { * Enable or disable output recording. */ recordOutputs?: boolean; + /** + * Enable or disable truncation of recorded input messages. + * Defaults to `true`. + */ + enableTruncation?: boolean; } /** From f83aad73e398ae9f6cc768f12c25b8d3e56c2f3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:08:27 +0200 Subject: [PATCH 17/73] chore(deps-dev): Bump vite from 7.2.0 to 7.3.2 in /dev-packages/e2e-tests/test-applications/tanstackstart-react (#20107) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.2.0 to 7.3.2.
Release notes

Sourced from vite's releases.

v7.3.2

Please refer to CHANGELOG.md for details.

v7.3.1

Please refer to CHANGELOG.md for details.

v7.3.0

Please refer to CHANGELOG.md for details.

v7.2.7

Please refer to CHANGELOG.md for details.

v7.2.6

Please refer to CHANGELOG.md for details.

v7.2.5

Please refer to CHANGELOG.md for details.

Note: 7.2.5 failed to publish so it is skipped on npm

v7.2.4

Please refer to CHANGELOG.md for details.

v7.2.3

Please refer to CHANGELOG.md for details.

v7.2.2

Please refer to CHANGELOG.md for details.

plugin-legacy@7.2.1

Please refer to CHANGELOG.md for details.

v7.2.1

Please refer to CHANGELOG.md for details.

Changelog

Sourced from vite's changelog.

7.3.2 (2026-04-06)

Bug Fixes

7.3.1 (2026-01-07)

Features

  • add ignoreOutdatedRequests option to optimizeDeps (#21364) (9d39d37)

7.3.0 (2025-12-15)

Features

  • deps: update esbuild from ^0.25.0 to ^0.27.0 (#21183) (cff26ec)

7.2.7 (2025-12-08)

Bug Fixes

7.2.6 (2025-12-01)

7.2.5 (2025-12-01)

Bug Fixes

Performance Improvements

Documentation

  • clarify manifest.json imports field is JS chunks only (#21136) (46d3077)

Miscellaneous Chores

7.2.4 (2025-11-20)

Bug Fixes

  • revert "perf(deps): replace debug with obug (#21107)" (2d66b7b)

7.2.3 (2025-11-20)

... (truncated)

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../test-applications/tanstackstart-react/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 97629d7d259b..bcfb3279f684 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -28,7 +28,7 @@ "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react-swc": "^3.5.0", "typescript": "^5.9.0", - "vite": "7.2.0", + "vite": "7.3.2", "vite-tsconfig-paths": "^5.1.4", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils" From 7cb960b4f57de52f548b1386c01d89143f044039 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:08:57 +0200 Subject: [PATCH 18/73] chore(deps): Bump hono from 4.12.7 to 4.12.12 in /dev-packages/e2e-tests/test-applications/cloudflare-hono (#20119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [hono](https://github.com/honojs/hono) from 4.12.7 to 4.12.12.
Release notes

Sourced from hono's releases.

v4.12.12

Security fixes

This release includes fixes for the following security issues:

Middleware bypass via repeated slashes in serveStatic

Affects: Serve Static middleware. Fixes a path normalization inconsistency where repeated slashes (//) could bypass route-based middleware protections and allow access to protected static files. GHSA-wmmm-f939-6g9c

Path traversal in toSSG() allows writing files outside the output directory

Affects: toSSG() for Static Site Generation. Fixes a path traversal issue where crafted ssgParams values could write files outside the configured output directory. GHSA-xf4j-xp2r-rqqx

Incorrect IP matching in ipRestriction() for IPv4-mapped IPv6 addresses

Affects: IP Restriction Middleware. Fixes improper handling of IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) that could cause allow/deny rules to be bypassed. GHSA-xpcf-pg52-r92g

Missing validation of cookie name on write path in setCookie()

Affects: setCookie(), serialize(), and serializeSigned() from hono/cookie. Fixes missing validation of cookie names on the write path, preventing inconsistent handling between parsing and serialization. GHSA-26pp-8wgv-hjvm

Non-breaking space prefix bypass in cookie name handling in getCookie()

Affects: getCookie() from hono/cookie. Fixes a discrepancy in cookie name handling that could allow attacker-controlled cookies to override legitimate ones and bypass prefix protections. GHSA-r5rp-j6wh-rvv4


Users who use Serve Static, Static Site Generation, Cookie utilities, or IP restriction middleware are strongly encouraged to upgrade to this version.

v4.12.11

What's Changed

New Contributors

Full Changelog: https://github.com/honojs/hono/compare/v4.12.10...v4.12.11

v4.12.10

What's Changed

New Contributors

... (truncated)

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../e2e-tests/test-applications/cloudflare-hono/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json index a8e6c9d538ae..3d536e6fbabe 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@sentry/cloudflare": "latest || *", - "hono": "4.12.7" + "hono": "4.12.12" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.31", From 6b3b09bbc2cb2666d794b6e2252bf188ea4901ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:09:42 +0200 Subject: [PATCH 19/73] chore(deps): Bump axios from 1.13.5 to 1.15.0 (#20180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [axios](https://github.com/axios/axios) from 1.13.5 to 1.15.0.
Release notes

Sourced from axios's releases.

v1.15.0

This release delivers two critical security patches, adds runtime support for Deno and Bun, and includes significant CI hardening, documentation improvements, and routine dependency updates.

⚠️ Important Changes

  • Deprecation: url.parse() usage has been replaced to address Node.js deprecation warnings. If you are on a recent version of Node.js, this resolves console warnings you may have been seeing. (#10625)

🔒 Security Fixes

  • Proxy Handling: Fixed a no_proxy hostname normalisation bypass that could lead to Server-Side Request Forgery (SSRF). (#10661)
  • Header Injection: Fixed an unrestricted cloud metadata exfiltration vulnerability via a header injection chain. (#10660)

🚀 New Features

  • Runtime Support: Added compatibility checks and documentation for Deno and Bun environments. (#10652, #10653)

🔧 Maintenance & Chores

  • CI Security: Hardened workflow permissions to least privilege, added the zizmor security scanner, pinned action versions, and gated npm publishing with OIDC and environment protection. (#10618, #10619, #10627, #10637, #10666)
  • Dependencies: Bumped serialize-javascript, handlebars, picomatch, vite, and denoland/setup-deno to latest versions. Added a 7-day Dependabot cooldown period. (#10574, #10572, #10568, #10663, #10664, #10665, #10669, #10670, #10616)
  • Documentation: Unified docs, improved beforeRedirect credential leakage example, clarified withCredentials/withXSRFToken behaviour, HTTP/2 support notes, async/await timeout error handling, header case preservation, and various typo fixes. (#10649, #10624, #7452, #7471, #10654, #10644, #10589)
  • Housekeeping: Removed stale files, regenerated lockfile, and updated sponsor scripts and blocks. (#10584, #10650, #10582, #10640, #10659, #10668)
  • Tests: Added regression coverage for urlencoded Content-Type casing. (#10573)

🌟 New Contributors

We are thrilled to welcome our new contributors. Thank you for helping improve Axios:

v1.14.0

This release focuses on compatibility fixes, adapter stability improvements, and test/tooling modernisation.

⚠️ Important Changes

  • Breaking Changes: None identified in this release.
  • Action Required: If you rely on env-based proxy behaviour or CJS resolution edge-cases, validate your integration after upgrade (notably proxy-from-env v2 alignment and main entry compatibility fix).

🚀 New Features

  • Runtime Features: No new end-user features were introduced in this release.
  • Test Coverage Expansion: Added broader smoke/module test coverage for CJS and ESM package usage. (#7510)

🐛 Bug Fixes

  • Headers: Trim trailing CRLF in normalised header values. (#7456)
  • HTTP/2: Close detached HTTP/2 sessions on timeout to avoid lingering sessions. (#7457)
  • Fetch Adapter: Cancel ReadableStream created during request-stream capability probing to prevent async resource leaks. (#7515)
  • Proxy Handling: Fixed env proxy behavior with proxy-from-env v2 usage. (#7499)

... (truncated)

Changelog

Sourced from axios's changelog.

Changelog

1.13.3 (2026-01-20)

Bug Fixes

  • http2: Use port 443 for HTTPS connections by default. (#7256) (d7e6065)
  • interceptor: handle the error in the same interceptor (#6269) (5945e40)
  • main field in package.json should correspond to cjs artifacts (#5756) (7373fbf)
  • package.json: add 'bun' package.json 'exports' condition. Load the Node.js build in Bun instead of the browser build (#5754) (b89217e)
  • silentJSONParsing=false should throw on invalid JSON (#7253) (#7257) (7d19335)
  • turn AxiosError into a native error (#5394) (#5558) (1c6a86d)
  • types: add handlers to AxiosInterceptorManager interface (#5551) (8d1271b)
  • types: restore AxiosError.cause type from unknown to Error (#7327) (d8233d9)
  • unclear error message is thrown when specifying an empty proxy authorization (#6314) (6ef867e)

Features

Reverts

  • Revert "fix: silentJSONParsing=false should throw on invalid JSON (#7253) (#7…" (#7298) (a4230f5), closes #7253 #7 #7298
  • deps: bump peter-evans/create-pull-request from 7 to 8 in the github-actions group (#7334) (2d6ad5e)

Contributors to this release

... (truncated)

Commits
  • 772a4e5 chore(release): prepare release 1.15.0 (#10671)
  • 4b07137 chore(deps-dev): bump vite from 8.0.0 to 8.0.5 in /tests/smoke/esm (#10663)
  • 51e57b3 chore(deps-dev): bump vite from 8.0.2 to 8.0.5 (#10664)
  • fba1a77 chore(deps-dev): bump vite from 8.0.2 to 8.0.5 in /tests/module/esm (#10665)
  • 0bf6e28 chore(deps): bump denoland/setup-deno in the github-actions group (#10669)
  • 8107157 chore(deps-dev): bump the development_dependencies group with 4 updates (#10670)
  • e66530e ci: require npm-publish environment for releases (#10666)
  • 49f23cb chore(sponsor): update sponsor block (#10668)
  • 3631854 fix: unrestricted cloud metadata exfiltration via header injection chain (#10...
  • fb3befb fix: no_proxy hostname normalization bypass leads to ssrf (#10661)
  • Additional commits viewable in compare view
Install script changes

This version modifies prepare script that runs during installation. Review the package contents before updating.


Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../browser-integration-tests/package.json | 2 +- yarn.lock | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 2803a03c8eec..0c28fbfabce5 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -62,7 +62,7 @@ "@sentry-internal/rrweb": "2.34.0", "@sentry/browser": "10.48.0", "@supabase/supabase-js": "2.49.3", - "axios": "1.13.5", + "axios": "1.15.0", "babel-loader": "^10.1.1", "fflate": "0.8.2", "html-webpack-plugin": "^5.5.0", diff --git a/yarn.lock b/yarn.lock index 17e2c001080d..e70d6caa8b6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11846,14 +11846,14 @@ aws-ssl-profiles@^1.1.2: resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641" integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== -axios@1.13.5, axios@^1.12.0: - version "1.13.5" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43" - integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q== +axios@1.15.0, axios@^1.12.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.0.tgz#0fcee91ef03d386514474904b27863b2c683bf4f" + integrity sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q== dependencies: follow-redirects "^1.15.11" form-data "^4.0.5" - proxy-from-env "^1.1.0" + proxy-from-env "^2.1.0" axobject-query@^3.2.1: version "3.2.1" @@ -25657,6 +25657,11 @@ proxy-from-env@^1.1.0: resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== + proxy@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/proxy/-/proxy-2.1.1.tgz#45f9b307508ffcae12bdc71678d44a4ab79cbf8b" From a6418127444528b276bd8d31c85c07a47eb9b488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Fri, 10 Apr 2026 15:51:46 +0200 Subject: [PATCH 20/73] feat(cloudflare): Propagate traceparent to RPC calls - via fetch (#19991) relates to #19327 related to #16898 (it is not really closing it as we just add context propagation without adding spans for individual calls. It needs to be defined if we need it) It is important to know that these RPC calls do only work with the `.fetch` call: ```js const id = env.MY_DURABLE_OBJECT.idFromName('workflow-test'); const stub = env.MY_DURABLE_OBJECT.get(id); await stub.fetch(new Request('http://my-worker/my-do-call')); ``` This adds RPC fetch calls between: - Workers -> Workers ([Service bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/)) - Workers -> DurableObjects (via [standard RPC](https://developers.cloudflare.com/workers/runtime-apis/rpc/)) - Workflows -> DurableObjects (also via standard RPC) This works by instrumenting `env` (via `instrumentEnv`), which then goes over the bindings and see if there is a DurableObject or a normal Fetcher (full list of current bindings: https://developers.cloudflare.com/workers/runtime-apis/bindings/). This got inspired by how `otel-cf-workers` instruments their env: https://github.com/evanderkoogh/otel-cf-workers/blob/effeb549f0a4ed1c55ea0c4f0d8e8e37e5494fb3/src/instrumentation/env.ts With this PR I added a lot of tests to check if trace propagation works (so this PR might look like it added a lot of LoC, but it is mostly tests). So I added it for `schedule` and `queue`, but it is not possible for `email` and `tail` with `wrangler dev`. ## Potential things to change ### Trace propagagtion I added the `addTraceHeaders.ts` helper, as there is currently no way to reuse the existing logic (it is baked-in into the fetch instrumentations). It would be nice once #19960 lands that we can reuse it in Cloudflare to reuse existing code. I tried to write couple of tests so we don't have duplicated headers. ### Adding extra spans So there is actually a guide by OTel to [add RPC spans](https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/), but was talking with someone from the OTel maintainers and they meant that this wouldn't be necessary as we already have an `http.server` span from out instrumented DurableObjects (and other resources) - so it wouldn't add much of information. Without RPC span: Screenshot 2026-03-25 at 10 59 01 With RPC span: Screenshot 2026-03-25 at 10 55 48 --- .../cloudflare-integration-tests/runner.ts | 6 + .../tracing/instrument-fetcher/index.ts | 94 +++++ .../suites/tracing/instrument-fetcher/test.ts | 131 +++++++ .../tracing/instrument-fetcher/wrangler.jsonc | 20 ++ .../tracing/propagation/worker-do/index.ts | 60 ++++ .../tracing/propagation/worker-do/test.ts | 206 +++++++++++ .../propagation/worker-do/wrangler.jsonc | 39 +++ .../index-sub-worker.ts | 0 .../worker-service-binding/index.ts | 0 .../worker-service-binding/test.ts | 21 +- .../wrangler-sub-worker.jsonc | 0 .../worker-service-binding/wrangler.jsonc | 0 .../tracing/propagation/workflow-do/index.ts | 75 ++++ .../tracing/propagation/workflow-do/test.ts | 63 ++++ .../propagation/workflow-do/wrangler.jsonc | 30 ++ packages/cloudflare/src/durableobject.ts | 5 +- .../instrumentDurableObjectNamespace.ts | 48 +++ .../worker/instrumentEmail.ts | 2 + .../instrumentations/worker/instrumentEnv.ts | 66 ++++ .../worker/instrumentFetch.ts | 2 + .../worker/instrumentFetcher.ts | 34 ++ .../worker/instrumentQueue.ts | 2 + .../worker/instrumentScheduled.ts | 2 + .../instrumentations/worker/instrumentTail.ts | 2 + packages/cloudflare/src/utils/isBinding.ts | 61 ++++ packages/cloudflare/src/workflows.ts | 2 + .../instrumentDurableObjectNamespace.test.ts | 192 ++++++++++ .../instrumentations/instrumentEnv.test.ts | 137 ++++++++ .../test/utils/instrumentFetcher.test.ts | 328 ++++++++++++++++++ .../cloudflare/test/utils/isBinding.test.ts | 122 +++++++ packages/cloudflare/test/workflow.test.ts | 25 ++ 31 files changed, 1771 insertions(+), 4 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/wrangler.jsonc rename dev-packages/cloudflare-integration-tests/suites/tracing/{ => propagation}/worker-service-binding/index-sub-worker.ts (100%) rename dev-packages/cloudflare-integration-tests/suites/tracing/{ => propagation}/worker-service-binding/index.ts (100%) rename dev-packages/cloudflare-integration-tests/suites/tracing/{ => propagation}/worker-service-binding/test.ts (59%) rename dev-packages/cloudflare-integration-tests/suites/tracing/{ => propagation}/worker-service-binding/wrangler-sub-worker.jsonc (100%) rename dev-packages/cloudflare-integration-tests/suites/tracing/{ => propagation}/worker-service-binding/wrangler.jsonc (100%) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/wrangler.jsonc create mode 100644 packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts create mode 100644 packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts create mode 100644 packages/cloudflare/src/instrumentations/worker/instrumentFetcher.ts create mode 100644 packages/cloudflare/src/utils/isBinding.ts create mode 100644 packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts create mode 100644 packages/cloudflare/test/instrumentations/instrumentEnv.test.ts create mode 100644 packages/cloudflare/test/utils/instrumentFetcher.test.ts create mode 100644 packages/cloudflare/test/utils/isBinding.test.ts diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index b0b439eb122a..e0a48dd33ff5 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -68,12 +68,17 @@ export function createRunner(...paths: string[]) { // By default, we ignore session & sessions const ignored: Set = new Set(['session', 'sessions', 'client_report']); let serverUrl: string | undefined; + const extraWranglerArgs: string[] = []; return { withServerUrl: function (url: string) { serverUrl = url; return this; }, + withWranglerArgs: function (...args: string[]) { + extraWranglerArgs.push(...args); + return this; + }, expect: function (expected: Expected) { expectedEnvelopes.push(expected); return this; @@ -237,6 +242,7 @@ export function createRunner(...paths: string[]) { `SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`, '--var', `SERVER_URL:${serverUrl}`, + ...extraWranglerArgs, ], { stdio, signal }, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts new file mode 100644 index 000000000000..6480d9e83770 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts @@ -0,0 +1,94 @@ +import { instrumentDurableObjectWithSentry, withSentry } from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + ECHO_HEADERS_DO: DurableObjectNamespace; +} + +class EchoHeadersDurableObjectBase extends DurableObject { + async fetch(incoming: Request): Promise { + return Response.json({ + sentryTrace: incoming.headers.get('sentry-trace'), + baggage: incoming.headers.get('baggage'), + authorization: incoming.headers.get('authorization'), + xFromInit: incoming.headers.get('x-from-init'), + xExtra: incoming.headers.get('x-extra'), + xMergeProbe: incoming.headers.get('x-merge-probe'), + }); + } +} + +export const EchoHeadersDurableObject = instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + EchoHeadersDurableObjectBase, +); + +export default withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + const id = env.ECHO_HEADERS_DO.idFromName('instrument-fetcher-echo'); + const stub = env.ECHO_HEADERS_DO.get(id); + const doUrl = new URL(request.url); + + let subResponse: Response; + + if (url.pathname === '/via-init') { + subResponse = await stub.fetch(doUrl, { + headers: { + Authorization: 'Bearer from-init', + 'X-Extra': 'init-extra', + 'X-Merge-Probe': 'via-init-probe', + }, + }); + } else if (url.pathname === '/via-request') { + subResponse = await stub.fetch( + new Request(doUrl, { + headers: { + Authorization: 'Bearer from-request', + 'X-Extra': 'request-extra', + 'X-Merge-Probe': 'via-request-probe', + }, + }), + ); + } else if (url.pathname === '/via-request-and-init') { + subResponse = await stub.fetch( + new Request(doUrl, { + headers: { + Authorization: 'Bearer from-request', + 'X-Extra': 'request-extra', + 'X-Merge-Probe': 'dropped-from-request', + }, + }), + { + headers: { + 'X-From-Init': '1', + 'X-Merge-Probe': 'via-init-wins', + }, + }, + ); + } else if (url.pathname === '/with-preset-sentry-baggage') { + subResponse = await stub.fetch( + new Request(doUrl, { + headers: { + baggage: 'sentry-environment=preset,acme=vendor', + }, + }), + ); + } else { + return new Response('not found', { status: 404 }); + } + + const payload: unknown = await subResponse.json(); + return Response.json(payload); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts new file mode 100644 index 000000000000..ae38568e34ab --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts @@ -0,0 +1,131 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +type EchoedHeaders = { + sentryTrace: string | null; + baggage: string | null; + authorization: string | null; + xFromInit: string | null; + xExtra: string | null; + xMergeProbe: string | null; +}; + +const SENTRY_TRACE_HEADER_RE = /^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/; + +type ScenarioPath = '/via-init' | '/via-request' | '/via-request-and-init' | '/with-preset-sentry-baggage'; + +function startStubFetchScenario(path: ScenarioPath, signal: AbortSignal) { + let mainTraceId: string | undefined; + let mainSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const traceBase = { + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }; + + const { makeRequest, completed } = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining(traceBase), + }), + transaction: `GET ${path}`, + }), + ); + expect(parentSpanId).toBeUndefined(); + + mainTraceId = transactionEvent.contexts?.trace?.trace_id as string; + mainSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining(traceBase), + }), + transaction: `GET ${path}`, + }), + ); + expect(parentSpanId).toBeDefined(); + + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = parentSpanId as string; + }) + .unordered() + .start(signal); + + return { + makeRequest, + async completedWithTraceCheck(): Promise { + await completed(); + expect(mainTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(mainTraceId).toBe(doTraceId); + expect(mainSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(mainSpanId); + }, + }; +} + +it('stub.fetch: headers in init (URL string + init)', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-init', signal); + const body = await makeRequest('get', '/via-init'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id='); + expect(body?.authorization).toBe('Bearer from-init'); + expect(body?.xExtra).toBe('init-extra'); + expect(body?.xMergeProbe).toBe('via-init-probe'); + expect(body?.xFromInit).toBeNull(); +}); + +it('stub.fetch: headers on Request (URL from incoming request)', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request', signal); + const body = await makeRequest('get', '/via-request'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id='); + expect(body?.authorization).toBe('Bearer from-request'); + expect(body?.xExtra).toBe('request-extra'); + expect(body?.xMergeProbe).toBe('via-request-probe'); + expect(body?.xFromInit).toBeNull(); +}); + +it('stub.fetch: Request + init — only init headers are sent', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request-and-init', signal); + const body = await makeRequest('get', '/via-request-and-init'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id='); + expect(body?.authorization).toBeNull(); + expect(body?.xExtra).toBeNull(); + expect(body?.xMergeProbe).toBe('via-init-wins'); + expect(body?.xFromInit).toBe('1'); +}); + +it('stub.fetch: does not append SDK baggage when the Request already includes Sentry baggage', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/with-preset-sentry-baggage', signal); + const body = await makeRequest('get', '/with-preset-sentry-baggage'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + // Dynamic SDK baggage includes `sentry-trace_id=…`; appending it again would change this string. + expect(body?.baggage).toBe('sentry-environment=preset,acme=vendor'); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/wrangler.jsonc new file mode 100644 index 000000000000..28d4a0a81f19 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-instrument-fetcher", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["EchoHeadersDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "EchoHeadersDurableObject", + "name": "ECHO_HEADERS_DO", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts new file mode 100644 index 000000000000..22551809d210 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts @@ -0,0 +1,60 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; + MY_QUEUE: Queue; +} + +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request) { + return new Response('DO is fine'); + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/queue/send') { + await env.MY_QUEUE.send({ action: 'test' }); + return new Response('Queued'); + } + + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + const response = await stub.fetch(new Request('http://fake-host/hello')); + const text = await response.text(); + return new Response(text); + }, + + async queue(batch, env, _ctx) { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + for (const message of batch.messages) { + await stub.fetch(new Request('http://fake-host/hello')); + message.ack(); + } + }, + + async scheduled(controller, env, _ctx) { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + await stub.fetch(new Request('http://fake-host/hello')); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts new file mode 100644 index 000000000000..d1c5385f8fbf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts @@ -0,0 +1,206 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to durable object', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); + +it('propagates trace from queue handler to durable object', async ({ signal }) => { + let queueTraceId: string | undefined; + let queueSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'queue.process', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.queue', + }), + origin: 'auto.faas.cloudflare.queue', + }), + }), + transaction: 'process my-queue', + }), + ); + queueTraceId = transactionEvent.contexts?.trace?.trace_id as string; + queueSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + // Also expect the fetch transaction from the /queue/send request + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /queue/send', + }), + ); + }) + .unordered() + .start(signal); + // The fetch handler sends a message to the queue, which triggers the queue consumer + await runner.makeRequest('get', '/queue/send'); + await runner.completed(); + + expect(queueTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(queueTraceId).toBe(doTraceId); + + expect(queueSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(queueSpanId); +}); + +it('propagates trace from scheduled handler to durable object', async ({ signal }) => { + let scheduledTraceId: string | undefined; + let scheduledSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .withWranglerArgs('--test-scheduled') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'faas.cron', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.scheduled', + }), + origin: 'auto.faas.cloudflare.scheduled', + }), + }), + }), + ); + scheduledTraceId = transactionEvent.contexts?.trace?.trace_id as string; + scheduledSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/__scheduled?cron=*+*+*+*+*'); + await runner.completed(); + + expect(scheduledTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(scheduledTraceId).toBe(doTraceId); + + expect(scheduledSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(scheduledSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/wrangler.jsonc new file mode 100644 index 000000000000..b6dc58439427 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/wrangler.jsonc @@ -0,0 +1,39 @@ +{ + "name": "cloudflare-durable-objects", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, + "queues": { + "producers": [ + { + "binding": "MY_QUEUE", + "queue": "my-queue", + }, + ], + "consumers": [ + { + "queue": "my-queue", + }, + ], + }, + "triggers": { + "crons": ["* * * * *"], + }, + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts similarity index 59% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts index fd64c0d31d27..878b307ca5f4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts @@ -1,8 +1,13 @@ import { expect, it } from 'vitest'; import type { Event } from '@sentry/core'; -import { createRunner } from '../../../runner'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to worker via service binding', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let subWorkerTraceId: string | undefined; + let subWorkerParentSpanId: string | undefined; -it('adds a trace to a worker via service binding', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as Event; @@ -20,6 +25,8 @@ it('adds a trace to a worker via service binding', async ({ signal }) => { transaction: 'GET /', }), ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; }) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as Event; @@ -37,9 +44,19 @@ it('adds a trace to a worker via service binding', async ({ signal }) => { transaction: 'GET /hello', }), ); + subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; }) .unordered() .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(subWorkerTraceId).toBeDefined(); + expect(workerTraceId).toBe(subWorkerTraceId); + + expect(workerSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBe(workerSpanId); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler-sub-worker.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler-sub-worker.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts new file mode 100644 index 000000000000..06c846afc378 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts @@ -0,0 +1,75 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkflowEntrypoint } from 'cloudflare:workers'; +import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; + MY_WORKFLOW: Workflow; +} + +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request) { + return new Response('DO is fine'); + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyDurableObjectBase, +); + +class MyWorkflowBase extends WorkflowEntrypoint { + async run(_event: WorkflowEvent, step: WorkflowStep): Promise { + await step.do('workflow-env-test', async () => { + const id = this.env.MY_DURABLE_OBJECT.idFromName('workflow-test'); + const stub = this.env.MY_DURABLE_OBJECT.get(id); + const response = await stub.fetch(new Request('http://fake-host/workflow-test')); + return response.text(); + }); + } +} + +export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyWorkflowBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === '/workflow/trigger') { + const instance = await env.MY_WORKFLOW.create(); + // Poll until workflow completes (or timeout after 15s) + for (let i = 0; i < 15; i++) { + try { + const s = await instance.status(); + if (s.status === 'complete' || s.status === 'errored') { + return new Response(JSON.stringify({ id: instance.id, ...s }), { + headers: { 'content-type': 'application/json' }, + }); + } + } catch { + // status() may not be available in local dev + } + await new Promise(r => setTimeout(r, 1000)); + } + return new Response(JSON.stringify({ id: instance.id, status: 'timeout' }), { + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('Hello World!'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts new file mode 100644 index 000000000000..818e92d8d677 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts @@ -0,0 +1,63 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('traces a workflow that calls a durable object with the same trace id', async ({ signal }) => { + let workflowTraceId: string | undefined; + let workflowSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'function.step.do', + data: expect.objectContaining({ + 'sentry.op': 'function.step.do', + 'sentry.origin': 'auto.faas.cloudflare.workflow', + }), + origin: 'auto.faas.cloudflare.workflow', + }), + }), + transaction: 'workflow-env-test', + }), + ); + workflowTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workflowSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /workflow-test', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/workflow/trigger'); + await runner.completed(); + + expect(workflowTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workflowTraceId).toBe(doTraceId); + + expect(workflowSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workflowSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/wrangler.jsonc new file mode 100644 index 000000000000..fd8a63daf3f5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/wrangler.jsonc @@ -0,0 +1,30 @@ +{ + "name": "cloudflare-durable-objects", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, + "workflows": [ + { + "name": "my-workflow", + "binding": "MY_WORKFLOW", + "class_name": "MyWorkflow", + }, + ], + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, +} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index af60ab5e59e0..8f6788c67748 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -4,6 +4,7 @@ import type { DurableObject } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { ensureInstrumented, getInstrumented, markAsInstrumented } from './instrument'; +import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { instrumentContext } from './utils/instrumentContext'; @@ -52,10 +53,10 @@ export function instrumentDurableObjectWithSentry< construct(target, [ctx, env]) { setAsyncLocalStorageAsyncContextStrategy(); const context = instrumentContext(ctx); - const options = getFinalOptions(optionsCallback(env), env); + const instrumentedEnv = instrumentEnv(env); - const obj = new target(context, env); + const obj = new target(context, instrumentedEnv); // These are the methods that are available on a Durable Object // ref: https://developers.cloudflare.com/durable-objects/api/base/ diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts new file mode 100644 index 000000000000..886b14a6ab5c --- /dev/null +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts @@ -0,0 +1,48 @@ +import type { DurableObjectNamespace, DurableObjectStub } from '@cloudflare/workers-types'; +import { instrumentFetcher } from './worker/instrumentFetcher'; + +/** + * Instruments a DurableObjectNamespace binding to create spans for DO interactions. + * + * Wraps: + * - `namespace.get(id)` / `namespace.getByName(name)` with a span + instruments returned stub + * - `namespace.idFromName(name)` / `namespace.idFromString(id)` / `namespace.newUniqueId()` with breadcrumbs + */ +export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespace): DurableObjectNamespace { + return new Proxy(namespace, { + get(target, prop, _receiver) { + const value = Reflect.get(target, prop) as unknown; + + if (typeof value !== 'function') { + return value; + } + + if (prop === 'get' || prop === 'getByName') { + return function (this: unknown, ...args: unknown[]) { + const stub = Reflect.apply(value, target, args); + + return instrumentDurableObjectStub(stub); + }; + } + + return value.bind(target); + }, + }); +} + +/** + * Instruments a DurableObjectStub to create spans for outgoing fetch calls. + */ +function instrumentDurableObjectStub(stub: DurableObjectStub): DurableObjectStub { + return new Proxy(stub, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + + if (prop === 'fetch' && typeof value === 'function') { + return instrumentFetcher((input, init) => Reflect.apply(value, target, [input, init])); + } + + return value; + }, + }); +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts index 0fe140c79beb..a557bdcb164d 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts @@ -14,6 +14,7 @@ import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; import { instrumentContext } from '../../utils/instrumentContext'; +import { instrumentEnv } from './instrumentEnv'; /** * Core email handler logic - wraps execution with Sentry instrumentation. @@ -75,6 +76,7 @@ export function instrumentExportedHandlerEmail>) { const [emailMessage, env, ctx] = args; const context = instrumentContext(ctx); + args[1] = instrumentEnv(env); args[2] = context; const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts new file mode 100644 index 000000000000..6a2e83214093 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -0,0 +1,66 @@ +import { isDurableObjectNamespace, isJSRPC } from '../../utils/isBinding'; +import { instrumentDurableObjectNamespace } from '../instrumentDurableObjectNamespace'; +import { instrumentFetcher } from './instrumentFetcher'; + +function isProxyable(item: unknown): item is object { + return item !== null && (typeof item === 'object' || typeof item === 'function'); +} + +const instrumentedBindings = new WeakMap(); + +/** + * Wraps the Cloudflare `env` object in a Proxy that detects binding types + * on property access and returns instrumented versions. + * + * Currently detects: + * - DurableObjectNamespace (via `idFromName` duck-typing) + * - Service bindings / JSRPC proxies (wraps `fetch` for trace propagation) + * + * Extensible for future binding types (KV, D1, Queue, etc.). + */ +export function instrumentEnv>(env: Env): Env { + if (!env || typeof env !== 'object') { + return env; + } + + return new Proxy(env, { + get(target, prop, receiver) { + const item = Reflect.get(target, prop, receiver); + + if (!isProxyable(item)) { + return item; + } + + const cached = instrumentedBindings.get(item); + + if (cached) { + return cached; + } + + if (isDurableObjectNamespace(item)) { + const instrumented = instrumentDurableObjectNamespace(item); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + + if (isJSRPC(item)) { + const instrumented = new Proxy(item, { + get(target, p, rcv) { + const value = Reflect.get(target, p, rcv); + + if (p === 'fetch' && typeof value === 'function') { + return instrumentFetcher(value.bind(target)); + } + + return value; + }, + }); + + instrumentedBindings.set(item, instrumented); + return instrumented; + } + + return item; + }, + }); +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts index d584a7d5d453..a7904f5177da 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts @@ -5,6 +5,7 @@ import { ensureInstrumented } from '../../instrument'; import { getFinalOptions } from '../../options'; import { wrapRequestHandler } from '../../request'; import { instrumentContext } from '../../utils/instrumentContext'; +import { instrumentEnv } from './instrumentEnv'; /** * Instruments a fetch handler for ExportedHandler (env/ctx come from args). @@ -25,6 +26,7 @@ export function instrumentExportedHandlerFetch>) { const [request, env, ctx] = args; const context = instrumentContext(ctx); + args[1] = instrumentEnv(env); args[2] = context; const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentFetcher.ts b/packages/cloudflare/src/instrumentations/worker/instrumentFetcher.ts new file mode 100644 index 000000000000..3c2a8116942a --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentFetcher.ts @@ -0,0 +1,34 @@ +import type { Fetcher } from '@cloudflare/workers-types'; +import { _INTERNAL_getTracingHeadersForFetchRequest } from '@sentry/core'; + +/** + * Wraps a fetch-like function to create a span and propagate trace headers + * (`sentry-trace` and `baggage`) on the outgoing request. + * + * Useful for instrumenting Cloudflare bindings that expose a `fetch` method + * (e.g. Durable Object stubs, Service bindings). + */ +export function instrumentFetcher(fetchFn: Fetcher['fetch']): Fetcher['fetch'] { + return function (input: RequestInfo | URL, init?: RequestInit): Promise { + const headers = _INTERNAL_getTracingHeadersForFetchRequest(input, { headers: init?.headers }); + + if (input instanceof Request && init === undefined) { + if (!headers) { + return fetchFn(input); + } + + // Newly created headers already include the previous headers from the original request + // so we can clone the request and pass in all headers. + const requestWithTracing = new Request(input, { headers: headers as HeadersInit }); + + return fetchFn(requestWithTracing); + } + + const mergedInit = { + ...init, + ...(headers ? { headers } : {}), + } as NonNullable[1]>; + + return fetchFn(input, mergedInit); + }; +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts index df41a4afc8b3..0f7df2e27adc 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -15,6 +15,7 @@ import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; import { instrumentContext } from '../../utils/instrumentContext'; +import { instrumentEnv } from './instrumentEnv'; /** * Core queue handler logic - wraps execution with Sentry instrumentation. @@ -81,6 +82,7 @@ export function instrumentExportedHandlerQueue>) { const [batch, env, ctx] = args; const context = instrumentContext(ctx); + args[1] = instrumentEnv(env); args[2] = context; const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index d76c980ed621..455acab2b0dd 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -14,6 +14,7 @@ import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; import { instrumentContext } from '../../utils/instrumentContext'; +import { instrumentEnv } from './instrumentEnv'; function wrapScheduledHandler( controller: ScheduledController, @@ -74,6 +75,7 @@ export function instrumentExportedHandlerScheduled>) { const [controller, env, ctx] = args; const context = instrumentContext(ctx); + args[1] = instrumentEnv(env); args[2] = context; const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts index 38abfcc0e777..283a238053c0 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts @@ -8,6 +8,7 @@ import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; import { instrumentContext } from '../../utils/instrumentContext'; +import { instrumentEnv } from './instrumentEnv'; /** * Core tail handler logic - wraps execution with Sentry instrumentation. @@ -52,6 +53,7 @@ export function instrumentExportedHandlerTail>) { const [, env, ctx] = args; const context = instrumentContext(ctx); + args[1] = instrumentEnv(env); args[2] = context; const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts new file mode 100644 index 000000000000..26801578dde2 --- /dev/null +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -0,0 +1,61 @@ +/** + * Adapted from https://github.com/evanderkoogh/otel-cf-workers/blob/effeb549f0a4ed1c55ea0c4f0d8e8e37e5494fb3/src/instrumentation/env.ts + * + * BSD 3-Clause License + * + * Copyright (c) 2023, Erwin van der Koogh + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import type { DurableObjectNamespace } from '@cloudflare/workers-types'; + +/** + * Checks if a value is a JSRPC proxy (service binding). + * + * JSRPC proxies return a truthy value for ANY property access, including + * properties that don't exist. This makes other duck-type checks unreliable + * unless we exclude JSRPC first. + * + * Must be checked before other binding type checks. + */ +export function isJSRPC(item: unknown): item is Service { + try { + return !!(item as Record)[`__some_property_that_will_never_exist__${Math.random()}`]; + } catch { + return false; + } +} + +const isNotJSRPC = (item: unknown): item is Record => !isJSRPC(item); + +/** + * Duck-type check for DurableObjectNamespace bindings. + * DurableObjectNamespace has `idFromName`, `idFromString`, `get`, `newUniqueId`. + */ +export function isDurableObjectNamespace(item: unknown): item is DurableObjectNamespace { + return item != null && isNotJSRPC(item) && typeof item.idFromName === 'function'; +} diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 6515a330ca99..c44a9c436bcf 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -21,6 +21,7 @@ import type { import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { flushAndDispose } from './flush'; +import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; import { instrumentContext } from './utils/instrumentContext'; @@ -164,6 +165,7 @@ export function instrumentWorkflowWithSentry< const [ctx, env] = args; const context = instrumentContext(ctx); args[0] = context; + args[1] = instrumentEnv(env as Record) as E; const options = optionsCallback(env); const instance = Reflect.construct(target, args, newTarget) as T; diff --git a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts new file mode 100644 index 000000000000..1b29d5062ce2 --- /dev/null +++ b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentDurableObjectNamespace } from '../../src/instrumentations/instrumentDurableObjectNamespace'; + +const { getTraceDataMock } = vi.hoisted(() => ({ + getTraceDataMock: vi.fn(), +})); + +/** + * `_INTERNAL_getTracingHeadersForFetchRequest` imports `getTraceData` from this module, not from the + * `@sentry/core` barrel — spying on `SentryCore.getTraceData` does not affect it. + */ +vi.mock('../../../core/build/esm/utils/traceData.js', () => ({ + getTraceData: getTraceDataMock, +})); +vi.mock('../../../core/build/cjs/utils/traceData.js', () => ({ + getTraceData: getTraceDataMock, +})); + +describe('instrumentDurableObjectNamespace', () => { + beforeEach(() => { + vi.clearAllMocks(); + getTraceDataMock.mockReturnValue({}); + }); + + function createMockNamespace() { + const mockStub = { + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + name: 'test-name', + fetch: vi.fn().mockResolvedValue(new Response('ok')), + }; + + return { + namespace: { + idFromName: vi.fn().mockReturnValue({ toString: () => 'id-from-name', equals: () => false, name: 'test' }), + idFromString: vi.fn().mockReturnValue({ toString: () => 'id-from-string', equals: () => false }), + newUniqueId: vi.fn().mockReturnValue({ toString: () => 'unique-id', equals: () => false }), + get: vi.fn().mockReturnValue(mockStub), + getByName: vi.fn().mockReturnValue(mockStub), + jurisdiction: vi.fn(), + }, + mockStub, + }; + } + + describe('idFromName', () => { + it('delegates to original', () => { + const { namespace } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const result = instrumented.idFromName('global-counter'); + + expect(namespace.idFromName).toHaveBeenCalledWith('global-counter'); + expect(result).toEqual({ toString: expect.any(Function), equals: expect.any(Function), name: 'test' }); + }); + }); + + describe('idFromString', () => { + it('delegates to original', () => { + const { namespace } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + instrumented.idFromString('some-hex-id'); + + expect(namespace.idFromString).toHaveBeenCalledWith('some-hex-id'); + }); + }); + + describe('newUniqueId', () => { + it('delegates to original', () => { + const { namespace } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + instrumented.newUniqueId(); + + expect(namespace.newUniqueId).toHaveBeenCalled(); + }); + }); + + describe('get', () => { + it('delegates to original', () => { + const { namespace } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + instrumented.get(mockId as any); + + expect(namespace.get).toHaveBeenCalledWith(mockId); + }); + + it('returns an instrumented stub', async () => { + const { namespace, mockStub } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + const stub = instrumented.get(mockId as any); + + await (stub as any).fetch('https://example.com/path'); + + expect(mockStub.fetch).toHaveBeenCalledWith('https://example.com/path', expect.any(Object)); + }); + }); + + describe('getByName', () => { + it('delegates to original', () => { + const { namespace } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + instrumented.getByName('my-counter'); + + expect(namespace.getByName).toHaveBeenCalledWith('my-counter'); + }); + }); + + describe('stub instrumentation', () => { + it('calls stub.fetch with URL object', async () => { + const { namespace, mockStub } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + const stub = instrumented.get(mockId as any); + + const url = new URL('https://example.com/api/test'); + await (stub as any).fetch(url); + + expect(mockStub.fetch).toHaveBeenCalledWith(url, expect.any(Object)); + }); + + it('calls stub.fetch with Request object', async () => { + getTraceDataMock.mockReturnValue({}); + + const { namespace, mockStub } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + const stub = instrumented.get(mockId as any); + + const request = new Request('https://example.com/api/data'); + await (stub as any).fetch(request); + + // When there are no trace headers and input is a Request, instrumentFetcher + // passes the request through without an init object. + expect(mockStub.fetch).toHaveBeenCalled(); + const [passedRequest, passedInit] = mockStub.fetch.mock.calls[0]!; + expect(passedRequest).toBe(request); + expect(passedInit).toBeUndefined(); + }); + + it('propagates trace headers on stub.fetch', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const { namespace, mockStub } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + const stub = instrumented.get(mockId as any); + + await (stub as any).fetch('https://example.com/api'); + + const [, init] = mockStub.fetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('passes non-fetch properties through', () => { + const { namespace, mockStub } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + const stub = instrumented.get(mockId as any); + + expect((stub as any).id).toBe(mockStub.id); + expect((stub as any).name).toBe(mockStub.name); + }); + }); + + describe('non-function properties', () => { + it('returns non-function properties unchanged', () => { + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + someProperty: 'value', + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + expect((instrumented as any).someProperty).toBe('value'); + }); + }); +}); diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts new file mode 100644 index 000000000000..ef713eadcea4 --- /dev/null +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentEnv } from '../../src/instrumentations/worker/instrumentEnv'; + +vi.mock('../../src/instrumentations/instrumentDurableObjectNamespace', () => ({ + instrumentDurableObjectNamespace: vi.fn((namespace: unknown) => ({ + __instrumented: true, + __original: namespace, + })), +})); + +import { instrumentDurableObjectNamespace } from '../../src/instrumentations/instrumentDurableObjectNamespace'; + +describe('instrumentEnv', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns primitive values unchanged', () => { + const env = { SENTRY_DSN: 'https://key@sentry.io/123', PORT: 8080, DEBUG: true }; + const instrumented = instrumentEnv(env); + + expect(instrumented.SENTRY_DSN).toBe('https://key@sentry.io/123'); + expect(instrumented.PORT).toBe(8080); + expect(instrumented.DEBUG).toBe(true); + }); + + it('passes through unknown object bindings unchanged', () => { + const unknownBinding = { someMethod: () => 'value' }; + const env = { UNKNOWN: unknownBinding }; + const instrumented = instrumentEnv(env); + + expect(instrumented.UNKNOWN).toBe(unknownBinding); + }); + + it('detects and instruments DurableObjectNamespace bindings', () => { + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const env = { COUNTER: doNamespace }; + const instrumented = instrumentEnv(env); + + const result = instrumented.COUNTER; + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); + expect((result as any).__instrumented).toBe(true); + }); + + it('caches instrumented bindings across repeated access', () => { + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const env = { COUNTER: doNamespace }; + const instrumented = instrumentEnv(env); + + const first = instrumented.COUNTER; + const second = instrumented.COUNTER; + + expect(first).toBe(second); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledTimes(1); + }); + + it('instruments multiple DO bindings independently', () => { + const doNamespace1 = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const doNamespace2 = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const env = { COUNTER: doNamespace1, SESSIONS: doNamespace2 }; + const instrumented = instrumentEnv(env); + + instrumented.COUNTER; + instrumented.SESSIONS; + + expect(instrumentDurableObjectNamespace).toHaveBeenCalledTimes(2); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace1); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace2); + }); + + it('wraps JSRPC proxy with a Proxy that instruments fetch', () => { + const mockFetch = vi.fn(); + const jsrpcProxy = new Proxy( + { fetch: mockFetch }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + // JSRPC behavior: return truthy for any property + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env); + + const result = instrumented.SERVICE; + // Should NOT be the same reference — it's wrapped in a Proxy + expect(result).not.toBe(jsrpcProxy); + expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); + }); + + it('does not instrument JSRPC proxies as DurableObjectNamespace', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env); + + instrumented.SERVICE; + expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); + }); + + it('returns null and undefined values unchanged', () => { + const env = { NULL_VAL: null, UNDEF_VAL: undefined } as Record; + const instrumented = instrumentEnv(env); + + expect(instrumented.NULL_VAL).toBeNull(); + expect(instrumented.UNDEF_VAL).toBeUndefined(); + }); +}); diff --git a/packages/cloudflare/test/utils/instrumentFetcher.test.ts b/packages/cloudflare/test/utils/instrumentFetcher.test.ts new file mode 100644 index 000000000000..6edb66bc8e2c --- /dev/null +++ b/packages/cloudflare/test/utils/instrumentFetcher.test.ts @@ -0,0 +1,328 @@ +import type { RequestInfo } from '@cloudflare/workers-types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentFetcher } from '../../src/instrumentations/worker/instrumentFetcher'; + +const { getTraceDataMock } = vi.hoisted(() => ({ + getTraceDataMock: vi.fn(), +})); + +/** + * `_INTERNAL_getTracingHeadersForFetchRequest` imports `getTraceData` from this module, not from the + * `@sentry/core` barrel — spying on `SentryCore.getTraceData` does not affect it. + */ +vi.mock('../../../core/build/esm/utils/traceData.js', () => ({ + getTraceData: getTraceDataMock, +})); +vi.mock('../../../core/build/cjs/utils/traceData.js', () => ({ + getTraceData: getTraceDataMock, +})); + +/** Vitest's `Request` is not typed identically to Workers `RequestInfo`. */ +function workerRequest(r: Request): RequestInfo { + return r as unknown as RequestInfo; +} + +describe('instrumentFetcher', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls the original fetch with the input and init', async () => { + getTraceDataMock.mockReturnValue({}); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com/path'); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/path', {}); + }); + + it('adds sentry-trace and baggage headers', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com'); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('does not overwrite existing sentry-trace header', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': 'auto-generated-trace', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: { 'sentry-trace': 'manual-trace' }, + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('sentry-trace')).toBe('manual-trace'); + }); + + it('preserves existing custom headers when adding sentry headers', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: { + Authorization: 'Bearer my-token', + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + }, + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBe('Bearer my-token'); + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('X-Custom-Header')).toBe('custom-value'); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('preserves headers from a Request object when init has no headers', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + const request = new Request('https://example.com', { + headers: { + Authorization: 'Bearer request-token', + 'X-Request-Id': '123', + }, + }); + await wrapped(workerRequest(request)); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [passed] = mockFetch.mock.calls[0]!; + expect(passed).toBeInstanceOf(Request); + expect(mockFetch.mock.calls[0]).toHaveLength(1); + expect((passed as Request).headers.get('Authorization')).toBe('Bearer request-token'); + expect((passed as Request).headers.get('X-Request-Id')).toBe('123'); + expect((passed as Request).headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect((passed as Request).headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('does not overwrite sentry-trace from a Request object', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': 'auto-generated-trace', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + const request = new Request('https://example.com', { + headers: { 'sentry-trace': 'request-trace-value' }, + }); + await wrapped(workerRequest(request)); + + const [passed] = mockFetch.mock.calls[0]!; + expect(passed).toBeInstanceOf(Request); + expect(mockFetch.mock.calls[0]).toHaveLength(1); + expect((passed as Request).headers.get('sentry-trace')).toBe('request-trace-value'); + }); + + it('preserves custom headers alongside existing sentry-trace in init', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': 'auto-generated-trace', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: { + 'sentry-trace': 'manual-trace', + Authorization: 'Bearer my-token', + 'X-Custom': 'value', + }, + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('sentry-trace')).toBe('manual-trace'); + expect(headers.get('Authorization')).toBe('Bearer my-token'); + expect(headers.get('X-Custom')).toBe('value'); + }); + + it('works with Headers object in init', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + const existingHeaders = new Headers({ + Authorization: 'Bearer headers-obj-token', + 'X-Custom': 'from-headers-obj', + }); + await wrapped('https://example.com', { headers: existingHeaders }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBe('Bearer headers-obj-token'); + expect(headers.get('X-Custom')).toBe('from-headers-obj'); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('works with array-of-tuples headers in init', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: [ + ['Authorization', 'Bearer tuple-token'], + ['X-Custom', 'from-tuple'], + ], + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBe('Bearer tuple-token'); + expect(headers.get('X-Custom')).toBe('from-tuple'); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('preserves baggage from Request object and appends sentry baggage', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + const request = new Request('https://example.com', { + headers: { baggage: 'custom-key=custom-value' }, + }); + await wrapped(workerRequest(request)); + + const [passed] = mockFetch.mock.calls[0]!; + expect(passed).toBeInstanceOf(Request); + expect(mockFetch.mock.calls[0]).toHaveLength(1); + expect((passed as Request).headers.get('baggage')).toBe('custom-key=custom-value,sentry-environment=production'); + }); + + it('when Request and init are both passed, tracing headers are merged into init', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + const request = new Request('https://example.com', { + headers: { Authorization: 'Bearer from-request' }, + }); + await wrapped(workerRequest(request), { + headers: { 'X-From-Init': '1' }, + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [arg0, arg1] = mockFetch.mock.calls[0]!; + expect(arg0).toBe(request); + const headers = new Headers(arg1?.headers); + expect(headers.get('X-From-Init')).toBe('1'); + expect(headers.get('Authorization')).toBeNull(); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('appends baggage to existing non-sentry baggage', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: { baggage: 'custom-key=custom-value' }, + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('baggage')).toBe('custom-key=custom-value,sentry-environment=production'); + }); + + it('does not duplicate sentry baggage values', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: { baggage: 'sentry-environment=staging' }, + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('baggage')).toBe('sentry-environment=staging'); + }); + + it('passes through original init options', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { method: 'POST', body: 'test' }); + + const [, init] = mockFetch.mock.calls[0]!; + expect(init.method).toBe('POST'); + expect(init.body).toBe('test'); + }); + + it('works when getTraceData returns empty object', async () => { + getTraceDataMock.mockReturnValue({}); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com'); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com', {}); + }); +}); diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts new file mode 100644 index 000000000000..2c6599ed2e42 --- /dev/null +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { isDurableObjectNamespace, isJSRPC } from '../../src/utils/isBinding'; + +describe('isJSRPC', () => { + it('returns false for a plain object', () => { + expect(isJSRPC({ foo: 'bar' })).toBe(false); + }); + + it('returns false for null', () => { + expect(isJSRPC(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isJSRPC(undefined)).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isJSRPC(42)).toBe(false); + expect(isJSRPC('string')).toBe(false); + expect(isJSRPC(true)).toBe(false); + expect(isJSRPC(false)).toBe(false); + expect(isJSRPC(0)).toBe(false); + expect(isJSRPC('')).toBe(false); + expect(isJSRPC(BigInt(42))).toBe(false); + expect(isJSRPC(Symbol('test'))).toBe(false); + }); + + it('returns false for functions, arrays, and other object types', () => { + expect(isJSRPC(() => {})).toBe(false); + expect(isJSRPC(function named() {})).toBe(false); + expect(isJSRPC([1, 2, 3])).toBe(false); + expect(isJSRPC(new Date())).toBe(false); + expect(isJSRPC(/regex/)).toBe(false); + expect(isJSRPC(new Map())).toBe(false); + expect(isJSRPC(new Set())).toBe(false); + expect(isJSRPC(new Error('test'))).toBe(false); + }); + + it('returns false for a DurableObjectNamespace-like object', () => { + const doNamespace = { + idFromName: () => ({}), + idFromString: () => ({}), + get: () => ({}), + newUniqueId: () => ({}), + }; + expect(isJSRPC(doNamespace)).toBe(false); + }); + + it('returns true for a JSRPC proxy that returns truthy for any property', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isJSRPC(jsrpcProxy)).toBe(true); + }); +}); + +describe('isDurableObjectNamespace', () => { + it('returns true for an object with idFromName method', () => { + const doNamespace = { + idFromName: () => ({}), + idFromString: () => ({}), + get: () => ({}), + newUniqueId: () => ({}), + }; + expect(isDurableObjectNamespace(doNamespace)).toBe(true); + }); + + it('returns false for a plain object without idFromName', () => { + expect(isDurableObjectNamespace({ foo: 'bar' })).toBe(false); + }); + + it('returns false for null', () => { + expect(isDurableObjectNamespace(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isDurableObjectNamespace(undefined)).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isDurableObjectNamespace(42)).toBe(false); + expect(isDurableObjectNamespace('string')).toBe(false); + expect(isDurableObjectNamespace(true)).toBe(false); + expect(isDurableObjectNamespace(false)).toBe(false); + expect(isDurableObjectNamespace(0)).toBe(false); + expect(isDurableObjectNamespace('')).toBe(false); + expect(isDurableObjectNamespace(BigInt(42))).toBe(false); + expect(isDurableObjectNamespace(Symbol('test'))).toBe(false); + }); + + it('returns false for functions, arrays, and other object types', () => { + expect(isDurableObjectNamespace(() => {})).toBe(false); + expect(isDurableObjectNamespace(function named() {})).toBe(false); + expect(isDurableObjectNamespace([1, 2, 3])).toBe(false); + expect(isDurableObjectNamespace(new Date())).toBe(false); + expect(isDurableObjectNamespace(/regex/)).toBe(false); + expect(isDurableObjectNamespace(new Map())).toBe(false); + expect(isDurableObjectNamespace(new Set())).toBe(false); + expect(isDurableObjectNamespace(new Error('test'))).toBe(false); + }); + + it('returns false for a JSRPC proxy even though it has idFromName', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isDurableObjectNamespace(jsrpcProxy)).toBe(false); + }); + + it('returns false when idFromName is not a function', () => { + expect(isDurableObjectNamespace({ idFromName: 'not-a-function' })).toBe(false); + }); +}); diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index f21bee8612a8..14bb7e78a90e 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -4,6 +4,12 @@ import type { WorkflowEvent, WorkflowStep, WorkflowStepConfig } from 'cloudflare import { beforeEach, describe, expect, test, vi } from 'vitest'; import { deterministicTraceIdFromInstanceId, instrumentWorkflowWithSentry } from '../src/workflows'; +vi.mock('../src/instrumentations/worker/instrumentEnv', () => ({ + instrumentEnv: vi.fn((env: unknown) => env), +})); + +import { instrumentEnv } from '../src/instrumentations/worker/instrumentEnv'; + const NODE_MAJOR_VERSION = parseInt(process.versions.node.split('.')[0]!); const MOCK_STEP_CTX = { attempt: 1 }; @@ -146,6 +152,25 @@ describe.skipIf(NODE_MAJOR_VERSION < 20)('workflows', () => { ]); }); + test('Wraps env with instrumentEnv', async () => { + class EnvTestWorkflow { + constructor(_ctx: ExecutionContext, _env: unknown) {} + + async run(_event: Readonly>, step: WorkflowStep): Promise { + await step.do('first step', async () => { + return { ok: true }; + }); + } + } + + const mockEnv = { SENTRY_DSN: 'https://key@sentry.io/123', MY_SERVICE: {} }; + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(getSentryOptions, EnvTestWorkflow as any); + new TestWorkflowInstrumented(mockContext, mockEnv as any); + + expect(instrumentEnv).toHaveBeenCalledTimes(1); + expect(instrumentEnv).toHaveBeenCalledWith(mockEnv); + }); + test('Calls expected functions with non-uuid instance id', async () => { class BasicTestWorkflow { constructor(_ctx: ExecutionContext, _env: unknown) {} From d8dd265f7228afcc0f511f60cedc46a1fb483444 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:02:14 +0200 Subject: [PATCH 21/73] test(react): Remove duplicated test mock (#20200) The `getActiveSpan` mock calls `actual.getActiveSpan()` and immediately assigns to the returned span without guarding against `undefined`. When the router subscriber fires outside an active span context, `span` is `undefined` and the property assignment throws a TypeError. Additionally, there are two `vi.mock('@sentry/core')` declarations for the same module; the first (lines 66-73) is dead code since the second one overrides it. Closes https://github.com/getsentry/sentry-javascript/issues/20199 --- packages/react/test/reactrouter-cross-usage.test.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx index e21fca13a077..424821a9ad98 100644 --- a/packages/react/test/reactrouter-cross-usage.test.tsx +++ b/packages/react/test/reactrouter-cross-usage.test.tsx @@ -63,15 +63,6 @@ vi.mock('@sentry/browser', async requireActual => { }; }); -vi.mock('@sentry/core', async requireActual => { - return { - ...(await requireActual()), - getRootSpan: () => { - return mockRootSpan; - }, - }; -}); - vi.mock('@sentry/core', async requireActual => { const actual = (await requireActual()) as any; return { @@ -82,6 +73,8 @@ vi.mock('@sentry/core', async requireActual => { getActiveSpan: () => { const span = actual.getActiveSpan(); + if (!span) return undefined; + span.updateName = mockNavigationSpan.updateName; span.setAttribute = mockNavigationSpan.setAttribute; From a3a662d63f810fd127b5ae9635675418dfbd93c3 Mon Sep 17 00:00:00 2001 From: "fix-it-felix-sentry[bot]" <260785270+fix-it-felix-sentry[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:03:16 +0000 Subject: [PATCH 22/73] fix(ci): Prevent command injection in ci-metadata workflow (#19899) ## Summary This PR fixes a high-severity security vulnerability where GitHub context data was being directly interpolated into a shell script, potentially allowing command injection attacks. ## Changes - Moved `github.event.pull_request.head.sha` and related GitHub context expressions into an environment variable `COMMIT_SHA_EXPR` - Updated the shell script to reference the environment variable with proper quoting (`"$COMMIT_SHA_EXPR"`) - This prevents untrusted input from being directly executed in the shell ## Security Impact Before this fix, an attacker could potentially inject malicious code through pull request metadata, which would be executed in the GitHub Actions runner with access to secrets and code. After this fix, the GitHub context data is safely passed through an environment variable, preventing command injection. ## References - Parent ticket: https://linear.app/getsentry/issue/VULN-1328 - Child ticket: https://linear.app/getsentry/issue/JS-1972 - [GitHub Actions Security Hardening](https://docs.github.com/en/actions/learn-github-actions/security-hardening-for-github-actions#understanding-the-risk-of-script-injections) - [GitHub Security Lab: Untrusted Input](https://securitylab.github.com/research/github-actions-untrusted-input/) - [Semgrep Rule](https://semgrep.dev/r/yaml.github-actions.security.run-shell-injection.run-shell-injection) --------- Co-authored-by: fix-it-felix-sentry[bot] <260785270+fix-it-felix-sentry[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 Co-authored-by: Lukas Stracke --- .github/workflows/ci-metadata.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-metadata.yml b/.github/workflows/ci-metadata.yml index c4fca988d724..00271d1fe6b0 100644 --- a/.github/workflows/ci-metadata.yml +++ b/.github/workflows/ci-metadata.yml @@ -51,8 +51,11 @@ jobs: id: get_metadata # We need to try a number of different options for finding the head commit, because each kind of trigger event # stores it in a different location + env: + COMMIT_SHA_EXPR: + ${{ github.event.pull_request.head.sha || github.event.head_commit.id || inputs.head_commit }} run: | - COMMIT_SHA=$(git rev-parse --short ${{ github.event.pull_request.head.sha || github.event.head_commit.id || inputs.head_commit }}) + COMMIT_SHA=$(git rev-parse --short "$COMMIT_SHA_EXPR") echo "COMMIT_SHA=$COMMIT_SHA" >> $GITHUB_ENV echo "COMMIT_MESSAGE=$(git log -n 1 --pretty=format:%s $COMMIT_SHA)" >> $GITHUB_ENV From 05ab20717122d65dd6a4d93908a785c5754ade23 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:18:32 +0000 Subject: [PATCH 23/73] fix(node-integration-tests): Fix flaky kafkajs test race condition (#20189) The kafkajs integration test asserted producer and consumer transactions in a fixed order, but they can arrive in either order due to Kafka's async nature. To fix the flake, we collect both transactions via callbacks, then assert after both have arrived using `find()` by transaction name instead of relying on arrival order closes #20121 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Lukas Stracke --- .../suites/tracing/kafkajs/test.ts | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts index 176d947e1ecf..84e8d4a5612e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts @@ -1,3 +1,4 @@ +import type { TransactionEvent } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -8,16 +9,50 @@ describe('kafkajs', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('traces producers and consumers', { timeout: 60_000 }, async () => { + // The producer and consumer transactions can arrive in any order, + // so we collect them and assert after both have been received. + const receivedTransactions: TransactionEvent[] = []; + await createRunner() .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['9092'], }) .expect({ - transaction: { - transaction: 'send test-topic', - contexts: { - trace: expect.objectContaining({ + transaction: (transaction: TransactionEvent) => { + receivedTransactions.push(transaction); + }, + }) + .expect({ + transaction: (transaction: TransactionEvent) => { + receivedTransactions.push(transaction); + + const producer = receivedTransactions.find( + t => t.contexts?.trace?.data?.['sentry.origin'] === 'auto.kafkajs.otel.producer', + ); + const consumer = receivedTransactions.find( + t => t.contexts?.trace?.data?.['sentry.origin'] === 'auto.kafkajs.otel.consumer', + ); + + expect(producer).toBeDefined(); + expect(consumer).toBeDefined(); + + for (const t of [producer, consumer]) { + // just to assert on the basic shape (for more straight-forward tests, this is usually done by the runner) + expect(t).toMatchObject({ + event_id: expect.any(String), + timestamp: expect.anything(), + start_timestamp: expect.anything(), + spans: expect.any(Array), + type: 'transaction', + }); + } + + expect(producer!.transaction).toBe('send test-topic'); + expect(consumer!.transaction).toBe('process test-topic'); + + expect(producer!.contexts?.trace).toMatchObject( + expect.objectContaining({ op: 'message', status: 'ok', data: expect.objectContaining({ @@ -28,14 +63,10 @@ describe('kafkajs', () => { 'sentry.origin': 'auto.kafkajs.otel.producer', }), }), - }, - }, - }) - .expect({ - transaction: { - transaction: 'process test-topic', - contexts: { - trace: expect.objectContaining({ + ); + + expect(consumer!.contexts?.trace).toMatchObject( + expect.objectContaining({ op: 'message', status: 'ok', data: expect.objectContaining({ @@ -46,7 +77,7 @@ describe('kafkajs', () => { 'sentry.origin': 'auto.kafkajs.otel.consumer', }), }), - }, + ); }, }) .start() From f1932c98513c119da22d3d29d29b47b953231b47 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 10 Apr 2026 17:40:40 +0200 Subject: [PATCH 24/73] ci: Add automatic flaky test detector (#18684) Manually checking for flakes and opening issues is a bit annoying. I was thinking we could add a ci workflow to automate this. The action only runs when merging to develop. Could also be done on PRs but seems unnecessarily complicated. My thinking is that for a push to develop to happen, all the test must first have passed in the original PR. Therefore if the test then fails on develop we know it's a flake. Open for ideas/improvements/cleanups or let me know if there might be any cases I am missing that could lead to false positives. Example issue created with this: https://github.com/getsentry/sentry-javascript/issues/18693 It doesn't get all the details but I think basically the most important is a link to the run so we can then investigate further. Also the logic for creating the issues is a bit ugly, but not sure if we can make it cleaner given that I want to create one issue per failed test not dump it all into one issue. --- .github/FLAKY_CI_FAILURE_TEMPLATE.md | 24 +++++++++ .github/workflows/build.yml | 78 ++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 .github/FLAKY_CI_FAILURE_TEMPLATE.md diff --git a/.github/FLAKY_CI_FAILURE_TEMPLATE.md b/.github/FLAKY_CI_FAILURE_TEMPLATE.md new file mode 100644 index 000000000000..2a2ad5109561 --- /dev/null +++ b/.github/FLAKY_CI_FAILURE_TEMPLATE.md @@ -0,0 +1,24 @@ +--- +title: '[Flaky CI]: {{ env.JOB_NAME }}' +labels: Tests +--- + +### Flakiness Type + +Other / Unknown + +### Name of Job + +{{ env.JOB_NAME }} + +### Name of Test + +_Not available - check the run link for details_ + +### Link to Test Run + +{{ env.RUN_LINK }} + +--- + +_This issue was automatically created._ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cf00b5d00435..1bf07adf661c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1192,7 +1192,85 @@ jobs: # Always run this, even if a dependent job failed if: always() runs-on: ubuntu-24.04 + permissions: + issues: write steps: + - name: Check out current commit + if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') + uses: actions/checkout@v6 + with: + sparse-checkout: .github + + - name: Create issues for failed jobs + if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + // Fetch actual job details from the API to get descriptive names + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + per_page: 100 + }); + + const failedJobs = jobs.filter(job => job.conclusion === 'failure'); + + if (failedJobs.length === 0) { + console.log('No failed jobs found'); + return; + } + + // Read and parse template + const template = fs.readFileSync('.github/FLAKY_CI_FAILURE_TEMPLATE.md', 'utf8'); + const [, frontmatter, bodyTemplate] = template.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + + // Get existing open issues with Tests label + const existing = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'Tests', + per_page: 100 + }); + + for (const job of failedJobs) { + const jobName = job.name; + const jobUrl = job.html_url; + + // Replace template variables + const vars = { + 'JOB_NAME': jobName, + 'RUN_LINK': jobUrl + }; + + let title = frontmatter.match(/title:\s*'(.*)'/)[1]; + let issueBody = bodyTemplate; + for (const [key, value] of Object.entries(vars)) { + const pattern = new RegExp(`\\{\\{\\s*env\\.${key}\\s*\\}\\}`, 'g'); + title = title.replace(pattern, value); + issueBody = issueBody.replace(pattern, value); + } + + const existingIssue = existing.find(i => i.title === title); + + if (existingIssue) { + console.log(`Issue already exists for ${jobName}: #${existingIssue.number}`); + continue; + } + + const newIssue = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: issueBody.trim(), + labels: ['Tests'] + }); + console.log(`Created issue #${newIssue.data.number} for ${jobName}`); + } + - name: Check for failures if: cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: | From 2af59be7d39d0a72dcde63808453cbf8f4b1d2cd Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:58:54 +0900 Subject: [PATCH 25/73] fix(deno): Handle `reader.closed` rejection from `releaseLock()` in streaming (#20187) This PR replaces `reader.closed.finally(() => onDone())` with `reader.closed.then(() => onDone(), () => onDone())` in `monitorStream`. Per the WHATWG Streams spec, `reader.releaseLock()` rejects `reader.closed` when the promise is still pending. `.finally()` propagates that rejection as an unhandled promise rejection, while `.then(f, f)` suppresses it by handling both the fulfilled and rejected cases. I was not able to reproduce the error directly on my deno version but this should prevent the issue. Closes: #20177 --- packages/deno/src/utils/streaming.ts | 6 ++- packages/deno/test/streaming.test.ts | 56 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 packages/deno/test/streaming.test.ts diff --git a/packages/deno/src/utils/streaming.ts b/packages/deno/src/utils/streaming.ts index 045a104c5e93..35233f9b1a5f 100644 --- a/packages/deno/src/utils/streaming.ts +++ b/packages/deno/src/utils/streaming.ts @@ -81,8 +81,10 @@ function monitorStream( onDone: () => void, ): ReadableStream> { const reader = stream.getReader(); - // oxlint-disable-next-line typescript/no-floating-promises - reader.closed.finally(() => onDone()); + reader.closed.then( + () => onDone(), + () => onDone(), + ); return new ReadableStream({ async start(controller) { let result: ReadableStreamReadResult>; diff --git a/packages/deno/test/streaming.test.ts b/packages/deno/test/streaming.test.ts new file mode 100644 index 000000000000..d9849ece3c76 --- /dev/null +++ b/packages/deno/test/streaming.test.ts @@ -0,0 +1,56 @@ +// + +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; + +Deno.test('reader.closed.then(f, f) suppresses rejection when releaseLock is called on an open stream', async () => { + // Reproduces the bug from GitHub issue #20177: + // In monitorStream, reader.releaseLock() is called while the source stream + // is still open (e.g. the error path when controller.enqueue() throws). + // Per WHATWG Streams spec, this rejects reader.closed with a TypeError. + // Using .then(onDone, onDone) handles both cases; .finally() would propagate + // the rejection as unhandled. + + let onDoneCalled = false; + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data')); + // intentionally not closing — stream stays open + }, + }); + + const reader = stream.getReader(); + + // This is the exact pattern from monitorStream (line 84 in streaming.ts). + // With .finally(() => onDone()), this would propagate the rejection. + reader.closed.then( + () => { + onDoneCalled = true; + }, + () => { + onDoneCalled = true; + }, + ); + + await reader.read(); + + // This is what monitorStream does on the error path (line 98) when + // controller.enqueue() throws — releaseLock while the source is still open. + reader.releaseLock(); + + let unhandledRejection: PromiseRejectionEvent | undefined; + const handler = (e: PromiseRejectionEvent): void => { + e.preventDefault(); + unhandledRejection = e; + }; + globalThis.addEventListener('unhandledrejection', handler); + + try { + await new Promise(resolve => setTimeout(resolve, 50)); + + assertEquals(onDoneCalled, true, 'onDone should have been called via the rejection handler'); + assertEquals(unhandledRejection, undefined, 'should not have caused an unhandled promise rejection'); + } finally { + globalThis.removeEventListener('unhandledrejection', handler); + } +}); From 88a4c3ee7648fb576103fb1d54a2c75d6bf08807 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Sat, 11 Apr 2026 23:19:40 +0900 Subject: [PATCH 26/73] Add enableTruncation option to Cloudflare, Deno, and Vercel Edge integrations --- .../cloudflare/src/integrations/tracing/vercelai.ts | 11 ++++++++++- packages/deno/src/integrations/tracing/vercelai.ts | 11 ++++++++++- .../vercel-edge/src/integrations/tracing/vercelai.ts | 11 ++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/integrations/tracing/vercelai.ts b/packages/cloudflare/src/integrations/tracing/vercelai.ts index c513568997ab..70483113e886 100644 --- a/packages/cloudflare/src/integrations/tracing/vercelai.ts +++ b/packages/cloudflare/src/integrations/tracing/vercelai.ts @@ -13,9 +13,18 @@ import { addVercelAiProcessors, defineIntegration } from '@sentry/core'; const INTEGRATION_NAME = 'VercelAI'; -const _vercelAIIntegration = (() => { +interface VercelAiOptions { + /** + * Enable or disable truncation of recorded input messages. + * Defaults to `true`. + */ + enableTruncation?: boolean; +} + +const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { return { name: INTEGRATION_NAME, + options, setup(client) { addVercelAiProcessors(client); }, diff --git a/packages/deno/src/integrations/tracing/vercelai.ts b/packages/deno/src/integrations/tracing/vercelai.ts index c400babf0d64..fb1f53f174ea 100644 --- a/packages/deno/src/integrations/tracing/vercelai.ts +++ b/packages/deno/src/integrations/tracing/vercelai.ts @@ -7,9 +7,18 @@ import { addVercelAiProcessors, defineIntegration } from '@sentry/core'; const INTEGRATION_NAME = 'VercelAI'; -const _vercelAIIntegration = (() => { +interface VercelAiOptions { + /** + * Enable or disable truncation of recorded input messages. + * Defaults to `true`. + */ + enableTruncation?: boolean; +} + +const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { return { name: INTEGRATION_NAME, + options, setup(client) { addVercelAiProcessors(client); }, diff --git a/packages/vercel-edge/src/integrations/tracing/vercelai.ts b/packages/vercel-edge/src/integrations/tracing/vercelai.ts index c513568997ab..70483113e886 100644 --- a/packages/vercel-edge/src/integrations/tracing/vercelai.ts +++ b/packages/vercel-edge/src/integrations/tracing/vercelai.ts @@ -13,9 +13,18 @@ import { addVercelAiProcessors, defineIntegration } from '@sentry/core'; const INTEGRATION_NAME = 'VercelAI'; -const _vercelAIIntegration = (() => { +interface VercelAiOptions { + /** + * Enable or disable truncation of recorded input messages. + * Defaults to `true`. + */ + enableTruncation?: boolean; +} + +const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { return { name: INTEGRATION_NAME, + options, setup(client) { addVercelAiProcessors(client); }, From 2b3cfaef6fcff5378862cecd64f05218f5559a2d Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:19:48 +0900 Subject: [PATCH 27/73] feat(core): Automatically disable truncation when span streaming is enabled in OpenAI integration (#20227) When span streaming is enabled, the `enableTruncation` option now defaults to `false` unless the user has explicitly set it. Closes: #20221 --- .../instrument-streaming-with-truncation.mjs | 16 ++++++ .../tracing/openai/instrument-streaming.mjs | 11 ++++ .../tracing/openai/scenario-no-truncation.mjs | 2 + .../suites/tracing/openai/test.ts | 54 +++++++++++++++++++ packages/core/src/tracing/ai/utils.ts | 11 ++++ packages/core/src/tracing/openai/index.ts | 5 +- 6 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming-with-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..097c7adcf087 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.openAIIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs index f19345653c07..838f7e35d285 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs @@ -75,6 +75,8 @@ async function run() { }); }); + // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits + await Sentry.flush(); server.close(); } diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index d3bdc0a6a80c..8e2554c7762c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -1019,4 +1019,58 @@ describe('OpenAI integration', () => { .completed(); }); }); + + const streamingLongContent = 'A'.repeat(50_000); + const streamingLongString = 'B'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-no-truncation.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + + const responsesSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongString), + ); + expect(responsesSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, content should be truncated despite streaming. + // Find the chat span by matching the start of the truncated content (the 'A' repeated messages). + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('[{"role":"user","content":"AAAA'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongContent.length, + ); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index d3cce644dbc1..05502b249efb 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -3,6 +3,7 @@ */ import { captureException } from '../../exports'; import { getClient } from '../../currentScopes'; +import { hasSpanStreamingEnabled } from '../spans/hasSpanStreamingEnabled'; import type { Span } from '../../types-hoist/span'; import { isThenable } from '../../utils/is'; import { @@ -56,6 +57,16 @@ export function resolveAIRecordingOptions(options? } as T & Required; } +/** + * Resolves whether truncation should be enabled. + * If the user explicitly set `enableTruncation`, that value is used. + * Otherwise, truncation is disabled when span streaming is active. + */ +export function shouldEnableTruncation(enableTruncation: boolean | undefined): boolean { + const client = getClient(); + return enableTruncation ?? !(client && hasSpanStreamingEnabled(client)); +} + /** * Build method path from current traversal */ diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index f1c4d3a06516..820128050b12 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -22,6 +22,7 @@ import { getJsonString, getTruncatedJsonString, resolveAIRecordingOptions, + shouldEnableTruncation, wrapPromiseWithMethods, } from '../ai/utils'; import { OPENAI_METHOD_REGISTRY } from './constants'; @@ -170,7 +171,7 @@ function instrumentMethod( originalResult = originalMethod.apply(context, args); if (options.recordInputs && params) { - addRequestAttributes(span, params, operationName, options.enableTruncation ?? true); + addRequestAttributes(span, params, operationName, shouldEnableTruncation(options.enableTruncation)); } // Return async processing @@ -208,7 +209,7 @@ function instrumentMethod( originalResult = originalMethod.apply(context, args); if (options.recordInputs && params) { - addRequestAttributes(span, params, operationName, options.enableTruncation ?? true); + addRequestAttributes(span, params, operationName, shouldEnableTruncation(options.enableTruncation)); } return originalResult.then( From 43ce2acac7d8a844011ff531f08cb0980c212bda Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:20:18 +0900 Subject: [PATCH 28/73] feat(core): Automatically disable truncation when span streaming is enabled in Vercel AI integration (#20232) When span streaming is enabled, the `enableTruncation` option now defaults to `false` unless the user has explicitly set it. Should be merged after: https://github.com/getsentry/sentry-javascript/pull/20195 Closes: #20226 --- .../instrument-streaming-with-truncation.mjs | 16 +++++++ .../tracing/vercelai/instrument-streaming.mjs | 11 +++++ .../tracing/vercelai/scenario-streaming.mjs | 30 ++++++++++++ .../suites/tracing/vercelai/test.ts | 46 +++++++++++++++++++ packages/core/src/tracing/ai/utils.ts | 11 +++++ packages/core/src/tracing/vercel-ai/index.ts | 3 +- 6 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming-with-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..39c60d69dacb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.vercelAIIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-streaming.mjs new file mode 100644 index 000000000000..7f824dee4a3e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-streaming.mjs @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const longContent = 'A'.repeat(50_000); + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 5 }, + text: 'Response', + }), + }), + messages: [ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ], + }); + }); + + // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index d75a1faf8ea0..df2632ad635b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -983,4 +983,50 @@ describe('Vercel AI integration', () => { }); }, ); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-streaming.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, truncation keeps only the last message + // and drops the long content. The result should NOT contain the full 50k 'A' string. + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes('Follow-up question'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value).not.toContain(streamingLongContent); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index d3cce644dbc1..05502b249efb 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -3,6 +3,7 @@ */ import { captureException } from '../../exports'; import { getClient } from '../../currentScopes'; +import { hasSpanStreamingEnabled } from '../spans/hasSpanStreamingEnabled'; import type { Span } from '../../types-hoist/span'; import { isThenable } from '../../utils/is'; import { @@ -56,6 +57,16 @@ export function resolveAIRecordingOptions(options? } as T & Required; } +/** + * Resolves whether truncation should be enabled. + * If the user explicitly set `enableTruncation`, that value is used. + * Otherwise, truncation is disabled when span streaming is active. + */ +export function shouldEnableTruncation(enableTruncation: boolean | undefined): boolean { + const client = getClient(); + return enableTruncation ?? !(client && hasSpanStreamingEnabled(client)); +} + /** * Build method path from current traversal */ diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 90fb1b16fc65..569233cf8321 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -2,6 +2,7 @@ import type { Client } from '../../client'; import { getClient } from '../../currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { shouldEnableTruncation } from '../ai/utils'; import type { Event } from '../../types-hoist/event'; import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON } from '../../types-hoist/span'; import { spanToJSON } from '../../utils/spanUtils'; @@ -119,7 +120,7 @@ function onVercelAiSpanStart(span: Span): void { const integration = client?.getIntegrationByName('VercelAI') as | { options?: { enableTruncation?: boolean } } | undefined; - const enableTruncation = integration?.options?.enableTruncation ?? true; + const enableTruncation = shouldEnableTruncation(integration?.options?.enableTruncation); processGenerateSpan(span, name, attributes, enableTruncation); } From 506d0bc3df97889f2055ee58f242f26dfe693535 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:26:28 +0900 Subject: [PATCH 29/73] feat(core): Automatically disable truncation when span streaming is enabled in Anthropic AI integration (#20228) When span streaming is enabled, the `enableTruncation` option now defaults to `false` unless the user has explicitly set it. Closes: #20222 --- .../instrument-streaming-with-truncation.mjs | 16 ++++++ .../anthropic/instrument-streaming.mjs | 11 ++++ .../tracing/anthropic/scenario-streaming.mjs | 53 +++++++++++++++++++ .../suites/tracing/anthropic/test.ts | 48 +++++++++++++++++ .../core/src/tracing/anthropic-ai/index.ts | 7 +-- 5 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming-with-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..9d8360708ab3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.anthropicAIIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-streaming.mjs new file mode 100644 index 000000000000..53594bb60058 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-streaming.mjs @@ -0,0 +1,53 @@ +import Anthropic from '@anthropic-ai/sdk'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json({ limit: '10mb' })); + + app.post('/anthropic/v1/messages', (req, res) => { + res.send({ + id: 'msg_streaming_test', + type: 'message', + model: req.body.model, + role: 'assistant', + content: [{ type: 'text', text: 'Response' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 5 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Anthropic({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}/anthropic`, + }); + + // Long content that would normally be truncated + const longContent = 'A'.repeat(50_000); + await client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + messages: [{ role: 'user', content: longContent }], + }); + }); + + // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits + await Sentry.flush(2000); + + server.close(); +} + +run(); 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 7dcc7f8743f9..26e842443b4e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -844,4 +844,52 @@ describe('Anthropic integration', () => { }); }, ); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-streaming.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, content should be truncated despite streaming. + // Find the chat span by matching the start of the truncated content (the 'A' repeated messages). + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('[{"role":"user","content":"AAAA'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongContent.length, + ); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index 323c7feb2bb2..a32dccccbc67 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -25,6 +25,7 @@ import { buildMethodPath, resolveAIRecordingOptions, setTokenUsageAttributes, + shouldEnableTruncation, wrapPromiseWithMethods, } from '../ai/utils'; import { ANTHROPIC_METHOD_REGISTRY } from './constants'; @@ -206,7 +207,7 @@ function handleStreamingRequest( originalResult = originalMethod.apply(context, args) as Promise; if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params, options.enableTruncation ?? true); + addPrivateRequestAttributes(span, params, shouldEnableTruncation(options.enableTruncation)); } return (async () => { @@ -228,7 +229,7 @@ function handleStreamingRequest( return startSpanManual(spanConfig, span => { try { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params, options.enableTruncation ?? true); + addPrivateRequestAttributes(span, params, shouldEnableTruncation(options.enableTruncation)); } const messageStream = target.apply(context, args); return instrumentMessageStream(messageStream, span, options.recordOutputs ?? false); @@ -289,7 +290,7 @@ function instrumentMethod( originalResult = target.apply(context, args) as Promise; if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params, options.enableTruncation ?? true); + addPrivateRequestAttributes(span, params, shouldEnableTruncation(options.enableTruncation)); } return originalResult.then( From c8c81d0dfdcf26d46a1b8522d0495ef5a445843d Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:26:58 +0900 Subject: [PATCH 30/73] feat(core): Automatically disable truncation when span streaming is enabled in Google GenAI integration (#20229) When span streaming is enabled, the `enableTruncation` option now defaults to `false` unless the user has explicitly set it. Closes: #20223 --- .../instrument-streaming-with-truncation.mjs | 16 ++++++ .../google-genai/instrument-streaming.mjs | 11 ++++ .../google-genai/scenario-span-streaming.mjs | 51 +++++++++++++++++++ .../suites/tracing/google-genai/test.ts | 50 ++++++++++++++++++ .../core/src/tracing/google-genai/index.ts | 10 +++- 5 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming-with-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..e706163aea04 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.googleGenAIIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs new file mode 100644 index 000000000000..f5b2656b5cb0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs @@ -0,0 +1,51 @@ +import { GoogleGenAI } from '@google/genai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockGoogleGenAIServer() { + const app = express(); + app.use(express.json({ limit: '10mb' })); + + app.post('/v1beta/models/:model\\:generateContent', (req, res) => { + res.json({ + candidates: [ + { + content: { parts: [{ text: 'Response' }], role: 'model' }, + finishReason: 'STOP', + }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockGoogleGenAIServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new GoogleGenAI({ + apiKey: 'mock-api-key', + httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, + }); + + // Long content that would normally be truncated + const longContent = 'A'.repeat(50_000); + await client.models.generateContent({ + model: 'gemini-1.5-flash', + contents: [{ role: 'user', parts: [{ text: longContent }] }], + }); + }); + + // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index b6271a03f4fc..9839ef5fa2c0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -686,4 +686,54 @@ describe('Google GenAI integration', () => { }); }, ); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-span-streaming.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, content should be truncated despite streaming. + // Find the chat span by matching the start of the truncated content (the 'A' repeated messages). + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith( + '[{"role":"user","parts":[{"text":"AAAA', + ), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongContent.length, + ); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index 51ca11f612fa..7d5f6023f271 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -34,6 +34,7 @@ import { getJsonString, getTruncatedJsonString, resolveAIRecordingOptions, + shouldEnableTruncation, } from '../ai/utils'; import { GOOGLE_GENAI_METHOD_REGISTRY, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; @@ -297,7 +298,12 @@ function instrumentMethod( async (span: Span) => { try { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params, isEmbeddings, options.enableTruncation ?? true); + addPrivateRequestAttributes( + span, + params, + isEmbeddings, + shouldEnableTruncation(options.enableTruncation), + ); } const stream = await target.apply(context, args); return instrumentStream(stream, span, Boolean(options.recordOutputs)) as R; @@ -325,7 +331,7 @@ function instrumentMethod( }, (span: Span) => { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params, isEmbeddings, options.enableTruncation ?? true); + addPrivateRequestAttributes(span, params, isEmbeddings, shouldEnableTruncation(options.enableTruncation)); } return handleCallbackErrors( From 8ace386d508151f70c0b9104f4ffccf30299876c Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:27:47 +0900 Subject: [PATCH 31/73] feat(core): Automatically disable truncation when span streaming is enabled in LangChain integration (#20230) When span streaming is enabled, the `enableTruncation` option now defaults to `false` unless the user has explicitly set it. Closes: https://github.com/getsentry/sentry-javascript/issues/20224 --- .../instrument-streaming-with-truncation.mjs | 16 +++++++ .../langchain/instrument-streaming.mjs | 11 +++++ .../suites/tracing/langchain/test.ts | 46 +++++++++++++++++++ packages/core/src/tracing/langchain/index.ts | 4 +- 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming-with-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..cdfebbf845fc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.langChainIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts index 434001c92965..ca2a1d9f73ec 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -582,4 +582,50 @@ describe('LangChain integration', () => { }); }, ); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-no-truncation.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, truncation keeps only the last message + // and drops the long content. The result should NOT contain the full 50k 'A' string. + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes('Follow-up question'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value).not.toContain(streamingLongContent); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/tracing/langchain/index.ts b/packages/core/src/tracing/langchain/index.ts index 7acc35400c99..64e9058d8ce2 100644 --- a/packages/core/src/tracing/langchain/index.ts +++ b/packages/core/src/tracing/langchain/index.ts @@ -10,7 +10,7 @@ import { GEN_AI_TOOL_NAME_ATTRIBUTE, GEN_AI_TOOL_OUTPUT_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { resolveAIRecordingOptions } from '../ai/utils'; +import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils'; import { LANGCHAIN_ORIGIN } from './constants'; import type { LangChainCallbackHandler, @@ -34,7 +34,7 @@ import { */ export function createLangChainCallbackHandler(options: LangChainOptions = {}): LangChainCallbackHandler { const { recordInputs, recordOutputs } = resolveAIRecordingOptions(options); - const enableTruncation = options.enableTruncation ?? true; + const enableTruncation = shouldEnableTruncation(options.enableTruncation); // Internal state - single instance tracks all spans const spanMap = new Map(); From 95b1e956b0d86aa9b9a6a9cf101e340c5c95923c Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:30:05 +0900 Subject: [PATCH 32/73] feat(core): Automatically disable truncation when span streaming is enabled in LangGraph integration (#20231) When span streaming is enabled, the `enableTruncation` option now defaults to `false` unless the user has explicitly set it. Closes: #20225 --- .../instrument-streaming-with-truncation.mjs | 16 +++++++ .../langgraph/instrument-streaming.mjs | 11 +++++ .../suites/tracing/langgraph/test.ts | 46 +++++++++++++++++++ packages/core/src/tracing/langgraph/index.ts | 3 +- 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming-with-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..2d8d986a2cd1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.langGraphIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 329cb914851a..387694c70563 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -398,4 +398,50 @@ describe('LangGraph integration', () => { }); }, ); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-no-truncation.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, truncation keeps only the last message + // and drops the long content. The result should NOT contain the full 50k 'A' string. + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes('Follow-up question'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value).not.toContain(streamingLongContent); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index 5230b43bb54d..d188fe90d97f 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -17,6 +17,7 @@ import { getJsonString, getTruncatedJsonString, resolveAIRecordingOptions, + shouldEnableTruncation, } from '../ai/utils'; import type { LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; @@ -150,7 +151,7 @@ function instrumentCompiledGraphInvoke( span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, systemInstructions); } - const enableTruncation = options.enableTruncation ?? true; + const enableTruncation = shouldEnableTruncation(options.enableTruncation); const filteredLength = Array.isArray(filteredMessages) ? filteredMessages.length : 0; span.setAttributes({ [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: enableTruncation From 4a5f90bf7e4f6aa3f729906740b19511598e3d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 13 Apr 2026 12:53:59 +0200 Subject: [PATCH 33/73] test: Fix flaky ANR test by increasing blocking duration (#20239) Closes #20215 Closes [JS-2120](https://linear.app/getsentry/issue/JS-2120/flaky-ci-node-24-node-core-integration-tests) Increase `longWork` iterations from 20 to 50 to ensure the blocking function is more likely to be captured in ANR stack sampling, reducing flakiness from sampling during timer processing. A rerun solved it, but this would hopefully make it less flaky. Co-authored-by: Claude Opus 4.5 --- .../node-core-integration-tests/suites/anr/app-path.mjs | 2 +- .../node-core-integration-tests/suites/anr/basic-multiple.mjs | 2 +- .../node-core-integration-tests/suites/anr/basic-session.js | 2 +- dev-packages/node-core-integration-tests/suites/anr/basic.js | 2 +- dev-packages/node-core-integration-tests/suites/anr/basic.mjs | 2 +- dev-packages/node-core-integration-tests/suites/anr/forked.js | 2 +- .../node-core-integration-tests/suites/anr/isolated.mjs | 2 +- .../node-core-integration-tests/suites/anr/stop-and-start.js | 4 ++-- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/dev-packages/node-core-integration-tests/suites/anr/app-path.mjs b/dev-packages/node-core-integration-tests/suites/anr/app-path.mjs index 2cf1cff1ea32..28f245851b01 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/app-path.mjs +++ b/dev-packages/node-core-integration-tests/suites/anr/app-path.mjs @@ -25,7 +25,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/basic-multiple.mjs b/dev-packages/node-core-integration-tests/suites/anr/basic-multiple.mjs index 9c8b17b590bc..1caf96d3abdb 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/basic-multiple.mjs +++ b/dev-packages/node-core-integration-tests/suites/anr/basic-multiple.mjs @@ -21,7 +21,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/basic-session.js b/dev-packages/node-core-integration-tests/suites/anr/basic-session.js index 541c5ee25e36..e89a65e79ad2 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/basic-session.js +++ b/dev-packages/node-core-integration-tests/suites/anr/basic-session.js @@ -20,7 +20,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/basic.js b/dev-packages/node-core-integration-tests/suites/anr/basic.js index 738810f2fa2f..9010fb296c48 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/basic.js +++ b/dev-packages/node-core-integration-tests/suites/anr/basic.js @@ -22,7 +22,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/basic.mjs b/dev-packages/node-core-integration-tests/suites/anr/basic.mjs index 5902394e8109..109a201ecb98 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/basic.mjs +++ b/dev-packages/node-core-integration-tests/suites/anr/basic.mjs @@ -21,7 +21,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/forked.js b/dev-packages/node-core-integration-tests/suites/anr/forked.js index be4848abee5c..90148e549ce1 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/forked.js +++ b/dev-packages/node-core-integration-tests/suites/anr/forked.js @@ -21,7 +21,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/isolated.mjs b/dev-packages/node-core-integration-tests/suites/anr/isolated.mjs index 37e804d01b71..8d24c4cbd021 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/isolated.mjs +++ b/dev-packages/node-core-integration-tests/suites/anr/isolated.mjs @@ -18,7 +18,7 @@ setupOtel(client); async function longWork() { await new Promise(resolve => setTimeout(resolve, 1000)); - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/stop-and-start.js b/dev-packages/node-core-integration-tests/suites/anr/stop-and-start.js index c377c8716814..bec3d83b7d39 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/stop-and-start.js +++ b/dev-packages/node-core-integration-tests/suites/anr/stop-and-start.js @@ -23,7 +23,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWorkIgnored() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); @@ -31,7 +31,7 @@ function longWorkIgnored() { } function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); From 45d7b0675decb032607f224799b5a47fa2917a10 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 13 Apr 2026 14:28:55 +0200 Subject: [PATCH 34/73] chore(ci): Remove codecov steps from jobs that produce no coverage/JUnit data (#20244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Remove `getsentry/codecov-action` from CI jobs that don't produce coverage or JUnit XML files, eliminating noisy warnings in every CI run - Disable coverage upload for Playwright browser integration tests that only produce JUnit XML ### Jobs cleaned up: | Job | Issue | Fix | |-----|-------|-----| | Node Integration Tests | Coverage disabled in vitest config, custom reporters without JUnit | Removed codecov step entirely | | Node-Core Integration Tests | Same as above | Removed codecov step entirely | | E2E Tests | Uses ts-node/Playwright, not vitest | Removed codecov step entirely | | Remix Tests | Custom vitest config without coverage/JUnit | Removed codecov step entirely | | Playwright Browser Tests | No coverage files (Playwright, not vitest) | Added `enable-coverage: false` | | PW Loader Tests | Same as above | Added `enable-coverage: false` | ### Warnings eliminated: - `No coverage files found` (from integration tests, E2E, Remix, Playwright jobs) - `No JUnit XML files found matching pattern: dev-packages/{node,node-core}-integration-tests/**/*.junit.xml` - `No JUnit XML files found matching pattern: dev-packages/e2e-tests/**/*.junit.xml` - `Please ensure your test framework is generating JUnit XML output` - `Supported formats: clover, cobertura, jacoco, lcov, istanbul, go, codecov` ### Not addressed (upstream issue): - `Entity expansion limit exceeded: 100X > 1000` on vitest JUnit XML files — this is a parser limitation in `getsentry/codecov-action` that affects large test suites (browser, core, node, nextjs, etc.) ## Test plan - [ ] CI runs cleanly without the removed warnings - [ ] Coverage still uploads correctly for Browser Unit Tests and Node Unit Tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 39 ++----------------------------------- 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1bf07adf661c..38e12b1fa422 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -605,6 +605,7 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} directory: dev-packages/browser-integration-tests + enable-coverage: false name: browser-playwright-${{ matrix.bundle }}-${{ matrix.project }}${{ matrix.shard && format('-{0}', matrix.shard) || '' }} @@ -669,6 +670,7 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} directory: dev-packages/browser-integration-tests + enable-coverage: false name: browser-loader-${{ matrix.bundle }} job_check_for_faulty_dts: @@ -737,15 +739,6 @@ jobs: working-directory: dev-packages/node-integration-tests run: yarn test - - name: Parse and Upload Coverage - if: cancelled() == false - continue-on-error: true - uses: getsentry/codecov-action@main - with: - token: ${{ secrets.GITHUB_TOKEN }} - directory: dev-packages/node-integration-tests - name: node-integration-${{ matrix.node }}${{ matrix.typescript && format('-ts{0}', matrix.typescript) || '' }} - job_node_core_integration_tests: name: Node (${{ matrix.node }})${{ (matrix.typescript && format(' (TS {0})', matrix.typescript)) || '' }} Node-Core @@ -787,16 +780,6 @@ jobs: working-directory: dev-packages/node-core-integration-tests run: yarn test - - name: Parse and Upload Coverage - if: cancelled() == false - continue-on-error: true - uses: getsentry/codecov-action@main - with: - token: ${{ secrets.GITHUB_TOKEN }} - directory: dev-packages/node-core-integration-tests - name: - node-core-integration-${{ matrix.node }}${{ matrix.typescript && format('-ts{0}', matrix.typescript) || ''}} - job_cloudflare_integration_tests: name: Cloudflare Integration Tests needs: [job_get_metadata, job_build] @@ -856,15 +839,6 @@ jobs: cd packages/remix yarn test:integration:ci - - name: Parse and Upload Coverage - if: cancelled() == false - continue-on-error: true - uses: getsentry/codecov-action@main - with: - directory: packages/remix - token: ${{ secrets.GITHUB_TOKEN }} - name: ${{ matrix.node }} - job_e2e_prepare: name: Prepare E2E tests # We want to run this if: @@ -1053,15 +1027,6 @@ jobs: retention-days: 7 if-no-files-found: ignore - - name: Parse and Upload Coverage - if: cancelled() == false - continue-on-error: true - uses: getsentry/codecov-action@main - with: - directory: dev-packages/e2e-tests - token: ${{ secrets.GITHUB_TOKEN }} - name: e2e-${{ matrix.test-application }} - # - We skip optional tests on release branches job_optional_e2e_tests: name: E2E ${{ matrix.label || matrix.test-application }} Test (optional) From 3b33442753ee1a2432f2451a7aa4b06901102ffe Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 13 Apr 2026 15:26:31 +0200 Subject: [PATCH 35/73] chore(ci): Bump dorny/paths-filter from v3.0.1 to v4.0.1 (#20251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Upgrade `dorny/paths-filter` from v3.0.1 to v4.0.1 in `ci-metadata` and `flaky-test-detector` workflows - The v4 major bump was solely for the Node.js runtime upgrade from node20 to node24 — no functional breaking changes ## Changelog (v3.0.1 → v4.0.1) - **v3.0.2/v3.0.3**: Added `predicate-quantifier` parameter (not used by us) - **v4.0.0**: Upgraded action runtime from `node20` to `node24` (the only breaking change) - **v4.0.1**: Added `merge_group` event support (not used by us) ## Usage verification Our usage relies on: - `filters` input with glob patterns → unchanged in v4 - `list-files: json` input → unchanged in v4 - `outputs.` (string `'true'`/`'false'`) → unchanged in v4 - `outputs._files` (JSON file list) → unchanged in v4 No configuration changes needed — drop-in replacement. ## Test plan - [ ] CI workflows pass with the updated action - [ ] `ci-metadata` job correctly detects changed packages - [ ] `flaky-test-detector` job correctly detects changed test files 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/ci-metadata.yml | 2 +- .github/workflows/flaky-test-detector.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-metadata.yml b/.github/workflows/ci-metadata.yml index 00271d1fe6b0..c436baa52b93 100644 --- a/.github/workflows/ci-metadata.yml +++ b/.github/workflows/ci-metadata.yml @@ -61,7 +61,7 @@ jobs: # Most changed packages are determined in job_build via Nx - name: Determine changed packages - uses: dorny/paths-filter@v3.0.1 + uses: dorny/paths-filter@v4.0.1 id: changed with: filters: | diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index aa2c33336bd2..c0a8f1f720b1 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -55,7 +55,7 @@ jobs: browsers: 'chromium' - name: Determine changed tests - uses: dorny/paths-filter@v3.0.1 + uses: dorny/paths-filter@v4.0.1 id: changed with: list-files: json From 6b5e16f5450ab61229adc128c1434635e05d7929 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 13 Apr 2026 15:32:11 +0200 Subject: [PATCH 36/73] chore(ci): Remove node-overhead GitHub Action (#20246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Remove the `node-overhead-gh-action` dev-package and its CI job entirely The node overhead check was too flaky to be reliable and was mostly ignored in practice. ### What's removed: - `dev-packages/node-overhead-gh-action/` — the entire action package (16 files) - `job_node_overhead_check` job from `.github/workflows/build.yml` - `changed_node_overhead_action` build output variable - Workspace entry from root `package.json` ## Test plan - [x] No remaining references to `node-overhead` in the codebase - [x] Workflow YAML validates correctly (24 jobs, down from 25) - [ ] CI passes without the removed job 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 34 --- .../node-overhead-gh-action/.oxlintrc.json | 7 - .../node-overhead-gh-action/README.md | 3 - .../node-overhead-gh-action/action.yml | 17 -- .../node-overhead-gh-action/db/init/init.sql | 25 -- .../docker-compose.yml | 12 - .../node-overhead-gh-action/index.mjs | 236 ----------------- .../lib/getArtifactsForBranchAndWorkflow.mjs | 122 --------- .../lib/getOverheadMeasurements.mjs | 250 ------------------ .../lib/markdown-table-formatter.mjs | 112 -------- .../node-overhead-gh-action/package.json | 45 ---- .../node-overhead-gh-action/run-local.mjs | 11 - .../node-overhead-gh-action/src/app.mjs | 58 ---- .../src/instrument-error-only.mjs | 5 - .../src/instrument.mjs | 6 - package.json | 1 - 16 files changed, 944 deletions(-) delete mode 100644 dev-packages/node-overhead-gh-action/.oxlintrc.json delete mode 100644 dev-packages/node-overhead-gh-action/README.md delete mode 100644 dev-packages/node-overhead-gh-action/action.yml delete mode 100644 dev-packages/node-overhead-gh-action/db/init/init.sql delete mode 100644 dev-packages/node-overhead-gh-action/docker-compose.yml delete mode 100644 dev-packages/node-overhead-gh-action/index.mjs delete mode 100644 dev-packages/node-overhead-gh-action/lib/getArtifactsForBranchAndWorkflow.mjs delete mode 100644 dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs delete mode 100644 dev-packages/node-overhead-gh-action/lib/markdown-table-formatter.mjs delete mode 100644 dev-packages/node-overhead-gh-action/package.json delete mode 100644 dev-packages/node-overhead-gh-action/run-local.mjs delete mode 100644 dev-packages/node-overhead-gh-action/src/app.mjs delete mode 100644 dev-packages/node-overhead-gh-action/src/instrument-error-only.mjs delete mode 100644 dev-packages/node-overhead-gh-action/src/instrument.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 38e12b1fa422..1da734901b12 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -152,9 +152,6 @@ jobs: changed_node: ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, '@sentry/node') }} - changed_node_overhead_action: - ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, - '@sentry-internal/node-overhead-gh-action') }} changed_deno: ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, '@sentry/deno') }} @@ -208,37 +205,6 @@ jobs: # Only run comparison against develop if this is a PR comparison_branch: ${{ (github.event_name == 'pull_request' && github.base_ref) || ''}} - job_node_overhead_check: - name: Node Overhead Check - needs: [job_get_metadata, job_build] - timeout-minutes: 15 - runs-on: ubuntu-24.04 - if: - (needs.job_build.outputs.changed_node == 'true' && github.event_name == 'pull_request') || - (needs.job_build.outputs.changed_node_overhead_action == 'true' && github.event_name == 'pull_request') || - needs.job_get_metadata.outputs.is_base_branch == 'true' || needs.job_get_metadata.outputs.is_release == 'true' - steps: - - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v6 - with: - ref: ${{ env.HEAD_COMMIT }} - - name: Set up Node - uses: actions/setup-node@v6 - with: - node-version-file: 'package.json' - - name: Restore caches - uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: Check node overhead - uses: ./dev-packages/node-overhead-gh-action - env: - DEBUG: '1' - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - # Only run comparison against develop if this is a PR - comparison_branch: ${{ (github.event_name == 'pull_request' && github.base_ref) || ''}} - job_lint: name: Lint # Even though the linter only checks source code, not built code, it needs the built code in order check that all diff --git a/dev-packages/node-overhead-gh-action/.oxlintrc.json b/dev-packages/node-overhead-gh-action/.oxlintrc.json deleted file mode 100644 index 5bffa72a1a08..000000000000 --- a/dev-packages/node-overhead-gh-action/.oxlintrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "../../node_modules/oxlint/configuration_schema.json", - "extends": ["../.oxlintrc.json"], - "env": { - "node": true - } -} diff --git a/dev-packages/node-overhead-gh-action/README.md b/dev-packages/node-overhead-gh-action/README.md deleted file mode 100644 index 1759ab7bd7c3..000000000000 --- a/dev-packages/node-overhead-gh-action/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# node-overhead-gh-action - -Capture the overhead of Sentry in a node app. diff --git a/dev-packages/node-overhead-gh-action/action.yml b/dev-packages/node-overhead-gh-action/action.yml deleted file mode 100644 index e90aef2e4342..000000000000 --- a/dev-packages/node-overhead-gh-action/action.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: 'node-overhead-gh-action' -description: 'Run node overhead comparison' -inputs: - github_token: - required: true - description: 'a github access token' - comparison_branch: - required: false - default: '' - description: 'If set, compare the current branch with this branch' - threshold: - required: false - default: '3' - description: 'The percentage threshold for size changes before posting a comment' -runs: - using: 'node24' - main: 'index.mjs' diff --git a/dev-packages/node-overhead-gh-action/db/init/init.sql b/dev-packages/node-overhead-gh-action/db/init/init.sql deleted file mode 100644 index 44071266aab5..000000000000 --- a/dev-packages/node-overhead-gh-action/db/init/init.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE DATABASE mydb; -USE mydb - --- SQL script to create the 'users' table and insert initial data. - --- 1. Create the 'users' table --- This table stores basic user information. --- 'id' is the primary key and will automatically increment for each new record. --- 'name' stores the user's name, up to 255 characters. --- 'age' stores the user's age as an integer. - -CREATE TABLE users ( - id INT PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(255) NOT NULL, - age INT -); - --- 2. Insert 5 rows into the 'users' table --- Populating the table with some sample data. - -INSERT INTO users (name, age) VALUES ('Alice Johnson', 28); -INSERT INTO users (name, age) VALUES ('Bob Smith', 45); -INSERT INTO users (name, age) VALUES ('Charlie Brown', 32); -INSERT INTO users (name, age) VALUES ('Diana Prince', 25); -INSERT INTO users (name, age) VALUES ('Ethan Hunt', 41); diff --git a/dev-packages/node-overhead-gh-action/docker-compose.yml b/dev-packages/node-overhead-gh-action/docker-compose.yml deleted file mode 100644 index a929dd5c5c88..000000000000 --- a/dev-packages/node-overhead-gh-action/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -services: - db: - image: mysql:8 - restart: always - container_name: node-overhead-gh-action-mysql - ports: - - '3306:3306' - environment: - MYSQL_ROOT_PASSWORD: password - volumes: - # - ./db/data:/var/lib/mysql - - ./db/init:/docker-entrypoint-initdb.d/:ro diff --git a/dev-packages/node-overhead-gh-action/index.mjs b/dev-packages/node-overhead-gh-action/index.mjs deleted file mode 100644 index 8c3e2c56873b..000000000000 --- a/dev-packages/node-overhead-gh-action/index.mjs +++ /dev/null @@ -1,236 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { DefaultArtifactClient } from '@actions/artifact'; -import * as core from '@actions/core'; -import { exec } from '@actions/exec'; -import { context, getOctokit } from '@actions/github'; -import * as glob from '@actions/glob'; -import * as io from '@actions/io'; -import { markdownTable } from 'markdown-table'; -import { getArtifactsForBranchAndWorkflow } from './lib/getArtifactsForBranchAndWorkflow.mjs'; -import { getAveragedOverheadMeasurements } from './lib/getOverheadMeasurements.mjs'; -import { formatResults, hasChanges } from './lib/markdown-table-formatter.mjs'; - -const NODE_OVERHEAD_HEADING = '## node-overhead report 🧳'; -const ARTIFACT_NAME = 'node-overhead-action'; -const RESULTS_FILE = 'node-overhead-results.json'; - -function getResultsFilePath() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - return path.resolve(__dirname, RESULTS_FILE); -} - -const { getInput, setFailed } = core; - -async function fetchPreviousComment(octokit, repo, pr) { - const { data: commentList } = await octokit.rest.issues.listComments({ - ...repo, - issue_number: pr.number, - }); - - return commentList.find(comment => comment.body.startsWith(NODE_OVERHEAD_HEADING)); -} - -async function run() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - - try { - const { payload, repo } = context; - const pr = payload.pull_request; - - const comparisonBranch = getInput('comparison_branch'); - const githubToken = getInput('github_token'); - const threshold = getInput('threshold') || 1; - - if (comparisonBranch && !pr) { - throw new Error('No PR found. Only pull_request workflows are supported.'); - } - - const octokit = getOctokit(githubToken); - const resultsFilePath = getResultsFilePath(); - - // If we have no comparison branch, we just run overhead check & store the result as artifact - if (!comparisonBranch) { - return runNodeOverheadOnComparisonBranch(); - } - - // Else, we run overhead check for the current branch, AND fetch it for the comparison branch - let base; - let current; - let baseIsNotLatest = false; - let baseWorkflowRun; - - try { - const workflowName = `${process.env.GITHUB_WORKFLOW || ''}`; - core.startGroup(`getArtifactsForBranchAndWorkflow - workflow:"${workflowName}", branch:"${comparisonBranch}"`); - const artifacts = await getArtifactsForBranchAndWorkflow(octokit, { - ...repo, - artifactName: ARTIFACT_NAME, - branch: comparisonBranch, - workflowName, - }); - core.endGroup(); - - if (!artifacts) { - throw new Error('No artifacts found'); - } - - baseWorkflowRun = artifacts.workflowRun; - - await downloadOtherWorkflowArtifact(octokit, { - ...repo, - artifactName: ARTIFACT_NAME, - artifactId: artifacts.artifact.id, - downloadPath: __dirname, - }); - - base = JSON.parse(await fs.readFile(resultsFilePath, { encoding: 'utf8' })); - - if (!artifacts.isLatest) { - baseIsNotLatest = true; - core.info('Base artifact is not the latest one. This may lead to incorrect results.'); - } - } catch (error) { - core.startGroup('Warning, unable to find base results'); - core.error(error); - core.endGroup(); - } - - core.startGroup('Getting current overhead measurements'); - try { - current = await getAveragedOverheadMeasurements(); - } catch (error) { - core.error('Error getting current overhead measurements'); - core.endGroup(); - throw error; - } - core.debug(`Current overhead measurements: ${JSON.stringify(current, null, 2)}`); - core.endGroup(); - - const thresholdNumber = Number(threshold); - - const nodeOverheadComment = await fetchPreviousComment(octokit, repo, pr); - - if (nodeOverheadComment) { - core.debug('Found existing node overhead comment, updating it instead of creating a new one...'); - } - - const shouldComment = isNaN(thresholdNumber) || hasChanges(base, current, thresholdNumber) || nodeOverheadComment; - - if (shouldComment) { - const bodyParts = [ - NODE_OVERHEAD_HEADING, - 'Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.', - ]; - - if (baseIsNotLatest) { - bodyParts.push( - '⚠️ **Warning:** Base artifact is not the latest one, because the latest workflow run is not done yet. This may lead to incorrect results. Try to re-run all tests to get up to date results.', - ); - } - try { - bodyParts.push(markdownTable(formatResults(base, current))); - } catch (error) { - core.error('Error generating markdown table'); - throw error; - } - - if (baseWorkflowRun) { - bodyParts.push(''); - bodyParts.push(`[View base workflow run](${baseWorkflowRun.html_url})`); - } - - const body = bodyParts.join('\r\n'); - - try { - if (!nodeOverheadComment) { - await octokit.rest.issues.createComment({ - ...repo, - issue_number: pr.number, - body, - }); - } else { - await octokit.rest.issues.updateComment({ - ...repo, - comment_id: nodeOverheadComment.id, - body, - }); - } - } catch { - core.error( - "Error updating comment. This can happen for PR's originating from a fork without write permissions.", - ); - } - } else { - core.debug('Skipping comment because there are no changes.'); - } - } catch (error) { - core.error(error); - setFailed(error.message); - } -} - -async function runNodeOverheadOnComparisonBranch() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const resultsFilePath = getResultsFilePath(); - - const artifactClient = new DefaultArtifactClient(); - - const result = await getAveragedOverheadMeasurements(); - - try { - await fs.writeFile(resultsFilePath, JSON.stringify(result), 'utf8'); - } catch (error) { - core.error('Error parsing node overhead output. The output should be a json.'); - throw error; - } - - const globber = await glob.create(resultsFilePath, { - followSymbolicLinks: false, - }); - const files = await globber.glob(); - - await artifactClient.uploadArtifact(ARTIFACT_NAME, files, __dirname); -} - -run(); - -/** - * Use GitHub API to fetch artifact download url, then - * download and extract artifact to `downloadPath` - */ -async function downloadOtherWorkflowArtifact(octokit, { owner, repo, artifactId, artifactName, downloadPath }) { - const artifact = await octokit.rest.actions.downloadArtifact({ - owner, - repo, - artifact_id: artifactId, - archive_format: 'zip', - }); - - // Make sure output path exists - try { - await io.mkdirP(downloadPath); - } catch { - // ignore errors - } - - const downloadFile = path.resolve(downloadPath, `${artifactName}.zip`); - - await exec('wget', [ - '-nv', - '--retry-connrefused', - '--waitretry=1', - '--read-timeout=20', - '--timeout=15', - '-t', - '0', - '-O', - downloadFile, - artifact.url, - ]); - - await exec('unzip', ['-q', '-d', downloadPath, downloadFile], { - silent: true, - }); -} diff --git a/dev-packages/node-overhead-gh-action/lib/getArtifactsForBranchAndWorkflow.mjs b/dev-packages/node-overhead-gh-action/lib/getArtifactsForBranchAndWorkflow.mjs deleted file mode 100644 index ca7e4e20e9e5..000000000000 --- a/dev-packages/node-overhead-gh-action/lib/getArtifactsForBranchAndWorkflow.mjs +++ /dev/null @@ -1,122 +0,0 @@ -import * as core from '@actions/core'; - -// max pages of workflows to pagination through -const DEFAULT_MAX_PAGES = 50; -// max results per page -const DEFAULT_PAGE_LIMIT = 10; - -/** - * Fetch artifacts from a workflow run from a branch - * - * This is a bit hacky since GitHub Actions currently does not directly - * support downloading artifacts from other workflows - */ -export async function getArtifactsForBranchAndWorkflow(octokit, { owner, repo, workflowName, branch, artifactName }) { - let repositoryWorkflow = null; - - // For debugging - const allWorkflows = []; - - // - // Find workflow id from `workflowName` - // - for await (const response of octokit.paginate.iterator(octokit.rest.actions.listRepoWorkflows, { - owner, - repo, - })) { - const targetWorkflow = response.data.find(({ name }) => name === workflowName); - - allWorkflows.push(...response.data.map(({ name }) => name)); - - // If not found in responses, continue to search on next page - if (!targetWorkflow) { - continue; - } - - repositoryWorkflow = targetWorkflow; - break; - } - - if (!repositoryWorkflow) { - core.info( - `Unable to find workflow with name "${workflowName}" in the repository. Found workflows: ${allWorkflows.join( - ', ', - )}`, - ); - return null; - } - - const workflow_id = repositoryWorkflow.id; - - let currentPage = 0; - let latestWorkflowRun = null; - - for await (const response of octokit.paginate.iterator(octokit.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id, - branch, - per_page: DEFAULT_PAGE_LIMIT, - event: 'push', - })) { - if (!response.data.length) { - core.warning(`Workflow ${workflow_id} not found in branch ${branch}`); - return null; - } - - // Do not allow downloading artifacts from a fork. - const filtered = response.data.filter(workflowRun => workflowRun.head_repository.full_name === `${owner}/${repo}`); - - // Sort to ensure the latest workflow run is the first - filtered.sort((a, b) => { - return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); - }); - - // Store the first workflow run, to determine if this is the latest one... - if (!latestWorkflowRun) { - latestWorkflowRun = filtered[0]; - } - - // Search through workflow artifacts until we find a workflow run w/ artifact name that we are looking for - for (const workflowRun of filtered) { - core.info(`Checking artifacts for workflow run: ${workflowRun.html_url}`); - - const { - data: { artifacts }, - } = await octokit.rest.actions.listWorkflowRunArtifacts({ - owner, - repo, - run_id: workflowRun.id, - }); - - if (!artifacts) { - core.warning( - `Unable to fetch artifacts for branch: ${branch}, workflow: ${workflow_id}, workflowRunId: ${workflowRun.id}`, - ); - } else { - const foundArtifact = artifacts.find(({ name }) => name === artifactName); - if (foundArtifact) { - core.info(`Found suitable artifact: ${foundArtifact.url}`); - return { - artifact: foundArtifact, - workflowRun, - isLatest: latestWorkflowRun.id === workflowRun.id, - }; - } else { - core.info(`No artifact found for ${artifactName}, trying next workflow run...`); - } - } - } - - if (currentPage > DEFAULT_MAX_PAGES) { - core.warning(`Workflow ${workflow_id} not found in branch: ${branch}`); - return null; - } - - currentPage++; - } - - core.warning(`Artifact not found: ${artifactName}`); - core.endGroup(); - return null; -} diff --git a/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs b/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs deleted file mode 100644 index 266b62cd7742..000000000000 --- a/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs +++ /dev/null @@ -1,250 +0,0 @@ -import { execSync, spawn } from 'child_process'; -import { dirname, join } from 'path'; -import treeKill from 'tree-kill'; -import { fileURLToPath } from 'url'; - -const DEBUG = !!process.env.DEBUG; - -const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); - -async function getMeasurements(instrumentFile, autocannonCommand = 'yarn test:get') { - const args = [join(packageRoot, './src/app.mjs')]; - - if (instrumentFile) { - args.unshift('--import', join(packageRoot, instrumentFile)); - } - - const cmd = `node ${args.join(' ')}`; - - log('--------------------------------'); - log(`Getting measurements for "${cmd}"`); - - const killAppProcess = await startAppProcess(cmd); - - log('Example app listening, running autocannon...'); - - try { - const result = await startAutocannonProcess(autocannonCommand); - await killAppProcess(); - return result; - } catch (error) { - //oxlint-disable-next-line restrict-template-expressions - log(`Error running autocannon: ${error}`); - await killAppProcess(); - throw error; - } -} - -async function startAppProcess(cmd) { - const appProcess = spawn(cmd, { shell: true }); - - log('Child process started, waiting for example app...'); - - // Promise to keep track of the app process being closed - let resolveAppClose, rejectAppClose; - const appClosePromise = new Promise((resolve, reject) => { - resolveAppClose = resolve; - rejectAppClose = reject; - }); - - appProcess.on('close', code => { - if (code && code !== 0) { - rejectAppClose(new Error(`App process exited with code ${code}`)); - } else { - resolveAppClose(); - } - }); - - await new Promise((resolve, reject) => { - appProcess.stdout.on('data', data => { - log(`appProcess: ${data}`); - if (`${data}`.includes('Example app listening on port')) { - resolve(); - } - }); - - appProcess.stderr.on('data', data => { - log(`appProcess stderr: ${data}`); - killProcess(appProcess); - reject(data); - }); - }); - - return async () => { - log('Killing app process...'); - appProcess.stdin.end(); - appProcess.stdout.end(); - appProcess.stderr.end(); - - await killProcess(appProcess); - await appClosePromise; - log('App process killed'); - }; -} - -async function startAutocannonProcess(autocannonCommand) { - const autocannon = spawn(autocannonCommand, { - shell: true, - cwd: packageRoot, - }); - - let lastJson = undefined; - autocannon.stdout.on('data', data => { - log(`autocannon: ${data}`); - try { - lastJson = JSON.parse(data); - } catch { - // do nothing - } - }); - - return new Promise((resolve, reject) => { - autocannon.stderr.on('data', data => { - log(`autocannon stderr: ${data}`); - lastJson = undefined; - killProcess(autocannon); - }); - - autocannon.on('close', code => { - log(`autocannon closed with code ${code}`); - log(`Average requests: ${lastJson?.requests.average}`); - - if ((code && code !== 0) || !lastJson?.requests.average) { - reject(new Error(`Autocannon process exited with code ${code}`)); - } else { - resolve(Math.floor(lastJson.requests.average)); - } - }); - }); -} - -function startDb() { - const closeDb = () => { - execSync('yarn db:down', { - shell: true, - cwd: packageRoot, - }); - }; - - // Ensure eventually open DB is closed fist - closeDb(); - - return new Promise((resolve, reject) => { - const child = spawn('yarn db:up', { - shell: true, - cwd: packageRoot, - }); - - const timeout = setTimeout(() => { - closeDb(); - reject(new Error('Timed out waiting for docker-compose')); - }, 60000); - - const readyMatch = 'port: 3306'; - - function newData(data) { - const text = data.toString('utf8'); - log(text); - - if (text.includes(readyMatch)) { - child.stdout.removeAllListeners(); - child.stderr.removeAllListeners(); - clearTimeout(timeout); - resolve(closeDb); - } - } - - child.stdout.on('data', newData); - child.stderr.on('data', newData); - }); -} - -async function getOverheadMeasurements() { - const GET = { - baseline: await getMeasurements(undefined, 'yarn test:get'), - withInstrument: await getMeasurements('./src/instrument.mjs', 'yarn test:get'), - withInstrumentErrorOnly: await getMeasurements('./src/instrument-error-only.mjs', 'yarn test:get'), - }; - - const POST = { - baseline: await getMeasurements(undefined, 'yarn test:post'), - withInstrument: await getMeasurements('./src/instrument.mjs', 'yarn test:post'), - withInstrumentErrorOnly: await getMeasurements('./src/instrument-error-only.mjs', 'yarn test:post'), - }; - - const MYSQL = { - baseline: await getMeasurements(undefined, 'yarn test:mysql'), - withInstrument: await getMeasurements('./src/instrument.mjs', 'yarn test:mysql'), - withInstrumentErrorOnly: await getMeasurements('./src/instrument-error-only.mjs', 'yarn test:mysql'), - }; - - return { - GET, - POST, - MYSQL, - }; -} - -export async function getAveragedOverheadMeasurements() { - const closeDb = await startDb(); - const repeat = process.env.REPEAT ? parseInt(process.env.REPEAT) : 1; - - const results = []; - for (let i = 0; i < repeat; i++) { - const result = await getOverheadMeasurements(); - results.push(result); - } - - closeDb(); - - // Calculate averages for each scenario - const averaged = { - GET: { - baseline: Math.floor(results.reduce((sum, r) => sum + r.GET.baseline, 0) / results.length), - withInstrument: Math.floor(results.reduce((sum, r) => sum + r.GET.withInstrument, 0) / results.length), - withInstrumentErrorOnly: Math.floor( - results.reduce((sum, r) => sum + r.GET.withInstrumentErrorOnly, 0) / results.length, - ), - }, - POST: { - baseline: Math.floor(results.reduce((sum, r) => sum + r.POST.baseline, 0) / results.length), - withInstrument: Math.floor(results.reduce((sum, r) => sum + r.POST.withInstrument, 0) / results.length), - withInstrumentErrorOnly: Math.floor( - results.reduce((sum, r) => sum + r.POST.withInstrumentErrorOnly, 0) / results.length, - ), - }, - MYSQL: { - baseline: Math.floor(results.reduce((sum, r) => sum + r.MYSQL.baseline, 0) / results.length), - withInstrument: Math.floor(results.reduce((sum, r) => sum + r.MYSQL.withInstrument, 0) / results.length), - withInstrumentErrorOnly: Math.floor( - results.reduce((sum, r) => sum + r.MYSQL.withInstrumentErrorOnly, 0) / results.length, - ), - }, - }; - - return averaged; -} - -function log(message) { - if (DEBUG) { - // eslint-disable-next-line no-console - console.log(message); - } -} - -function killProcess(process) { - return new Promise(resolve => { - const pid = process.pid; - - if (!pid) { - log('Process has no PID, fallback killing process...'); - process.kill(); - resolve(); - return; - } - - treeKill(pid, () => { - resolve(); - }); - }); -} diff --git a/dev-packages/node-overhead-gh-action/lib/markdown-table-formatter.mjs b/dev-packages/node-overhead-gh-action/lib/markdown-table-formatter.mjs deleted file mode 100644 index 3119d6ad0edd..000000000000 --- a/dev-packages/node-overhead-gh-action/lib/markdown-table-formatter.mjs +++ /dev/null @@ -1,112 +0,0 @@ -const NODE_OVERHEAD_RESULTS_HEADER = ['Scenario', 'Requests/s', '% of Baseline', 'Prev. Requests/s', 'Change %']; - -const ROUND_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { - style: 'decimal', - minimumFractionDigits: 0, - maximumFractionDigits: 0, -}); - -export function formatResults(baseScenarios, currentScenarios) { - const headers = NODE_OVERHEAD_RESULTS_HEADER; - - const scenarios = getScenarios(baseScenarios, currentScenarios); - const rows = [headers]; - - scenarios.forEach(scenario => { - const base = baseScenarios?.[scenario]; - const current = currentScenarios?.[scenario]; - const baseline = current?.baseline; - - rows.push(formatResult(`${scenario} Baseline`, base?.baseline, current?.baseline)); - rows.push(formatResult(`${scenario} With Sentry`, base?.withInstrument, current?.withInstrument, baseline)); - rows.push( - formatResult( - `${scenario} With Sentry (error only)`, - base?.withInstrumentErrorOnly, - current?.withInstrumentErrorOnly, - baseline, - ), - ); - }); - - return rows; -} -export function hasChanges(baseScenarios, currentScenarios, threshold = 0) { - if (!baseScenarios || !currentScenarios) { - return true; - } - - const names = ['baseline', 'withInstrument', 'withInstrumentErrorOnly']; - const scenarios = getScenarios(baseScenarios, currentScenarios); - - return scenarios.some(scenario => { - const base = baseScenarios?.[scenario]; - const current = currentScenarios?.[scenario]; - - return names.some(name => { - const baseResult = base[name]; - const currentResult = current[name]; - - if (!baseResult || !currentResult) { - return true; - } - - return Math.abs((currentResult - baseResult) / baseResult) * 100 > threshold; - }); - }); -} - -function formatResult(name, base, current, baseline) { - const currentValue = current ? ROUND_NUMBER_FORMATTER.format(current) : '-'; - const baseValue = base ? ROUND_NUMBER_FORMATTER.format(base) : '-'; - - return [ - name, - currentValue, - baseline != null ? formatPercentageDecrease(baseline, current) : '-', - baseValue, - formatPercentageChange(base, current), - ]; -} - -function formatPercentageChange(baseline, value) { - if (!baseline) { - return 'added'; - } - - if (!value) { - return 'removed'; - } - - const percentage = ((value - baseline) / baseline) * 100; - return formatChange(percentage); -} - -function formatPercentageDecrease(baseline, value) { - if (!baseline) { - return 'added'; - } - - if (!value) { - return 'removed'; - } - - const percentage = (value / baseline) * 100; - return `${ROUND_NUMBER_FORMATTER.format(percentage)}%`; -} - -function formatChange(value) { - if (value === 0) { - return '-'; - } - - if (value > 0) { - return `+${ROUND_NUMBER_FORMATTER.format(value)}%`; - } - - return `${ROUND_NUMBER_FORMATTER.format(value)}%`; -} - -function getScenarios(baseScenarios = {}, currentScenarios = {}) { - return Array.from(new Set([...Object.keys(baseScenarios), ...Object.keys(currentScenarios)])); -} diff --git a/dev-packages/node-overhead-gh-action/package.json b/dev-packages/node-overhead-gh-action/package.json deleted file mode 100644 index 54a10b24f543..000000000000 --- a/dev-packages/node-overhead-gh-action/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@sentry-internal/node-overhead-gh-action", - "version": "10.48.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "private": true, - "type": "module", - "main": "index.mjs", - "scripts": { - "dev": "node ./run-local.mjs", - "start": "node ./src/app.mjs", - "start:sentry": "node --import ./src/instrument.mjs ./src/app.mjs", - "start:sentry-error-only": "node --import ./src/instrument-error-only.mjs ./src/app.mjs", - "test:get": "autocannon --json -c 100 -p 10 -d 10 -W [ -c 100 -d 5] http://localhost:3030/test-get", - "test:mysql": "autocannon --json -c 100 -p 10 -d 10 -W [ -c 100 -d 5] http://localhost:3030/test-mysql", - "test:post": "autocannon --json -m POST -b \"{\\\"data\\\":\\\"test\\\"}\" --headers \"Content-type: application/json\" -c 100 -p 10 -d 10 -W [ -c 100 -d 5] http://localhost:3030/test-post", - "clean": "rimraf -g **/node_modules", - "db:up": "docker compose up", - "db:down": "docker compose down --volumes", - "lint": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --type-aware", - "lint:fix": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --fix --type-aware" - }, - "dependencies": { - "@sentry/node": "10.48.0", - "express": "^4.21.2", - "mysql2": "^3.19.1" - }, - "devDependencies": { - "@actions/artifact": "5.0.3", - "@actions/core": "1.10.1", - "@actions/exec": "1.1.1", - "@actions/github": "^5.0.0", - "@actions/glob": "0.6.1", - "@actions/io": "1.1.3", - "autocannon": "^8.0.0", - "eslint-plugin-regexp": "^1.15.0", - "markdown-table": "3.0.3", - "tree-kill": "1.2.2" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/node-overhead-gh-action/run-local.mjs b/dev-packages/node-overhead-gh-action/run-local.mjs deleted file mode 100644 index fd890d559b5b..000000000000 --- a/dev-packages/node-overhead-gh-action/run-local.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import { getAveragedOverheadMeasurements } from './lib/getOverheadMeasurements.mjs'; -import { formatResults } from './lib/markdown-table-formatter.mjs'; - -async function run() { - const measurements = await getAveragedOverheadMeasurements(); - - // eslint-disable-next-line no-console - console.log(formatResults(undefined, measurements)); -} - -run(); diff --git a/dev-packages/node-overhead-gh-action/src/app.mjs b/dev-packages/node-overhead-gh-action/src/app.mjs deleted file mode 100644 index 185340837aeb..000000000000 --- a/dev-packages/node-overhead-gh-action/src/app.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import * as Sentry from '@sentry/node'; -import express from 'express'; -import mysql from 'mysql2/promise'; - -const app = express(); -const port = 3030; - -const pool = mysql.createPool({ - user: 'root', - password: 'password', - host: 'localhost', - database: 'mydb', - port: 3306, - waitForConnections: true, - connectionLimit: 10, - maxIdle: 10, // max idle connections, the default value is the same as `connectionLimit` - idleTimeout: 60000, // idle connections timeout, in milliseconds, the default value 60000 - queueLimit: 0, - enableKeepAlive: true, - keepAliveInitialDelay: 0, -}); - -app.use(express.json()); - -app.get('/test-get', function (req, res) { - res.send({ version: 'v1' }); -}); - -app.post('/test-post', function (req, res) { - const body = req.body; - res.send(generateResponse(body)); -}); - -app.get('/test-mysql', function (_req, res) { - pool.query('SELECT * from users').then(([users]) => { - res.send({ version: 'v1', users }); - }); -}); - -Sentry.setupExpressErrorHandler(app); - -app.listen(port, () => { - // eslint-disable-next-line no-console - console.log(`Example app listening on port ${port}`); -}); - -// This is complicated on purpose to simulate a real-world response -function generateResponse(body) { - const bodyStr = JSON.stringify(body); - const RES_BODY_SIZE = 10000; - - const bodyLen = bodyStr.length; - let resBody = ''; - for (let i = 0; i < RES_BODY_SIZE; i++) { - resBody += `${i}${bodyStr[i % bodyLen]}-`; - } - return { version: 'v1', length: bodyLen, resBody }; -} diff --git a/dev-packages/node-overhead-gh-action/src/instrument-error-only.mjs b/dev-packages/node-overhead-gh-action/src/instrument-error-only.mjs deleted file mode 100644 index 6476a071226a..000000000000 --- a/dev-packages/node-overhead-gh-action/src/instrument-error-only.mjs +++ /dev/null @@ -1,5 +0,0 @@ -import * as Sentry from '@sentry/node'; - -Sentry.init({ - dsn: process.env.E2E_TEST_DSN || 'https://1234567890@sentry.io/1234567890', -}); diff --git a/dev-packages/node-overhead-gh-action/src/instrument.mjs b/dev-packages/node-overhead-gh-action/src/instrument.mjs deleted file mode 100644 index 8a49ebb67a7e..000000000000 --- a/dev-packages/node-overhead-gh-action/src/instrument.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import * as Sentry from '@sentry/node'; - -Sentry.init({ - dsn: process.env.E2E_TEST_DSN || 'https://1234567890@sentry.io/1234567890', - tracesSampleRate: 1, -}); diff --git a/package.json b/package.json index d05b71e7fdbc..f07218aaf7a3 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,6 @@ "dev-packages/clear-cache-gh-action", "dev-packages/external-contributor-gh-action", "dev-packages/rollup-utils", - "dev-packages/node-overhead-gh-action", "dev-packages/bundler-tests" ], "devDependencies": { From 7a9837c8a1cc678d5eba51466e71fd8434169249 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 13 Apr 2026 16:01:35 +0200 Subject: [PATCH 37/73] chore: Fix lint warnings (#20250) There were a few lint warnings that were easily resolved. --- .../suites/tracing/propagation/worker-do/index.ts | 2 +- .../propagation/worker-service-binding/index-sub-worker.ts | 2 +- .../suites/tracing/propagation/workflow-do/index.ts | 2 +- packages/core/src/fetch.ts | 5 ++--- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts index 22551809d210..6ec278fc9ace 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts @@ -8,7 +8,7 @@ interface Env { } class MyDurableObjectBase extends DurableObject { - async fetch(request: Request) { + async fetch(_request: Request) { return new Response('DO is fine'); } } diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts index 06c79931b880..95de55198929 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts @@ -5,7 +5,7 @@ interface Env { } const myWorker = { - async fetch(request: Request) { + async fetch(_request: Request) { return new Response('Hello from another worker!'); }, }; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts index 06c846afc378..0ffd77866e1f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts @@ -9,7 +9,7 @@ interface Env { } class MyDurableObjectBase extends DurableObject { - async fetch(request: Request) { + async fetch(_request: Request) { return new Response('DO is fine'); } } diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 0de9685528c3..21a89b15c825 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -287,11 +287,10 @@ export function _INTERNAL_getTracingHeadersForFetchRequest( 'sentry-trace': string; baggage: string | undefined; traceparent?: string; - } = { - ...originalHeaders, + } = Object.assign({}, originalHeaders, { 'sentry-trace': (existingSentryTraceHeader as string | undefined) ?? sentryTrace, baggage: newBaggageHeaders.length > 0 ? newBaggageHeaders.join(',') : undefined, - }; + }); if (propagateTraceparent && traceparent && !existingTraceparentHeader) { newHeaders.traceparent = traceparent; From d60b826e89e136b4bee5f9aeaf78f5b60a1a43c6 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 10 Apr 2026 08:31:14 -0700 Subject: [PATCH 38/73] fix(core, node): support loading Express options lazily (#20211) Update the Express integration to accept the module export and a configuration function, rather than a configuration object. This is needed to support lazily calling Sentry.init *after* the module has been instrumented, without re-wrapping the methods to get the new config. via: @mydea in #20188 --- .../suites/express/late-init/instrument.mjs | 19 +++++ .../suites/express/late-init/scenario.mjs | 18 +++++ .../suites/express/late-init/test.ts | 49 ++++++++++++ .../core/src/integrations/express/index.ts | 60 ++++++++++++-- .../src/integrations/express/patch-layer.ts | 8 +- .../core/src/integrations/express/types.ts | 7 +- .../lib/integrations/express/index.test.ts | 80 +++++++++++-------- .../integrations/express/patch-layer.test.ts | 30 +++---- .../node/src/integrations/tracing/express.ts | 10 +-- 9 files changed, 219 insertions(+), 62 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/express/late-init/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/express/late-init/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/express/late-init/test.ts diff --git a/dev-packages/node-integration-tests/suites/express/late-init/instrument.mjs b/dev-packages/node-integration-tests/suites/express/late-init/instrument.mjs new file mode 100644 index 000000000000..eea577d83ebc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/late-init/instrument.mjs @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// First: preload the express instrumentation without calling Sentry.init(). +// registers OTel module hook, patches the Express module with no config. +Sentry.preloadOpenTelemetry({ integrations: ['Express'] }); + +// call Sentry.init() with express integration config. +// instrumentExpress is already registered, so this calls setConfig() on the +// existing instrumentation to update its options. The lazy getOptions() +// in patchLayer ensures the updated options are read at request time. +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + // suppress the middleware layer that the cors module generates + integrations: [Sentry.expressIntegration({ ignoreLayersType: ['middleware'] })], +}); diff --git a/dev-packages/node-integration-tests/suites/express/late-init/scenario.mjs b/dev-packages/node-integration-tests/suites/express/late-init/scenario.mjs new file mode 100644 index 000000000000..faea295143ef --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/late-init/scenario.mjs @@ -0,0 +1,18 @@ +import cors from 'cors'; +import express from 'express'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +// cors() would normally create a 'middleware' type span, but the +// ignoreLayersType: ['middleware'] option set via Sentry.init() suppresses it. +app.use(cors()); + +app.get('/test/express', (_req, res) => { + res.send({ response: 'response 1' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/late-init/test.ts b/dev-packages/node-integration-tests/suites/express/late-init/test.ts new file mode 100644 index 000000000000..d7aeba94fd2b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/late-init/test.ts @@ -0,0 +1,49 @@ +import { afterAll, describe, expect } from 'vitest'; +import { assertSentryTransaction } from '../../../utils/assertions'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('express late init', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('applies expressIntegration config set via Sentry.init() called after instrumentExpress()', async () => { + const runner = createRunner() + .expect({ + transaction: transaction => { + assertSentryTransaction(transaction, { + transaction: 'GET /test/express', + contexts: { + trace: { + op: 'http.server', + status: 'ok', + }, + }, + }); + // request_handler span IS present + // confirms the express patch was applied. + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'express.type': 'request_handler', + }), + }), + ); + // Middleware spans NOT present, ignoreLayersType: ['middleware'] + // configured via the Sentry.init() AFTER instrumentExpress(). + expect(transaction.spans).not.toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'express.type': 'middleware', + }), + }), + ); + }, + }) + .start(); + runner.makeRequest('get', '/test/express'); + await runner.completed(); + }); + }); +}); diff --git a/packages/core/src/integrations/express/index.ts b/packages/core/src/integrations/express/index.ts index 4b2d5e2f0677..bbb1f8fe8a28 100644 --- a/packages/core/src/integrations/express/index.ts +++ b/packages/core/src/integrations/express/index.ts @@ -60,6 +60,24 @@ import { setSDKProcessingMetadata } from './set-sdk-processing-metadata'; const getExpressExport = (express: ExpressModuleExport): ExpressExport => hasDefaultProp(express) ? express.default : (express as ExpressExport); +function isLegacyOptions( + options: ExpressModuleExport | (ExpressIntegrationOptions & { express: ExpressModuleExport }), +): options is ExpressIntegrationOptions & { express: ExpressModuleExport } { + return !!(options as { express: ExpressModuleExport }).express; +} + +// TODO: remove this deprecation handling in v11 +let didLegacyDeprecationWarning = false; +function deprecationWarning() { + if (!didLegacyDeprecationWarning) { + didLegacyDeprecationWarning = true; + DEBUG_BUILD && + debug.warn( + '[Express] `patchExpressModule(options)` is deprecated. Use `patchExpressModule(moduleExports, getOptions)` instead.', + ); + } +} + /** * This is a portable instrumentatiton function that works in any environment * where Express can be loaded, without depending on OpenTelemetry. @@ -69,11 +87,39 @@ const getExpressExport = (express: ExpressModuleExport): ExpressExport => * import express from 'express'; * import * as Sentry from '@sentry/deno'; // or any SDK that extends core * - * Sentry.patchExpressModule({ express }) + * Sentry.patchExpressModule(express, () => ({})); + * ``` */ -export const patchExpressModule = (options: ExpressIntegrationOptions) => { +export function patchExpressModule( + moduleExports: ExpressModuleExport, + getOptions: () => ExpressIntegrationOptions, +): ExpressModuleExport; +/** + * @deprecated Pass the Express module export as the first argument and options getter as the second argument. + */ +export function patchExpressModule( + options: ExpressIntegrationOptions & { express: ExpressModuleExport }, +): ExpressModuleExport; +export function patchExpressModule( + optionsOrExports: ExpressModuleExport | (ExpressIntegrationOptions & { express: ExpressModuleExport }), + maybeGetOptions?: () => ExpressIntegrationOptions, +): ExpressModuleExport { + let getOptions: () => ExpressIntegrationOptions; + let moduleExports: ExpressModuleExport; + if (!maybeGetOptions && isLegacyOptions(optionsOrExports)) { + const { express, ...options } = optionsOrExports; + moduleExports = express; + getOptions = () => options; + deprecationWarning(); + } else if (typeof maybeGetOptions !== 'function') { + throw new TypeError('`patchExpressModule(moduleExports, getOptions)` requires a `getOptions` callback'); + } else { + getOptions = maybeGetOptions; + moduleExports = optionsOrExports as ExpressModuleExport; + } + // pass in the require() or import() result of express - const express = getExpressExport(options.express); + const express = getExpressExport(moduleExports); const routerProto: ExpressRouterv4 | ExpressRouterv5 | undefined = isExpressWithRouterPrototype(express) ? express.Router.prototype // Express v5 : isExpressWithoutRouterPrototype(express) @@ -93,7 +139,7 @@ export const patchExpressModule = (options: ExpressIntegrationOptions) => { function routeTrace(this: ExpressRouter, ...args: Parameters[]) { const route = originalRouteMethod.apply(this, args); const layer = this.stack[this.stack.length - 1] as ExpressLayer; - patchLayer(options, layer, getLayerPath(args)); + patchLayer(getOptions, layer, getLayerPath(args)); return route; }, ); @@ -113,7 +159,7 @@ export const patchExpressModule = (options: ExpressIntegrationOptions) => { if (!layer) { return route; } - patchLayer(options, layer, getLayerPath(args)); + patchLayer(getOptions, layer, getLayerPath(args)); return route; }, ); @@ -141,7 +187,7 @@ export const patchExpressModule = (options: ExpressIntegrationOptions) => { if (router) { const layer = router.stack[router.stack.length - 1]; if (layer) { - patchLayer(options, layer, getLayerPath(args)); + patchLayer(getOptions, layer, getLayerPath(args)); } } return route; @@ -152,7 +198,7 @@ export const patchExpressModule = (options: ExpressIntegrationOptions) => { } return express; -}; +} /** * An Express-compatible error handler, used by setupExpressErrorHandler diff --git a/packages/core/src/integrations/express/patch-layer.ts b/packages/core/src/integrations/express/patch-layer.ts index 5f537e403524..3114cbafb320 100644 --- a/packages/core/src/integrations/express/patch-layer.ts +++ b/packages/core/src/integrations/express/patch-layer.ts @@ -61,7 +61,11 @@ export type ExpressPatchLayerOptions = Pick< 'onRouteResolved' | 'ignoreLayers' | 'ignoreLayersType' >; -export function patchLayer(options: ExpressPatchLayerOptions, maybeLayer?: ExpressLayer, layerPath?: string): void { +export function patchLayer( + getOptions: () => ExpressPatchLayerOptions, + maybeLayer?: ExpressLayer, + layerPath?: string, +): void { if (!maybeLayer?.handle) { return; } @@ -86,6 +90,8 @@ export function patchLayer(options: ExpressPatchLayerOptions, maybeLayer?: Expre //oxlint-disable-next-line no-explicit-any ...otherArgs: any[] ) { + const options = getOptions(); + // Set normalizedRequest here because expressRequestHandler middleware // (registered via setupExpressErrorHandler) is added after routes and // therefore never runs for successful requests — route handlers typically diff --git a/packages/core/src/integrations/express/types.ts b/packages/core/src/integrations/express/types.ts index 66d6f1de3c9e..f2ed5eed6013 100644 --- a/packages/core/src/integrations/express/types.ts +++ b/packages/core/src/integrations/express/types.ts @@ -136,7 +136,12 @@ export type ExpressRouter = { export type IgnoreMatcher = string | RegExp | ((name: string) => boolean); export type ExpressIntegrationOptions = { - express: ExpressModuleExport; //Express + /** + * @deprecated Pass the express module as the first argument, and an + * options getter as the second argument to patchExpressModule. + */ + express?: ExpressModuleExport; + /** Ignore specific based on their name */ ignoreLayers?: IgnoreMatcher[]; /** Ignore specific layers based on their type */ diff --git a/packages/core/test/lib/integrations/express/index.test.ts b/packages/core/test/lib/integrations/express/index.test.ts index 55fe442efb22..7b6ce83120a7 100644 --- a/packages/core/test/lib/integrations/express/index.test.ts +++ b/packages/core/test/lib/integrations/express/index.test.ts @@ -52,8 +52,10 @@ vi.mock('../../../../src/debug-build', () => ({ DEBUG_BUILD: true, })); const debugErrors: [string, Error][] = []; +const debugWarnings: string[] = []; vi.mock('../../../../src/utils/debug-logger', () => ({ debug: { + warn: (msg: string) => debugWarnings.push(msg), error: (msg: string, er: Error) => { debugErrors.push([msg, er]); }, @@ -61,12 +63,12 @@ vi.mock('../../../../src/utils/debug-logger', () => ({ })); beforeEach(() => (patchLayerCalls.length = 0)); -const patchLayerCalls: [options: ExpressIntegrationOptions, layer: ExpressLayer, layerPath?: string][] = []; +const patchLayerCalls: [getOptions: () => ExpressIntegrationOptions, layer: ExpressLayer, layerPath?: string][] = []; vi.mock('../../../../src/integrations/express/patch-layer', () => ({ - patchLayer: (options: ExpressIntegrationOptions, layer?: ExpressLayer, layerPath?: string) => { + patchLayer: (getOptions: () => ExpressIntegrationOptions, layer?: ExpressLayer, layerPath?: string) => { if (layer) { - patchLayerCalls.push([options, layer, layerPath]); + patchLayerCalls.push([getOptions, layer, layerPath]); } }, })); @@ -129,26 +131,34 @@ function getExpress5(): ExpressExportv5 & { spies: ExpressSpies } { } describe('patchExpressModule', () => { - it('throws trying to patch/unpatch the wrong thing', () => { + it('throws trying to patch the wrong thing', () => { expect(() => { - patchExpressModule({ - express: {} as unknown as ExpressModuleExport, - } as unknown as ExpressIntegrationOptions); + patchExpressModule({} as unknown as ExpressModuleExport, () => ({})); }).toThrowError('no valid Express route function to instrument'); }); - it('can patch and restore expressv4 style module', () => { + it('throws trying to patch without a getOptions getter', () => { + const express = getExpress4(); + expect(() => { + //@ts-expect-error The type error prevents this, by design + patchExpressModule(express); + }).toThrowError('`patchExpressModule(moduleExports, getOptions)` requires a `getOptions` callback'); + }); + + it('can patch expressv4 style module', () => { for (const useDefault of [false, true]) { const express = getExpress4(); - const module = useDefault ? { default: express } : express; + const moduleExports = useDefault ? { default: express } : express; const r = express.Router as ExpressRouterv4; const a = express.application; - const options = { express: module } as unknown as ExpressIntegrationOptions; expect((r.use as WrappedFunction).__sentry_original__).toBe(undefined); expect((r.route as WrappedFunction).__sentry_original__).toBe(undefined); expect((a.use as WrappedFunction).__sentry_original__).toBe(undefined); - patchExpressModule(options); + patchExpressModule({ express: moduleExports }); + expect(debugWarnings).toStrictEqual([ + '[Express] `patchExpressModule(options)` is deprecated. Use `patchExpressModule(moduleExports, getOptions)` instead.', + ]); expect(typeof (r.use as WrappedFunction).__sentry_original__).toBe('function'); expect(typeof (r.route as WrappedFunction).__sentry_original__).toBe('function'); @@ -156,18 +166,23 @@ describe('patchExpressModule', () => { } }); - it('can patch and restore expressv5 style module', () => { + it('can patch expressv5 style module', () => { for (const useDefault of [false, true]) { const express = getExpress5(); const r = express.Router as ExpressRouterv5; const a = express.application; - const module = useDefault ? { default: express } : express; - const options = { express: module } as unknown as ExpressIntegrationOptions; + const moduleExports = useDefault ? { default: express } : express; expect((r.prototype.use as WrappedFunction).__sentry_original__).toBe(undefined); expect((r.prototype.route as WrappedFunction).__sentry_original__).toBe(undefined); expect((a.use as WrappedFunction).__sentry_original__).toBe(undefined); - patchExpressModule(options); + // verify that the debug warning doesn't fire a second time + // vitest doesn't guarantee test ordering, so just verify + // in both places that there's only one warning. + patchExpressModule({ express: moduleExports }); + expect(debugWarnings).toStrictEqual([ + '[Express] `patchExpressModule(options)` is deprecated. Use `patchExpressModule(moduleExports, getOptions)` instead.', + ]); expect(typeof (r.prototype.use as WrappedFunction).__sentry_original__).toBe('function'); expect(typeof (r.prototype.route as WrappedFunction).__sentry_original__).toBe('function'); @@ -178,8 +193,8 @@ describe('patchExpressModule', () => { it('calls patched and original Router.route', () => { const expressv4 = getExpress4(); const { spies } = expressv4; - const options = { express: expressv4 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv4, getOptions); expressv4.Router.route('a'); expect(spies.routerRoute).toHaveBeenCalledExactlyOnceWith('a'); }); @@ -187,18 +202,18 @@ describe('patchExpressModule', () => { it('calls patched and original Router.use', () => { const expressv4 = getExpress4(); const { spies } = expressv4; - const options = { express: expressv4 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv4, getOptions); expressv4.Router.use('a'); - expect(patchLayerCalls).toStrictEqual([[options, { name: 'layerFinal' }, 'a']]); + expect(patchLayerCalls).toStrictEqual([[getOptions, { name: 'layerFinal' }, 'a']]); expect(spies.routerUse).toHaveBeenCalledExactlyOnceWith('a'); }); it('skips patchLayer call in Router.use if no layer in the stack', () => { const expressv4 = getExpress4(); const { spies } = expressv4; - const options = { express: expressv4 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv4, getOptions); const { stack } = expressv4.Router; expressv4.Router.stack = []; expressv4.Router.use('a'); @@ -210,28 +225,28 @@ describe('patchExpressModule', () => { it('calls patched and original application.use', () => { const expressv4 = getExpress4(); const { spies } = expressv4; - const options = { express: expressv4 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv4, getOptions); expressv4.application.use('a'); - expect(patchLayerCalls).toStrictEqual([[options, { name: 'layerFinal' }, 'a']]); + expect(patchLayerCalls).toStrictEqual([[getOptions, { name: 'layerFinal' }, 'a']]); expect(spies.appUse).toHaveBeenCalledExactlyOnceWith('a'); }); it('calls patched and original application.use on express v5', () => { const expressv5 = getExpress5(); const { spies } = expressv5; - const options = { express: expressv5 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv5, getOptions); expressv5.application.use('a'); - expect(patchLayerCalls).toStrictEqual([[options, { name: 'layerFinal' }, 'a']]); + expect(patchLayerCalls).toStrictEqual([[getOptions, { name: 'layerFinal' }, 'a']]); expect(spies.appUse).toHaveBeenCalledExactlyOnceWith('a'); }); it('skips patchLayer on application.use if no router found', () => { const expressv4 = getExpress4(); const { spies } = expressv4; - const options = { express: expressv4 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv4, getOptions); const app = expressv4.application as { _router?: ExpressRoute; }; @@ -246,8 +261,9 @@ describe('patchExpressModule', () => { it('debug error when patching fails', () => { const expressv5 = getExpress5(); - patchExpressModule({ express: expressv5 }); - patchExpressModule({ express: expressv5 }); + const getOptions = () => ({}); + patchExpressModule(expressv5, getOptions); + patchExpressModule(expressv5, getOptions); expect(debugErrors).toStrictEqual([ ['Failed to patch express route method:', new Error('Attempting to wrap method route multiple times')], ['Failed to patch express use method:', new Error('Attempting to wrap method use multiple times')], diff --git a/packages/core/test/lib/integrations/express/patch-layer.test.ts b/packages/core/test/lib/integrations/express/patch-layer.test.ts index 254ffb79edde..8953955ee373 100644 --- a/packages/core/test/lib/integrations/express/patch-layer.test.ts +++ b/packages/core/test/lib/integrations/express/patch-layer.test.ts @@ -150,12 +150,12 @@ describe('patchLayer', () => { describe('no-ops', () => { it('if layer is missing', () => { // mostly for coverage, verifying it doesn't throw or anything - patchLayer({}); + patchLayer(() => ({})); }); it('if layer.handle is missing', () => { // mostly for coverage, verifying it doesn't throw or anything - patchLayer({}, { handle: null } as unknown as ExpressLayer); + patchLayer(() => ({}), { handle: null } as unknown as ExpressLayer); }); it('if layer already patched', () => { @@ -166,7 +166,7 @@ describe('patchLayer', () => { const layer = { handle: wrapped, } as unknown as ExpressLayer; - patchLayer({}, layer); + patchLayer(() => ({}), layer); expect(layer.handle).toBe(wrapped); }); @@ -177,7 +177,7 @@ describe('patchLayer', () => { const layer = { handle: original, } as unknown as ExpressLayer; - patchLayer({}, layer); + patchLayer(() => ({}), layer); expect(layer.handle).toBe(original); }); @@ -188,7 +188,7 @@ describe('patchLayer', () => { const layer = { handle: original, } as unknown as ExpressLayer; - patchLayer({}, layer); + patchLayer(() => ({}), layer); expect(getOriginalFunction(layer.handle)).toBe(original); }); }); @@ -212,7 +212,7 @@ describe('patchLayer', () => { storeLayer(req, '/:boo'); storeLayer(req, '/:car'); - patchLayer(options, layer); + patchLayer(() => options, layer); layer.handle(req, res); expect(layerHandleOriginal).toHaveBeenCalledOnce(); @@ -244,7 +244,7 @@ describe('patchLayer', () => { storeLayer(req, '/:boo'); storeLayer(req, '/:car'); - patchLayer(options, layer, '/layerPath'); + patchLayer(() => options, layer, '/layerPath'); layer.handle(req, res); expect(onRouteResolved).toHaveBeenCalledExactlyOnceWith('/a/:boo/:car/layerPath'); expect(layerHandleOriginal).toHaveBeenCalledOnce(); @@ -290,7 +290,7 @@ describe('patchLayer', () => { // 'router' → router, 'bound dispatch' → request_handler, other → middleware const layerName = type === 'router' ? 'router' : 'bound dispatch'; const layer = { name: layerName, handle: layerHandleOriginal } as unknown as ExpressLayer; - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); // storeLayer('/c') happens inside the patched handle, before being popped // after handle returns, storedLayers should be back to ['/a', '/b'] @@ -327,7 +327,7 @@ describe('patchLayer', () => { storeLayer(req, '/:boo'); storeLayer(req, '/:car'); - patchLayer(options, layer, '/layerPath'); + patchLayer(() => options, layer, '/layerPath'); expect(getOriginalFunction(layer.handle)).toBe(layerHandleOriginal); expect(layer.handle.x).toBe(true); layer.handle.x = false; @@ -382,7 +382,7 @@ describe('patchLayer', () => { storeLayer(req, '/:boo'); storeLayer(req, '/:car'); - patchLayer(options, layer); + patchLayer(() => options, layer); expect(getOriginalFunction(layer.handle)).toBe(layerHandleOriginal); warnings.length = 0; layer.handle(req, res); @@ -441,7 +441,7 @@ describe('patchLayer', () => { storeLayer(req, '/a'); storeLayer(req, '/b'); - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); layer.handle(req, res); expect(onRouteResolved).toHaveBeenCalledExactlyOnceWith('/a/b/c'); const span = mockSpans[0]; @@ -482,7 +482,7 @@ describe('patchLayer', () => { storeLayer(req, '/a'); storeLayer(req, '/b'); - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); layer.handle(req, res); expect(onRouteResolved).toHaveBeenCalledExactlyOnceWith(undefined); const span = mockSpans[0]; @@ -526,7 +526,7 @@ describe('patchLayer', () => { storeLayer(req, '/a'); storeLayer(req, '/b'); - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); expect(getStoredLayers(req)).toStrictEqual(['/a', '/b']); const callback = vi.fn(() => { @@ -576,7 +576,7 @@ describe('patchLayer', () => { storeLayer(req, '/a'); storeLayer(req, '/b'); - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); expect(getStoredLayers(req)).toStrictEqual(['/a', '/b']); const callback = vi.fn(() => { @@ -622,7 +622,7 @@ describe('patchLayer', () => { storeLayer(req, '/a'); storeLayer(req, '/b'); - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); expect(() => { layer.handle(req, res); }).toThrowError('yur head asplode'); diff --git a/packages/node/src/integrations/tracing/express.ts b/packages/node/src/integrations/tracing/express.ts index eb396b81a6ee..c0f7cbc2414f 100644 --- a/packages/node/src/integrations/tracing/express.ts +++ b/packages/node/src/integrations/tracing/express.ts @@ -48,16 +48,15 @@ export class ExpressInstrumentation extends InstrumentationBase { try { - patchExpressModule({ + patchExpressModule(express, () => ({ ...this.getConfig(), - express, onRouteResolved(route) { const rpcMetadata = getRPCMetadata(context.active()); if (route && rpcMetadata?.type === RPCType.HTTP) { rpcMetadata.route = route; } }, - }); + })); } catch (e) { DEBUG_BUILD && debug.error('Failed to patch express module:', e); } @@ -69,8 +68,7 @@ export class ExpressInstrumentation extends InstrumentationBase { +const _expressIntegration = ((options?: ExpressInstrumentationConfig) => { return { name: INTEGRATION_NAME, setupOnce() { @@ -79,4 +77,4 @@ const _expressInstrumentation = ((options?: ExpressInstrumentationConfig) => { }; }) satisfies IntegrationFn; -export const expressIntegration = defineIntegration(_expressInstrumentation); +export const expressIntegration = defineIntegration(_expressIntegration); From 68e78ab915b3398d7c7f3abe50794241c5bd4289 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 13 Apr 2026 16:31:08 +0100 Subject: [PATCH 39/73] feat(node-native): Add support for V8 v14 (Node v25+) (#20125) - Ref https://github.com/getsentry/sentry-electron/issues/1250 This PR updates to the latest release of `@sentry-internal/node-native-stacktrace` which adds supports for V8 v14+ which was added in this PR: - https://github.com/getsentry/sentry-javascript-node-native-stacktrace/pull/32 ie. - Node v25+ - Electron v38+ --- packages/node-native/package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/node-native/package.json b/packages/node-native/package.json index c2d6c55c72c6..f9ea472565b3 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -62,7 +62,7 @@ "build:tarball": "npm pack" }, "dependencies": { - "@sentry-internal/node-native-stacktrace": "^0.3.0", + "@sentry-internal/node-native-stacktrace": "^0.4.0", "@sentry/core": "10.48.0", "@sentry/node": "10.48.0" }, diff --git a/yarn.lock b/yarn.lock index e70d6caa8b6d..95937c3d01b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7742,13 +7742,13 @@ detect-libc "^2.0.3" node-abi "^3.73.0" -"@sentry-internal/node-native-stacktrace@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.3.0.tgz#68c80dcf11ee070a3a54406b35d4571952caa793" - integrity sha512-ef0M2y2JDrC/H0AxMJJQInGTdZTlnwa6AAVWR4fMOpJRubkfdH2IZXE/nWU0Nj74oeJLQgdPtS6DeijLJtqq8Q== +"@sentry-internal/node-native-stacktrace@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.4.0.tgz#8f6e7a21537373a5623714c14d3350e1bb4602f0" + integrity sha512-cuRBBqnsHOJJqLCii9GvwedzjetsihIarq7TxCjgG88JyF8TZWRMlUBu/OogWhYZVU8uHqAeSvpbzolnmdhdkw== dependencies: detect-libc "^2.0.4" - node-abi "^3.73.0" + node-abi "^3.89.0" "@sentry-internal/rrdom@2.34.0": version "2.34.0" @@ -22993,10 +22993,10 @@ nock@^13.5.5: json-stringify-safe "^5.0.1" propagate "^2.0.0" -node-abi@^3.3.0, node-abi@^3.73.0: - version "3.75.0" - resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.75.0.tgz#2f929a91a90a0d02b325c43731314802357ed764" - integrity sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg== +node-abi@^3.3.0, node-abi@^3.73.0, node-abi@^3.89.0: + version "3.89.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.89.0.tgz#eea98bf89d4534743bbbf2defa9f4f9bd3bdccfd" + integrity sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA== dependencies: semver "^7.3.5" From 134a66abc501761c8fe8e6d19ad3dd1283a29904 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 13 Apr 2026 16:31:26 +0100 Subject: [PATCH 40/73] feat(node): Include global scope for `eventLoopBlockIntegration` (#20108) - Closes https://github.com/getsentry/sentry-electron/issues/1320 Global scope is not captured by the native module through the `AsyncLocalStorage` so this PR sends that via the polling mechanism. --- .../suites/thread-blocked-native/isolated.mjs | 2 ++ .../suites/thread-blocked-native/test.ts | 2 +- packages/node-native/src/common.ts | 3 ++- .../src/event-loop-block-integration.ts | 22 +++++++++++++++++-- .../src/event-loop-block-watchdog.ts | 15 +++++++------ 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs index c2c0f39fc44e..992a07c083da 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs @@ -24,6 +24,8 @@ const fns = [ neverResolve, ]; +Sentry.getGlobalScope().setUser({ email: 'something@gmail.com' }); + setTimeout(() => { for (let id = 0; id < 10; id++) { Sentry.withIsolationScope(async () => { diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts index 75f957f07af5..8dd49d126b67 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts @@ -249,7 +249,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { message: 'Starting task 5', }, ], - user: { id: 5 }, + user: { id: 5, email: 'something@gmail.com' }, threads: { values: [ { diff --git a/packages/node-native/src/common.ts b/packages/node-native/src/common.ts index 2a96050dbc34..ba61aee7343c 100644 --- a/packages/node-native/src/common.ts +++ b/packages/node-native/src/common.ts @@ -1,4 +1,4 @@ -import type { Contexts, DsnComponents, Primitive, SdkMetadata, Session } from '@sentry/core'; +import type { Contexts, DsnComponents, Primitive, ScopeData, SdkMetadata, Session } from '@sentry/core'; export const POLL_RATIO = 2; @@ -40,5 +40,6 @@ export interface WorkerStartData extends ThreadBlockedIntegrationOptions { export interface ThreadState { session: Session | undefined; + scope: ScopeData; debugImages: Record; } diff --git a/packages/node-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts index 7b5c4bc43430..1262eb5fb41d 100644 --- a/packages/node-native/src/event-loop-block-integration.ts +++ b/packages/node-native/src/event-loop-block-integration.ts @@ -8,8 +8,18 @@ import type { EventHint, Integration, IntegrationFn, + ScopeData, +} from '@sentry/core'; +import { + debug, + defineIntegration, + getClient, + getCurrentScope, + getFilenameToDebugIdMap, + getGlobalScope, + getIsolationScope, + mergeScopeData, } from '@sentry/core'; -import { debug, defineIntegration, getClient, getFilenameToDebugIdMap, getIsolationScope } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; import { registerThread, threadPoll } from '@sentry-internal/node-native-stacktrace'; import type { ThreadBlockedIntegrationOptions, WorkerStartData } from './common'; @@ -37,6 +47,13 @@ async function getContexts(client: NodeClient): Promise { return event?.contexts || {}; } +function getLocalScopeData(): ScopeData { + const globalScope = getGlobalScope().getScopeData(); + const currentScope = getCurrentScope().getScopeData(); + mergeScopeData(globalScope, currentScope); + return globalScope; +} + type IntegrationInternal = { start: () => void; stop: () => void }; function poll(enabled: boolean, clientOptions: ClientOptions): void { @@ -45,8 +62,9 @@ function poll(enabled: boolean, clientOptions: ClientOptions): void { // We need to copy the session object and remove the toJSON method so it can be sent to the worker // serialized without making it a SerializedSession const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; + const scope = getLocalScopeData(); // message the worker to tell it the main event loop is still running - threadPoll(enabled, { session, debugImages: getFilenameToDebugIdMap(clientOptions.stackParser) }); + threadPoll(enabled, { session, scope, debugImages: getFilenameToDebugIdMap(clientOptions.stackParser) }); } catch { // we ignore all errors } diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts index a4eb696c7a95..cac20909d6a5 100644 --- a/packages/node-native/src/event-loop-block-watchdog.ts +++ b/packages/node-native/src/event-loop-block-watchdog.ts @@ -21,7 +21,6 @@ import type { ThreadState, WorkerStartData } from './common'; import { POLL_RATIO } from './common'; type CurrentScopes = { - scope: Scope; isolationScope: Scope; }; @@ -275,14 +274,16 @@ async function sendBlockEvent(crashedThreadId: string): Promise { ...getExceptionAndThreads(crashedThreadId, threads), }; - const asyncState = threads[crashedThreadId]?.asyncState; - if (asyncState) { - // We need to rehydrate the scopes from the serialized objects so we can call getScopeData() - const scope = Object.assign(new Scope(), asyncState.scope).getScopeData(); - const isolationScope = Object.assign(new Scope(), asyncState.isolationScope).getScopeData(); + const scope = crashedThread.pollState?.scope + ? new Scope().update(crashedThread.pollState.scope).getScopeData() + : new Scope().getScopeData(); + + if (crashedThread?.asyncState?.isolationScope) { + // We need to rehydrate the scope from the serialized object with properties beginning with _user, etc + const isolationScope = Object.assign(new Scope(), crashedThread.asyncState.isolationScope).getScopeData(); mergeScopeData(scope, isolationScope); - applyScopeToEvent(event, scope); } + applyScopeToEvent(event, scope); const allDebugImages: Record = Object.values(threads).reduce((acc, threadState) => { return { ...acc, ...threadState.pollState?.debugImages }; From 34869c7b783f1b8762599120a52bea2fb84bdd32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 13 Apr 2026 17:44:03 +0200 Subject: [PATCH 41/73] feat(cloudflare): Split alarms into multiple traces and link them (#19373) closes #19105 closes [JS-1604](https://linear.app/getsentry/issue/JS-1604/cloudflare-alarm-split-in-different-traces) closes #19453 closes [JS-1774](https://linear.app/getsentry/issue/JS-1774/cloudflare-instrument-alarm-api) This actually splits up [alarms](https://developers.cloudflare.com/durable-objects/api/alarms/) into its own traces and binding them with [span links](https://develop.sentry.dev/sdk/telemetry/traces/span-links/). It also adds the `setAlarm`, `getAlarm` and `deleteAlarm` instrumentation, which is needed to make this work. The logic works as following. When `setAlarm` is getting called it will store the alarm inside the durable object. Once the `alarm` is being executed the previous trace link will be retrieved via `ctx.storage.get` and then set as span link. Using the durable object itself as storage between alarms is even used on [Cloudflare's alarm page](https://developers.cloudflare.com/durable-objects/api/alarms/#scheduling-multiple-events-with-a-single-alarm). Also it is worth to mention that only 1 alarm at a time can happen, so it is safe to use a fixed key for the previous trace. I implemented the trace links, so they could be reused in the future for other methods as well, so they are not exclusively for alarms. Example alarm that triggers 3 new alarms to show the span links: https://sentry-sdks.sentry.io/explore/traces/trace/1ef3f388601b425d96d1ed9de0d5b7b4/ --- .../cloudflare-workers/src/index.ts | 13 + .../cloudflare-workers/tests/index.test.ts | 44 +++ packages/cloudflare/src/durableobject.ts | 6 +- .../instrumentDurableObjectStorage.ts | 45 ++- .../cloudflare/src/utils/instrumentContext.ts | 3 +- packages/cloudflare/src/utils/traceLinks.ts | 77 +++++ .../cloudflare/src/wrapMethodWithSentry.ts | 151 +++++++--- .../instrumentDurableObjectStorage.test.ts | 132 ++++++++- packages/cloudflare/test/traceLinks.test.ts | 201 +++++++++++++ .../test/wrapMethodWithSentry.test.ts | 268 +++++++++++++++++- packages/core/src/index.ts | 1 + 11 files changed, 880 insertions(+), 61 deletions(-) create mode 100644 packages/cloudflare/src/utils/traceLinks.ts create mode 100644 packages/cloudflare/test/traceLinks.test.ts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts index cc71748c44f8..b76eb516e221 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts @@ -19,6 +19,13 @@ class MyDurableObjectBase extends DurableObject { throw new Error('Should be recorded in Sentry.'); } + async alarm(): Promise { + const action = await this.ctx.storage.get('alarm-action'); + if (action === 'throw') { + throw new Error('Alarm error captured by Sentry'); + } + } + async fetch(request: Request) { const url = new URL(request.url); switch (url.pathname) { @@ -32,6 +39,12 @@ class MyDurableObjectBase extends DurableObject { this.ctx.acceptWebSocket(server); return new Response(null, { status: 101, webSocket: client }); } + case '/setAlarm': { + const action = url.searchParams.get('action') || 'succeed'; + await this.ctx.storage.put('alarm-action', action); + await this.ctx.storage.setAlarm(Date.now() + 500); + return new Response('Alarm set'); + } case '/storage/put': { await this.ctx.storage.put('test-key', 'test-value'); return new Response('Stored'); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts index 4235ca7d17cc..d43cb21770a0 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts @@ -99,3 +99,47 @@ test('Storage operations create spans in Durable Object transactions', async ({ expect(putSpan?.data?.['db.system.name']).toBe('cloudflare.durable_object.storage'); expect(putSpan?.data?.['db.operation.name']).toBe('put'); }); + +test.describe('Alarm instrumentation', () => { + test.describe.configure({ mode: 'serial' }); + + test('captures error from alarm handler', async ({ baseURL }) => { + const errorWaiter = waitForError('cloudflare-workers', event => { + return event.exception?.values?.[0]?.value === 'Alarm error captured by Sentry'; + }); + + const response = await fetch(`${baseURL}/pass-to-object/setAlarm?action=throw`); + expect(response.status).toBe(200); + + const event = await errorWaiter; + expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object'); + }); + + test('creates a transaction for alarm with new trace linked to setAlarm', async ({ baseURL }) => { + const setAlarmTransactionWaiter = waitForTransaction('cloudflare-workers', event => { + return event.spans?.some(span => span.description?.includes('storage_setAlarm')) ?? false; + }); + + const alarmTransactionWaiter = waitForTransaction('cloudflare-workers', event => { + return event.transaction === 'alarm' && event.contexts?.trace?.op === 'function'; + }); + + const response = await fetch(`${baseURL}/pass-to-object/setAlarm`); + expect(response.status).toBe(200); + + const setAlarmTransaction = await setAlarmTransactionWaiter; + const alarmTransaction = await alarmTransactionWaiter; + + // Alarm creates a transaction with correct attributes + expect(alarmTransaction.contexts?.trace?.op).toBe('function'); + expect(alarmTransaction.contexts?.trace?.origin).toBe('auto.faas.cloudflare.durable_object'); + + // Alarm starts a new trace (different trace ID from the request that called setAlarm) + expect(alarmTransaction.contexts?.trace?.trace_id).not.toBe(setAlarmTransaction.contexts?.trace?.trace_id); + + // Alarm links to the trace that called setAlarm via sentry.previous_trace attribute + const previousTrace = alarmTransaction.contexts?.trace?.data?.['sentry.previous_trace']; + expect(previousTrace).toBeDefined(); + expect(previousTrace).toContain(setAlarmTransaction.contexts?.trace?.trace_id); + }); +}); diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 8f6788c67748..4dbe7ea3d2f0 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -83,7 +83,11 @@ export function instrumentDurableObjectWithSentry< } if (obj.alarm && typeof obj.alarm === 'function') { - obj.alarm = wrapMethodWithSentry({ options, context, spanName: 'alarm' }, obj.alarm); + // Alarms are independent invocations, so we start a new trace and link to the previous alarm + obj.alarm = wrapMethodWithSentry( + { options, context, spanName: 'alarm', spanOp: 'function', startNewTrace: true }, + obj.alarm, + ); } if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts index 984bcb22707e..1b41167b206c 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts @@ -1,20 +1,31 @@ import type { DurableObjectStorage } from '@cloudflare/workers-types'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import { isThenable, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import { storeSpanContext } from '../utils/traceLinks'; -const STORAGE_METHODS_TO_INSTRUMENT = ['get', 'put', 'delete', 'list'] as const; +const STORAGE_METHODS_TO_INSTRUMENT = ['get', 'put', 'delete', 'list', 'setAlarm', 'getAlarm', 'deleteAlarm'] as const; type StorageMethod = (typeof STORAGE_METHODS_TO_INSTRUMENT)[number]; +type WaitUntil = (promise: Promise) => void; + /** * Instruments DurableObjectStorage methods with Sentry spans. * * Wraps the following async methods: * - get, put, delete, list (KV API) + * - setAlarm, getAlarm, deleteAlarm (Alarm API) + * + * When setAlarm is called, it also stores the current span context so that when + * the alarm fires later, it can link back to the trace that called setAlarm. * * @param storage - The DurableObjectStorage instance to instrument + * @param waitUntil - Optional waitUntil function to defer span context storage * @returns An instrumented DurableObjectStorage instance */ -export function instrumentDurableObjectStorage(storage: DurableObjectStorage): DurableObjectStorage { +export function instrumentDurableObjectStorage( + storage: DurableObjectStorage, + waitUntil?: WaitUntil, +): DurableObjectStorage { return new Proxy(storage, { get(target, prop, _receiver) { // Use `target` as the receiver instead of the proxy (`_receiver`). @@ -46,7 +57,33 @@ export function instrumentDurableObjectStorage(storage: DurableObjectStorage): D }, }, () => { - return (original as (...args: unknown[]) => unknown).apply(target, args); + const teardown = async (): Promise => { + // When setAlarm is called, store the current span context so that when the alarm + // fires later, it can link back to the trace that called setAlarm. + // We use the original (uninstrumented) storage (target) to avoid creating a span + // for this internal operation. The storage is deferred via waitUntil to not block. + if (methodName === 'setAlarm') { + await storeSpanContext(target, 'alarm'); + } + }; + + const result = (original as (...args: unknown[]) => unknown).apply(target, args); + + if (!isThenable(result)) { + waitUntil?.(teardown()); + + return result; + } + + return result.then( + res => { + waitUntil?.(teardown()); + return res; + }, + e => { + throw e; + }, + ); }, ); }; diff --git a/packages/cloudflare/src/utils/instrumentContext.ts b/packages/cloudflare/src/utils/instrumentContext.ts index a8c04c318a2d..2cfb65869e79 100644 --- a/packages/cloudflare/src/utils/instrumentContext.ts +++ b/packages/cloudflare/src/utils/instrumentContext.ts @@ -41,13 +41,14 @@ export function instrumentContext(ctx: T): T { // If so, wrap the storage with instrumentation if ('storage' in ctx && ctx.storage) { const originalStorage = ctx.storage; + const waitUntil = 'waitUntil' in ctx && typeof ctx.waitUntil === 'function' ? ctx.waitUntil.bind(ctx) : undefined; let instrumentedStorage: DurableObjectStorage | undefined; descriptors.storage = { configurable: true, enumerable: true, get: () => { if (!instrumentedStorage) { - instrumentedStorage = instrumentDurableObjectStorage(originalStorage); + instrumentedStorage = instrumentDurableObjectStorage(originalStorage, waitUntil); } return instrumentedStorage; }, diff --git a/packages/cloudflare/src/utils/traceLinks.ts b/packages/cloudflare/src/utils/traceLinks.ts new file mode 100644 index 000000000000..46359955ea4f --- /dev/null +++ b/packages/cloudflare/src/utils/traceLinks.ts @@ -0,0 +1,77 @@ +import type { DurableObjectStorage } from '@cloudflare/workers-types'; +import { TraceFlags } from '@opentelemetry/api'; +import type { SpanLink } from '@sentry/core'; +import { debug, getActiveSpan, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanIsSampled } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +/** Storage key prefix for the span context that links consecutive method invocations */ +const SENTRY_TRACE_LINK_KEY_PREFIX = '__SENTRY_TRACE_LINK__'; + +/** Stored span context for creating span links */ +export interface StoredSpanContext { + traceId: string; + spanId: string; + sampled: boolean; +} + +/** + * Gets the storage key for a specific method's trace link. + */ +export function getTraceLinkKey(methodName: string): string { + return `${SENTRY_TRACE_LINK_KEY_PREFIX}${methodName}`; +} + +/** + * Stores the current span context in Durable Object storage for trace linking. + * Uses the original uninstrumented storage to avoid creating spans for internal operations. + * Errors are silently ignored to prevent internal storage failures from propagating to user code. + */ +export async function storeSpanContext(originalStorage: DurableObjectStorage, methodName: string): Promise { + try { + const activeSpan = getActiveSpan(); + if (activeSpan) { + const spanContext = activeSpan.spanContext(); + const storedContext: StoredSpanContext = { + traceId: spanContext.traceId, + spanId: spanContext.spanId, + sampled: spanIsSampled(activeSpan), + }; + await originalStorage.put(getTraceLinkKey(methodName), storedContext); + } + } catch (error) { + // Silently ignore storage errors to prevent internal failures from affecting user code + DEBUG_BUILD && debug.log(`[CloudflareClient] Error storing span context for method ${methodName}`, error); + } +} + +/** + * Retrieves a stored span context from Durable Object storage. + */ +export async function getStoredSpanContext( + originalStorage: DurableObjectStorage, + methodName: string, +): Promise { + try { + return await originalStorage.get(getTraceLinkKey(methodName)); + } catch { + return undefined; + } +} + +/** + * Builds span links from a stored span context. + */ +export function buildSpanLinks(storedContext: StoredSpanContext): SpanLink[] { + return [ + { + context: { + traceId: storedContext.traceId, + spanId: storedContext.spanId, + traceFlags: storedContext.sampled ? TraceFlags.SAMPLED : TraceFlags.NONE, + }, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ]; +} diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 4080017526a2..3a7218057c4a 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -6,6 +6,7 @@ import { type Scope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startNewTrace as startNewTraceCore, startSpan, withIsolationScope, withScope, @@ -14,6 +15,7 @@ import type { CloudflareOptions } from './client'; import { flushAndDispose } from './flush'; import { ensureInstrumented } from './instrument'; import { init } from './sdk'; +import { buildSpanLinks, getStoredSpanContext, storeSpanContext } from './utils/traceLinks'; /** Extended DurableObjectState with originalStorage exposed by instrumentContext */ interface InstrumentedDurableObjectState extends DurableObjectState { @@ -24,7 +26,18 @@ type MethodWrapperOptions = { spanName?: string; spanOp?: string; options: CloudflareOptions; - context: ExecutionContext | DurableObjectState; + context: ExecutionContext | InstrumentedDurableObjectState; + /** + * If true, starts a fresh trace instead of inheriting from a parent trace. + * Useful for scheduled/independent invocations like alarms. + * + * If true, it also stores the current span context and links to the previous invocation's span. + * Uses Durable Object storage to persist the link. The link is set asynchronously via `span.addLinks()` + * in a `waitUntil` to avoid blocking. + * + * @default false + */ + startNewTrace?: boolean; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -32,7 +45,8 @@ export type UncheckedMethod = (...args: any[]) => any; type OriginalMethod = UncheckedMethod; /** - * Wraps a method with Sentry tracing. + * Wraps a method with Sentry error tracking and optional tracing. + * Supports starting new traces and linking to previous invocations via Durable Object storage. * * @param wrapperOptions - The options for the wrapper. * @param handler - The method to wrap. @@ -51,44 +65,58 @@ export function wrapMethodWithSentry( original => new Proxy(original, { apply(target, thisArg, args: Parameters) { + const { startNewTrace } = wrapperOptions; + + // For startNewTrace, always use withIsolationScope to ensure a fresh scope + // Otherwise, use existing client's scope or isolation scope const currentClient = getClient(); - // if a client is already set, use withScope, otherwise use withIsolationScope - const sentryWithScope = currentClient ? withScope : withIsolationScope; + const sentryWithScope = startNewTrace ? withIsolationScope : currentClient ? withScope : withIsolationScope; - const wrappedFunction = (scope: Scope): unknown => { + const wrappedFunction = (scope: Scope): unknown | Promise => { // In certain situations, the passed context can become undefined. // For example, for Astro while prerendering pages at build time. // see: https://github.com/getsentry/sentry-javascript/issues/13217 - const context = wrapperOptions.context as InstrumentedDurableObjectState | undefined; + const context: typeof wrapperOptions.context | undefined = wrapperOptions.context; const waitUntil = context?.waitUntil?.bind?.(context); + const storage = context && 'originalStorage' in context ? context.originalStorage : undefined; - let currentClient = scope.getClient(); + let scopeClient = scope.getClient(); // Check if client exists AND is still usable (transport not disposed) // This handles the case where a previous handler disposed the client // but the scope still holds a reference to it (e.g., alarm handlers in Durable Objects) - if (!currentClient?.getTransport()) { + // For startNewTrace, always create a fresh client + if (startNewTrace || !scopeClient?.getTransport()) { const client = init({ ...wrapperOptions.options, ctx: context as unknown as ExecutionContext | undefined, }); scope.setClient(client); - currentClient = client; + scopeClient = client; } - const clientToDispose = currentClient; + const clientToDispose = scopeClient; + const methodName = wrapperOptions.spanName || 'unknown'; + + const teardown = async (): Promise => { + if (startNewTrace && storage) { + await storeSpanContext(storage, methodName); + } + await flushAndDispose(clientToDispose); + }; if (!wrapperOptions.spanName) { try { if (callback) { callback(...args); } + const result = Reflect.apply(target, thisArg, args); if (isThenable(result)) { return result.then( (res: unknown) => { - waitUntil?.(flushAndDispose(clientToDispose)); + waitUntil?.(teardown()); return res; }, (e: unknown) => { @@ -98,12 +126,12 @@ export function wrapMethodWithSentry( handled: false, }, }); - waitUntil?.(flushAndDispose(clientToDispose)); + waitUntil?.(teardown()); throw e; }, ); } else { - waitUntil?.(flushAndDispose(clientToDispose)); + waitUntil?.(teardown()); return result; } } catch (e) { @@ -113,7 +141,7 @@ export function wrapMethodWithSentry( handled: false, }, }); - waitUntil?.(flushAndDispose(clientToDispose)); + waitUntil?.(teardown()); throw e; } } @@ -125,42 +153,71 @@ export function wrapMethodWithSentry( } : {}; - return startSpan({ name: wrapperOptions.spanName, attributes }, () => { - try { - const result = Reflect.apply(target, thisArg, args); + const executeSpan = (): unknown => { + return startSpan({ name: methodName, attributes }, span => { + // When linking to previous trace, fetch the stored context and add links asynchronously + // This avoids blocking the response while fetching from storage + if (startNewTrace && storage) { + waitUntil?.( + getStoredSpanContext(storage, methodName).then(storedContext => { + if (storedContext) { + span.addLinks(buildSpanLinks(storedContext)); + // TODO: Remove this once EAP can store span links. We currently only set this attribute so that we + // can obtain the previous trace information from the EAP store. Long-term, EAP will handle + // span links and then we should remove this again. Also throwing in a TODO(v11), to remind us + // to check this at v11 time :) + const sampledFlag = storedContext.sampled ? '1' : '0'; + span.setAttribute( + 'sentry.previous_trace', + `${storedContext.traceId}-${storedContext.spanId}-${sampledFlag}`, + ); + } + }), + ); + } - if (isThenable(result)) { - return result.then( - (res: unknown) => { - waitUntil?.(flushAndDispose(clientToDispose)); - return res; - }, - (e: unknown) => { - captureException(e, { - mechanism: { - type: 'auto.faas.cloudflare.durable_object', - handled: false, - }, - }); - waitUntil?.(flushAndDispose(clientToDispose)); - throw e; + try { + const result = Reflect.apply(target, thisArg, args); + + if (isThenable(result)) { + return result.then( + (res: unknown) => { + waitUntil?.(teardown()); + return res; + }, + (e: unknown) => { + captureException(e, { + mechanism: { + type: 'auto.faas.cloudflare.durable_object', + handled: false, + }, + }); + waitUntil?.(teardown()); + throw e; + }, + ); + } else { + waitUntil?.(teardown()); + return result; + } + } catch (e) { + captureException(e, { + mechanism: { + type: 'auto.faas.cloudflare.durable_object', + handled: false, }, - ); - } else { - waitUntil?.(flushAndDispose(clientToDispose)); - return result; + }); + waitUntil?.(teardown()); + throw e; } - } catch (e) { - captureException(e, { - mechanism: { - type: 'auto.faas.cloudflare.durable_object', - handled: false, - }, - }); - waitUntil?.(flushAndDispose(clientToDispose)); - throw e; - } - }); + }); + }; + + if (startNewTrace) { + return startNewTraceCore(() => executeSpan()); + } + + return executeSpan(); }; return sentryWithScope(wrappedFunction); diff --git a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts index 188b007a0b59..d023f9565df7 100644 --- a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts +++ b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts @@ -1,9 +1,10 @@ import * as sentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentDurableObjectStorage } from '../src/instrumentations/instrumentDurableObjectStorage'; +import * as traceLinks from '../src/utils/traceLinks'; vi.mock('@sentry/core', async importOriginal => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, startSpan: vi.fn((opts, callback) => callback()), @@ -11,6 +12,14 @@ vi.mock('@sentry/core', async importOriginal => { }; }); +vi.mock('../src/utils/traceLinks', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + storeSpanContext: vi.fn().mockResolvedValue(undefined), + }; +}); + describe('instrumentDurableObjectStorage', () => { beforeEach(() => { vi.clearAllMocks(); @@ -150,18 +159,131 @@ describe('instrumentDurableObjectStorage', () => { }); }); - describe('non-instrumented methods', () => { - it('does not instrument alarm methods', async () => { + describe('alarm methods', () => { + it('instruments setAlarm', async () => { const mockStorage = createMockStorage(); const instrumented = instrumentDurableObjectStorage(mockStorage); - await instrumented.getAlarm(); await instrumented.setAlarm(Date.now() + 1000); + + expect(sentryCore.startSpan).toHaveBeenCalledWith( + { + name: 'durable_object_storage_setAlarm', + op: 'db', + attributes: expect.objectContaining({ + 'db.operation.name': 'setAlarm', + }), + }, + expect.any(Function), + ); + }); + + it('stores span context when setAlarm is called (async)', async () => { + const mockStorage = createMockStorage(); + const waitUntil = vi.fn(); + const instrumented = instrumentDurableObjectStorage(mockStorage, waitUntil); + + await instrumented.setAlarm(Date.now() + 1000); + + expect(waitUntil).toHaveBeenCalledTimes(1); + expect(traceLinks.storeSpanContext).toHaveBeenCalledWith(mockStorage, 'alarm'); + }); + + it('calls teardown after promise resolves (async case)', async () => { + const callOrder: string[] = []; + let resolveStorage: () => void; + const storagePromise = new Promise(resolve => { + resolveStorage = resolve; + }); + + const mockStorage = createMockStorage(); + mockStorage.setAlarm = vi.fn().mockImplementation(() => { + callOrder.push('setAlarm started'); + return storagePromise.then(() => { + callOrder.push('setAlarm resolved'); + }); + }); + + const waitUntil = vi.fn().mockImplementation(() => { + callOrder.push('waitUntil called'); + }); + + const instrumented = instrumentDurableObjectStorage(mockStorage, waitUntil); + const resultPromise = instrumented.setAlarm(Date.now() + 1000); + + // Before resolving, waitUntil should not have been called yet + expect(waitUntil).not.toHaveBeenCalled(); + expect(callOrder).toEqual(['setAlarm started']); + + // Resolve the storage promise + resolveStorage!(); + await resultPromise; + + // After resolving, waitUntil should have been called + expect(waitUntil).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['setAlarm started', 'setAlarm resolved', 'waitUntil called']); + }); + + it('calls teardown immediately for sync results', () => { + const callOrder: string[] = []; + + const mockStorage = createMockStorage(); + // Make setAlarm return a sync value (not a promise) + mockStorage.setAlarm = vi.fn().mockImplementation(() => { + callOrder.push('setAlarm executed'); + return undefined; // sync return + }); + + const waitUntil = vi.fn().mockImplementation(() => { + callOrder.push('waitUntil called'); + }); + + const instrumented = instrumentDurableObjectStorage(mockStorage, waitUntil); + instrumented.setAlarm(Date.now() + 1000); + + // For sync results, waitUntil should be called immediately after + expect(waitUntil).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['setAlarm executed', 'waitUntil called']); + }); + + it('instruments getAlarm', async () => { + const mockStorage = createMockStorage(); + const instrumented = instrumentDurableObjectStorage(mockStorage); + + await instrumented.getAlarm(); + + expect(sentryCore.startSpan).toHaveBeenCalledWith( + { + name: 'durable_object_storage_getAlarm', + op: 'db', + attributes: expect.objectContaining({ + 'db.operation.name': 'getAlarm', + }), + }, + expect.any(Function), + ); + }); + + it('instruments deleteAlarm', async () => { + const mockStorage = createMockStorage(); + const instrumented = instrumentDurableObjectStorage(mockStorage); + await instrumented.deleteAlarm(); - expect(sentryCore.startSpan).not.toHaveBeenCalled(); + expect(sentryCore.startSpan).toHaveBeenCalledWith( + { + name: 'durable_object_storage_deleteAlarm', + op: 'db', + attributes: expect.objectContaining({ + 'db.operation.name': 'deleteAlarm', + }), + }, + expect.any(Function), + ); }); + }); + describe('non-instrumented methods', () => { it('does not instrument deleteAll, sync, transaction', async () => { const mockStorage = createMockStorage(); const instrumented = instrumentDurableObjectStorage(mockStorage); diff --git a/packages/cloudflare/test/traceLinks.test.ts b/packages/cloudflare/test/traceLinks.test.ts new file mode 100644 index 000000000000..76507a1f616a --- /dev/null +++ b/packages/cloudflare/test/traceLinks.test.ts @@ -0,0 +1,201 @@ +import { TraceFlags } from '@opentelemetry/api'; +import * as sentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildSpanLinks, getStoredSpanContext, getTraceLinkKey, storeSpanContext } from '../src/utils/traceLinks'; + +vi.mock('@sentry/core', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + getActiveSpan: vi.fn(), + }; +}); + +describe('traceLinks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getTraceLinkKey', () => { + it('returns prefixed key for method name', () => { + expect(getTraceLinkKey('alarm')).toBe('__SENTRY_TRACE_LINK__alarm'); + }); + + it('returns prefixed key for custom method name', () => { + expect(getTraceLinkKey('myCustomMethod')).toBe('__SENTRY_TRACE_LINK__myCustomMethod'); + }); + + it('handles empty method name', () => { + expect(getTraceLinkKey('')).toBe('__SENTRY_TRACE_LINK__'); + }); + }); + + describe('storeSpanContext', () => { + it('stores span context with sampled=true when traceFlags is SAMPLED', async () => { + const mockSpanContext = { + traceId: 'abc123def456789012345678901234ab', + spanId: '1234567890abcdef', + traceFlags: TraceFlags.SAMPLED, + }; + const mockSpan = { + spanContext: vi.fn().mockReturnValue(mockSpanContext), + }; + vi.mocked(sentryCore.getActiveSpan).mockReturnValue(mockSpan as any); + + const mockStorage = createMockStorage(); + await storeSpanContext(mockStorage, 'alarm'); + + expect(mockStorage.put).toHaveBeenCalledWith('__SENTRY_TRACE_LINK__alarm', { + traceId: 'abc123def456789012345678901234ab', + spanId: '1234567890abcdef', + sampled: true, + }); + }); + + it('stores span context with sampled=false when traceFlags is NONE', async () => { + const mockSpanContext = { + traceId: 'abc123def456789012345678901234ab', + spanId: '1234567890abcdef', + traceFlags: TraceFlags.NONE, + }; + const mockSpan = { + spanContext: vi.fn().mockReturnValue(mockSpanContext), + }; + vi.mocked(sentryCore.getActiveSpan).mockReturnValue(mockSpan as any); + + const mockStorage = createMockStorage(); + await storeSpanContext(mockStorage, 'alarm'); + + expect(mockStorage.put).toHaveBeenCalledWith('__SENTRY_TRACE_LINK__alarm', { + traceId: 'abc123def456789012345678901234ab', + spanId: '1234567890abcdef', + sampled: false, + }); + }); + + it('does not store when no active span', async () => { + vi.mocked(sentryCore.getActiveSpan).mockReturnValue(undefined); + + const mockStorage = createMockStorage(); + await storeSpanContext(mockStorage, 'alarm'); + + expect(mockStorage.put).not.toHaveBeenCalled(); + }); + + it('silently ignores storage errors', async () => { + const mockSpanContext = { + traceId: 'abc123def456789012345678901234ab', + spanId: '1234567890abcdef', + traceFlags: TraceFlags.SAMPLED, + }; + const mockSpan = { + spanContext: vi.fn().mockReturnValue(mockSpanContext), + }; + vi.mocked(sentryCore.getActiveSpan).mockReturnValue(mockSpan as any); + + const mockStorage = createMockStorage(); + mockStorage.put = vi.fn().mockRejectedValue(new Error('Storage quota exceeded')); + + await expect(storeSpanContext(mockStorage, 'alarm')).resolves.toBeUndefined(); + }); + }); + + describe('getStoredSpanContext', () => { + it('retrieves stored span context', async () => { + const storedContext = { + traceId: 'abc123def456789012345678901234ab', + spanId: '1234567890abcdef', + sampled: true, + }; + const mockStorage = createMockStorage(); + mockStorage.get = vi.fn().mockResolvedValue(storedContext); + + const result = await getStoredSpanContext(mockStorage, 'alarm'); + + expect(mockStorage.get).toHaveBeenCalledWith('__SENTRY_TRACE_LINK__alarm'); + expect(result).toEqual(storedContext); + }); + + it('returns undefined when no stored context', async () => { + const mockStorage = createMockStorage(); + mockStorage.get = vi.fn().mockResolvedValue(undefined); + + const result = await getStoredSpanContext(mockStorage, 'alarm'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when storage throws', async () => { + const mockStorage = createMockStorage(); + mockStorage.get = vi.fn().mockRejectedValue(new Error('Storage error')); + + const result = await getStoredSpanContext(mockStorage, 'alarm'); + + expect(result).toBeUndefined(); + }); + }); + + describe('buildSpanLinks', () => { + it('builds span links with SAMPLED traceFlags when sampled is true', () => { + const storedContext = { + traceId: 'abc123def456789012345678901234ab', + spanId: '1234567890abcdef', + sampled: true, + }; + + const links = buildSpanLinks(storedContext); + + expect(links).toHaveLength(1); + expect(links[0]).toEqual({ + context: { + traceId: 'abc123def456789012345678901234ab', + spanId: '1234567890abcdef', + traceFlags: TraceFlags.SAMPLED, + }, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }); + }); + + it('builds span links with NONE traceFlags when sampled is false', () => { + const storedContext = { + traceId: 'abc123def456789012345678901234ab', + spanId: '1234567890abcdef', + sampled: false, + }; + + const links = buildSpanLinks(storedContext); + + expect(links).toHaveLength(1); + expect(links[0]).toEqual({ + context: { + traceId: 'abc123def456789012345678901234ab', + spanId: '1234567890abcdef', + traceFlags: TraceFlags.NONE, + }, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }); + }); + }); +}); + +function createMockStorage(): any { + return { + get: vi.fn().mockResolvedValue(undefined), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(false), + list: vi.fn().mockResolvedValue(new Map()), + getAlarm: vi.fn().mockResolvedValue(null), + setAlarm: vi.fn().mockResolvedValue(undefined), + deleteAlarm: vi.fn().mockResolvedValue(undefined), + deleteAll: vi.fn().mockResolvedValue(undefined), + sync: vi.fn().mockResolvedValue(undefined), + transaction: vi.fn().mockImplementation(async (cb: () => unknown) => cb()), + sql: { + exec: vi.fn(), + }, + }; +} diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index fdd3475d24c4..a812258c2c94 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -32,6 +32,7 @@ vi.mock('@sentry/core', async importOriginal => { withIsolationScope: vi.fn((callback: (scope: unknown) => unknown) => callback(createMockScope())), withScope: vi.fn((callback: (scope: unknown) => unknown) => callback(createMockScope())), startSpan: vi.fn((opts, callback) => callback(createMockSpan())), + startNewTrace: vi.fn(callback => callback()), captureException: vi.fn(), flush: vi.fn().mockResolvedValue(true), getActiveSpan: vi.fn(), @@ -51,6 +52,7 @@ function createMockSpan() { return { setAttribute: vi.fn(), setAttributes: vi.fn(), + addLinks: vi.fn(), spanContext: vi.fn().mockReturnValue({ traceId: 'test-trace-id-12345678901234567890', spanId: 'test-span-id', @@ -82,7 +84,7 @@ describe('wrapMethodWithSentry', () => { }); describe('basic wrapping', () => { - it('wraps a sync method and returns its result', () => { + it('wraps a sync method and returns its result synchronously (not a Promise)', () => { const handler = vi.fn().mockReturnValue('sync-result'); const options = { options: {}, @@ -90,9 +92,44 @@ describe('wrapMethodWithSentry', () => { }; const wrapped = wrapMethodWithSentry(options, handler); - wrapped(); + const result = wrapped(); expect(handler).toHaveBeenCalled(); + expect(result).not.toBeInstanceOf(Promise); + expect(result).toBe('sync-result'); + }); + + it('wraps a sync method with spanName and preserves sync behavior', () => { + const handler = vi.fn().mockReturnValue('sync-result'); + const options = { + options: {}, + context: createMockContext(), + spanName: 'test-span', + }; + + const wrapped = wrapMethodWithSentry(options, handler); + const result = wrapped(); + + expect(handler).toHaveBeenCalled(); + expect(result).not.toBeInstanceOf(Promise); + expect(result).toBe('sync-result'); + }); + + it('wraps a sync method with startNewTrace and preserves sync behavior', () => { + const handler = vi.fn().mockReturnValue('sync-result'); + const options = { + options: {}, + context: createMockContext(), + spanName: 'test-span', + startNewTrace: true, + }; + + const wrapped = wrapMethodWithSentry(options, handler); + const result = wrapped(); + + expect(handler).toHaveBeenCalled(); + expect(result).not.toBeInstanceOf(Promise); + expect(result).toBe('sync-result'); }); it('wraps an async method and returns a promise', async () => { @@ -103,11 +140,43 @@ describe('wrapMethodWithSentry', () => { }; const wrapped = wrapMethodWithSentry(options, handler); - await wrapped(); + const result = wrapped(); + expect(result).toBeInstanceOf(Promise); + await expect(result).resolves.toBe('async-result'); expect(handler).toHaveBeenCalled(); }); + it('does not change sync/async behavior when startNewTrace is true (links are set via waitUntil)', () => { + const handler = vi.fn().mockReturnValue('sync-result'); + const mockStorage = { + get: vi.fn().mockResolvedValue(undefined), + put: vi.fn().mockResolvedValue(undefined), + }; + const waitUntilPromises: Promise[] = []; + const context = { + waitUntil: vi.fn((p: Promise) => waitUntilPromises.push(p)), + originalStorage: mockStorage, + } as any; + + const options = { + options: {}, + context, + spanName: 'alarm', + startNewTrace: true, + }; + + const wrapped = wrapMethodWithSentry(options, handler); + const result = wrapped(); + + // startNewTrace does not make the result async - links are set via waitUntil + expect(result).not.toBeInstanceOf(Promise); + expect(result).toBe('sync-result'); + + // The link fetching happens via waitUntil, not blocking the response + expect(context.waitUntil).toHaveBeenCalled(); + }); + it('marks handler as instrumented', () => { const handler = vi.fn(); const options = { @@ -228,6 +297,199 @@ describe('wrapMethodWithSentry', () => { }); }); + describe('startNewTrace option', () => { + it('uses withIsolationScope when startNewTrace is true', async () => { + const handler = vi.fn().mockResolvedValue('result'); + const options = { + options: {}, + context: createMockContext(), + startNewTrace: true, + spanName: 'alarm', + }; + + const wrapped = wrapMethodWithSentry(options, handler); + await wrapped(); + + expect(sentryCore.withIsolationScope).toHaveBeenCalled(); + }); + + it('uses startNewTrace when startNewTrace is true and spanName is set', async () => { + const handler = vi.fn().mockResolvedValue('result'); + const options = { + options: {}, + context: createMockContext(), + startNewTrace: true, + spanName: 'alarm', + }; + + const wrapped = wrapMethodWithSentry(options, handler); + await wrapped(); + + expect(sentryCore.startNewTrace).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('does not use startNewTrace when startNewTrace is false', async () => { + const handler = vi.fn().mockResolvedValue('result'); + const options = { + options: {}, + context: createMockContext(), + startNewTrace: false, + spanName: 'test-span', + }; + + const wrapped = wrapMethodWithSentry(options, handler); + await wrapped(); + + expect(sentryCore.startNewTrace).not.toHaveBeenCalled(); + }); + }); + + describe('span linking', () => { + it('retrieves stored span context when startNewTrace is true', async () => { + const storedContext = { + traceId: 'previous-trace-id-1234567890123456', + spanId: 'previous-span-id', + }; + const mockStorage = { + get: vi.fn().mockResolvedValue(storedContext), + put: vi.fn().mockResolvedValue(undefined), + }; + const context = { + waitUntil: vi.fn(), + originalStorage: mockStorage, + } as any; + + const handler = vi.fn().mockResolvedValue('result'); + const options = { + options: {}, + context, + startNewTrace: true, + spanName: 'alarm', + }; + + const wrapped = wrapMethodWithSentry(options, handler); + await wrapped(); + + expect(mockStorage.get).toHaveBeenCalledWith('__SENTRY_TRACE_LINK__alarm'); + }); + + it('builds span links from stored context', async () => { + const storedContext = { + traceId: 'previous-trace-id-1234567890123456', + spanId: 'previous-span-id', + }; + const mockStorage = { + get: vi.fn().mockResolvedValue(storedContext), + put: vi.fn().mockResolvedValue(undefined), + }; + + const mockSpan = createMockSpan(); + vi.mocked(sentryCore.startSpan).mockImplementation((opts, callback) => callback(mockSpan as any)); + + const waitUntilPromises: Promise[] = []; + const context = { + waitUntil: vi.fn((p: Promise) => waitUntilPromises.push(p)), + originalStorage: mockStorage, + } as any; + + const handler = vi.fn().mockResolvedValue('result'); + const options = { + options: {}, + context, + startNewTrace: true, + spanName: 'alarm', + }; + + const wrapped = wrapMethodWithSentry(options, handler); + await wrapped(); + + // Wait for waitUntil promises to resolve (setSpanLinks is called via waitUntil) + await Promise.all(waitUntilPromises); + + // addLinks should be called on the span with the stored context + expect(mockSpan.addLinks).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + context: expect.objectContaining({ + traceId: 'previous-trace-id-1234567890123456', + spanId: 'previous-span-id', + }), + attributes: { 'sentry.link.type': 'previous_trace' }, + }), + ]), + ); + }); + + it('stores span context after execution when startNewTrace is true', async () => { + vi.mocked(sentryCore.getActiveSpan).mockReturnValue({ + spanContext: vi.fn().mockReturnValue({ + traceId: 'current-trace-id-123456789012345678', + spanId: 'current-span-id', + }), + } as any); + + const mockStorage = { + get: vi.fn().mockResolvedValue(undefined), + put: vi.fn().mockResolvedValue(undefined), + }; + const context = { + waitUntil: vi.fn(), + originalStorage: mockStorage, + } as any; + + const handler = vi.fn().mockResolvedValue('result'); + const options = { + options: {}, + context, + startNewTrace: true, + spanName: 'alarm', + }; + + const wrapped = wrapMethodWithSentry(options, handler); + await wrapped(); + + // Should store span context for future linking + expect(mockStorage.put).toHaveBeenCalledWith('__SENTRY_TRACE_LINK__alarm', expect.any(Object)); + }); + + it('does not store span context when startNewTrace is false', async () => { + vi.mocked(sentryCore.getActiveSpan).mockReturnValue({ + spanContext: vi.fn().mockReturnValue({ + traceId: 'current-trace-id-123456789012345678', + spanId: 'current-span-id', + }), + } as any); + + const mockStorage = { + get: vi.fn().mockResolvedValue(undefined), + put: vi.fn().mockResolvedValue(undefined), + }; + + const waitUntilPromises: Promise[] = []; + const context = { + waitUntil: vi.fn((p: Promise) => waitUntilPromises.push(p)), + originalStorage: mockStorage, + } as any; + + const handler = vi.fn().mockResolvedValue('result'); + const options = { + options: {}, + context, + startNewTrace: false, + spanName: 'alarm', + }; + + const wrapped = wrapMethodWithSentry(options, handler); + await wrapped(); + + // Wait for all waitUntil promises to resolve + await Promise.all(waitUntilPromises); + + // Should NOT store span context when startNewTrace is false + expect(mockStorage.put).not.toHaveBeenCalledWith('__SENTRY_TRACE_LINK__alarm', expect.any(Object)); + }); + }); + describe('callback execution', () => { it('executes callback before handler', async () => { const callOrder: string[] = []; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5bc834862395..219410a1a3cf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -485,6 +485,7 @@ export type { } from './types-hoist/span'; export type { SpanStatus } from './types-hoist/spanStatus'; export type { Log, LogSeverityLevel } from './types-hoist/log'; +export type { SpanLink } from './types-hoist/link'; export type { Metric, MetricType, From efaf6cf3b5acbe2443ba7b97a72ef3373eaed390 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 13 Apr 2026 20:52:24 +0100 Subject: [PATCH 42/73] feat(browser): Add View Hierarchy integration (#14981) By default it captures the entire DOM, but it is configurable: Capture only React components (uses attributes added by Sentry bundler plugins): ```ts import * as Sentry from '@sentry/browser'; Sentry.init({ dsn: '__DSN__', integrations: [Sentry.viewHierarchyIntegration({ onElement: ({componentName}) => componentName ? {} : 'children' })], }); ``` Capture only Web Components: ```ts import * as Sentry from '@sentry/browser'; Sentry.init({ dsn: '__DSN__', integrations: [Sentry.viewHierarchyIntegration({ onElement: ({tagName}) => tagName.includes('-') ? {} : 'children' })], }); ``` --- .../suites/integrations/viewHierarchy/init.js | 9 ++ .../integrations/viewHierarchy/subject.js | 1 + .../integrations/viewHierarchy/template.html | 11 ++ .../suites/integrations/viewHierarchy/test.ts | 39 +++++ packages/browser/src/index.ts | 1 + .../src/integrations/view-hierarchy.ts | 144 ++++++++++++++++++ .../core/src/types-hoist/view-hierarchy.ts | 1 + packages/core/src/utils/browser.ts | 5 +- 8 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/test.ts create mode 100644 packages/browser/src/integrations/view-hierarchy.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/init.js b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/init.js new file mode 100644 index 000000000000..16e92edb9230 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; +import { viewHierarchyIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [viewHierarchyIntegration()], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/subject.js b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/subject.js new file mode 100644 index 000000000000..f7060a33f05c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/subject.js @@ -0,0 +1 @@ +throw new Error('Some error'); diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/template.html b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/template.html new file mode 100644 index 000000000000..9e600d2a7e60 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/template.html @@ -0,0 +1,11 @@ + + + + + + + +

Some title

+

Some text

+ + diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/test.ts b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/test.ts new file mode 100644 index 000000000000..d3caf6ff9b3e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/test.ts @@ -0,0 +1,39 @@ +import { expect } from '@playwright/test'; +import type { ViewHierarchyData } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests, envelopeParser } from '../../../utils/helpers'; + +sentryTest('Captures view hierarchy as attachment', async ({ getLocalTestUrl, page }) => { + const bundle = process.env.PW_BUNDLE; + if (bundle != null && !bundle.includes('esm') && !bundle.includes('cjs')) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const [, events] = await Promise.all([ + page.goto(url), + getMultipleSentryEnvelopeRequests( + page, + 1, + {}, + req => envelopeParser(req)?.[4] as ViewHierarchyData, + ), + ]); + + expect(events).toHaveLength(1); + const event: ViewHierarchyData = events[0]; + + expect(event.rendering_system).toBe('DOM'); + expect(event.positioning).toBe('absolute'); + expect(event.windows).toHaveLength(2); + expect(event.windows[0].type).toBe('h1'); + expect(event.windows[0].visible).toBe(true); + expect(event.windows[0].alpha).toBe(1); + expect(event.windows[0].children).toHaveLength(0); + + expect(event.windows[1].type).toBe('p'); + expect(event.windows[1].visible).toBe(true); + expect(event.windows[1].alpha).toBe(1); + expect(event.windows[1].children).toHaveLength(0); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 25415f99894f..7bfa67cb37ba 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -7,6 +7,7 @@ export { reportingObserverIntegration } from './integrations/reportingobserver'; export { httpClientIntegration } from './integrations/httpclient'; export { contextLinesIntegration } from './integrations/contextlines'; export { graphqlClientIntegration } from './integrations/graphqlClient'; +export { viewHierarchyIntegration } from './integrations/view-hierarchy'; export { captureConsoleIntegration, diff --git a/packages/browser/src/integrations/view-hierarchy.ts b/packages/browser/src/integrations/view-hierarchy.ts new file mode 100644 index 000000000000..fa35ad7e00a2 --- /dev/null +++ b/packages/browser/src/integrations/view-hierarchy.ts @@ -0,0 +1,144 @@ +import type { Attachment, Event, EventHint, ViewHierarchyData, ViewHierarchyWindow } from '@sentry/core'; +import { defineIntegration, getComponentName } from '@sentry/core'; +import { WINDOW } from '../helpers'; + +interface OnElementArgs { + /** + * The element being processed. + */ + element: HTMLElement; + /** + * Lowercase tag name of the element. + */ + tagName: string; + /** + * The component name of the element. + */ + componentName?: string; + + /** + * The current depth of the element in the view hierarchy. The root element will have a depth of 0. + * + * This allows you to limit the traversal depth for large DOM trees. + */ + depth?: number; +} + +interface Options { + /** + * Whether to attach the view hierarchy to the event. + * + * Default: Always attach. + */ + shouldAttach?: (event: Event, hint: EventHint) => boolean; + + /** + * A function that returns the root element to start walking the DOM from. + * + * Default: `window.document.body` + */ + rootElement?: () => HTMLElement | undefined; + + /** + * Called for each HTMLElement as we walk the DOM. + * + * Return an object to include the element with any additional properties. + * Return `skip` to exclude the element and its children. + * Return `children` to skip the element but include its children. + */ + onElement?: (prop: OnElementArgs) => Record | 'skip' | 'children'; +} + +/** + * An integration to include a view hierarchy attachment which contains the DOM. + */ +export const viewHierarchyIntegration = defineIntegration((options: Options = {}) => { + const skipHtmlTags = ['script']; + + /** Walk an element */ + function walk(element: HTMLElement, windows: ViewHierarchyWindow[], depth = 0): void { + if (!element) { + return; + } + + // With Web Components, we need to walk into shadow DOMs + const children = 'shadowRoot' in element && element.shadowRoot ? element.shadowRoot.children : element.children; + + for (const child of children) { + if (!(child instanceof HTMLElement)) { + continue; + } + + const componentName = getComponentName(child, 1) || undefined; + const tagName = child.tagName.toLowerCase(); + + if (skipHtmlTags.includes(tagName)) { + continue; + } + + const result = options.onElement?.({ element: child, componentName, tagName, depth }) || {}; + + if (result === 'skip') { + continue; + } + + // Skip this element but include its children + if (result === 'children') { + walk(child, windows, depth + 1); + continue; + } + + const { x, y, width, height } = child.getBoundingClientRect(); + + const window: ViewHierarchyWindow = { + identifier: (child.id || undefined) as string, + type: componentName || tagName, + visible: true, + alpha: 1, + height, + width, + x, + y, + ...result, + }; + + const children: ViewHierarchyWindow[] = []; + window.children = children; + + // Recursively walk the children + walk(child, window.children, depth + 1); + + windows.push(window); + } + } + + return { + name: 'ViewHierarchy', + processEvent: (event, hint) => { + // only capture for error events + if (event.type !== undefined || options.shouldAttach?.(event, hint) === false) { + return event; + } + + const root: ViewHierarchyData = { + rendering_system: 'DOM', + positioning: 'absolute', + windows: [], + }; + + walk(options.rootElement?.() || WINDOW.document.body, root.windows); + + const attachment: Attachment = { + filename: 'view-hierarchy.json', + attachmentType: 'event.view_hierarchy', + contentType: 'application/json', + data: JSON.stringify(root), + }; + + hint.attachments = hint.attachments || []; + hint.attachments.push(attachment); + + return event; + }, + }; +}); diff --git a/packages/core/src/types-hoist/view-hierarchy.ts b/packages/core/src/types-hoist/view-hierarchy.ts index a066bfbe42e6..453f8c7daca8 100644 --- a/packages/core/src/types-hoist/view-hierarchy.ts +++ b/packages/core/src/types-hoist/view-hierarchy.ts @@ -14,5 +14,6 @@ export type ViewHierarchyWindow = { export type ViewHierarchyData = { rendering_system: string; + positioning?: 'absolute' | 'relative'; windows: ViewHierarchyWindow[]; }; diff --git a/packages/core/src/utils/browser.ts b/packages/core/src/utils/browser.ts index 6c062f8f6f60..9237af237ba2 100644 --- a/packages/core/src/utils/browser.ts +++ b/packages/core/src/utils/browser.ts @@ -145,15 +145,14 @@ export function getLocationHref(): string { * * @returns a string representation of the component for the provided DOM element, or `null` if not found */ -export function getComponentName(elem: unknown): string | null { +export function getComponentName(elem: unknown, maxTraverseHeight: number = 5): string | null { // @ts-expect-error WINDOW has HTMLElement if (!WINDOW.HTMLElement) { return null; } let currentElem = elem as SimpleNode; - const MAX_TRAVERSE_HEIGHT = 5; - for (let i = 0; i < MAX_TRAVERSE_HEIGHT; i++) { + for (let i = 0; i < maxTraverseHeight; i++) { if (!currentElem) { return null; } From f6fc6a25fc10e79d70e2bc02212ba39c044fcad3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:55:34 +0200 Subject: [PATCH 43/73] chore: Add PR review reminder workflow (#20175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/pr-review-reminder.yml and scripts/pr-review-reminder.mjs. - Schedule: weekdays 10:00 UTC; also workflow_dispatch. - Skips draft PRs and PRs opened by Bot users. - Only GitHub pending requested reviewers (users/teams not yet reviewed). - Fires when ≥2 full elapsed business days (UTC) since last review_requested or last reminder for that login/team; weekends excluded; US/CA/AT holidays via Nager.Date (fallback: weekdays only if API fails). - One issue comment per PR; per-reviewer HTML markers; dedup using prior github-actions[bot] comments. - Individual @mentions: omit repo outside collaborators (listCollaborators affiliation: outside); on API failure, skip individuals (warn), teams unchanged. - Team @mentions: only pending team slug team-javascript-sdks. - Warn if pending reviewer/team has no matching review_requested timeline event. - Job skipped on workflow_dispatch for forks; schedule always runs on default branch. - GITHUB_TOKEN permissions: contents: read, issues: write, pull-requests: read. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> Co-authored-by: Lukas Stracke Co-authored-by: Claude --- .github/workflows/pr-review-reminder.yml | 39 ++++ scripts/pr-review-reminder.mjs | 286 +++++++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 .github/workflows/pr-review-reminder.yml create mode 100644 scripts/pr-review-reminder.mjs diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml new file mode 100644 index 000000000000..3eda72221948 --- /dev/null +++ b/.github/workflows/pr-review-reminder.yml @@ -0,0 +1,39 @@ +name: 'PR: Review Reminder' + +on: + workflow_dispatch: + schedule: + # Run on weekdays at 10:00 AM UTC. No new reminders can fire on weekends because + # Saturday/Sunday are never counted as business days. + - cron: '0 10 * * 1-5' + +# pulls.* list + listRequestedReviewers → pull-requests: read +# issues timeline + comments + createComment → issues: write +# repos.listCollaborators (outside) → Metadata read on the token (see GitHub App permission map) +# checkout → contents: read +permissions: + contents: read + issues: write + pull-requests: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + remind-reviewers: + # `schedule` has no `repository` on github.event; forks must be skipped only for workflow_dispatch. + if: github.event_name == 'schedule' || github.event.repository.fork != true + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Remind pending reviewers + uses: actions/github-script@v7 + with: + script: | + const { default: run } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/pr-review-reminder.mjs` + ); + await run({ github, context, core }); diff --git a/scripts/pr-review-reminder.mjs b/scripts/pr-review-reminder.mjs new file mode 100644 index 000000000000..535f2d430331 --- /dev/null +++ b/scripts/pr-review-reminder.mjs @@ -0,0 +1,286 @@ +/** + * PR Review Reminder script. + * + * Posts reminder comments on open PRs whose requested reviewers have not + * responded within 2 business days. Re-nags every 2 business days thereafter + * until the review is submitted (or the request is removed). + * + * @mentions are narrowed as follows: + * - Individual users: not [outside collaborators](https://docs.github.com/en/organizations/managing-outside-collaborators) + * on this repo (via `repos.listCollaborators` with `affiliation: outside` — repo-scoped, no extra token). + * - Team reviewers: only the org team `team-javascript-sdks` (by slug). + * + * Business days exclude weekends and a small set of recurring public holidays + * (same calendar date each year) for US, CA, and AT. + * + * Intended to be called from a GitHub Actions workflow via actions/github-script: + * + * const { default: run } = await import( + * `${process.env.GITHUB_WORKSPACE}/scripts/pr-review-reminder.mjs` + * ); + * await run({ github, context, core }); + */ + +// Team @mentions only for this slug. Individuals are filtered using outside-collaborator list (see below). +const SDK_TEAM_SLUG = 'team-javascript-sdks'; + +// --------------------------------------------------------------------------- +// Outside collaborators (repo API — works with default GITHUB_TOKEN). +// Org members with access via teams or default permissions are not listed here. +// --------------------------------------------------------------------------- + +async function loadOutsideCollaboratorLogins(github, owner, repo, core) { + try { + const users = await github.paginate(github.rest.repos.listCollaborators, { + owner, + repo, + affiliation: 'outside', + per_page: 100, + }); + return new Set(users.map(u => u.login)); + } catch (e) { + const status = e.response?.status; + core.warning( + `Could not list outside collaborators for ${owner}/${repo} (${status ? `HTTP ${status}` : 'no status'}): ${e.message}. ` + + 'Skipping @mentions for individual reviewers (team reminders unchanged).', + ); + return null; + } +} + +// --------------------------------------------------------------------------- +// Recurring public holidays (month–day in UTC, same date every year). +// A calendar day counts as a holiday if it appears in any country list. +// --------------------------------------------------------------------------- + +const RECURRING_PUBLIC_HOLIDAYS_AT = [ + '01-01', + '01-06', + '05-01', + '08-15', + '10-26', + '11-01', + '12-08', + '12-24', + '12-25', + '12-26', + '12-31', +]; + +const RECURRING_PUBLIC_HOLIDAYS_CA = ['01-01', '07-01', '09-30', '11-11', '12-24', '12-25', '12-26', '12-31']; + +const RECURRING_PUBLIC_HOLIDAYS_US = ['01-01', '06-19', '07-04', '11-11', '12-24', '12-25', '12-26', '12-31']; + +const RECURRING_PUBLIC_HOLIDAY_MM_DD = new Set([ + ...RECURRING_PUBLIC_HOLIDAYS_AT, + ...RECURRING_PUBLIC_HOLIDAYS_CA, + ...RECURRING_PUBLIC_HOLIDAYS_US, +]); + +function monthDayUTC(date) { + const m = String(date.getUTCMonth() + 1).padStart(2, '0'); + const d = String(date.getUTCDate()).padStart(2, '0'); + return `${m}-${d}`; +} + +// --------------------------------------------------------------------------- +// Business-day counter. +// Counts fully-elapsed business days (Mon–Fri, not a public holiday) between +// requestedAt and now. "Fully elapsed" means the day has completely passed, +// so today is not included — giving the reviewer the rest of today to respond. +// +// Example: review requested Friday → elapsed complete days include Sat, Sun, +// Mon, Tue, … The first two business days are Mon and Tue, so the reminder +// fires on Wednesday morning. That gives the reviewer all of Monday and +// Tuesday to respond. +// --------------------------------------------------------------------------- + +function countElapsedBusinessDays(requestedAt, now) { + // Walk from the day after the request up to (but not including) today. + const start = new Date(requestedAt); + start.setUTCHours(0, 0, 0, 0); + start.setUTCDate(start.getUTCDate() + 1); + + const todayUTC = new Date(now); + todayUTC.setUTCHours(0, 0, 0, 0); + + let count = 0; + const cursor = new Date(start); + while (cursor < todayUTC) { + const dow = cursor.getUTCDay(); // 0 = Sun, 6 = Sat + if (dow !== 0 && dow !== 6) { + if (!RECURRING_PUBLIC_HOLIDAY_MM_DD.has(monthDayUTC(cursor))) { + count++; + } + } + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return count; +} + +// --------------------------------------------------------------------------- +// Reminder marker helpers +// --------------------------------------------------------------------------- + +// Returns a unique HTML comment marker for a reviewer key (login or "team:slug"). +// Used for precise per-reviewer deduplication in existing comments. +function reminderMarker(key) { + return ``; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export default async function run({ github, context, core }) { + const { owner, repo } = context.repo; + const now = new Date(); + + core.info(`Using ${RECURRING_PUBLIC_HOLIDAY_MM_DD.size} recurring public holiday month–day values (US/CA/AT union)`); + + const outsideCollaboratorLogins = await loadOutsideCollaboratorLogins(github, owner, repo, core); + if (outsideCollaboratorLogins) { + core.info(`Excluding ${outsideCollaboratorLogins.size} outside collaborator login(s) from individual @mentions`); + } + + // --------------------------------------------------------------------------- + // Main loop + // --------------------------------------------------------------------------- + + // Fetch all open PRs + const prs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + + core.info(`Found ${prs.length} open PRs`); + + for (const pr of prs) { + // Skip draft PRs and PRs opened by bots + if (pr.draft) continue; + if (pr.user?.type === 'Bot') continue; + + // Get currently requested reviewers (only those who haven't reviewed yet — + // GitHub automatically removes a reviewer from this list once they submit a review) + const { data: requested } = await github.rest.pulls.listRequestedReviewers({ + owner, + repo, + pull_number: pr.number, + }); + + const pendingReviewers = requested.users; // individual users + const pendingTeams = requested.teams; // team reviewers + if (pendingReviewers.length === 0 && pendingTeams.length === 0) continue; + + // Fetch the PR timeline to determine when each review was (last) requested + const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { + owner, + repo, + issue_number: pr.number, + per_page: 100, + }); + + // Fetch existing comments so we can detect previous reminders + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pr.number, + per_page: 100, + }); + + const botComments = comments.filter(c => c.user?.login === 'github-actions[bot]'); + + // Returns the date of the most recent reminder comment that contains the given marker, + // or null if no such comment exists. + function latestReminderDate(key) { + const marker = reminderMarker(key); + const matches = botComments + .filter(c => c.body.includes(marker)) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + return matches.length > 0 ? new Date(matches[0].created_at) : null; + } + + // Returns true if a reminder is due for a reviewer/team: + // - The "anchor" is the later of: the review-request date, or the last + // reminder we already posted for this reviewer. This means the + // 2-business-day clock restarts after every reminder (re-nagging), and + // also resets when a new push re-requests the review. + // - A reminder fires when ≥ 2 full business days have elapsed since the anchor. + function needsReminder(requestedAt, key) { + const lastReminded = latestReminderDate(key); + const anchor = lastReminded && lastReminded > requestedAt ? lastReminded : requestedAt; + return countElapsedBusinessDays(anchor, now) >= 2; + } + + // Collect overdue individual reviewers + const toRemind = []; // { key, mention } + + for (const reviewer of pendingReviewers) { + const requestEvents = timeline + .filter(e => e.event === 'review_requested' && e.requested_reviewer?.login === reviewer.login) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + + if (requestEvents.length === 0) { + core.warning( + `PR #${pr.number}: pending reviewer @${reviewer.login} has no matching review_requested timeline event; skipping reminder for them.`, + ); + continue; + } + + const requestedAt = new Date(requestEvents[0].created_at); + if (!needsReminder(requestedAt, reviewer.login)) continue; + + if (outsideCollaboratorLogins === null) { + continue; + } + if (outsideCollaboratorLogins.has(reviewer.login)) { + continue; + } + + toRemind.push({ key: reviewer.login, mention: `@${reviewer.login}` }); + } + + // Collect overdue team reviewers + for (const team of pendingTeams) { + if (team.slug !== SDK_TEAM_SLUG) { + continue; + } + + const requestEvents = timeline + .filter(e => e.event === 'review_requested' && e.requested_team?.slug === team.slug) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + + if (requestEvents.length === 0) { + core.warning( + `PR #${pr.number}: pending team reviewer @${owner}/${team.slug} has no matching review_requested timeline event; skipping reminder for them.`, + ); + continue; + } + + const requestedAt = new Date(requestEvents[0].created_at); + const key = `team:${team.slug}`; + if (!needsReminder(requestedAt, key)) continue; + + toRemind.push({ key, mention: `@${owner}/${team.slug}` }); + } + + if (toRemind.length === 0) continue; + + // Build a single comment that includes per-reviewer markers (for precise dedup + // on subsequent runs) and @-mentions all overdue reviewers/teams. + const markers = toRemind.map(({ key }) => reminderMarker(key)).join('\n'); + const mentions = toRemind.map(({ mention }) => mention).join(', '); + const body = `${markers}\n👋 ${mentions} — Please review this PR when you get a chance!`; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body, + }); + + core.info(`Posted review reminder on PR #${pr.number} for: ${mentions}`); + } +} From 239eb3b421a0bca2e28baea548bc0bd538bdc6df Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:38:50 +0200 Subject: [PATCH 44/73] fix(e2e-tests): Remove flaky navigation breadcrumb assertions from parameterized-routes tests (#20202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation breadcrumb assertion in nextjs-16 parameterized-routes test is flaky — the breadcrumb isn't reliably present due to timing. These tests validate route parameterization, not breadcrumb recording, so the assertion is unnecessary. - Removed `breadcrumbs: expect.arrayContaining([...])` from all parameterized-routes tests across all Next.js versions (13, 14, 15, 16, 16-bun, 16-cf-workers) - Core assertions retained: transaction name, source, trace context, request URL, environment --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> --- .../tests/client/parameterized-routes.test.ts | 28 ------------------- .../tests/parameterized-routes.test.ts | 28 ------------------- .../tests/parameterized-routes.test.ts | 28 ------------------- .../tests/parameterized-routes.test.ts | 28 ------------------- .../tests/parameterized-routes.test.ts | 28 ------------------- .../tests/parameterized-routes.test.ts | 28 ------------------- 6 files changed, 168 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts index b53cda3ac968..ba446f2e7c4e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts @@ -13,13 +13,6 @@ test('should create a parameterized transaction when the `app` directory is used const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -61,13 +54,6 @@ test('should create a static transaction when the `app` directory is used and th const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -107,13 +93,6 @@ test('should create a partially parameterized transaction when the `app` directo const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -154,13 +133,6 @@ test('should create a nested parameterized transaction when the `app` directory const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts index 2a5e2910050a..55ac655dfc5a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts @@ -13,13 +13,6 @@ test('should create a parameterized transaction when the `app` directory is used const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -61,13 +54,6 @@ test('should create a static transaction when the `app` directory is used and th const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -107,13 +93,6 @@ test('should create a partially parameterized transaction when the `app` directo const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -154,13 +133,6 @@ test('should create a nested parameterized transaction when the `app` directory const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts index fb93e77aaf8b..ab2086622ca3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts @@ -13,13 +13,6 @@ test('should create a parameterized transaction when the `app` directory is used const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -61,13 +54,6 @@ test('should create a static transaction when the `app` directory is used and th const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -107,13 +93,6 @@ test('should create a partially parameterized transaction when the `app` directo const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -154,13 +133,6 @@ test('should create a nested parameterized transaction when the `app` directory const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts index dc16f1590aa3..07e5f007efad 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts @@ -13,13 +13,6 @@ test('should create a parameterized transaction when the `app` directory is used const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -61,13 +54,6 @@ test('should create a static transaction when the `app` directory is used and th const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -107,13 +93,6 @@ test('should create a partially parameterized transaction when the `app` directo const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -154,13 +133,6 @@ test('should create a nested parameterized transaction when the `app` directory const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts index 3c9ab427b3de..30faebe69548 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts @@ -14,13 +14,6 @@ test.skip('should create a parameterized transaction when the `app` directory is const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -63,13 +56,6 @@ test.skip('should create a static transaction when the `app` directory is used a const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -110,13 +96,6 @@ test.skip('should create a partially parameterized transaction when the `app` di const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -158,13 +137,6 @@ test.skip('should create a nested parameterized transaction when the `app` direc const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts index 4078ded5734d..43a2aa6191de 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts @@ -13,13 +13,6 @@ test('should create a parameterized transaction when the `app` directory is used const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -61,13 +54,6 @@ test('should create a static transaction when the `app` directory is used and th const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -107,13 +93,6 @@ test('should create a partially parameterized transaction when the `app` directo const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -154,13 +133,6 @@ test('should create a nested parameterized transaction when the `app` directory const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { From 4fccad55e7ac5bebf59b3e772bc5891944ce5d8e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:42:29 +0000 Subject: [PATCH 45/73] chore(deps-dev): Bump @sveltejs/kit from 2.53.3 to 2.57.1 (#20216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.53.3 to 2.57.1.
Release notes

Sourced from @​sveltejs/kit's releases.

@​sveltejs/kit@​2.57.1

Patch Changes

  • fix: better validation for redirect inputs (10d7b44)

  • fix: enforce BODY_SIZE_LIMIT on chunked requests (3202ed6)

  • fix: use default values as fallbacks (#15680)

  • fix: relax form typings for union types (#15687)

@​sveltejs/kit@​2.57.0

Minor Changes

  • feat: return boolean from submit to indicate submission validity for enhanced form remote functions (#15530)

Patch Changes

  • fix: use array type for select fields that accept multiple values (#15591)

  • fix: silently 404 Chrome DevTools workspaces request in dev and preview (#15656)

  • fix: config.kit.csp.directives['trusted-types'] requires 'svelte-trusted-html' (and 'sveltekit-trusted-url' when a service worker is automatically registered) if it is configured (#15323)

  • fix: avoid inlineDynamicImports ignored with codeSplitting warning when using Vite 8 (#15647)

  • fix: reimplement treeshaking non-dynamic prerendered remote functions (#15447)

@​sveltejs/kit@​2.56.1

Patch Changes

  • chore: update JSDoc (#15640)

@​sveltejs/kit@​2.56.0

Minor Changes

  • breaking: rework client-driven refreshes (#15562)

  • breaking: stabilize remote function caching by sorting object keys (#15570)

  • breaking: add run() method to queries, disallow awaiting queries outside render (#15533)

... (truncated)

Changelog

Sourced from @​sveltejs/kit's changelog.

2.57.1

Patch Changes

  • fix: better validation for redirect inputs (10d7b44)

  • fix: enforce BODY_SIZE_LIMIT on chunked requests (3202ed6)

  • fix: use default values as fallbacks (#15680)

  • fix: relax form typings for union types (#15687)

2.57.0

Minor Changes

  • feat: return boolean from submit to indicate submission validity for enhanced form remote functions (#15530)

Patch Changes

  • fix: use array type for select fields that accept multiple values (#15591)

  • fix: silently 404 Chrome DevTools workspaces request in dev and preview (#15656)

  • fix: config.kit.csp.directives['trusted-types'] requires 'svelte-trusted-html' (and 'sveltekit-trusted-url' when a service worker is automatically registered) if it is configured (#15323)

  • fix: avoid inlineDynamicImports ignored with codeSplitting warning when using Vite 8 (#15647)

  • fix: reimplement treeshaking non-dynamic prerendered remote functions (#15447)

2.56.1

Patch Changes

  • chore: update JSDoc (#15640)

2.56.0

Minor Changes

  • breaking: rework client-driven refreshes (#15562)

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@sveltejs/kit&package-manager=npm_and_yarn&previous-version=2.53.3&new-version=2.57.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/sveltekit/package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index d841eb0fa7d2..5f6f2d64ca5e 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -58,7 +58,7 @@ "sorcery": "1.0.0" }, "devDependencies": { - "@sveltejs/kit": "^2.53.3", + "@sveltejs/kit": "^2.57.1", "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.2.8", "vite": "^5.4.11" diff --git a/yarn.lock b/yarn.lock index 95937c3d01b6..4b60b6131151 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8775,17 +8775,17 @@ resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz#ac0bde368d6623727b0e0bc568cf6b4e5d5c4baa" integrity sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA== -"@sveltejs/kit@^2.53.3": - version "2.53.3" - resolved "https://registry.yarnpkg.com/@sveltejs/kit/-/kit-2.53.3.tgz#72283a76e63ca62ddc7f500f47ed4aaf86b2b0c4" - integrity sha512-tshOeBUid2v5LAblUpatIdFm5Cyykbw2EiKWOunAAX0A/oJaR7DOdC9wLR5Qqh9zUf3QUISA2m9A3suBdQSYQg== +"@sveltejs/kit@^2.57.1": + version "2.57.1" + resolved "https://registry.yarnpkg.com/@sveltejs/kit/-/kit-2.57.1.tgz#3700c5b5549f1ffbfd42e5f71a80c2c82c0d849e" + integrity sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw== dependencies: "@standard-schema/spec" "^1.0.0" "@sveltejs/acorn-typescript" "^1.0.5" "@types/cookie" "^0.6.0" acorn "^8.14.1" cookie "^0.6.0" - devalue "^5.6.3" + devalue "^5.6.4" esm-env "^1.2.2" kleur "^4.1.5" magic-string "^0.30.5" @@ -14870,10 +14870,10 @@ devalue@^4.3.2: resolved "https://registry.yarnpkg.com/devalue/-/devalue-4.3.3.tgz#e35df3bdc49136837e77986f629b9fa6fef50726" integrity sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg== -devalue@^5.1.1, devalue@^5.6.2, devalue@^5.6.3: - version "5.6.4" - resolved "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz" - integrity sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA== +devalue@^5.1.1, devalue@^5.6.2, devalue@^5.6.4: + version "5.7.1" + resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.7.1.tgz#93e2d9412b909a7901d7b966ebb3479d15a390fd" + integrity sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA== devlop@^1.0.0: version "1.1.0" From ac0d8888c821aac6e7e4e56006abb70ba82a6a64 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 14 Apr 2026 09:45:43 +0200 Subject: [PATCH 46/73] feat(cloudflare,deno,vercel-edge): Add span streaming support (#20127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds span streaming support to cloudflare, deno and vercel edge SDKs. Similarly to Node, we check for the `traceLifecycle` option and add `spanStreamingIntegration` based on the option. Also added unit, integration and e2e tests to ensure that the SDKs actually send spans. Similarly to Node and Browser, we'll likely still miss data from event processors but we can follow up with this later. h/t @JPeer264 for the e2e test helpers! I cherry-picked them from getsentry/sentry-javascript#17852 and adjusted them for naming. ref [JS-1010](https://linear.app/getsentry/issue/JS-1010/span-streaming-implementation) #17836 --------- Co-authored-by: Jan Peer Stöcklmair --- .github/workflows/build.yml | 2 +- .../public-api/startSpan-streamed/index.ts | 36 +++ .../public-api/startSpan-streamed/test.ts | 264 ++++++++++++++++ .../startSpan-streamed/wrangler.jsonc | 6 + .../test-applications/deno-streamed/.npmrc | 2 + .../test-applications/deno-streamed/deno.json | 11 + .../deno-streamed/package.json | 25 ++ .../deno-streamed/playwright.config.mjs | 8 + .../deno-streamed/src/app.ts | 66 ++++ .../deno-streamed/start-event-proxy.mjs | 6 + .../deno-streamed/tests/spans.test.ts | 284 ++++++++++++++++++ .../test-utils/src/event-proxy-server.ts | 193 ++++++++++++ dev-packages/test-utils/src/index.ts | 4 + packages/cloudflare/src/index.ts | 1 + packages/cloudflare/src/sdk.ts | 8 +- packages/cloudflare/test/sdk.test.ts | 41 +++ packages/deno/src/index.ts | 1 + packages/deno/src/sdk.ts | 8 +- packages/deno/test/sdk.test.ts | 30 +- packages/vercel-edge/src/index.ts | 1 + packages/vercel-edge/src/sdk.ts | 8 +- packages/vercel-edge/test/sdk.test.ts | 39 +++ 22 files changed, 1039 insertions(+), 5 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/deno.json create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/package.json create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts create mode 100644 packages/vercel-edge/test/sdk.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1da734901b12..6032cee30fdc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -910,7 +910,7 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: matrix.test-application == 'deno' + if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' uses: denoland/setup-deno@v2.0.3 with: deno-version: v2.1.5 diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts new file mode 100644 index 000000000000..76039b6892ee --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + release: '1.0.0', + }), + { + async fetch(_request, _env, _ctx) { + Sentry.startSpan({ name: 'test-span', op: 'test' }, segmentSpan => { + Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => { + // noop + }); + + const inactiveSpan = Sentry.startInactiveSpan({ name: 'test-inactive-span' }); + inactiveSpan.addLink({ + context: segmentSpan.spanContext(), + attributes: { 'sentry.link.type': 'some_relation' }, + }); + inactiveSpan.end(); + + Sentry.startSpanManual({ name: 'test-manual-span' }, span => { + span.end(); + }); + }); + + return new Response('OK'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts new file mode 100644 index 000000000000..090142714d5b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts @@ -0,0 +1,264 @@ +import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +const CLOUDFLARE_SDK = 'sentry.javascript.cloudflare'; + +function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { + const spanItem = envelope[1].find(item => item[0].type === 'span'); + expect(spanItem).toBeDefined(); + return spanItem![1] as SerializedStreamedSpanContainer; +} + +it('sends a streamed span envelope with correct envelope header', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + expect(getSpanContainer(envelope).items.length).toBeGreaterThan(0); + + expect(envelope[0]).toEqual( + expect.objectContaining({ + sent_at: expect.any(String), + sdk: { + name: CLOUDFLARE_SDK, + version: SDK_VERSION, + }, + trace: expect.objectContaining({ + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }), + }), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); + +it('sends a streamed span envelope with correct spans for a manually started span with children', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const container = getSpanContainer(envelope); + const spans = container.items; + + // Cloudflare `withSentry` wraps fetch in an http.server span (segment) around the scenario. + expect(spans.length).toBe(5); + + const segmentSpan = spans.find(s => !!s.is_segment); + expect(segmentSpan).toBeDefined(); + + const segmentSpanId = segmentSpan!.span_id; + const traceId = segmentSpan!.trace_id; + const segmentName = segmentSpan!.name; + + const parentTestSpan = spans.find(s => s.name === 'test-span'); + expect(parentTestSpan).toBeDefined(); + expect(parentTestSpan!.parent_span_id).toBe(segmentSpanId); + + const childSpan = spans.find(s => s.name === 'test-child-span'); + expect(childSpan).toBeDefined(); + expect(childSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'test-child', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + }, + name: 'test-child-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const inactiveSpan = spans.find(s => s.name === 'test-inactive-span'); + expect(inactiveSpan).toBeDefined(); + expect(inactiveSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + links: [ + { + attributes: { + 'sentry.link.type': { + type: 'string', + value: 'some_relation', + }, + }, + sampled: true, + span_id: parentTestSpan!.span_id, + trace_id: traceId, + }, + ], + name: 'test-inactive-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const manualSpan = spans.find(s => s.name === 'test-manual-span'); + expect(manualSpan).toBeDefined(); + expect(manualSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + name: 'test-manual-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + expect(parentTestSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + }, + name: 'test-span', + is_segment: false, + parent_span_id: segmentSpanId, + trace_id: traceId, + span_id: parentTestSpan!.span_id, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + expect(segmentSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.cloudflare' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'http.server' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'route' }, + 'sentry.span.source': { type: 'string', value: 'route' }, + 'server.address': { + type: 'string', + value: 'localhost', + }, + 'url.full': { + type: 'string', + value: expect.stringMatching(/^http:\/\/localhost:.+$/), + }, + 'url.path': { + type: 'string', + value: '/', + }, + 'url.port': { + type: 'string', + value: '8787', + }, + 'url.scheme': { + type: 'string', + value: 'http:', + }, + 'user_agent.original': { + type: 'string', + value: 'node', + }, + 'http.request.header.accept': { + type: 'string', + value: '*/*', + }, + 'http.request.header.accept_encoding': { + type: 'string', + value: 'br, gzip', + }, + 'http.request.header.accept_language': { + type: 'string', + value: '*', + }, + 'http.request.header.cf_connecting_ip': { + type: 'string', + value: '::1', + }, + 'http.request.header.host': { + type: 'string', + value: expect.stringMatching(/^localhost:.+$/), + }, + 'http.request.header.sec_fetch_mode': { + type: 'string', + value: 'cors', + }, + 'http.request.header.user_agent': { + type: 'string', + value: 'node', + }, + 'http.request.method': { + type: 'string', + value: 'GET', + }, + 'http.response.status_code': { + type: 'integer', + value: 200, + }, + 'network.protocol.name': { + type: 'string', + value: 'HTTP/1.1', + }, + }, + is_segment: true, + trace_id: traceId, + span_id: segmentSpanId, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + name: 'GET /', + }); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc new file mode 100644 index 000000000000..b247aa82fb26 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "start-span-streamed", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/.npmrc b/dev-packages/e2e-tests/test-applications/deno-streamed/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/deno.json b/dev-packages/e2e-tests/test-applications/deno-streamed/deno.json new file mode 100644 index 000000000000..35242c740171 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/deno.json @@ -0,0 +1,11 @@ +{ + "imports": { + "@sentry/deno": "npm:@sentry/deno", + "@sentry/core": "npm:@sentry/core", + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", + "ai": "npm:ai@^3.0.0", + "ai/test": "npm:ai@^3.0.0/test", + "zod": "npm:zod@^3.22.4" + }, + "nodeModulesDir": "manual" +} diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/package.json b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json new file mode 100644 index 000000000000..70a20db2de05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json @@ -0,0 +1,25 @@ +{ + "name": "deno-streamed-app", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "deno run --allow-net --allow-env --allow-read src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/deno": "latest || *", + "@opentelemetry/api": "^1.9.0", + "ai": "^3.0.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs new file mode 100644 index 000000000000..3d3ab7d8df02 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts new file mode 100644 index 000000000000..206eb7f6f387 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts @@ -0,0 +1,66 @@ +import { trace } from '@opentelemetry/api'; + +// Simulate a pre-existing OTel provider (like Supabase Edge Runtime registers +// before user code runs). Without trace.disable() in Sentry's setup, this would +// cause setGlobalTracerProvider to be a no-op, silently dropping all OTel spans. +const fakeProvider = { + getTracer: () => ({ + startSpan: () => ({ end: () => {}, setAttributes: () => {} }), + startActiveSpan: (_name: string, fn: Function) => fn({ end: () => {}, setAttributes: () => {} }), + }), +}; +trace.setGlobalTracerProvider(fakeProvider as any); + +// Sentry.init() must call trace.disable() to clear the fake provider above +import * as Sentry from '@sentry/deno'; + +Sentry.init({ + environment: 'qa', + dsn: Deno.env.get('E2E_TEST_DSN'), + debug: !!Deno.env.get('DEBUG'), + tunnel: 'http://localhost:3031/', + traceLifecycle: 'stream', + tracesSampleRate: 1, + sendDefaultPii: true, + enableLogs: true, +}); + +const port = 3030; + +function flushDeferred() { + setTimeout(() => { + Sentry.flush(); + }, 100); +} + +Deno.serve({ port }, async (req: Request) => { + const url = new URL(req.url); + + // Test Sentry.startSpan — uses Sentry's internal pipeline + if (url.pathname === '/test-sentry-span') { + Sentry.startSpan({ name: 'test-sentry-span' }, () => { + // noop + }); + flushDeferred(); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test interop: OTel span inside a Sentry span + if (url.pathname === '/test-interop') { + Sentry.startSpan({ name: 'sentry-parent' }, () => { + const tracer = trace.getTracer('test-tracer'); + const span = tracer.startSpan('otel-child'); + span.end(); + }); + flushDeferred(); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response('Not found', { status: 404 }); +}); + +console.log(`Deno test app listening on port ${port}`); diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs new file mode 100644 index 000000000000..a0c7bfc7222f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'deno-streamed', +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts new file mode 100644 index 000000000000..023429b07f41 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts @@ -0,0 +1,284 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +const SEGMENT_SPAN = { + attributes: { + 'client.address': { + type: 'string', + value: expect.any(String), + }, + 'client.port': { + type: 'integer', + value: expect.any(Number), + }, + 'http.request.header.accept': { + type: 'string', + value: '*/*', + }, + 'http.request.header.accept_encoding': { + type: 'string', + value: 'gzip, deflate', + }, + 'http.request.header.accept_language': { + type: 'string', + value: '*', + }, + 'http.request.header.connection': { + type: 'string', + value: 'keep-alive', + }, + 'http.request.header.host': { + type: 'string', + value: expect.stringMatching(/^localhost:\d+$/), + }, + 'http.request.header.sec_fetch_mode': { + type: 'string', + value: 'cors', + }, + 'http.request.header.user_agent': { + type: 'string', + value: 'node', + }, + 'http.request.method': { + type: 'string', + value: 'GET', + }, + 'http.response.header.content_type': { + type: 'string', + value: 'application/json', + }, + 'http.response.status_code': { + type: 'integer', + value: expect.any(Number), + }, + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.op': { + type: 'string', + value: 'http.server', + }, + 'sentry.origin': { + type: 'string', + value: 'auto.http.deno', + }, + 'sentry.sample_rate': { + type: 'integer', + value: 1, + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-sentry-span', + }, + 'sentry.source': { + type: 'string', + value: 'url', + }, + 'sentry.span.source': { + type: 'string', + value: 'url', + }, + 'server.address': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-sentry-span$/), + }, + 'url.path': { + type: 'string', + value: '/test-sentry-span', + }, + 'url.port': { + type: 'string', + value: expect.any(String), + }, + 'url.scheme': { + type: 'string', + value: 'http:', + }, + 'user_agent.original': { + type: 'string', + value: 'node', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + name: 'GET /test-sentry-span', + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), +}; + +test('Sends streamed spans (http.server and manual with Sentry.startSpan)', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('deno-streamed', spans => { + return spans.some(span => span.name === 'test-sentry-span'); + }); + + await fetch(`${baseURL}/test-sentry-span`); + + const spans = await spansPromise; + expect(spans).toHaveLength(2); + + expect(spans).toEqual([ + { + attributes: { + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-sentry-span', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'test-sentry-span', + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }, + SEGMENT_SPAN, + ]); +}); + +test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('deno-streamed', spans => { + return spans.some(span => span.name === 'sentry-parent'); + }); + + await fetch(`${baseURL}/test-interop`); + + const spans = await spansPromise; + + expect(spans).toHaveLength(3); + + const httpServerSpan = spans.find(span => getSpanOp(span) === 'http.server'); + expect(httpServerSpan).toEqual({ + ...SEGMENT_SPAN, + name: 'GET /test-interop', + attributes: { + ...SEGMENT_SPAN.attributes, + 'sentry.segment.name': { type: 'string', value: 'GET /test-interop' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-interop$/) }, + 'url.path': { type: 'string', value: '/test-interop' }, + }, + }); + // Verify the OTel span is a child of the Sentry span + const sentrySpan = spans.find(span => span.name === 'sentry-parent'); + const otelSpan = spans.find(span => span.name === 'otel-child'); + + expect(otelSpan!.parent_span_id).toBe(sentrySpan!.span_id); + + expect(sentrySpan).toEqual({ + attributes: { + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-interop', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'sentry-parent', + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: httpServerSpan!.trace_id, + }); + + expect(otelSpan).toEqual({ + attributes: { + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-interop', + }, + 'sentry.deno_tracer': { + type: 'boolean', + value: true, + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'otel-child', + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: httpServerSpan!.trace_id, + }); +}); diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 9c411c3fc015..77effa924ff4 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -6,6 +6,8 @@ import type { SerializedMetric, SerializedMetricContainer, SerializedSession, + SerializedStreamedSpan, + StreamedSpanEnvelope, } from '@sentry/core'; import { parseEnvelope } from '@sentry/core'; import * as fs from 'fs'; @@ -427,6 +429,197 @@ export function waitForMetric( }); } +/** + * Check if an envelope item is a Span V2 container item. + */ +function isStreamedSpanEnvelopeItem( + envelopeItem: EnvelopeItem, +): envelopeItem is [ + { type: 'span'; content_type: 'application/vnd.sentry.items.span.v2+json'; item_count: number }, + { items: SerializedStreamedSpan[] }, +] { + const [header] = envelopeItem; + return ( + header.type === 'span' && + 'content_type' in header && + header.content_type === 'application/vnd.sentry.items.span.v2+json' + ); +} + +/** + * Wait for a Span V2 envelope to be sent. + * Returns the first Span V2 envelope that is sent that matches the callback. + * If no callback is provided, returns the first Span V2 envelope that is sent. + * + * @example + * ```ts + * const envelope = await waitForSpanV2Envelope(PROXY_SERVER_NAME); + * const spans = envelope[1][0][1].items; + * expect(spans.length).toBeGreaterThan(0); + * ``` + * + * @example + * ```ts + * // With a filter callback + * const envelope = await waitForSpanV2Envelope(PROXY_SERVER_NAME, envelope => { + * return envelope[1][0][1].items.length > 5; + * }); + * ``` + */ +export function waitForStreamedSpanEnvelope( + proxyServerName: string, + callback?: (spanEnvelope: StreamedSpanEnvelope) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForRequest( + proxyServerName, + async eventData => { + const envelope = eventData.envelope; + const envelopeItems = envelope[1]; + + // Check if this is a Span V2 envelope by looking for a Span V2 item + const hasSpanV2Item = envelopeItems.some(item => isStreamedSpanEnvelopeItem(item)); + if (!hasSpanV2Item) { + return false; + } + + const spanV2Envelope = envelope as StreamedSpanEnvelope; + + if (callback) { + return callback(spanV2Envelope); + } + + return true; + }, + timestamp, + ) + .then(eventData => resolve(eventData.envelope as StreamedSpanEnvelope)) + .catch(reject); + }); +} + +/** + * Wait for a single Span V2 to be sent that matches the callback. + * Returns the first Span V2 that is sent that matches the callback. + * If no callback is provided, returns the first Span V2 that is sent. + * + * @example + * ```ts + * const span = await waitForSpanV2(PROXY_SERVER_NAME, span => { + * return span.name === 'GET /api/users'; + * }); + * expect(span.status).toBe('ok'); + * ``` + * + * @example + * ```ts + * // Using the getSpanV2Op helper + * const span = await waitForSpanV2(PROXY_SERVER_NAME, span => { + * return getSpanV2Op(span) === 'http.client'; + * }); + * ``` + */ +export function waitForStreamedSpan( + proxyServerName: string, + callback: (span: SerializedStreamedSpan) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForRequest( + proxyServerName, + async eventData => { + const envelope = eventData.envelope; + const envelopeItems = envelope[1]; + + for (const envelopeItem of envelopeItems) { + if (!isStreamedSpanEnvelopeItem(envelopeItem)) { + continue; + } + + const spans = envelopeItem[1].items; + + for (const span of spans) { + if (await callback(span)) { + resolve(span); + return true; + } + } + } + return false; + }, + timestamp, + ).catch(reject); + }); +} + +/** + * Wait for Span V2 spans to be sent. Returns all spans from the envelope for which the callback returns true. + * If no callback is provided, returns all spans from the first Span V2 envelope. + * + * @example + * ```ts + * // Get all spans from the first envelope + * const spans = await waitForSpansV2(PROXY_SERVER_NAME); + * expect(spans.length).toBeGreaterThan(0); + * ``` + * + * @example + * ```ts + * // Filter for specific spans (same callback style as waitForSpanV2) + * const httpSpans = await waitForSpansV2(PROXY_SERVER_NAME, spans => { + * return spans.some(span => getSpanV2Op(span) === 'http.client'); + * }); + * expect(httpSpans.length).toBe(2); + * ``` + */ +export function waitForStreamedSpans( + proxyServerName: string, + callback?: (spans: SerializedStreamedSpan[]) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForRequest( + proxyServerName, + async eventData => { + const envelope = eventData.envelope; + const envelopeItems = envelope[1]; + + for (const envelopeItem of envelopeItems) { + if (isStreamedSpanEnvelopeItem(envelopeItem)) { + const spans = envelopeItem[1].items; + if (callback) { + if (await callback(spans)) { + resolve(spans); + return true; + } + } else { + resolve(spans); + return true; + } + } + } + return false; + }, + timestamp, + ).catch(reject); + }); +} + +/** + * Helper to get the span operation from a Span V2 JSON object. + * + * @example + * ```ts + * const span = await waitForSpanV2(PROXY_SERVER_NAME, span => { + * return getSpanV2Op(span) === 'http.client'; + * }); + * ``` + */ +export function getSpanOp(span: SerializedStreamedSpan): string | undefined { + return span.attributes?.['sentry.op']?.type === 'string' ? span.attributes['sentry.op'].value : undefined; +} + const TEMP_FILE_PREFIX = 'event-proxy-server-'; async function registerCallbackServerPort(serverName: string, port: string): Promise { diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 749cbdbdd663..54e5d11749b4 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -8,6 +8,10 @@ export { waitForSession, waitForPlainRequest, waitForMetric, + waitForStreamedSpan, + waitForStreamedSpans, + waitForStreamedSpanEnvelope, + getSpanOp, } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index f69978064277..961542e01446 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -108,6 +108,7 @@ export { logger, metrics, withStreamedSpan, + spanStreamingIntegration, instrumentLangGraph, } from '@sentry/core'; diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 0211fa7f96a9..a5eb7f4edcda 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -9,6 +9,7 @@ import { initAndBind, linkedErrorsIntegration, requestDataIntegration, + spanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import type { CloudflareClientOptions, CloudflareOptions } from './client'; @@ -52,10 +53,15 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined { const flushLock = options.ctx ? makeFlushLock(options.ctx) : undefined; delete options.ctx; + const resolvedIntegrations = getIntegrationsToSetup(options); + if (options.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) { + resolvedIntegrations.push(spanStreamingIntegration()); + } + const clientOptions: CloudflareClientOptions = { ...options, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), - integrations: getIntegrationsToSetup(options), + integrations: resolvedIntegrations, transport: options.transport || makeCloudflareTransport, flushLock, }; diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 2f4ec7844559..54b8ee609cda 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -1,8 +1,11 @@ import * as SentryCore from '@sentry/core'; +import type { Integration } from '@sentry/core'; +import { getClient } from '@sentry/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CloudflareClient } from '../src/client'; import { init } from '../src/sdk'; import { resetSdk } from './testUtils'; +import { spanStreamingIntegration } from '../src/'; describe('init', () => { beforeEach(() => { @@ -18,4 +21,42 @@ describe('init', () => { expect(client).toBeDefined(); expect(client).toBeInstanceOf(CloudflareClient); }); + + test('installs SpanStreaming integration when traceLifecycle is "stream"', () => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + traceLifecycle: 'stream', + }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + + test("does not install SpanStreaming integration when traceLifecycle is not 'stream'", () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.not.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + + type MarkedIntegration = Integration & { _custom?: boolean }; + + test("doesn't add spanStreamingIntegration if user added it manually", () => { + const customSpanStreamingIntegration: MarkedIntegration = spanStreamingIntegration(); + customSpanStreamingIntegration._custom = true; + + const client = init({ integrations: [customSpanStreamingIntegration], traceLifecycle: 'stream' }); + const integrations = client?.getOptions().integrations.filter(i => i.name === 'SpanStreaming'); + + expect(integrations?.length).toBe(1); + expect((integrations?.[0] as MarkedIntegration)?._custom).toBe(true); + }); }); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 18bb26f06a4f..424aad03d3d3 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -97,6 +97,7 @@ export { withStreamedSpan, logger, consoleLoggingIntegration, + spanStreamingIntegration, } from '@sentry/core'; export { DenoClient } from './client'; diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index a40055002f57..177c2e91234d 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -9,6 +9,7 @@ import { linkedErrorsIntegration, nodeStackLineParser, requestDataIntegration, + spanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import { DenoClient } from './client'; @@ -95,10 +96,15 @@ export function init(options: DenoOptions = {}): Client { options.defaultIntegrations = getDefaultIntegrations(options); } + const resolvedIntegrations = getIntegrationsToSetup(options); + if (options.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) { + resolvedIntegrations.push(spanStreamingIntegration()); + } + const clientOptions: ServerRuntimeClientOptions = { ...options, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), - integrations: getIntegrationsToSetup(options), + integrations: resolvedIntegrations, transport: options.transport || makeFetchTransport, }; diff --git a/packages/deno/test/sdk.test.ts b/packages/deno/test/sdk.test.ts index 7848f6d372eb..eac8f4d7997e 100644 --- a/packages/deno/test/sdk.test.ts +++ b/packages/deno/test/sdk.test.ts @@ -1,6 +1,34 @@ import { assertNotEquals } from 'https://deno.land/std@0.202.0/assert/assert_not_equals.ts'; -import { init } from '../build/esm/index.js'; +import { assertArrayIncludes } from 'https://deno.land/std@0.212.0/assert/assert_array_includes.ts'; +import { init, spanStreamingIntegration } from '../build/esm/index.js'; +import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; Deno.test('init() should return client', () => { assertNotEquals(init({}), undefined); }); + +Deno.test('adds spanStreamingIntegration when traceLifecycle is "stream"', () => { + const client = init({ traceLifecycle: 'stream' }); + const integrations = client.getOptions().integrations; + assertArrayIncludes( + integrations.map(i => i.name), + ['SpanStreaming'], + ); +}); + +Deno.test('doesn\'t add spanStreamingIntegration when traceLifecycle is not "stream"', () => { + const client = init({}); + const integrations = client.getOptions().integrations; + assert(!integrations.some(i => i.name === 'SpanStreaming')); +}); + +Deno.test("doesn't add spanStreamingIntegration if user added it manually", () => { + const client = init({ + traceLifecycle: 'stream', + integrations: [spanStreamingIntegration()], + }); + const integrations = client.getOptions().integrations.filter(i => i.name === 'SpanStreaming'); + assertEquals(integrations.length, 1); + assert(!integrations[0].isDefaultInstance); +}); diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index c10ca12ebecb..655ade4e18ad 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -103,6 +103,7 @@ export { logger, metrics, withStreamedSpan, + spanStreamingIntegration, } from '@sentry/core'; export { VercelEdgeClient } from './client'; diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 7c7c0626cffa..e35aa770c880 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -23,6 +23,7 @@ import { nodeStackLineParser, requestDataIntegration, SDK_VERSION, + spanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import { @@ -98,10 +99,15 @@ export function init(options: VercelEdgeOptions = {}): Client | undefined { options.environment = options.environment || process.env.SENTRY_ENVIRONMENT || getVercelEnv(false) || process.env.NODE_ENV; + const resolvedIntegrations = getIntegrationsToSetup(options); + if (options.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) { + resolvedIntegrations.push(spanStreamingIntegration()); + } + const client = new VercelEdgeClient({ ...options, stackParser: stackParserFromStackParserOptions(options.stackParser || nodeStackParser), - integrations: getIntegrationsToSetup(options), + integrations: resolvedIntegrations, transport: options.transport || makeEdgeTransport, }); // The client is on the current scope, from where it generally is inherited diff --git a/packages/vercel-edge/test/sdk.test.ts b/packages/vercel-edge/test/sdk.test.ts new file mode 100644 index 000000000000..d5a67edff718 --- /dev/null +++ b/packages/vercel-edge/test/sdk.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { init, spanStreamingIntegration } from '../src'; +import type { Integration } from '@sentry/core'; + +describe('init', () => { + it('adds spanStreamingIntegration when traceLifecycle is "stream"', () => { + const client = init({ traceLifecycle: 'stream' }); + const integrations = client?.getOptions().integrations; + + expect(integrations?.map(i => i.name)).toContain('SpanStreaming'); + }); + + it('doesn\'t add spanStreamingIntegration when traceLifecycle is not "stream"', () => { + const client = init({}); + const integrations = client?.getOptions().integrations; + + expect(integrations?.map(i => i.name)).not.toContain('SpanStreaming'); + }); + + it('adds spanStreaming integration even with custom defaultIntegrations', () => { + const client = init({ traceLifecycle: 'stream', defaultIntegrations: [] }); + const integrations = client?.getOptions().integrations; + + expect(integrations?.map(i => i.name)).toContain('SpanStreaming'); + }); + + type MarkedIntegration = Integration & { _custom?: boolean }; + + it("doesn't add spanStreamingIntegration if user added it manually", () => { + const customSpanStreamingIntegration: MarkedIntegration = spanStreamingIntegration(); + customSpanStreamingIntegration._custom = true; + + const client = init({ traceLifecycle: 'stream', integrations: [customSpanStreamingIntegration] }); + const integrations = client?.getOptions().integrations.filter(i => i.name === 'SpanStreaming'); + + expect(integrations?.length).toBe(1); + expect((integrations?.[0] as MarkedIntegration)?._custom).toBe(true); + }); +}); From 4457493a1aa24c09d8607bc2bb9a4ba7006a1bc1 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 14 Apr 2026 10:17:03 +0200 Subject: [PATCH 47/73] feat(opentelemetry): Vendor `AsyncLocalStorageContextManager` (#20243) This vendors in the required code from `@opentelemetry/context-async-hooks`. This also deprecates the `wrapContextManagerClass` method. It will continue to work until v11, but will be removed then - you'll no longer be able to use that, instead you'll have to use the provided context manager for integrated setup. Old code should continue to work as expected, but `@sentry/node` has one dependency less (and node-core one peer dependency less) and should work the same as before. --------- Co-authored-by: isaacs --- .../package.json | 1 - .../package.json | 1 - .../node-core-express-otel-v1/package.json | 1 - .../package.json | 1 - .../package.json | 1 - .../node-core-express-otel-v2/package.json | 1 - .../node-core-integration-tests/package.json | 1 - packages/node-core/README.md | 5 +- packages/node-core/package.json | 5 - packages/node-core/src/otel/contextManager.ts | 10 +- packages/node/package.json | 1 - packages/opentelemetry/README.md | 9 +- packages/opentelemetry/package.json | 2 - .../src/asyncLocalStorageContextManager.ts | 214 ++++++++++++++++++ packages/opentelemetry/src/contextManager.ts | 55 +---- packages/opentelemetry/src/index.ts | 1 + .../src/utils/buildContextWithSentryScopes.ts | 56 +++++ .../opentelemetry/test/helpers/initOtel.ts | 8 +- yarn.lock | 6 - 19 files changed, 286 insertions(+), 93 deletions(-) create mode 100644 packages/opentelemetry/src/asyncLocalStorageContextManager.ts create mode 100644 packages/opentelemetry/src/utils/buildContextWithSentryScopes.ts diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json index d1957655916b..6f379575019b 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/instrumentation-http": "^0.57.1", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json index 69decb891620..b7d9b06647b3 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", "@opentelemetry/instrumentation": "^0.57.2", "@opentelemetry/instrumentation-http": "^0.57.2", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/package.json index abb49f748d96..28d17064a5ff 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/package.json @@ -14,7 +14,6 @@ "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/instrumentation-http": "^0.57.1", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json index 974d0711acc8..f79c0894bfc9 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-http": "^0.214.0", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json index 00e1ab056be6..dd294c205b32 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-http": "^0.214.0", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json index 77b6006ee947..7c1ea4377070 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json @@ -14,7 +14,6 @@ "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-http": "^0.214.0", diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index 038cb87dc03d..e46fe5825af8 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -27,7 +27,6 @@ "@nestjs/core": "^11", "@nestjs/platform-express": "^11", "@opentelemetry/api": "^1.9.1", - "@opentelemetry/context-async-hooks": "^2.6.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-http": "0.214.0", diff --git a/packages/node-core/README.md b/packages/node-core/README.md index a6245cbd9b0e..c3ccc6df1b3d 100644 --- a/packages/node-core/README.md +++ b/packages/node-core/README.md @@ -13,7 +13,6 @@ Unlike the `@sentry/node` SDK, this SDK comes with no OpenTelemetry auto-instrumentation out of the box. It requires the following OpenTelemetry dependencies and supports both v1 and v2 of OpenTelemetry: - `@opentelemetry/api` -- `@opentelemetry/context-async-hooks` - `@opentelemetry/core` - `@opentelemetry/instrumentation` - `@opentelemetry/resources` @@ -23,10 +22,10 @@ Unlike the `@sentry/node` SDK, this SDK comes with no OpenTelemetry auto-instrum ## Installation ```bash -npm install @sentry/node-core @sentry/opentelemetry @opentelemetry/api @opentelemetry/core @opentelemetry/context-async-hooks @opentelemetry/instrumentation @opentelemetry/resources @opentelemetry/sdk-trace-base @opentelemetry/semantic-conventions +npm install @sentry/node-core @sentry/opentelemetry @opentelemetry/api @opentelemetry/core @opentelemetry/instrumentation @opentelemetry/resources @opentelemetry/sdk-trace-base @opentelemetry/semantic-conventions # Or yarn -yarn add @sentry/node-core @sentry/opentelemetry @opentelemetry/api @opentelemetry/core @opentelemetry/context-async-hooks @opentelemetry/instrumentation @opentelemetry/resources @opentelemetry/sdk-trace-base @opentelemetry/semantic-conventions +yarn add @sentry/node-core @sentry/opentelemetry @opentelemetry/api @opentelemetry/core @opentelemetry/instrumentation @opentelemetry/resources @opentelemetry/sdk-trace-base @opentelemetry/semantic-conventions ``` ## Usage diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 726897c30319..ae247548614e 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -78,7 +78,6 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.1.0", @@ -90,9 +89,6 @@ "@opentelemetry/api": { "optional": true }, - "@opentelemetry/context-async-hooks": { - "optional": true - }, "@opentelemetry/core": { "optional": true }, @@ -119,7 +115,6 @@ }, "devDependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/context-async-hooks": "^2.6.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", "@opentelemetry/instrumentation": "^0.214.0", diff --git a/packages/node-core/src/otel/contextManager.ts b/packages/node-core/src/otel/contextManager.ts index 252508eb7c88..8a41e322cfad 100644 --- a/packages/node-core/src/otel/contextManager.ts +++ b/packages/node-core/src/otel/contextManager.ts @@ -1,11 +1,7 @@ -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { wrapContextManagerClass } from '@sentry/opentelemetry'; +import { SentryAsyncLocalStorageContextManager } from '@sentry/opentelemetry'; /** - * This is a custom ContextManager for OpenTelemetry, which extends the default AsyncLocalStorageContextManager. + * This is a custom ContextManager for OpenTelemetry & Sentry. * It ensures that we create a new hub per context, so that the OTEL Context & the Sentry Scopes are always in sync. - * - * Note that we currently only support AsyncHooks with this, - * but since this should work for Node 14+ anyhow that should be good enough. */ -export const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); +export const SentryContextManager = SentryAsyncLocalStorageContextManager; diff --git a/packages/node/package.json b/packages/node/package.json index a348cb4affa7..7d1d17bfe4a8 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -66,7 +66,6 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/context-async-hooks": "^2.6.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-amqplib": "0.61.0", diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index 86a644f5c467..dd20135c268b 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -43,7 +43,7 @@ This is how you can use this in your app: 2. Call `setupEventContextTrace(client)` 3. Add `SentrySampler` as sampler 4. Add `SentrySpanProcessor` as span processor -5. Add a context manager wrapped via `wrapContextManagerClass` +5. Register the Sentry context manager (`SentryAsyncLocalStorageContextManager`, or `wrapContextManagerClass` for a custom base) 6. Add `SentryPropagator` as propagator 7. Setup OTEL-powered async context strategy for Sentry via `setOpenTelemetryContextAsyncContextStrategy()` @@ -52,14 +52,13 @@ For example, you could set this up as follows: ```js import * as Sentry from '@sentry/node'; import { + SentryAsyncLocalStorageContextManager, SentryPropagator, SentrySampler, SentrySpanProcessor, setupEventContextTrace, - wrapContextManagerClass, setOpenTelemetryContextAsyncContextStrategy, } from '@sentry/opentelemetry'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import { context, propagation, trace } from '@opentelemetry/api'; function setupSentry() { @@ -75,12 +74,10 @@ function setupSentry() { }); provider.addSpanProcessor(new SentrySpanProcessor()); - const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); - // Initialize the provider trace.setGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - context.setGlobalContextManager(new SentryContextManager()); + context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); setOpenTelemetryContextAsyncContextStrategy(); } diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index d63ec3ee2097..64b22768bf7a 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -43,14 +43,12 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, "devDependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/context-async-hooks": "^2.6.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0" diff --git a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts new file mode 100644 index 000000000000..e1a7db98e527 --- /dev/null +++ b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts @@ -0,0 +1,214 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * This implementation follows the behavior of OpenTelemetry’s `@opentelemetry/context-async-hooks` + * package, combining logic that upstream splits across: + * - https://github.com/open-telemetry/opentelemetry-js/blob/main/packages/opentelemetry-context-async-hooks/src/AbstractAsyncHooksContextManager.ts + * - https://github.com/open-telemetry/opentelemetry-js/blob/main/packages/opentelemetry-context-async-hooks/src/AsyncLocalStorageContextManager.ts + * It is a single-class re-implementation for Sentry (not a verbatim copy of those files). + */ + +import type { Context, ContextManager } from '@opentelemetry/api'; +import { ROOT_CONTEXT } from '@opentelemetry/api'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { EventEmitter } from 'node:events'; +import { SENTRY_SCOPES_CONTEXT_KEY } from './constants'; +import type { AsyncLocalStorageLookup } from './contextManager'; +import { buildContextWithSentryScopes } from './utils/buildContextWithSentryScopes'; +import { setIsSetup } from './utils/setupCheck'; + +type ListenerFn = (...args: unknown[]) => unknown; + +/** + * Per-event map from user listeners to context-bound listeners. + */ +type PatchMap = Record>; + +const ADD_LISTENER_METHODS = ['addListener', 'on', 'once', 'prependListener', 'prependOnceListener'] as const; + +/** + * OpenTelemetry-compatible context manager using Node.js `AsyncLocalStorage`. + * Semantics match `@opentelemetry/context-async-hooks` (function `bind` + `EventEmitter` patching). + */ +export class SentryAsyncLocalStorageContextManager implements ContextManager { + protected readonly _asyncLocalStorage = new AsyncLocalStorage(); + + private readonly _kOtListeners = Symbol('OtListeners'); + private _wrapped = false; + + public constructor() { + setIsSetup('SentryContextManager'); + } + + public active(): Context { + return this._asyncLocalStorage.getStore() ?? ROOT_CONTEXT; + } + + public with ReturnType>( + context: Context, + fn: F, + thisArg?: ThisParameterType, + ...args: A + ): ReturnType { + const ctx2 = buildContextWithSentryScopes(context, this.active()); + const cb = thisArg == null ? fn : fn.bind(thisArg); + return this._asyncLocalStorage.run(ctx2, cb as never, ...args); + } + + public enable(): this { + return this; + } + + public disable(): this { + this._asyncLocalStorage.disable(); + return this; + } + + public bind(context: Context, target: T): T { + if (target instanceof EventEmitter) { + return this._bindEventEmitter(context, target); + } + if (typeof target === 'function') { + return this._bindFunction(context, target as unknown as ListenerFn) as T; + } + return target; + } + + /** + * Gets underlying AsyncLocalStorage and symbol to allow lookup of scope. + * This is Sentry-specific. + */ + public getAsyncLocalStorageLookup(): AsyncLocalStorageLookup { + return { + asyncLocalStorage: this._asyncLocalStorage, + contextSymbol: SENTRY_SCOPES_CONTEXT_KEY, + }; + } + + private _bindFunction(context: Context, target: ListenerFn): ListenerFn { + const managerWith = this.with.bind(this); + const contextWrapper = function (this: never, ...args: unknown[]) { + return managerWith(context, () => target.apply(this, args)); + }; + Object.defineProperty(contextWrapper, 'length', { + enumerable: false, + configurable: true, + writable: false, + value: target.length, + }); + return contextWrapper; + } + + private _bindEventEmitter(context: Context, ee: T): T { + if (this._getPatchMap(ee) !== undefined) { + return ee; + } + this._createPatchMap(ee); + + for (const methodName of ADD_LISTENER_METHODS) { + if (ee[methodName] === undefined) continue; + ee[methodName] = this._patchAddListener( + ee, + ee[methodName] as unknown as (...args: unknown[]) => unknown, + context, + ); + } + if (typeof ee.removeListener === 'function') { + // oxlint-disable-next-line @typescript-eslint/unbound-method -- patched like upstream OTel context manager + ee.removeListener = this._patchRemoveListener(ee, ee.removeListener as (...args: unknown[]) => unknown); + } + if (typeof ee.off === 'function') { + // oxlint-disable-next-line @typescript-eslint/unbound-method + ee.off = this._patchRemoveListener(ee, ee.off as (...args: unknown[]) => unknown); + } + if (typeof ee.removeAllListeners === 'function') { + ee.removeAllListeners = this._patchRemoveAllListeners( + ee, + // oxlint-disable-next-line @typescript-eslint/unbound-method + ee.removeAllListeners as (...args: unknown[]) => unknown, + ); + } + return ee; + } + + private _patchRemoveListener(ee: EventEmitter, original: (...args: unknown[]) => unknown) { + // oxlint-disable-next-line @typescript-eslint/no-this-alias + const contextManager = this; + return function (this: unknown, event: string, listener: ListenerFn) { + const events = contextManager._getPatchMap(ee)?.[event]; + if (events === undefined) { + return original.call(this, event, listener); + } + const patchedListener = events.get(listener); + return original.call(this, event, patchedListener || listener); + }; + } + + private _patchRemoveAllListeners(ee: EventEmitter, original: (...args: unknown[]) => unknown) { + // oxlint-disable-next-line @typescript-eslint/no-this-alias + const contextManager = this; + return function (this: unknown, event?: string) { + const map = contextManager._getPatchMap(ee); + if (map !== undefined) { + if (arguments.length === 0) { + contextManager._createPatchMap(ee); + } else if (event !== undefined && map[event] !== undefined) { + // oxlint-disable-next-line @typescript-eslint/no-dynamic-delete -- event-keyed listener map + delete map[event]; + } + } + return original.apply(this, arguments); + }; + } + + private _patchAddListener(ee: EventEmitter, original: (...args: unknown[]) => unknown, context: Context) { + // oxlint-disable-next-line @typescript-eslint/no-this-alias + const contextManager = this; + return function (this: unknown, event: string, listener: ListenerFn) { + if (contextManager._wrapped) { + return original.call(this, event, listener); + } + let map = contextManager._getPatchMap(ee); + if (map === undefined) { + map = contextManager._createPatchMap(ee); + } + let listeners = map[event]; + if (listeners === undefined) { + listeners = new WeakMap(); + map[event] = listeners; + } + const patchedListener = contextManager.bind(context, listener); + listeners.set(listener, patchedListener); + + contextManager._wrapped = true; + try { + return original.call(this, event, patchedListener); + } finally { + contextManager._wrapped = false; + } + }; + } + + private _createPatchMap(ee: EventEmitter): PatchMap { + const map = Object.create(null) as PatchMap; + (ee as unknown as Record)[this._kOtListeners] = map; + return map; + } + + private _getPatchMap(ee: EventEmitter): PatchMap | undefined { + return (ee as unknown as Record)[this._kOtListeners]; + } +} diff --git a/packages/opentelemetry/src/contextManager.ts b/packages/opentelemetry/src/contextManager.ts index f5a137397978..f1c3228c5dfa 100644 --- a/packages/opentelemetry/src/contextManager.ts +++ b/packages/opentelemetry/src/contextManager.ts @@ -1,16 +1,7 @@ import type { AsyncLocalStorage } from 'node:async_hooks'; import type { Context, ContextManager } from '@opentelemetry/api'; -import { trace } from '@opentelemetry/api'; -import type { Scope } from '@sentry/core'; -import { getCurrentScope, getIsolationScope } from '@sentry/core'; -import { - SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, - SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, - SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, - SENTRY_SCOPES_CONTEXT_KEY, - SENTRY_TRACE_STATE_CHILD_IGNORED, -} from './constants'; -import { getScopesFromContext, setContextOnScope, setScopesOnContext } from './utils/contextData'; +import { SENTRY_SCOPES_CONTEXT_KEY } from './constants'; +import { buildContextWithSentryScopes } from './utils/buildContextWithSentryScopes'; import { setIsSetup } from './utils/setupCheck'; export type AsyncLocalStorageLookup = { @@ -31,6 +22,8 @@ type ExtendedContextManagerInstance( ContextManagerClass: new (...args: unknown[]) => ContextManagerInstance, @@ -59,45 +52,7 @@ export function wrapContextManagerClass, ...args: A ): ReturnType { - // Remove ignored spans from context and restore the parent span so children - // naturally parent to the grandparent instead of starting a new trace. - // At this point, this.active() still holds the outer context (before super.with() - // updates AsyncLocalStorage), which has the grandparent span we want to restore. - const span = trace.getSpan(context); - let effectiveContext: Context; - if (span?.spanContext().traceState?.get(SENTRY_TRACE_STATE_CHILD_IGNORED) === '1') { - const contextWithoutSpan = trace.deleteSpan(context); - const parentSpan = trace.getSpan(this.active()); - effectiveContext = parentSpan ? trace.setSpan(contextWithoutSpan, parentSpan) : contextWithoutSpan; - } else { - effectiveContext = context; - } - - const currentScopes = getScopesFromContext(effectiveContext); - const currentScope = currentScopes?.scope || getCurrentScope(); - const currentIsolationScope = currentScopes?.isolationScope || getIsolationScope(); - - const shouldForkIsolationScope = effectiveContext.getValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY) === true; - const scope = effectiveContext.getValue(SENTRY_FORK_SET_SCOPE_CONTEXT_KEY) as Scope | undefined; - const isolationScope = effectiveContext.getValue(SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY) as - | Scope - | undefined; - - const newCurrentScope = scope || currentScope.clone(); - const newIsolationScope = - isolationScope || (shouldForkIsolationScope ? currentIsolationScope.clone() : currentIsolationScope); - const scopes = { scope: newCurrentScope, isolationScope: newIsolationScope }; - - const ctx1 = setScopesOnContext(effectiveContext, scopes); - - // Remove the unneeded values again - const ctx2 = ctx1 - .deleteValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY) - .deleteValue(SENTRY_FORK_SET_SCOPE_CONTEXT_KEY) - .deleteValue(SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY); - - setContextOnScope(newCurrentScope, ctx2); - + const ctx2 = buildContextWithSentryScopes(context, this.active()); return super.with(ctx2, fn, thisArg, ...args); } diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index f5260dc852c5..c5fe1d3376d7 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -41,6 +41,7 @@ export { setupEventContextTrace } from './setupEventContextTrace'; export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; export { wrapContextManagerClass } from './contextManager'; +export { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager'; export type { AsyncLocalStorageLookup } from './contextManager'; export { SentryPropagator, shouldPropagateTraceForUrl } from './propagator'; export { SentrySpanProcessor } from './spanProcessor'; diff --git a/packages/opentelemetry/src/utils/buildContextWithSentryScopes.ts b/packages/opentelemetry/src/utils/buildContextWithSentryScopes.ts new file mode 100644 index 000000000000..ac8c2a4dc19e --- /dev/null +++ b/packages/opentelemetry/src/utils/buildContextWithSentryScopes.ts @@ -0,0 +1,56 @@ +import type { Context } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import type { Scope } from '@sentry/core'; +import { getCurrentScope, getIsolationScope } from '@sentry/core'; +import { + SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, + SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, + SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, + SENTRY_TRACE_STATE_CHILD_IGNORED, +} from '../constants'; +import { getScopesFromContext, setContextOnScope, setScopesOnContext } from './contextData'; + +/** + * Merge Sentry scopes into an OpenTelemetry {@link Context} and apply trace-context adjustments + * used by Sentry OpenTelemetry context manager(s). + * + * @param context - Context passed into `ContextManager.with`. + * @param activeContext - Context that was active before entering `with` (e.g. `this.active()`), used + * to restore the parent span when the incoming span is marked ignored for children. + * @returns A new context ready for `super.with` / `AsyncLocalStorage.run`. + */ +export function buildContextWithSentryScopes(context: Context, activeContext: Context): Context { + const span = trace.getSpan(context); + let effectiveContext: Context; + if (span?.spanContext().traceState?.get(SENTRY_TRACE_STATE_CHILD_IGNORED) === '1') { + const contextWithoutSpan = trace.deleteSpan(context); + const parentSpan = trace.getSpan(activeContext); + effectiveContext = parentSpan ? trace.setSpan(contextWithoutSpan, parentSpan) : contextWithoutSpan; + } else { + effectiveContext = context; + } + + const currentScopes = getScopesFromContext(effectiveContext); + const currentScope = currentScopes?.scope || getCurrentScope(); + const currentIsolationScope = currentScopes?.isolationScope || getIsolationScope(); + + const shouldForkIsolationScope = effectiveContext.getValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY) === true; + const scope = effectiveContext.getValue(SENTRY_FORK_SET_SCOPE_CONTEXT_KEY) as Scope | undefined; + const isolationScope = effectiveContext.getValue(SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY) as Scope | undefined; + + const newCurrentScope = scope || currentScope.clone(); + const newIsolationScope = + isolationScope || (shouldForkIsolationScope ? currentIsolationScope.clone() : currentIsolationScope); + const scopes = { scope: newCurrentScope, isolationScope: newIsolationScope }; + + const ctx1 = setScopesOnContext(effectiveContext, scopes); + + const ctx2 = ctx1 + .deleteValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY) + .deleteValue(SENTRY_FORK_SET_SCOPE_CONTEXT_KEY) + .deleteValue(SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY); + + setContextOnScope(newCurrentScope, ctx2); + + return ctx2; +} diff --git a/packages/opentelemetry/test/helpers/initOtel.ts b/packages/opentelemetry/test/helpers/initOtel.ts index bf281f716657..f3b176f13b10 100644 --- a/packages/opentelemetry/test/helpers/initOtel.ts +++ b/packages/opentelemetry/test/helpers/initOtel.ts @@ -1,5 +1,4 @@ import { context, diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources'; import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { @@ -8,7 +7,7 @@ import { SEMRESATTRS_SERVICE_NAMESPACE, } from '@opentelemetry/semantic-conventions'; import { debug, getClient, SDK_VERSION } from '@sentry/core'; -import { wrapContextManagerClass } from '../../src/contextManager'; +import { SentryAsyncLocalStorageContextManager } from '../../src/asyncLocalStorageContextManager'; import { DEBUG_BUILD } from '../../src/debug-build'; import { SentryPropagator } from '../../src/propagator'; import { SentrySampler } from '../../src/sampler'; @@ -72,12 +71,9 @@ export function setupOtel(client: TestClientInterface): [BasicTracerProvider, Se spanProcessors: [spanProcessor], }); - // We use a custom context manager to keep context in sync with sentry scope - const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); - trace.setGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - context.setGlobalContextManager(new SentryContextManager()); + context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); return [provider, spanProcessor]; } diff --git a/yarn.lock b/yarn.lock index 4b60b6131151..7c65cb69ae99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6227,11 +6227,6 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05" integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q== -"@opentelemetry/context-async-hooks@^2.6.1": - version "2.6.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.1.tgz#06e60d5b3fba992a832af7f034758574e951bba3" - integrity sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ== - "@opentelemetry/core@2.6.1", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.6.1": version "2.6.1" resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.6.1.tgz#a59d22a9ae3be80bb41b280bbbe1fe9fbdb6c2a5" @@ -28562,7 +28557,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From 61c56021fa0a45b13cc047b02dec4c68316d9e4e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 14 Apr 2026 11:38:14 +0200 Subject: [PATCH 48/73] chore(ci): Remove craft changelog preview (#20271) Removing this workflow as we still rely on manual changelog generation for now. --- .github/workflows/changelog-preview.yml | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 .github/workflows/changelog-preview.yml diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml deleted file mode 100644 index 9aabf51e1070..000000000000 --- a/.github/workflows/changelog-preview.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Changelog Preview -on: - pull_request_target: - types: - - opened - - synchronize - - reopened - - edited - - labeled - - unlabeled -permissions: - contents: write - pull-requests: write - statuses: write - -jobs: - changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@2.25.2 - secrets: inherit From 13dc7a17ddb1366e06d91f798e2b90fb9ab16ecb Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 14 Apr 2026 14:27:17 +0200 Subject: [PATCH 49/73] feat(core): Expose `rewriteSources` top level option (#20142) - Adds `rewriteSources` to the base `SourceMapsOptions` interface so users can customize source path rewriting without the `unstable_*` escape hatch - Wires up the option in Nuxt, Next.js, and TanStack Start (SvelteKit, Astro, React Router already pass it through via spread) - Nuxt and Next.js preserve their default rewriting behavior when the option is not provided bundler plugins ref https://github.com/getsentry/sentry-javascript-bundler-plugins/pull/908 closes https://github.com/getsentry/sentry-javascript/issues/20028 --- .../buildTimeOptionsBase.ts | 13 ++++++++++++ .../src/config/getBuildPluginOptions.ts | 2 +- packages/nextjs/src/config/types.ts | 15 ++++++++++++++ .../test/config/getBuildPluginOptions.test.ts | 20 +++++++++++++++++++ packages/nuxt/src/vite/sourceMaps.ts | 2 +- packages/nuxt/test/vite/sourceMaps.test.ts | 10 ++++++++++ .../src/vite/sourceMaps.ts | 1 + .../test/vite/sourceMaps.test.ts | 19 ++++++++++++++++++ 8 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index 67f74f696dcf..f61aa6c40c94 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -241,6 +241,19 @@ interface SourceMapsOptions { * The globbing patterns must follow the implementation of the `glob` package: https://www.npmjs.com/package/glob#glob-primer */ filesToDeleteAfterUpload?: string | Array; + + /** + * Hook to rewrite the `sources` field inside the source map before being uploaded to Sentry. Does not modify the actual source map. + * + * The hook receives the following arguments: + * - `source` - the source file path from the source map's `sources` field + * - `map` - the source map object + * - `context` - an optional object containing `mapDir`, the absolute path to the directory of the source map file + * + * Defaults to making all sources relative to `process.cwd()` while building. + */ + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- matches the bundler plugin's RewriteSourcesHook type + rewriteSources?: (source: string, map: any, context?: { mapDir: string }) => string; } type AutoSetCommitsOptions = { diff --git a/packages/nextjs/src/config/getBuildPluginOptions.ts b/packages/nextjs/src/config/getBuildPluginOptions.ts index b9676e2ec17e..5018e1b4b196 100644 --- a/packages/nextjs/src/config/getBuildPluginOptions.ts +++ b/packages/nextjs/src/config/getBuildPluginOptions.ts @@ -326,7 +326,7 @@ export function getBuildPluginOptions({ url: sentryBuildOptions.sentryUrl, sourcemaps: { disable: skipSourcemapsUpload ? true : (sentryBuildOptions.sourcemaps?.disable ?? false), - rewriteSources: rewriteWebpackSources, + rewriteSources: sentryBuildOptions.sourcemaps?.rewriteSources ?? rewriteWebpackSources, assets: sentryBuildOptions.sourcemaps?.assets ?? sourcemapUploadAssets, ignore: finalIgnorePatterns, filesToDeleteAfterUpload, diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index c79dad7e694e..9aa31f79e535 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -294,6 +294,21 @@ export type SentryBuildOptions = { * ``` */ filesToDeleteAfterUpload?: string | string[]; + + /** + * Hook to rewrite the `sources` field inside the source map before being uploaded to Sentry. Does not modify the actual source map. + * + * The hook receives the following arguments: + * - `source` - the source file path from the source map's `sources` field + * - `map` - the source map object + * - `context` - an optional object containing `mapDir`, the absolute path to the directory of the source map file + * + * If not provided, the SDK defaults to stripping webpack-specific prefixes (`webpack://_N_E/`). + * + * Defaults to making all sources relative to `process.cwd()` while building. + */ + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- matches the bundler plugin's RewriteSourcesHook type + rewriteSources?: (source: string, map: any, context?: { mapDir: string }) => string; }; /** diff --git a/packages/nextjs/test/config/getBuildPluginOptions.test.ts b/packages/nextjs/test/config/getBuildPluginOptions.test.ts index 6eecd83905b8..c67135a5d8d3 100644 --- a/packages/nextjs/test/config/getBuildPluginOptions.test.ts +++ b/packages/nextjs/test/config/getBuildPluginOptions.test.ts @@ -692,6 +692,26 @@ describe('getBuildPluginOptions', () => { expect(rewriteSources('./components/Layout.tsx', {})).toBe('./components/Layout.tsx'); } }); + + it('allows user to override rewriteSources', () => { + const customRewrite = (source: string) => source.replace(/^custom\//, ''); + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + rewriteSources: customRewrite, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', + }); + + expect(result.sourcemaps?.rewriteSources).toBe(customRewrite); + }); }); describe('release configuration', () => { diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index c13126074871..16d0fd330649 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -216,7 +216,7 @@ export function getPluginOptions( : shouldDeleteFilesFallback?.server || shouldDeleteFilesFallback?.client ? fallbackFilesToDelete : undefined, - rewriteSources: (source: string) => normalizePath(source), + rewriteSources: sourcemapsOptions.rewriteSources ?? normalizePath, ...moduleOptions?.unstable_sentryBundlerPluginOptions?.sourcemaps, }, }; diff --git a/packages/nuxt/test/vite/sourceMaps.test.ts b/packages/nuxt/test/vite/sourceMaps.test.ts index 87e87d14b635..28e0336f43f5 100644 --- a/packages/nuxt/test/vite/sourceMaps.test.ts +++ b/packages/nuxt/test/vite/sourceMaps.test.ts @@ -118,6 +118,16 @@ describe('getPluginOptions', () => { expect(rewrite!('./local')).toBe('./local'); }); + it('allows user to override rewriteSources', () => { + const customRewrite = (source: string) => source.replace(/^src\//, 'custom/'); + const options = getPluginOptions({ + sourcemaps: { + rewriteSources: customRewrite, + }, + } as SentryNuxtModuleOptions); + expect(options.sourcemaps?.rewriteSources).toBe(customRewrite); + }); + it('prioritizes new BuildTimeOptionsBase options over deprecated ones', () => { const options: SentryNuxtModuleOptions = { // New options diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index e9ee193b8d8a..288c725dbc93 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -65,6 +65,7 @@ export function makeAddSentryVitePlugin(options: BuildTimeOptionsBase): Plugin[] assets: sourcemaps?.assets, disable: sourcemaps?.disable, ignore: sourcemaps?.ignore, + rewriteSources: sourcemaps?.rewriteSources, filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, }, telemetry: telemetry ?? true, diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index f3ddf6362847..58567f085b72 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -195,6 +195,25 @@ describe('makeAddSentryVitePlugin()', () => { consoleSpy.mockRestore(); }); + it('passes rewriteSources to the vite plugin', () => { + const customRewrite = (source: string) => source.replace(/^src\//, ''); + makeAddSentryVitePlugin({ + org: 'my-org', + authToken: 'my-token', + sourcemaps: { + rewriteSources: customRewrite, + }, + }); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ + rewriteSources: customRewrite, + }), + }), + ); + }); + it('sets the correct metaFramework in telemetry options', () => { makeAddSentryVitePlugin({ org: 'my-org', From 60498042061a49ce0e9e9f66cc839bb6188cf799 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 14 Apr 2026 09:36:16 -0400 Subject: [PATCH 50/73] fix(nextjs): skip custom browser tracing setup for bot user agents (#20263) Exposes `isBotUserAgent()` from the browser tracing integration to use it in the Next.js browser tracing wrapper so bot user agents also skip Next.js-specific router instrumentation which adds its own interval timer. This is intentionally the smallest change to test whether this fixes the Googlebot rendering issue. Longer term, we probably want a more general mechanism for integrations to skip their own setup, with a skip decision that can be downstreamed easily through wrappers and integration variants instead of checking the bot predicate ad hoc in each package. closes #19670 Co-authored-by: GPT-5.4 --- .../src/index.bundle.tracing.logs.metrics.ts | 1 + ...le.tracing.replay.feedback.logs.metrics.ts | 1 + .../index.bundle.tracing.replay.feedback.ts | 1 + ...ndex.bundle.tracing.replay.logs.metrics.ts | 1 + .../src/index.bundle.tracing.replay.ts | 1 + packages/browser/src/index.bundle.tracing.ts | 1 + packages/browser/src/index.ts | 1 + .../src/tracing/browserTracingIntegration.ts | 4 ++-- .../src/client/browserTracingIntegration.ts | 6 ++++- packages/nextjs/test/clientSdk.test.ts | 22 +++++++++++++++++++ 10 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/browser/src/index.bundle.tracing.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.logs.metrics.ts index d10bfea67687..0c5c4c0a81cd 100644 --- a/packages/browser/src/index.bundle.tracing.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.logs.metrics.ts @@ -22,6 +22,7 @@ export { export { browserTracingIntegration, + isBotUserAgent, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts index 6caef09459ae..5fb7c306cc87 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts @@ -22,6 +22,7 @@ export { export { browserTracingIntegration, + isBotUserAgent, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index dbff7b4dd7b3..9d9098b5be3d 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -27,6 +27,7 @@ export { export { browserTracingIntegration, + isBotUserAgent, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; diff --git a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts index 9972cd85ca8a..a000d456360b 100644 --- a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts @@ -22,6 +22,7 @@ export { export { browserTracingIntegration, + isBotUserAgent, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index f95e3d6cdcc9..496aacf348b9 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -27,6 +27,7 @@ export { export { browserTracingIntegration, + isBotUserAgent, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index 38186b3aded2..64126c101189 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -28,6 +28,7 @@ export { export { browserTracingIntegration, + isBotUserAgent, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 7bfa67cb37ba..844f6a170090 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -37,6 +37,7 @@ export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; export { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './tracing/request'; export { browserTracingIntegration, + isBotUserAgent, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 7eb87cd1d833..23436b34ed58 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -62,7 +62,7 @@ export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; const BOT_USER_AGENT_RE = /Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Slurp|DuckDuckBot|Baiduspider|YandexBot|Facebot|facebookexternalhit|LinkedInBot|Twitterbot|Applebot/i; -function _isBotUserAgent(): boolean { +export function isBotUserAgent(): boolean { const nav = WINDOW.navigator as Navigator | undefined; if (!nav?.userAgent) { return false; @@ -405,7 +405,7 @@ export const browserTracingIntegration = ((options: Partial void); let lastInteractionTimestamp: number | undefined; diff --git a/packages/nextjs/src/client/browserTracingIntegration.ts b/packages/nextjs/src/client/browserTracingIntegration.ts index ab9ee6c43748..cd957d1d62b5 100644 --- a/packages/nextjs/src/client/browserTracingIntegration.ts +++ b/packages/nextjs/src/client/browserTracingIntegration.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { browserTracingIntegration as originalBrowserTracingIntegration } from '@sentry/react'; +import { browserTracingIntegration as originalBrowserTracingIntegration, isBotUserAgent } from '@sentry/react'; import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation'; /** @@ -29,6 +29,10 @@ export function browserTracingIntegration( return { ...browserTracingIntegrationInstance, afterAllSetup(client) { + if (isBotUserAgent()) { + return; + } + // We need to run the navigation span instrumentation before the `afterAllSetup` hook on the normal browser // tracing integration because we need to ensure the order of execution is as follows: // Instrumentation to start span on RSC fetch request runs -> Instrumentation to put tracing headers from active span on fetch runs diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index b6c62303f0bf..873aa5a2511e 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -19,6 +19,7 @@ Object.defineProperty(global, 'addEventListener', { value: () => undefined, writ const originalGlobalDocument = WINDOW.document; const originalGlobalLocation = WINDOW.location; +const originalNavigator = WINDOW.navigator; // eslint-disable-next-line @typescript-eslint/unbound-method const originalGlobalAddEventListener = WINDOW.addEventListener; @@ -26,6 +27,7 @@ afterAll(() => { // Clean up JSDom Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument }); Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation }); + Object.defineProperty(WINDOW, 'navigator', { value: originalNavigator, writable: true, configurable: true }); Object.defineProperty(WINDOW, 'addEventListener', { value: originalGlobalAddEventListener }); }); @@ -43,6 +45,7 @@ describe('Client init()', () => { getIsolationScope().clear(); getCurrentScope().clear(); getCurrentScope().setClient(undefined); + Object.defineProperty(WINDOW, 'navigator', { value: originalNavigator, writable: true, configurable: true }); }); it('inits the React SDK', () => { @@ -160,6 +163,25 @@ describe('Client init()', () => { // @ts-expect-error Test setup for build-time flag delete globalThis.__SENTRY_TRACING__; }); + + it("doesn't run Next.js router instrumentation for bot user agents", () => { + Object.defineProperty(WINDOW, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + }, + writable: true, + configurable: true, + }); + + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval'); + + init({ + dsn: TEST_DSN, + tracesSampleRate: 1.0, + }); + + expect(setIntervalSpy).not.toHaveBeenCalled(); + }); }); }); From f9b07cad99b56115ddbbf1d3f650c2e27fd325c9 Mon Sep 17 00:00:00 2001 From: igz0 <37741728+igz0@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:19:59 +0900 Subject: [PATCH 51/73] fix(nextjs): preserve directive prologues in turbopack loaders (#20103) Fixes #20101. ## Summary When `_experimental.turbopackApplicationKey` is enabled, the Next.js Turbopack loaders inject code for metadata/value propagation. The previous implementation relied on a regex that only handled a single directive prologue entry. For modules that start with multiple directives, such as: ```js "use strict"; "use client"; ``` the injected code could end up between the directives and break the `"use client"` classification. This replaces the regex-based insertion point detection with a small linear scanner that: - skips leading whitespace and comments - walks consecutive directive prologue entries - returns the insertion point immediately after the last directive ## Tests - added coverage for multiple directives - added coverage for comments between directives - added coverage for semicolon-free directives - added coverage for non-directive string literals that must not be skipped ## Verification Using the reproduction from #20101 with the patched `@sentry/nextjs` tarball: - `npm run build` completes successfully - `/_not-found` prerendering succeeds - the previous `TypeError: (0, g.useEffect) is not a function` no longer occurs --------- Co-authored-by: Charly Gomez Co-authored-by: Charly Gomez --- .../loaders/moduleMetadataInjectionLoader.ts | 7 +- .../config/loaders/valueInjectionLoader.ts | 155 ++++++++++++++++-- .../moduleMetadataInjectionLoader.test.ts | 26 +++ .../test/config/valueInjectionLoader.test.ts | 114 ++++++++++++- 4 files changed, 285 insertions(+), 17 deletions(-) diff --git a/packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts b/packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts index 96c00569e06f..b26eb452e13b 100644 --- a/packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts +++ b/packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts @@ -1,5 +1,5 @@ import type { LoaderThis } from './types'; -import { SKIP_COMMENT_AND_DIRECTIVE_REGEX } from './valueInjectionLoader'; +import { findInjectionIndexAfterDirectives } from './valueInjectionLoader'; export type ModuleMetadataInjectionLoaderOptions = { applicationKey: string; @@ -39,7 +39,6 @@ export default function moduleMetadataInjectionLoader( `e._sentryModuleMetadata[(new e.Error).stack]=Object.assign({},e._sentryModuleMetadata[(new e.Error).stack],${metadata});` + '}catch(e){}}();'; - return userCode.replace(SKIP_COMMENT_AND_DIRECTIVE_REGEX, match => { - return match + injectedCode; - }); + const injectionIndex = findInjectionIndexAfterDirectives(userCode); + return `${userCode.slice(0, injectionIndex)}${injectedCode}${userCode.slice(injectionIndex)}`; } diff --git a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts b/packages/nextjs/src/config/loaders/valueInjectionLoader.ts index 62cabcf818b8..8e5fec0e75cf 100644 --- a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts +++ b/packages/nextjs/src/config/loaders/valueInjectionLoader.ts @@ -1,18 +1,150 @@ -// Rollup doesn't like if we put the directive regex as a literal (?). No idea why. -/* oxlint-disable sdk/no-regexp-constructor */ - import type { LoaderThis } from './types'; export type ValueInjectionLoaderOptions = { values: Record; }; -// We need to be careful not to inject anything before any `"use strict";`s or "use client"s or really any other directive. -// As an additional complication directives may come after any number of comments. -// This regex is shamelessly stolen from: https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/7f984482c73e4284e8b12a08dfedf23b5a82f0af/packages/bundler-plugin-core/src/index.ts#L535-L539 -export const SKIP_COMMENT_AND_DIRECTIVE_REGEX = - // Note: CodeQL complains that this regex potentially has n^2 runtime. This likely won't affect realistic files. - new RegExp('^(?:\\s*|/\\*(?:.|\\r|\\n)*?\\*/|//.*[\\n\\r])*(?:"[^"]*";?|\'[^\']*\';?)?'); +// We need to be careful not to inject anything before any `"use strict";`s or "use client"s or really any other directives. +export function findInjectionIndexAfterDirectives(userCode: string): number { + let index = 0; + let lastDirectiveEndIndex: number | undefined; + + while (index < userCode.length) { + const statementStartIndex = skipWhitespaceAndComments(userCode, index); + if (statementStartIndex === undefined) { + return lastDirectiveEndIndex ?? 0; + } + + index = statementStartIndex; + if (statementStartIndex === userCode.length) { + return lastDirectiveEndIndex ?? statementStartIndex; + } + + const quote = userCode[statementStartIndex]; + if (quote !== '"' && quote !== "'") { + return lastDirectiveEndIndex ?? statementStartIndex; + } + + const stringEndIndex = findStringLiteralEnd(userCode, statementStartIndex); + if (stringEndIndex === undefined) { + return lastDirectiveEndIndex ?? statementStartIndex; + } + + const statementEndIndex = findDirectiveTerminator(userCode, stringEndIndex); + if (statementEndIndex === undefined) { + return lastDirectiveEndIndex ?? statementStartIndex; + } + + index = statementEndIndex; + lastDirectiveEndIndex = statementEndIndex; + } + + return lastDirectiveEndIndex ?? index; +} + +function skipWhitespaceAndComments(userCode: string, startIndex: number): number | undefined { + let index = startIndex; + + while (index < userCode.length) { + const char = userCode[index]; + + if (char && /\s/.test(char)) { + index += 1; + continue; + } + + if (userCode.startsWith('//', index)) { + const newlineIndex = userCode.indexOf('\n', index + 2); + index = newlineIndex === -1 ? userCode.length : newlineIndex + 1; + continue; + } + + if (userCode.startsWith('/*', index)) { + const commentEndIndex = userCode.indexOf('*/', index + 2); + if (commentEndIndex === -1) { + return undefined; + } + + index = commentEndIndex + 2; + continue; + } + + break; + } + + return index; +} + +function findStringLiteralEnd(userCode: string, startIndex: number): number | undefined { + const quote = userCode[startIndex]; + let index = startIndex + 1; + + while (index < userCode.length) { + const char = userCode[index]; + + if (char === '\\') { + index += 2; + continue; + } + + if (char === quote) { + return index + 1; + } + + if (char === '\n' || char === '\r') { + return undefined; + } + + index += 1; + } + + return undefined; +} + +function findDirectiveTerminator(userCode: string, startIndex: number): number | undefined { + let index = startIndex; + + // Only a bare string literal followed by a statement terminator counts as a directive. + while (index < userCode.length) { + const char = userCode[index]; + + if (char === ';') { + return index + 1; + } + + if (char === '\n' || char === '\r' || char === '}') { + return index; + } + + if (char && /\s/.test(char)) { + index += 1; + continue; + } + + if (userCode.startsWith('//', index)) { + return index; + } + + if (userCode.startsWith('/*', index)) { + const commentEndIndex = userCode.indexOf('*/', index + 2); + if (commentEndIndex === -1) { + return undefined; + } + + const comment = userCode.slice(index + 2, commentEndIndex); + if (comment.includes('\n') || comment.includes('\r')) { + return index; + } + + index = commentEndIndex + 2; + continue; + } + + return undefined; + } + + return index; +} /** * Set values on the global/window object at the start of a module. @@ -36,7 +168,6 @@ export default function valueInjectionLoader(this: LoaderThis `globalThis["${key}"] = ${JSON.stringify(value)};`) .join(''); - return userCode.replace(SKIP_COMMENT_AND_DIRECTIVE_REGEX, match => { - return match + injectedCode; - }); + const injectionIndex = findInjectionIndexAfterDirectives(userCode); + return `${userCode.slice(0, injectionIndex)}${injectedCode}${userCode.slice(injectionIndex)}`; } diff --git a/packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts b/packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts index 1a6a2cd14b71..f6c1c613bd00 100644 --- a/packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts +++ b/packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts @@ -131,4 +131,30 @@ describe('moduleMetadataInjectionLoader', () => { expect(result).toContain('"_sentryBundlerPluginAppKey:test-key-123":true'); }); + + it('should inject after multiple directives', () => { + const loaderThis = createLoaderThis('my-app'); + const userCode = '"use strict";\n"use client";\nimport React from \'react\';'; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + const metadataIndex = result.indexOf('_sentryModuleMetadata'); + const clientDirectiveIndex = result.indexOf('"use client"'); + const importIndex = result.indexOf("import React from 'react';"); + + expect(metadataIndex).toBeGreaterThan(clientDirectiveIndex); + expect(metadataIndex).toBeLessThan(importIndex); + }); + + it('should inject after comments between multiple directives', () => { + const loaderThis = createLoaderThis('my-app'); + const userCode = '"use strict";\n/* keep */\n"use client";\nimport React from \'react\';'; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + const metadataIndex = result.indexOf('_sentryModuleMetadata'); + const clientDirectiveIndex = result.indexOf('"use client"'); + + expect(metadataIndex).toBeGreaterThan(clientDirectiveIndex); + }); }); diff --git a/packages/nextjs/test/config/valueInjectionLoader.test.ts b/packages/nextjs/test/config/valueInjectionLoader.test.ts index 57b40b006baa..83c0c1d5e0f9 100644 --- a/packages/nextjs/test/config/valueInjectionLoader.test.ts +++ b/packages/nextjs/test/config/valueInjectionLoader.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { LoaderThis } from '../../src/config/loaders/types'; import type { ValueInjectionLoaderOptions } from '../../src/config/loaders/valueInjectionLoader'; -import valueInjectionLoader from '../../src/config/loaders/valueInjectionLoader'; +import valueInjectionLoader, { findInjectionIndexAfterDirectives } from '../../src/config/loaders/valueInjectionLoader'; const defaultLoaderThis = { addDependency: () => undefined, @@ -66,6 +66,23 @@ describe.each([[clientConfigLoaderThis], [instrumentationLoaderThis]])('valueInj expect(result).toMatch(';globalThis["foo"] = "bar";'); }); + it('should correctly insert values with a single-quoted directive', () => { + const userCode = ` + 'use client'; + import * as Sentry from '@sentry/nextjs'; + Sentry.init(); + `; + + const result = valueInjectionLoader.call(loaderThis, userCode); + + const injectionIndex = result.indexOf(';globalThis["foo"] = "bar";'); + const clientDirectiveIndex = result.indexOf("'use client'"); + const importIndex = result.indexOf("import * as Sentry from '@sentry/nextjs';"); + + expect(injectionIndex).toBeGreaterThan(clientDirectiveIndex); + expect(injectionIndex).toBeLessThan(importIndex); + }); + it('should correctly insert values with directive and inline comments', () => { const userCode = ` // test @@ -149,4 +166,99 @@ describe.each([[clientConfigLoaderThis], [instrumentationLoaderThis]])('valueInj expect(result).toMatchSnapshot(); expect(result).toMatch(';globalThis["foo"] = "bar";'); }); + + it('should correctly insert values after multiple directives', () => { + const userCode = ` + "use strict"; + "use client"; + import * as Sentry from '@sentry/nextjs'; + Sentry.init(); + `; + + const result = valueInjectionLoader.call(loaderThis, userCode); + + const injectionIndex = result.indexOf(';globalThis["foo"] = "bar";'); + const clientDirectiveIndex = result.indexOf('"use client"'); + const importIndex = result.indexOf("import * as Sentry from '@sentry/nextjs';"); + + expect(injectionIndex).toBeGreaterThan(clientDirectiveIndex); + expect(injectionIndex).toBeLessThan(importIndex); + }); + + it('should correctly insert values after comments between multiple directives', () => { + const userCode = ` + "use strict"; + /* keep */ + "use client"; + import * as Sentry from '@sentry/nextjs'; + Sentry.init(); + `; + + const result = valueInjectionLoader.call(loaderThis, userCode); + + const injectionIndex = result.indexOf(';globalThis["foo"] = "bar";'); + const clientDirectiveIndex = result.indexOf('"use client"'); + + expect(injectionIndex).toBeGreaterThan(clientDirectiveIndex); + }); + + it('should correctly insert values after semicolon-free directives', () => { + const userCode = ` + "use strict" + "use client" + import * as Sentry from '@sentry/nextjs'; + Sentry.init(); + `; + + const result = valueInjectionLoader.call(loaderThis, userCode); + + const injectionIndex = result.indexOf(';globalThis["foo"] = "bar";'); + const clientDirectiveIndex = result.indexOf('"use client"'); + + expect(injectionIndex).toBeGreaterThan(clientDirectiveIndex); + }); +}); + +describe('findInjectionIndexAfterDirectives', () => { + it('returns the position immediately after the last directive', () => { + const userCode = '"use strict";\n"use client";\nimport React from \'react\';'; + + expect(userCode.slice(findInjectionIndexAfterDirectives(userCode))).toBe("\nimport React from 'react';"); + }); + + it('returns the end of the input when the last directive reaches EOF', () => { + const userCode = '"use strict";\n"use client";'; + + expect(findInjectionIndexAfterDirectives(userCode)).toBe(userCode.length); + }); + + it('does not skip a string literal that is not a directive', () => { + const userCode = '"use client" + suffix;'; + + expect(findInjectionIndexAfterDirectives(userCode)).toBe(0); + }); + + it('does not treat an escaped quote at EOF as a closed directive', () => { + const userCode = '"use client\\"'; + + expect(findInjectionIndexAfterDirectives(userCode)).toBe(0); + }); + + it('returns 0 for an unterminated leading block comment', () => { + const userCode = '/* unterminated'; + + expect(findInjectionIndexAfterDirectives(userCode)).toBe(0); + }); + + it('returns the last complete directive when followed by an unterminated block comment', () => { + const userCode = '"use client"; /* unterminated'; + + expect(findInjectionIndexAfterDirectives(userCode)).toBe('"use client";'.length); + }); + + it('treats a block comment without a line break as part of the same statement', () => { + const userCode = '"use client" /* comment */ + suffix;'; + + expect(findInjectionIndexAfterDirectives(userCode)).toBe(0); + }); }); From 47455a0014116807b9e60f5f8c902e8cecf7dd75 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 14 Apr 2026 17:20:14 +0200 Subject: [PATCH 52/73] fix(core): Use `ai.operationId` for Vercel AI V6 operation name mapping (#20285) Vercel AI SDK V6 appends `functionId` to the `operation.name` span attribute (e.g., `ai.streamText myAgent`), causing `mapVercelAiOperationName` to miss the exact-match lookup and leave `gen_ai.operation.name` unmapped. This fix uses the `ai.operationId` attribute (which always contains the bare operation like `ai.streamText`) when present, falling back to `operation.name` for older Vercel SDK versions. The existing `ToolLoopAgent` integration test is strengthened with a functionId to prevent regression. Closes https://github.com/getsentry/sentry-javascript/issues/20284 --- .../tracing/vercelai/v6/scenario-tool-loop-agent.mjs | 2 +- .../suites/tracing/vercelai/v6/test.ts | 3 +++ packages/core/src/tracing/vercel-ai/index.ts | 8 +++++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-tool-loop-agent.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-tool-loop-agent.mjs index fe485ce29a90..6967ec2efe94 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-tool-loop-agent.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-tool-loop-agent.mjs @@ -8,7 +8,7 @@ async function run() { let callCount = 0; const agent = new ToolLoopAgent({ - experimental_telemetry: { isEnabled: true }, + experimental_telemetry: { isEnabled: true, functionId: 'weather_agent' }, model: new MockLanguageModelV3({ doGenerate: async () => { if (callCount++ === 0) { diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts index 2c07366423cf..1b030804f8d2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts @@ -619,6 +619,7 @@ describe('Vercel AI integration (V6)', () => { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.vercelai.otel', [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'mock-model-id', }), + description: 'invoke_agent weather_agent', op: 'gen_ai.invoke_agent', origin: 'auto.vercelai.otel', status: 'ok', @@ -633,6 +634,7 @@ describe('Vercel AI integration (V6)', () => { [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 20, [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: ['tool-calls'], }), + description: 'generate_content mock-model-id', op: 'gen_ai.generate_content', origin: 'auto.vercelai.otel', status: 'ok', @@ -662,6 +664,7 @@ describe('Vercel AI integration (V6)', () => { [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 25, [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: ['stop'], }), + description: 'generate_content mock-model-id', op: 'gen_ai.generate_content', origin: 'auto.vercelai.otel', status: 'ok', diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 569233cf8321..95bf550e5ff2 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -322,7 +322,13 @@ function processEndedVercelAiSpan(span: SpanJSON): void { // Rename AI SDK attributes to standardized gen_ai attributes // Map operation.name to OpenTelemetry semantic convention values if (attributes[OPERATION_NAME_ATTRIBUTE]) { - const operationName = mapVercelAiOperationName(attributes[OPERATION_NAME_ATTRIBUTE] as string); + // V6+ sets ai.operationId to the bare operation (e.g. "ai.streamText") while + // operation.name appends functionId (e.g. "ai.streamText myAgent"). + // When ai.operationId is present, use it for correct mapping. + const rawOperationName = attributes[AI_OPERATION_ID_ATTRIBUTE] + ? (attributes[AI_OPERATION_ID_ATTRIBUTE] as string) + : (attributes[OPERATION_NAME_ATTRIBUTE] as string); + const operationName = mapVercelAiOperationName(rawOperationName); attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE] = operationName; // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete attributes[OPERATION_NAME_ATTRIBUTE]; From 2ba05f6f71772922c841925c1b4c91b7927fba1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 14 Apr 2026 17:58:24 +0200 Subject: [PATCH 53/73] test(cloudflare): Skip flaky durableobject-spans test (#20282) The test is timing out intermittently in CI, causing spurious failures. This will be fixed as part of #20208 Co-authored-by: Claude Opus 4 --- .../suites/tracing/durableobject-spans/test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts index 795eb03e27c2..1415950208cc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts @@ -6,7 +6,8 @@ import { createRunner } from '../../../runner'; // must appear as children of the DO transaction. The first invocation always worked; // the second invocation on the same DO instance previously lost its child spans // because the client was disposed after the first call. -it('sends child spans on repeated Durable Object calls', async ({ signal }) => { +// TODO: unskip - this test is flaky, timing out in CI +it.skip('sends child spans on repeated Durable Object calls', async ({ signal }) => { function assertDoWorkEnvelope(envelope: unknown): void { const transactionEvent = (envelope as any)[1]?.[0]?.[1]; From 5b3eb7e6cf29e5dc52219832aa2bfc8b8ccc2c9f Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 14 Apr 2026 19:10:29 +0200 Subject: [PATCH 54/73] chore(ci): Skip flaky issue creation for optional tests (#20288) Optional tests are usually optional for a reason. We shouldn't create a flaky test issue if an optional test fails on develop to reduce noise. Closes https://github.com/getsentry/sentry-javascript/issues/20269 Closes https://github.com/getsentry/sentry-javascript/issues/20242 Closes https://github.com/getsentry/sentry-javascript/issues/20213 --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6032cee30fdc..9dd91e42287f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1147,7 +1147,7 @@ jobs: per_page: 100 }); - const failedJobs = jobs.filter(job => job.conclusion === 'failure'); + const failedJobs = jobs.filter(job => job.conclusion === 'failure' && !job.name.includes('(optional)')); if (failedJobs.length === 0) { console.log('No failed jobs found'); From 7284606c1bee87ae43effda117e4502f415d0233 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 14 Apr 2026 19:16:02 +0200 Subject: [PATCH 55/73] fix(core): Set `conversation_id` only on `gen_ai` spans (#20274) We should only set the `conversation_id` for `gen_ai` spans. These are the only spans for which this attribute is relevant and setting it on other spans can lead to unnecessarily slow queries in the product. This works fine for all our AI integrations except Vercel. This is because the Vercel ai integration also registers a `spanStart` hook that transforms Vercel spans to sentry `gen_ai` spans. The conversation id integration fires before that happens, so we have to special case the Vercel ai spans here for this to work properly. Closes https://github.com/getsentry/sentry-javascript/issues/20272 --- .size-limit.js | 2 +- .../core/src/integrations/conversationId.ts | 11 +++++++++ .../lib/integrations/conversationId.test.ts | 24 +++++++++++++------ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 4100751f2c40..2d5baacbfd88 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -234,7 +234,7 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '83 KB', + limit: '83.5 KB', }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', diff --git a/packages/core/src/integrations/conversationId.ts b/packages/core/src/integrations/conversationId.ts index c11b587d3a71..445e3327419b 100644 --- a/packages/core/src/integrations/conversationId.ts +++ b/packages/core/src/integrations/conversationId.ts @@ -4,6 +4,7 @@ import { defineIntegration } from '../integration'; import { GEN_AI_CONVERSATION_ID_ATTRIBUTE } from '../semanticAttributes'; import type { IntegrationFn } from '../types-hoist/integration'; import type { Span } from '../types-hoist/span'; +import { spanToJSON } from '../utils/spanUtils'; const INTEGRATION_NAME = 'ConversationId'; @@ -18,6 +19,16 @@ const _conversationIdIntegration = (() => { const conversationId = scopeData.conversationId || isolationScopeData.conversationId; if (conversationId) { + const { op, data: attributes, description: name } = spanToJSON(span); + + // Only apply conversation ID to gen_ai spans. + // We also check for Vercel AI spans (ai.operationId attribute or ai.* span name) + // because the Vercel AI integration sets the gen_ai.* op in its own spanStart handler + // which fires after this, so the op is not yet available at this point. + if (!op?.startsWith('gen_ai.') && !attributes['ai.operationId'] && !name?.startsWith('ai.')) { + return; + } + span.setAttribute(GEN_AI_CONVERSATION_ID_ATTRIBUTE, conversationId); } }); diff --git a/packages/core/test/lib/integrations/conversationId.test.ts b/packages/core/test/lib/integrations/conversationId.test.ts index e9ea9cc50d45..be69a1476e83 100644 --- a/packages/core/test/lib/integrations/conversationId.test.ts +++ b/packages/core/test/lib/integrations/conversationId.test.ts @@ -26,7 +26,7 @@ describe('ConversationId', () => { it('applies conversation ID from current scope to span', () => { getCurrentScope().setConversationId('conv_test_123'); - startSpan({ name: 'test-span' }, span => { + startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToJSON(span); expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_test_123'); }); @@ -35,7 +35,7 @@ describe('ConversationId', () => { it('applies conversation ID from isolation scope when current scope does not have one', () => { getIsolationScope().setConversationId('conv_isolation_456'); - startSpan({ name: 'test-span' }, span => { + startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToJSON(span); expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_isolation_456'); }); @@ -45,14 +45,14 @@ describe('ConversationId', () => { getCurrentScope().setConversationId('conv_current_789'); getIsolationScope().setConversationId('conv_isolation_999'); - startSpan({ name: 'test-span' }, span => { + startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToJSON(span); expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_current_789'); }); }); it('does not apply conversation ID when not set in scope', () => { - startSpan({ name: 'test-span' }, span => { + startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToJSON(span); expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBeUndefined(); }); @@ -62,7 +62,7 @@ describe('ConversationId', () => { getCurrentScope().setConversationId('conv_test_123'); getCurrentScope().setConversationId(null); - startSpan({ name: 'test-span' }, span => { + startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToJSON(span); expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBeUndefined(); }); @@ -71,8 +71,8 @@ describe('ConversationId', () => { it('applies conversation ID to nested spans', () => { getCurrentScope().setConversationId('conv_nested_abc'); - startSpan({ name: 'parent-span' }, () => { - startSpan({ name: 'child-span' }, childSpan => { + startSpan({ name: 'parent-span', op: 'gen_ai.invoke_agent' }, () => { + startSpan({ name: 'child-span', op: 'gen_ai.chat' }, childSpan => { const childJSON = spanToJSON(childSpan); expect(childJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_nested_abc'); }); @@ -85,6 +85,7 @@ describe('ConversationId', () => { startSpan( { name: 'test-span', + op: 'gen_ai.chat', attributes: { [GEN_AI_CONVERSATION_ID_ATTRIBUTE]: 'conv_explicit', }, @@ -95,4 +96,13 @@ describe('ConversationId', () => { }, ); }); + + it('does not apply conversation ID to non-gen_ai spans', () => { + getCurrentScope().setConversationId('conv_test_123'); + + startSpan({ name: 'db-query', op: 'db.query' }, span => { + const spanJSON = spanToJSON(span); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBeUndefined(); + }); + }); }); From 3572788edfeebaa753f1a0381fb92d98bdb342c9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 14 Apr 2026 14:12:47 -0400 Subject: [PATCH 56/73] fix(replay): use live click attributes in breadcrumbs (#20262) Fixes replay element attributes grabbing a potentially stale version of the attributes, we basically now prefer the live element if available, otherwise we keep the old behavior. closes #20238 --------- Co-authored-by: GPT-5 --- .size-limit.js | 2 +- .../suites/replay/slowClick/mutation/test.ts | 60 ++++++++++ .../suites/replay/slowClick/template.html | 1 + .../src/util/handleRecordingEmit.ts | 44 ++++++- .../unit/util/handleRecordingEmit.test.ts | 113 +++++++++++++++++- 5 files changed, 216 insertions(+), 4 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 2d5baacbfd88..351c85ccca79 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -269,7 +269,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '247 KB', + limit: '248 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', diff --git a/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts b/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts index 08aad51de3ff..1373f78b3a5c 100644 --- a/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts @@ -56,6 +56,66 @@ sentryTest('mutation after threshold results in slow click', async ({ forceFlush expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3501); }); +sentryTest( + 'uses updated attributes for click breadcrumbs after mutation', + async ({ forceFlushReplay, getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const replayRequestPromise = waitForReplayRequest(page, 0); + const segmentReqWithClickBreadcrumbPromise = waitForReplayRequest(page, (_event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); + + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }); + + await page.goto(url); + await replayRequestPromise; + + await forceFlushReplay(); + + await page.evaluate(() => { + const target = document.getElementById('next-question-button'); + if (!target) { + throw new Error('Could not find target button'); + } + + target.id = 'save-note-button'; + target.setAttribute('data-testid', 'save-note-button'); + }); + + await page.getByRole('button', { name: 'Next question' }).click(); + await forceFlushReplay(); + + const segmentReqWithClickBreadcrumb = await segmentReqWithClickBreadcrumbPromise; + + const { breadcrumbs } = getCustomRecordingEvents(segmentReqWithClickBreadcrumb); + const updatedClickBreadcrumb = breadcrumbs.find(breadcrumb => breadcrumb.category === 'ui.click'); + + expect(updatedClickBreadcrumb).toEqual({ + category: 'ui.click', + data: { + node: { + attributes: { + id: 'save-note-button', + testId: 'save-note-button', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '**** ********', + }, + nodeId: expect.any(Number), + }, + message: 'body > button#save-note-button', + timestamp: expect.any(Number), + type: 'default', + }); + }, +); + sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); diff --git a/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html b/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html index 030401479a6b..2e0558870e1e 100644 --- a/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html +++ b/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html @@ -6,6 +6,7 @@
Trigger mutation
+