From b6a7773544bc220f5499262c6f4e4705fe38ffb6 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 11 Nov 2025 14:41:24 +0100 Subject: [PATCH 01/40] feat(core): Support truncation for LangChain integration request messages (#18157) This PR adds [truncation support for LangChain integration request messages](https://github.com/getsentry/sentry-javascript/issues/18018). All messages already get normalized to arrays of messages, so here we need no case distinction for strings. Adds tests to verify behavior for 1. simple string inputs and 2. conversations in the form of arrays of strings. Closes #18018 --- .../langchain/scenario-message-truncation.mjs | 72 +++++++++++++++++++ .../suites/tracing/langchain/test.ts | 51 +++++++++++++ packages/core/src/utils/langchain/utils.ts | 4 +- 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/scenario-message-truncation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-message-truncation.mjs new file mode 100644 index 000000000000..6dafe8572cec --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-message-truncation.mjs @@ -0,0 +1,72 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + const model = req.body.model; + + res.json({ + id: 'msg_truncation_test', + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: 'Response to truncated messages', + }, + ], + model: model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }); + }); + + 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, + }, + }); + + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + // Create one very large string that gets truncated to only include Cs + await model.invoke(largeContent3 + largeContent2); + + // Create an array of messages that gets truncated to only include the last message (result should again contain only Cs) + await model.invoke([ + { role: 'system', content: largeContent1 }, + { role: 'user', content: largeContent2 }, + { role: 'user', content: largeContent3 }, + ]); + }); + + 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 4de2f96b5dc5..ff0e95b8f8ad 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -194,4 +194,55 @@ describe('LangChain integration', () => { await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_TOOL_CALLS }).start().completed(); }); }); + + const EXPECTED_TRANSACTION_MESSAGE_TRUNCATION = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + // Messages should be present and should include truncated string input (contains only Cs) + 'gen_ai.request.messages': expect.stringMatching(/^\[\{"role":"user","content":"C+"\}\]$/), + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + // Messages should be present (truncation happened) and should be a JSON array of a single index (contains only Cs) + 'gen_ai.request.messages': expect.stringMatching(/^\[\{"role":"user","content":"C+"\}\]$/), + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_MESSAGE_TRUNCATION }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/utils/langchain/utils.ts b/packages/core/src/utils/langchain/utils.ts index 8464e71aecb0..caacf5059bdc 100644 --- a/packages/core/src/utils/langchain/utils.ts +++ b/packages/core/src/utils/langchain/utils.ts @@ -23,6 +23,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { truncateGenAiMessages } from '../ai/messageTruncation'; import { LANGCHAIN_ORIGIN, ROLE_MAP } from './constants'; import type { LangChainLLMResult, LangChainMessage, LangChainSerialized } from './types'; @@ -281,7 +282,8 @@ export function extractChatModelRequestAttributes( if (recordInputs && Array.isArray(langChainMessages) && langChainMessages.length > 0) { const normalized = normalizeLangChainMessages(langChainMessages.flat()); - setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(normalized)); + const truncated = truncateGenAiMessages(normalized); + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(truncated)); } return attrs; From 3be2092005a0c019b162c3db2ada702ddc1504a2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 14:51:18 +0100 Subject: [PATCH 02/40] test(browser-integration): Fix incorrect tag value assertions (#18162) Fixes faulty test assertions where we asserted for certain properties to _not_ be in an object but used `toMatchObject` to do so. --- .../public-api/setTag/with_non_primitives/test.ts | 12 ++++++++++-- .../suites/public-api/setTag/with_primitives/test.ts | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts b/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts index 181711650074..648187404c0e 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts @@ -3,11 +3,19 @@ import type { Event } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; -sentryTest('should not accept non-primitive tags', async ({ getLocalTestUrl, page }) => { +sentryTest('[bug] accepts non-primitive tags', async ({ getLocalTestUrl, page }) => { + // this is a bug that went unnoticed due to type definitions and a bad assertion + // TODO: We should not accept non-primitive tags. Fix this as a follow-up. const url = await getLocalTestUrl({ testDir: __dirname }); const eventData = await getFirstSentryEnvelopeRequest(page, url); expect(eventData.message).toBe('non_primitives'); - expect(eventData.tags).toMatchObject({}); + + // TODO: This should be an empty object but instead, it is: + expect(eventData.tags).toEqual({ + tag_1: {}, + tag_2: [], + tag_3: ['a', {}], + }); }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/setTag/with_primitives/test.ts b/dev-packages/browser-integration-tests/suites/public-api/setTag/with_primitives/test.ts index 47116b6554bb..2b4922e4a86e 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/setTag/with_primitives/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/setTag/with_primitives/test.ts @@ -9,7 +9,7 @@ sentryTest('should set primitive tags', async ({ getLocalTestUrl, page }) => { const eventData = await getFirstSentryEnvelopeRequest(page, url); expect(eventData.message).toBe('primitive_tags'); - expect(eventData.tags).toMatchObject({ + expect(eventData.tags).toEqual({ tag_1: 'foo', tag_2: 3.141592653589793, tag_3: false, From a36b67212ab04f9e194b814c21735ddce553bd43 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:15:56 +0100 Subject: [PATCH 03/40] test(profiling): Add test utils to validate Profile Chunk envelope (#18170) This PR adds two utility functions for testing the profile envelope: `validateProfilePayloadMetadata` and `validateProfile`. As More tests are going to be added, I don't want to copy-paste the same tests over and over. Part of https://github.com/getsentry/sentry-javascript/issues/17279 --- .../suites/profiling/legacyMode/test.ts | 88 ++------- .../suites/profiling/test-utils.ts | 151 +++++++++++++++ .../test.ts | 172 ++++-------------- .../test.ts | 121 ++---------- 4 files changed, 214 insertions(+), 318 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/profiling/test-utils.ts diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts index d473236cdfda..4d8caa3a2be3 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts @@ -6,6 +6,7 @@ import { shouldSkipTracingTest, waitForTransactionRequestOnUrl, } from '../../../utils/helpers'; +import { validateProfile } from '../test-utils'; sentryTest( 'does not send profile envelope when document-policy is not set', @@ -41,79 +42,16 @@ sentryTest('sends profile envelope in legacy mode', async ({ page, getLocalTestU const profile = profileEvent.profile; expect(profileEvent.profile).toBeDefined(); - expect(profile.samples).toBeDefined(); - expect(profile.stacks).toBeDefined(); - expect(profile.frames).toBeDefined(); - expect(profile.thread_metadata).toBeDefined(); - - // Samples - expect(profile.samples.length).toBeGreaterThanOrEqual(2); - for (const sample of profile.samples) { - expect(typeof sample.elapsed_since_start_ns).toBe('string'); - expect(sample.elapsed_since_start_ns).toMatch(/^\d+$/); // Numeric string - expect(parseInt(sample.elapsed_since_start_ns, 10)).toBeGreaterThanOrEqual(0); - - expect(typeof sample.stack_id).toBe('number'); - expect(sample.stack_id).toBeGreaterThanOrEqual(0); - expect(sample.thread_id).toBe('0'); // Should be main thread - } - - // Stacks - expect(profile.stacks.length).toBeGreaterThan(0); - for (const stack of profile.stacks) { - expect(Array.isArray(stack)).toBe(true); - for (const frameIndex of stack) { - expect(typeof frameIndex).toBe('number'); - expect(frameIndex).toBeGreaterThanOrEqual(0); - expect(frameIndex).toBeLessThan(profile.frames.length); - } - } - - // Frames - expect(profile.frames.length).toBeGreaterThan(0); - for (const frame of profile.frames) { - expect(frame).toHaveProperty('function'); - expect(typeof frame.function).toBe('string'); - - if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { - expect(frame).toHaveProperty('abs_path'); - expect(frame).toHaveProperty('lineno'); - expect(frame).toHaveProperty('colno'); - expect(typeof frame.abs_path).toBe('string'); - expect(typeof frame.lineno).toBe('number'); - expect(typeof frame.colno).toBe('number'); - } - } - - const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); - - if ((process.env.PW_BUNDLE || '').endsWith('min')) { - // Function names are minified in minified bundles - expect(functionNames.length).toBeGreaterThan(0); - expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings - } else { - expect(functionNames).toEqual( - expect.arrayContaining([ - '_startRootSpan', - 'withScope', - 'createChildOrRootSpan', - 'startSpanManual', - 'startProfileForSpan', - 'startJSSelfProfile', - ]), - ); - } - - expect(profile.thread_metadata).toHaveProperty('0'); - expect(profile.thread_metadata['0']).toHaveProperty('name'); - expect(profile.thread_metadata['0'].name).toBe('main'); - - // Test that profile duration makes sense (should be > 20ms based on test setup) - const startTime = parseInt(profile.samples[0].elapsed_since_start_ns, 10); - const endTime = parseInt(profile.samples[profile.samples.length - 1].elapsed_since_start_ns, 10); - const durationNs = endTime - startTime; - const durationMs = durationNs / 1_000_000; // Convert ns to ms - - // Should be at least 20ms based on our setTimeout(21) in the test - expect(durationMs).toBeGreaterThan(20); + validateProfile(profile, { + expectedFunctionNames: [ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startProfileForSpan', + 'startJSSelfProfile', + ], + minSampleDurationMs: 20, + isChunkFormat: false, + }); }); diff --git a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts new file mode 100644 index 000000000000..e150be2d56bc --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts @@ -0,0 +1,151 @@ +import { expect } from '@playwright/test'; +import type { ContinuousThreadCpuProfile, ProfileChunk, ThreadCpuProfile } from '@sentry/core'; + +interface ValidateProfileOptions { + expectedFunctionNames?: string[]; + minSampleDurationMs?: number; + isChunkFormat?: boolean; +} + +/** + * Validates the metadata of a profile chunk envelope. + * https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + */ +export function validateProfilePayloadMetadata(profileChunk: ProfileChunk): void { + expect(profileChunk.version).toBe('2'); + expect(profileChunk.platform).toBe('javascript'); + + expect(typeof profileChunk.profiler_id).toBe('string'); + expect(profileChunk.profiler_id).toMatch(/^[a-f\d]{32}$/); + + expect(typeof profileChunk.chunk_id).toBe('string'); + expect(profileChunk.chunk_id).toMatch(/^[a-f\d]{32}$/); + + expect(profileChunk.client_sdk).toBeDefined(); + expect(typeof profileChunk.client_sdk.name).toBe('string'); + expect(typeof profileChunk.client_sdk.version).toBe('string'); + + expect(typeof profileChunk.release).toBe('string'); + + expect(profileChunk.debug_meta).toBeDefined(); + expect(Array.isArray(profileChunk?.debug_meta?.images)).toBe(true); +} + +/** + * Validates the basic structure and content of a Sentry profile. + */ +export function validateProfile( + profile: ThreadCpuProfile | ContinuousThreadCpuProfile, + options: ValidateProfileOptions = {}, +): void { + const { expectedFunctionNames, minSampleDurationMs, isChunkFormat = false } = options; + + // Basic profile structure + expect(profile.samples).toBeDefined(); + expect(profile.stacks).toBeDefined(); + expect(profile.frames).toBeDefined(); + expect(profile.thread_metadata).toBeDefined(); + + // SAMPLES + expect(profile.samples.length).toBeGreaterThanOrEqual(2); + let previousTimestamp: number = Number.NEGATIVE_INFINITY; + + for (const sample of profile.samples) { + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.stack_id).toBeLessThan(profile.stacks.length); + + expect(sample.thread_id).toBe('0'); // Should be main thread + + // Timestamp validation - differs between chunk format (v2) and legacy format + if (isChunkFormat) { + const chunkProfileSample = sample as ContinuousThreadCpuProfile['samples'][number]; + + // Chunk format uses numeric timestamps (UNIX timestamp in seconds with microseconds precision) + expect(typeof chunkProfileSample.timestamp).toBe('number'); + const ts = chunkProfileSample.timestamp; + expect(Number.isFinite(ts)).toBe(true); + expect(ts).toBeGreaterThan(0); + // Monotonic non-decreasing timestamps + expect(ts).toBeGreaterThanOrEqual(previousTimestamp); + previousTimestamp = ts; + } else { + // Legacy format uses elapsed_since_start_ns as a string + const legacyProfileSample = sample as ThreadCpuProfile['samples'][number]; + + expect(typeof legacyProfileSample.elapsed_since_start_ns).toBe('string'); + expect(legacyProfileSample.elapsed_since_start_ns).toMatch(/^\d+$/); // Numeric string + expect(parseInt(legacyProfileSample.elapsed_since_start_ns, 10)).toBeGreaterThanOrEqual(0); + } + } + + // STACKS + expect(profile.stacks.length).toBeGreaterThan(0); + for (const stack of profile.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile.frames.length); + } + } + + // Frames + expect(profile.frames.length).toBeGreaterThan(0); + for (const frame of profile.frames) { + expect(frame).toHaveProperty('function'); + expect(typeof frame.function).toBe('string'); + + // Some browser functions (fetch, setTimeout) may not have file locations + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } + } + + // Function names validation (only when not minified and expected names provided) + if (expectedFunctionNames && expectedFunctionNames.length > 0) { + const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In minified bundles, just check that we have some non-empty function names + expect(functionNames.length).toBeGreaterThan(0); + expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); + } else { + // In non-minified bundles, check for expected function names + expect(functionNames).toEqual(expect.arrayContaining(expectedFunctionNames)); + } + } + + // THREAD METADATA + expect(profile.thread_metadata).toHaveProperty('0'); + expect(profile.thread_metadata['0']).toHaveProperty('name'); + expect(profile.thread_metadata['0'].name).toBe('main'); + + // DURATION + if (minSampleDurationMs !== undefined) { + let durationMs: number; + + if (isChunkFormat) { + // Chunk format: timestamps are in seconds + const chunkProfile = profile as ContinuousThreadCpuProfile; + + const startTimeSec = chunkProfile.samples[0].timestamp; + const endTimeSec = chunkProfile.samples[chunkProfile.samples.length - 1].timestamp; + durationMs = (endTimeSec - startTimeSec) * 1000; // Convert to ms + } else { + // Legacy format: elapsed_since_start_ns is in nanoseconds + const legacyProfile = profile as ThreadCpuProfile; + + const startTimeNs = parseInt(legacyProfile.samples[0].elapsed_since_start_ns, 10); + const endTimeNs = parseInt(legacyProfile.samples[legacyProfile.samples.length - 1].elapsed_since_start_ns, 10); + durationMs = (endTimeNs - startTimeNs) / 1_000_000; // Convert ns to ms + } + + expect(durationMs).toBeGreaterThan(minSampleDurationMs); + } +} diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts index 421cdfc1e645..5afc23a3a75f 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -7,6 +7,7 @@ import { properFullEnvelopeRequestParser, shouldSkipTracingTest, } from '../../../utils/helpers'; +import { validateProfile, validateProfilePayloadMetadata } from '../test-utils'; sentryTest( 'does not send profile envelope when document-policy is not set', @@ -51,109 +52,24 @@ sentryTest( const envelopeItemPayload1 = profileChunkEnvelopeItem[1]; expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); - expect(envelopeItemPayload1.profile).toBeDefined(); - expect(envelopeItemPayload1.version).toBe('2'); - expect(envelopeItemPayload1.platform).toBe('javascript'); - - // Required profile metadata (Sample Format V2) - expect(typeof envelopeItemPayload1.profiler_id).toBe('string'); - expect(envelopeItemPayload1.profiler_id).toMatch(/^[a-f\d]{32}$/); - expect(typeof envelopeItemPayload1.chunk_id).toBe('string'); - expect(envelopeItemPayload1.chunk_id).toMatch(/^[a-f\d]{32}$/); - expect(envelopeItemPayload1.client_sdk).toBeDefined(); - expect(typeof envelopeItemPayload1.client_sdk.name).toBe('string'); - expect(typeof envelopeItemPayload1.client_sdk.version).toBe('string'); - expect(typeof envelopeItemPayload1.release).toBe('string'); - expect(envelopeItemPayload1.debug_meta).toBeDefined(); - expect(Array.isArray(envelopeItemPayload1?.debug_meta?.images)).toBe(true); - - const profile1 = envelopeItemPayload1.profile; - - expect(profile1.samples).toBeDefined(); - expect(profile1.stacks).toBeDefined(); - expect(profile1.frames).toBeDefined(); - expect(profile1.thread_metadata).toBeDefined(); - - // Samples - expect(profile1.samples.length).toBeGreaterThanOrEqual(2); - let previousTimestamp = Number.NEGATIVE_INFINITY; - for (const sample of profile1.samples) { - expect(typeof sample.stack_id).toBe('number'); - expect(sample.stack_id).toBeGreaterThanOrEqual(0); - expect(sample.stack_id).toBeLessThan(profile1.stacks.length); - - // In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock) - expect(typeof (sample as any).timestamp).toBe('number'); - const ts = (sample as any).timestamp as number; - expect(Number.isFinite(ts)).toBe(true); - expect(ts).toBeGreaterThan(0); - // Monotonic non-decreasing timestamps - expect(ts).toBeGreaterThanOrEqual(previousTimestamp); - previousTimestamp = ts; - - expect(sample.thread_id).toBe('0'); // Should be main thread - } - - // Stacks - expect(profile1.stacks.length).toBeGreaterThan(0); - for (const stack of profile1.stacks) { - expect(Array.isArray(stack)).toBe(true); - for (const frameIndex of stack) { - expect(typeof frameIndex).toBe('number'); - expect(frameIndex).toBeGreaterThanOrEqual(0); - expect(frameIndex).toBeLessThan(profile1.frames.length); - } - } - - // Frames - expect(profile1.frames.length).toBeGreaterThan(0); - for (const frame of profile1.frames) { - expect(frame).toHaveProperty('function'); - expect(typeof frame.function).toBe('string'); - - if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { - expect(frame).toHaveProperty('abs_path'); - expect(frame).toHaveProperty('lineno'); - expect(frame).toHaveProperty('colno'); - expect(typeof frame.abs_path).toBe('string'); - expect(typeof frame.lineno).toBe('number'); - expect(typeof frame.colno).toBe('number'); - } - } - const functionNames = profile1.frames.map(frame => frame.function).filter(name => name !== ''); - - if ((process.env.PW_BUNDLE || '').endsWith('min')) { - // In bundled mode, function names are minified - expect(functionNames.length).toBeGreaterThan(0); - expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings - } else { - expect(functionNames).toEqual( - expect.arrayContaining([ - '_startRootSpan', - 'withScope', - 'createChildOrRootSpan', - 'startSpanManual', - 'startJSSelfProfile', - - // first function is captured (other one is in other chunk) - 'fibonacci', - ]), - ); - } - - expect(profile1.thread_metadata).toHaveProperty('0'); - expect(profile1.thread_metadata['0']).toHaveProperty('name'); - expect(profile1.thread_metadata['0'].name).toBe('main'); - - // Test that profile duration makes sense (should be > 20ms based on test setup) - const startTimeSec = (profile1.samples[0] as any).timestamp as number; - const endTimeSec = (profile1.samples[profile1.samples.length - 1] as any).timestamp as number; - const durationSec = endTimeSec - startTimeSec; - - // Should be at least 20ms based on our setTimeout(21) in the test - expect(durationSec).toBeGreaterThan(0.2); + validateProfilePayloadMetadata(envelopeItemPayload1); + + validateProfile(envelopeItemPayload1.profile, { + expectedFunctionNames: [ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + // first function is captured (other one is in other chunk) + 'fibonacci', + ], + // Should be at least 20ms based on our setTimeout(21) in the test + minSampleDurationMs: 20, + isChunkFormat: true, + }); // === PROFILE CHUNK 2 === @@ -161,46 +77,22 @@ sentryTest( const envelopeItemHeader2 = profileChunkEnvelopeItem2[0]; const envelopeItemPayload2 = profileChunkEnvelopeItem2[1]; - // Basic sanity on the second chunk: has correct envelope type and structure expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk'); expect(envelopeItemPayload2.profile).toBeDefined(); - expect(envelopeItemPayload2.version).toBe('2'); - expect(envelopeItemPayload2.platform).toBe('javascript'); - - // Required profile metadata (Sample Format V2) - // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ - expect(typeof envelopeItemPayload2.profiler_id).toBe('string'); - expect(envelopeItemPayload2.profiler_id).toMatch(/^[a-f\d]{32}$/); - expect(typeof envelopeItemPayload2.chunk_id).toBe('string'); - expect(envelopeItemPayload2.chunk_id).toMatch(/^[a-f\d]{32}$/); - expect(envelopeItemPayload2.client_sdk).toBeDefined(); - expect(typeof envelopeItemPayload2.client_sdk.name).toBe('string'); - expect(typeof envelopeItemPayload2.client_sdk.version).toBe('string'); - expect(typeof envelopeItemPayload2.release).toBe('string'); - expect(envelopeItemPayload2.debug_meta).toBeDefined(); - expect(Array.isArray(envelopeItemPayload2?.debug_meta?.images)).toBe(true); - - const profile2 = envelopeItemPayload2.profile; - - const functionNames2 = profile2.frames.map(frame => frame.function).filter(name => name !== ''); - - if ((process.env.PW_BUNDLE || '').endsWith('min')) { - // In bundled mode, function names are minified - expect(functionNames2.length).toBeGreaterThan(0); - expect((functionNames2 as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings - } else { - expect(functionNames2).toEqual( - expect.arrayContaining([ - '_startRootSpan', - 'withScope', - 'createChildOrRootSpan', - 'startSpanManual', - 'startJSSelfProfile', - - // second function is captured (other one is in other chunk) - 'largeSum', - ]), - ); - } + + validateProfilePayloadMetadata(envelopeItemPayload2); + + validateProfile(envelopeItemPayload2.profile, { + expectedFunctionNames: [ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + // second function is captured (other one is in other chunk) + 'largeSum', + ], + isChunkFormat: true, + }); }, ); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts index 161f74d64e83..fa66a225b49b 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -8,6 +8,7 @@ import { shouldSkipTracingTest, waitForTransactionRequestOnUrl, } from '../../../utils/helpers'; +import { validateProfile, validateProfilePayloadMetadata } from '../test-utils'; sentryTest( 'does not send profile envelope when document-policy is not set', @@ -52,111 +53,25 @@ sentryTest( const envelopeItemPayload = profileChunkEnvelopeItem[1]; expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); - expect(envelopeItemPayload.profile).toBeDefined(); - expect(envelopeItemPayload.version).toBe('2'); - expect(envelopeItemPayload.platform).toBe('javascript'); - - // Required profile metadata (Sample Format V2) - // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ - expect(typeof envelopeItemPayload.profiler_id).toBe('string'); - expect(envelopeItemPayload.profiler_id).toMatch(/^[a-f\d]{32}$/); - expect(typeof envelopeItemPayload.chunk_id).toBe('string'); - expect(envelopeItemPayload.chunk_id).toMatch(/^[a-f\d]{32}$/); - expect(envelopeItemPayload.client_sdk).toBeDefined(); - expect(typeof envelopeItemPayload.client_sdk.name).toBe('string'); - expect(typeof envelopeItemPayload.client_sdk.version).toBe('string'); - expect(typeof envelopeItemPayload.release).toBe('string'); - expect(envelopeItemPayload.debug_meta).toBeDefined(); - expect(Array.isArray(envelopeItemPayload?.debug_meta?.images)).toBe(true); - - const profile = envelopeItemPayload.profile; - - expect(profile.samples).toBeDefined(); - expect(profile.stacks).toBeDefined(); - expect(profile.frames).toBeDefined(); - expect(profile.thread_metadata).toBeDefined(); - - // Samples - expect(profile.samples.length).toBeGreaterThanOrEqual(2); - let previousTimestamp = Number.NEGATIVE_INFINITY; - for (const sample of profile.samples) { - expect(typeof sample.stack_id).toBe('number'); - expect(sample.stack_id).toBeGreaterThanOrEqual(0); - expect(sample.stack_id).toBeLessThan(profile.stacks.length); - - // In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock) - expect(typeof sample.timestamp).toBe('number'); - const ts = sample.timestamp; - expect(Number.isFinite(ts)).toBe(true); - expect(ts).toBeGreaterThan(0); - // Monotonic non-decreasing timestamps - expect(ts).toBeGreaterThanOrEqual(previousTimestamp); - previousTimestamp = ts; - - expect(sample.thread_id).toBe('0'); // Should be main thread - } - - // Stacks - expect(profile.stacks.length).toBeGreaterThan(0); - for (const stack of profile.stacks) { - expect(Array.isArray(stack)).toBe(true); - for (const frameIndex of stack) { - expect(typeof frameIndex).toBe('number'); - expect(frameIndex).toBeGreaterThanOrEqual(0); - expect(frameIndex).toBeLessThan(profile.frames.length); - } - } - - // Frames - expect(profile.frames.length).toBeGreaterThan(0); - for (const frame of profile.frames) { - expect(frame).toHaveProperty('function'); - expect(typeof frame.function).toBe('string'); - - if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { - expect(frame).toHaveProperty('abs_path'); - expect(frame).toHaveProperty('lineno'); - expect(frame).toHaveProperty('colno'); - expect(typeof frame.abs_path).toBe('string'); - expect(typeof frame.lineno).toBe('number'); - expect(typeof frame.colno).toBe('number'); - } - } - - const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); - - if ((process.env.PW_BUNDLE || '').endsWith('min')) { - // In bundled mode, function names are minified - expect(functionNames.length).toBeGreaterThan(0); - expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings - } else { - expect(functionNames).toEqual( - expect.arrayContaining([ - '_startRootSpan', - 'withScope', - 'createChildOrRootSpan', - 'startSpanManual', - 'startJSSelfProfile', - - // both functions are captured - 'fibonacci', - 'largeSum', - ]), - ); - } - - expect(profile.thread_metadata).toHaveProperty('0'); - expect(profile.thread_metadata['0']).toHaveProperty('name'); - expect(profile.thread_metadata['0'].name).toBe('main'); - - // Test that profile duration makes sense (should be > 20ms based on test setup) - const startTimeSec = (profile.samples[0] as any).timestamp as number; - const endTimeSec = (profile.samples[profile.samples.length - 1] as any).timestamp as number; - const durationSec = endTimeSec - startTimeSec; - // Should be at least 20ms based on our setTimeout(21) in the test - expect(durationSec).toBeGreaterThan(0.2); + validateProfilePayloadMetadata(envelopeItemPayload); + + validateProfile(envelopeItemPayload.profile, { + expectedFunctionNames: [ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + // both functions are captured + 'fibonacci', + 'largeSum', + ], + // Test that profile duration makes sense (should be > 20ms based on test setup + minSampleDurationMs: 20, + isChunkFormat: true, + }); }, ); From 9d99c99c067a074ee39a972003ef2a0cdfb09de4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 12 Nov 2025 09:43:34 +0100 Subject: [PATCH 04/40] fix(core): Decrease number of Sentry stack frames for messages from `captureConsoleIntegration` (#18096) This patch creates a synthetic exception already within the captureConsole handler, so that we minimize the number of Sentry stack frames in the stack trace. It also adjusts the `Client::captureMessage` method to favor an already provided `syntheticException` over the one it would create by itself. --- packages/core/src/exports.ts | 4 +- .../core/src/integrations/captureconsole.ts | 17 +++- packages/core/src/scope.ts | 2 +- .../lib/integrations/captureconsole.test.ts | 81 ++++++++++++------- 4 files changed, 68 insertions(+), 36 deletions(-) diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index a5dc716d8124..e3f658c88f2a 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -40,8 +40,8 @@ export function captureMessage(message: string, captureContext?: CaptureContext // This is necessary to provide explicit scopes upgrade, without changing the original // arity of the `captureMessage(message, level)` method. const level = typeof captureContext === 'string' ? captureContext : undefined; - const context = typeof captureContext !== 'string' ? { captureContext } : undefined; - return getCurrentScope().captureMessage(message, level, context); + const hint = typeof captureContext !== 'string' ? { captureContext } : undefined; + return getCurrentScope().captureMessage(message, level, hint); } /** diff --git a/packages/core/src/integrations/captureconsole.ts b/packages/core/src/integrations/captureconsole.ts index d5d34bd554aa..4c28e2c74a54 100644 --- a/packages/core/src/integrations/captureconsole.ts +++ b/packages/core/src/integrations/captureconsole.ts @@ -1,5 +1,5 @@ import { getClient, withScope } from '../currentScopes'; -import { captureException, captureMessage } from '../exports'; +import { captureException } from '../exports'; import { addConsoleInstrumentationHandler } from '../instrument/console'; import { defineIntegration } from '../integration'; import type { CaptureContext } from '../scope'; @@ -52,6 +52,17 @@ const _captureConsoleIntegration = ((options: CaptureConsoleOptions = {}) => { export const captureConsoleIntegration = defineIntegration(_captureConsoleIntegration); function consoleHandler(args: unknown[], level: string, handled: boolean): void { + const severityLevel = severityLevelFromString(level); + + /* + We create this error here already to attach a stack trace to captured messages, + if users set `attachStackTrace` to `true` in Sentry.init. + We do this here already because we want to minimize the number of Sentry SDK stack frames + within the error. Technically, Client.captureMessage will also do it but this happens several + stack frames deeper. + */ + const syntheticException = new Error(); + const captureContext: CaptureContext = { level: severityLevelFromString(level), extra: { @@ -75,7 +86,7 @@ function consoleHandler(args: unknown[], level: string, handled: boolean): void if (!args[0]) { const message = `Assertion failed: ${safeJoin(args.slice(1), ' ') || 'console.assert'}`; scope.setExtra('arguments', args.slice(1)); - captureMessage(message, captureContext); + scope.captureMessage(message, severityLevel, { captureContext, syntheticException }); } return; } @@ -87,6 +98,6 @@ function consoleHandler(args: unknown[], level: string, handled: boolean): void } const message = safeJoin(args, ' '); - captureMessage(message, captureContext); + scope.captureMessage(message, severityLevel, { captureContext, syntheticException }); }); } diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 8b1e21acfb4a..3287d8efbbbd 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -607,7 +607,7 @@ export class Scope { return eventId; } - const syntheticException = new Error(message); + const syntheticException = hint?.syntheticException ?? new Error(message); this._client.captureMessage( message, diff --git a/packages/core/test/lib/integrations/captureconsole.test.ts b/packages/core/test/lib/integrations/captureconsole.test.ts index 2ca059be4f86..a7e14f6536c3 100644 --- a/packages/core/test/lib/integrations/captureconsole.test.ts +++ b/packages/core/test/lib/integrations/captureconsole.test.ts @@ -29,13 +29,14 @@ describe('CaptureConsole setup', () => { let mockClient: Client; + const captureException = vi.fn(); + const mockScope = { setExtra: vi.fn(), addEventProcessor: vi.fn(), + captureMessage: vi.fn(), }; - const captureMessage = vi.fn(); - const captureException = vi.fn(); const withScope = vi.fn(callback => { return callback(mockScope); }); @@ -43,7 +44,6 @@ describe('CaptureConsole setup', () => { beforeEach(() => { mockClient = {} as Client; - vi.spyOn(SentryCore, 'captureMessage').mockImplementation(captureMessage); vi.spyOn(SentryCore, 'captureException').mockImplementation(captureException); vi.spyOn(CurrentScopes, 'getClient').mockImplementation(() => mockClient); vi.spyOn(CurrentScopes, 'withScope').mockImplementation(withScope); @@ -72,7 +72,7 @@ describe('CaptureConsole setup', () => { GLOBAL_OBJ.console.log('msg 2'); GLOBAL_OBJ.console.warn('msg 3'); - expect(captureMessage).toHaveBeenCalledTimes(2); + expect(mockScope.captureMessage).toHaveBeenCalledTimes(2); }); it('should fall back to default console levels if none are provided', () => { @@ -86,7 +86,7 @@ describe('CaptureConsole setup', () => { GLOBAL_OBJ.console.assert(false); - expect(captureMessage).toHaveBeenCalledTimes(7); + expect(mockScope.captureMessage).toHaveBeenCalledTimes(7); }); it('should not wrap any functions with an empty levels option', () => { @@ -97,7 +97,7 @@ describe('CaptureConsole setup', () => { GLOBAL_OBJ.console[key]('msg'); }); - expect(captureMessage).toHaveBeenCalledTimes(0); + expect(mockScope.captureMessage).toHaveBeenCalledTimes(0); }); }); @@ -121,8 +121,14 @@ describe('CaptureConsole setup', () => { GLOBAL_OBJ.console.log(); - expect(captureMessage).toHaveBeenCalledTimes(1); - expect(captureMessage).toHaveBeenCalledWith('', { extra: { arguments: [] }, level: 'log' }); + expect(mockScope.captureMessage).toHaveBeenCalledTimes(1); + expect(mockScope.captureMessage).toHaveBeenCalledWith('', 'log', { + captureContext: { + level: 'log', + extra: { arguments: [] }, + }, + syntheticException: expect.any(Error), + }); }); it('should add an event processor that sets the `debug` field of events', () => { @@ -148,10 +154,13 @@ describe('CaptureConsole setup', () => { GLOBAL_OBJ.console.assert(1 + 1 === 3); expect(mockScope.setExtra).toHaveBeenLastCalledWith('arguments', []); - expect(captureMessage).toHaveBeenCalledTimes(1); - expect(captureMessage).toHaveBeenCalledWith('Assertion failed: console.assert', { - extra: { arguments: [false] }, - level: 'log', + expect(mockScope.captureMessage).toHaveBeenCalledTimes(1); + expect(mockScope.captureMessage).toHaveBeenCalledWith('Assertion failed: console.assert', 'log', { + captureContext: { + level: 'log', + extra: { arguments: [false] }, + }, + syntheticException: expect.any(Error), }); }); @@ -162,10 +171,13 @@ describe('CaptureConsole setup', () => { GLOBAL_OBJ.console.assert(1 + 1 === 3, 'expression is false'); expect(mockScope.setExtra).toHaveBeenLastCalledWith('arguments', ['expression is false']); - expect(captureMessage).toHaveBeenCalledTimes(1); - expect(captureMessage).toHaveBeenCalledWith('Assertion failed: expression is false', { - extra: { arguments: [false, 'expression is false'] }, - level: 'log', + expect(mockScope.captureMessage).toHaveBeenCalledTimes(1); + expect(mockScope.captureMessage).toHaveBeenCalledWith('Assertion failed: expression is false', 'log', { + captureContext: { + level: 'log', + extra: { arguments: [false, 'expression is false'] }, + }, + syntheticException: expect.any(Error), }); }); @@ -175,7 +187,7 @@ describe('CaptureConsole setup', () => { GLOBAL_OBJ.console.assert(1 + 1 === 2); - expect(captureMessage).toHaveBeenCalledTimes(0); + expect(mockScope.captureMessage).toHaveBeenCalledTimes(0); }); it('should capture exception when console logs an error object with level set to "error"', () => { @@ -226,10 +238,13 @@ describe('CaptureConsole setup', () => { GLOBAL_OBJ.console.error('some message'); - expect(captureMessage).toHaveBeenCalledTimes(1); - expect(captureMessage).toHaveBeenCalledWith('some message', { - extra: { arguments: ['some message'] }, - level: 'error', + expect(mockScope.captureMessage).toHaveBeenCalledTimes(1); + expect(mockScope.captureMessage).toHaveBeenCalledWith('some message', 'error', { + captureContext: { + level: 'error', + extra: { arguments: ['some message'] }, + }, + syntheticException: expect.any(Error), }); }); @@ -239,10 +254,13 @@ describe('CaptureConsole setup', () => { GLOBAL_OBJ.console.error('some non-error message'); - expect(captureMessage).toHaveBeenCalledTimes(1); - expect(captureMessage).toHaveBeenCalledWith('some non-error message', { - extra: { arguments: ['some non-error message'] }, - level: 'error', + expect(mockScope.captureMessage).toHaveBeenCalledTimes(1); + expect(mockScope.captureMessage).toHaveBeenCalledWith('some non-error message', 'error', { + captureContext: { + level: 'error', + extra: { arguments: ['some non-error message'] }, + }, + syntheticException: expect.any(Error), }); expect(captureException).not.toHaveBeenCalled(); }); @@ -253,10 +271,13 @@ describe('CaptureConsole setup', () => { GLOBAL_OBJ.console.info('some message'); - expect(captureMessage).toHaveBeenCalledTimes(1); - expect(captureMessage).toHaveBeenCalledWith('some message', { - extra: { arguments: ['some message'] }, - level: 'info', + expect(mockScope.captureMessage).toHaveBeenCalledTimes(1); + expect(mockScope.captureMessage).toHaveBeenCalledWith('some message', 'info', { + captureContext: { + level: 'info', + extra: { arguments: ['some message'] }, + }, + syntheticException: expect.any(Error), }); }); @@ -293,7 +314,7 @@ describe('CaptureConsole setup', () => { // Should not capture messages GLOBAL_OBJ.console.log('some message'); - expect(captureMessage).not.toHaveBeenCalledWith(); + expect(mockScope.captureMessage).not.toHaveBeenCalledWith(); }); it("should not crash when the original console methods don't exist at time of invocation", () => { From 0f4c190b4024b3376af7e003bda227d2eefcba0e Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 12 Nov 2025 09:54:02 +0100 Subject: [PATCH 05/40] ref(e2e-ember): Remove `@embroider/addon-shim` override (#18180) We needed the override because version 10.0.1 didn't have a valid package.json (https://github.com/embroider-build/embroider/issues/2609). They released version 10.0.2 now. --- .../e2e-tests/test-applications/ember-embroider/package.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/ember-embroider/package.json b/dev-packages/e2e-tests/test-applications/ember-embroider/package.json index 78a2e202d1eb..b7a102917e80 100644 --- a/dev-packages/e2e-tests/test-applications/ember-embroider/package.json +++ b/dev-packages/e2e-tests/test-applications/ember-embroider/package.json @@ -68,10 +68,5 @@ }, "volta": { "extends": "../../package.json" - }, - "pnpm": { - "overrides": { - "@embroider/addon-shim": "1.10.0" - } } } From 679a02c8f5201e3a2595ac2e6b5385e0a591301d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 12 Nov 2025 13:09:01 +0100 Subject: [PATCH 06/40] ref(core): Optimize `Scope.setTag` bundle size and adjust test (#18182) Two changes: 1. Reduce bundle size slightly by optimizing `setTag` (+ adding some more tests around setTag(s)) 2. Adjust the integration test message since we no longer classify the SUT behaviour as a bug --- .../setTag/with_non_primitives/test.ts | 13 +++-- packages/core/src/scope.ts | 4 +- packages/core/test/lib/scope.test.ts | 47 +++++++++++++++---- 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts b/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts index 648187404c0e..465c2f684de0 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts @@ -3,16 +3,21 @@ import type { Event } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; -sentryTest('[bug] accepts non-primitive tags', async ({ getLocalTestUrl, page }) => { - // this is a bug that went unnoticed due to type definitions and a bad assertion - // TODO: We should not accept non-primitive tags. Fix this as a follow-up. +sentryTest('accepts and sends non-primitive tags', async ({ getLocalTestUrl, page }) => { + // Technically, accepting and sending non-primitive tags is a specification violation. + // This slipped through because a previous version of this test should have ensured that + // we don't accept non-primitive tags. However, the test was flawed. + // Turns out, Relay and our product handle invalid tag values gracefully. + // Our type definitions for setTag(s) also only allow primitive values. + // Therefore (to save some bundle size), we'll continue accepting and sending non-primitive + // tag values for now (but not adjust types). + // This test documents this decision, so that we know why we're accepting non-primitive tags. const url = await getLocalTestUrl({ testDir: __dirname }); const eventData = await getFirstSentryEnvelopeRequest(page, url); expect(eventData.message).toBe('non_primitives'); - // TODO: This should be an empty object but instead, it is: expect(eventData.tags).toEqual({ tag_1: {}, tag_2: [], diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 3287d8efbbbd..b23b01664431 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -291,9 +291,7 @@ export class Scope { * Set a single tag that will be sent as tags data with the event. */ public setTag(key: string, value: Primitive): this { - this._tags = { ...this._tags, [key]: value }; - this._notifyScopeListeners(); - return this; + return this.setTags({ [key]: value }); } /** diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index 280ba4c651ff..221ac14a6fa2 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -140,16 +140,47 @@ describe('Scope', () => { expect(scope['_extra']).toEqual({ a: undefined }); }); - test('setTag', () => { - const scope = new Scope(); - scope.setTag('a', 'b'); - expect(scope['_tags']).toEqual({ a: 'b' }); + describe('setTag', () => { + it('sets a tag', () => { + const scope = new Scope(); + scope.setTag('a', 'b'); + expect(scope['_tags']).toEqual({ a: 'b' }); + }); + + it('sets a tag with undefined', () => { + const scope = new Scope(); + scope.setTag('a', 'b'); + scope.setTag('a', undefined); + expect(scope['_tags']).toEqual({ a: undefined }); + }); + + it('notifies scope listeners once per call', () => { + const scope = new Scope(); + const listener = vi.fn(); + + scope.addScopeListener(listener); + scope.setTag('a', 'b'); + scope.setTag('a', 'c'); + + expect(listener).toHaveBeenCalledTimes(2); + }); }); - test('setTags', () => { - const scope = new Scope(); - scope.setTags({ a: 'b' }); - expect(scope['_tags']).toEqual({ a: 'b' }); + describe('setTags', () => { + it('sets tags', () => { + const scope = new Scope(); + scope.setTags({ a: 'b', c: 1 }); + expect(scope['_tags']).toEqual({ a: 'b', c: 1 }); + }); + + it('notifies scope listeners once per call', () => { + const scope = new Scope(); + const listener = vi.fn(); + scope.addScopeListener(listener); + scope.setTags({ a: 'b', c: 'd' }); + scope.setTags({ a: 'e', f: 'g' }); + expect(listener).toHaveBeenCalledTimes(2); + }); }); test('setUser', () => { From 5c184f4a85113d3b566ac5aa8799112ad03d194a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 12 Nov 2025 14:26:57 +0200 Subject: [PATCH 07/40] fix(nextjs): Avoid wrapping middleware files when in standalone mode (#18172) This PR attempts to fix #18001 by not wrapping the middleware files if Next.js 16 is the current version and is in standalone output mode which is the problematic scenario. Investigation: - Next.js renames `proxy` to `middleware` under the hood. - By wrapping the middleware a `proxy.js` entry is produced in `middleware.js.nft.json` which wouldn't be there otherwise, meaning if we don't wrap it then that entry doesn't get produced. So it seems like `@vercel/nft` is somehow adding the `proxy` file as a dependency of itself which fails to copy to the output directory because it was already copied and renamed to `proxy.js` or at least that is what I'm guessing is happening. --- packages/nextjs/src/config/webpack.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 4484b1194bd2..df32c31f392e 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -276,7 +276,8 @@ export function constructWebpackConfigFunction({ }); // Wrap middleware - if (userSentryOptions.autoInstrumentMiddleware ?? true) { + const canWrapStandaloneMiddleware = userNextConfig.output !== 'standalone' || !major || major < 16; + if ((userSentryOptions.autoInstrumentMiddleware ?? true) && canWrapStandaloneMiddleware) { newConfig.module.rules.unshift({ test: isMiddlewareResource, use: [ From 0cb306ef81dd74a6fe10ecddf43efe353417812a Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 12 Nov 2025 12:38:24 +0000 Subject: [PATCH 08/40] fix(browser): Add `ok` status to succesful `idleSpan`s (#18139) This came up while working on improvements for React Router wildcard routes. Looks like the successful browser `idleSpans` are reported with `unknown` status at the moment. --- .../tests/client-transactions.test.ts | 2 + .../nextjs-app-dir/tests/transactions.test.ts | 1 + .../tests/transactions.test.ts | 1 + .../tests/transactions.test.ts | 4 + .../tests/transactions.test.ts | 2 + .../tests/transactions.test.ts | 2 + .../tests/transactions.test.ts | 89 +++++++++++++++++++ packages/core/src/tracing/idleSpan.ts | 8 +- 8 files changed, 108 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts index cbb2cae29265..a539216efee7 100644 --- a/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts @@ -24,6 +24,7 @@ test('Sends a pageload transaction to Sentry', async ({ page }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), op: 'pageload', origin: 'auto.pageload.nextjs.pages_router_instrumentation', + status: 'ok', data: expect.objectContaining({ 'sentry.idle_span_finish_reason': 'idleTimeout', 'sentry.op': 'pageload', @@ -69,6 +70,7 @@ test('captures a navigation transaction to Sentry', async ({ page }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), op: 'navigation', origin: 'auto.navigation.nextjs.pages_router_instrumentation', + status: 'ok', data: expect.objectContaining({ 'sentry.idle_span_finish_reason': 'idleTimeout', 'sentry.op': 'navigation', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts index 9819507f5cb9..c938817e2642 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts @@ -33,6 +33,7 @@ test('Sends a pageload transaction', async ({ page }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), op: 'pageload', origin: 'auto.pageload.nextjs.app_router_instrumentation', + status: 'ok', data: expect.objectContaining({ 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts index 3569789ed995..5b648065e45a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts @@ -33,6 +33,7 @@ test('Sends a pageload transaction', async ({ page }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), op: 'pageload', origin: 'auto.pageload.nextjs.pages_router_instrumentation', + status: 'ok', data: expect.objectContaining({ 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.pages_router_instrumentation', diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts index ee0c507076fa..4af30d8e139d 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts @@ -36,6 +36,7 @@ test('Captures a pageload transaction', async ({ page }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), origin: 'auto.pageload.react.reactrouter_v6', + status: 'ok', }), ); }); @@ -72,6 +73,7 @@ test('Captures a navigation transaction', async ({ page }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), origin: 'auto.navigation.react.reactrouter_v6', + status: 'ok', }); expect(transactionEvent).toEqual( @@ -107,6 +109,7 @@ test('Captures a lazy pageload transaction', async ({ page }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), origin: 'auto.pageload.react.reactrouter_v6', + status: 'ok', }); expect(transactionEvent).toEqual( @@ -169,6 +172,7 @@ test('Captures a lazy navigation transaction', async ({ page }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), origin: 'auto.navigation.react.reactrouter_v6', + status: 'ok', }); expect(transactionEvent).toEqual( diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts index 36e6d0c18ee2..15cab5e8569e 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts @@ -31,6 +31,7 @@ test('Captures a pageload transaction', async ({ page }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), origin: 'auto.pageload.react.reactrouter_v6', + status: 'ok', }); expect(transactionEvent).toEqual( @@ -136,6 +137,7 @@ test('Captures a navigation transaction', async ({ page }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), origin: 'auto.navigation.react.reactrouter_v6', + status: 'ok', }); expect(transactionEvent).toEqual( diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts index 61a583a7bf55..5c60b704bb7a 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts @@ -33,6 +33,7 @@ test('Captures a pageload transaction', async ({ page }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), origin: 'auto.pageload.react.reactrouter_v6', + status: 'ok', }), ); }); @@ -69,6 +70,7 @@ test('Captures a navigation transaction', async ({ page }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), origin: 'auto.navigation.react.reactrouter_v6', + status: 'ok', }); expect(transactionEvent).toEqual( diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index 3901b0938ca5..a57199c52633 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -21,6 +21,7 @@ test('Creates a pageload transaction with parameterized route', async ({ page }) expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); expect(event.type).toBe('transaction'); expect(event.contexts?.trace?.op).toBe('pageload'); + expect(event.contexts?.trace?.status).toBe('ok'); }); test('Does not create a navigation transaction on initial load to deep lazy route', async ({ page }) => { @@ -82,6 +83,7 @@ test('Creates a navigation transaction inside a lazy route', async ({ page }) => expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); expect(event.type).toBe('transaction'); expect(event.contexts?.trace?.op).toBe('navigation'); + expect(event.contexts?.trace?.status).toBe('ok'); }); test('Creates navigation transactions between two different lazy routes', async ({ page }) => { @@ -498,3 +500,90 @@ test('Updates navigation transaction name correctly when span is cancelled early expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason); } }); + +test('Creates separate transactions for rapid consecutive navigations', async ({ page }) => { + await page.goto('/'); + + // First navigation: / -> /lazy/inner/:id/:anotherId/:someAnotherId + const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + const navigationToInner = page.locator('id=navigation'); + await expect(navigationToInner).toBeVisible(); + await navigationToInner.click(); + + const firstEvent = await firstTransactionPromise; + + // Verify first transaction + expect(firstEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(firstEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstEvent.contexts?.trace?.status).toBe('ok'); + const firstTraceId = firstEvent.contexts?.trace?.trace_id; + const firstSpanId = firstEvent.contexts?.trace?.span_id; + + // Second navigation: /lazy/inner -> /another-lazy/sub/:id/:subId + const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/another-lazy/sub/:id/:subId' + ); + }); + + const navigationToAnother = page.locator('id=navigate-to-another-from-inner'); + await expect(navigationToAnother).toBeVisible(); + await navigationToAnother.click(); + + const secondEvent = await secondTransactionPromise; + + // Verify second transaction + expect(secondEvent.transaction).toBe('/another-lazy/sub/:id/:subId'); + expect(secondEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondEvent.contexts?.trace?.status).toBe('ok'); + const secondTraceId = secondEvent.contexts?.trace?.trace_id; + const secondSpanId = secondEvent.contexts?.trace?.span_id; + + // Third navigation: /another-lazy -> /lazy/inner/:id/:anotherId/:someAnotherId (back to same route as first) + const thirdTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' && + // Ensure we're not matching the first transaction again + transactionEvent.contexts?.trace?.trace_id !== firstTraceId + ); + }); + + const navigationBackToInner = page.locator('id=navigate-to-inner-from-deep'); + await expect(navigationBackToInner).toBeVisible(); + await navigationBackToInner.click(); + + const thirdEvent = await thirdTransactionPromise; + + // Verify third transaction + expect(thirdEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(thirdEvent.contexts?.trace?.op).toBe('navigation'); + expect(thirdEvent.contexts?.trace?.status).toBe('ok'); + const thirdTraceId = thirdEvent.contexts?.trace?.trace_id; + const thirdSpanId = thirdEvent.contexts?.trace?.span_id; + + // Verify each navigation created a separate transaction with unique trace and span IDs + expect(firstTraceId).toBeDefined(); + expect(secondTraceId).toBeDefined(); + expect(thirdTraceId).toBeDefined(); + + // All trace IDs should be unique + expect(firstTraceId).not.toBe(secondTraceId); + expect(secondTraceId).not.toBe(thirdTraceId); + expect(firstTraceId).not.toBe(thirdTraceId); + + // All span IDs should be unique + expect(firstSpanId).not.toBe(secondSpanId); + expect(secondSpanId).not.toBe(thirdSpanId); + expect(firstSpanId).not.toBe(thirdSpanId); +}); diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index b35e31322ecd..53848a9c9191 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -19,7 +19,7 @@ import { timestampInSeconds } from '../utils/time'; import { freezeDscOnSpan, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; import { SentrySpan } from './sentrySpan'; -import { SPAN_STATUS_ERROR } from './spanstatus'; +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from './spanstatus'; import { startInactiveSpan } from './trace'; export const TRACING_DEFAULTS = { @@ -302,6 +302,12 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, _finishReason); } + // Set span status to 'ok' if it hasn't been explicitly set to an error status + const currentStatus = spanJSON.status; + if (!currentStatus || currentStatus === 'unknown') { + span.setStatus({ code: SPAN_STATUS_OK }); + } + debug.log(`[Tracing] Idle span "${spanJSON.op}" finished`); const childSpans = getSpanDescendants(span).filter(child => child !== span); From 2406e9058377436956028779268e510e02a25bc6 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Wed, 12 Nov 2025 14:06:17 +0100 Subject: [PATCH 09/40] feat(node-core): Add mechanism to prevent wrapping ai providers multiple times(#17972) When using higher-level integrations that wrap underlying libraries, both the wrapper integration and the underlying library integration can instrument the same API calls, resulting in duplicate spans. This is particularly problematic for: - LangChain wrapping AI providers (OpenAI, Anthropic, Google GenAI) - Any future providers that wrap other providers We expose 3 internal methods ```js _INTERNAL_skipAiProviderWrapping(providers: string[]) _INTERNAL_shouldSkipAiProviderWrapping(provider: string) _INTERNAL_clearAiProviderSkips() ``` To bail out of instrumenting providers when they are on the skip list. These are internal methods not meant for public consumers and may be changed or removed in the future. --------- Co-authored-by: Andrei Borza --- .../tracing/langchain/instrument-with-pii.mjs | 2 - .../suites/tracing/langchain/instrument.mjs | 2 - .../scenario-openai-before-langchain.mjs | 95 +++++++++++++++++++ .../suites/tracing/langchain/test.ts | 60 ++++++++++++ packages/core/src/index.ts | 7 +- packages/core/src/integration.ts | 2 +- packages/core/src/utils/ai/providerSkip.ts | 64 +++++++++++++ packages/core/test/lib/integration.test.ts | 6 +- .../test/lib/utils/ai/providerSkip.test.ts | 71 ++++++++++++++ packages/node-core/src/sdk/client.ts | 18 +++- .../tracing/anthropic-ai/instrumentation.ts | 13 ++- .../tracing/google-genai/instrumentation.ts | 16 +++- .../node/src/integrations/tracing/index.ts | 10 +- .../integrations/tracing/langchain/index.ts | 4 + .../tracing/langchain/instrumentation.ts | 20 +++- .../tracing/openai/instrumentation.ts | 13 ++- 16 files changed, 381 insertions(+), 22 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/scenario-openai-before-langchain.mjs create mode 100644 packages/core/src/utils/ai/providerSkip.ts create mode 100644 packages/core/test/lib/utils/ai/providerSkip.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs index 85b2a963d977..cb68a6f7683e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs @@ -7,8 +7,6 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, transport: loggingTransport, - // Filter out Anthropic integration to avoid duplicate spans with LangChain - integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'), beforeSendTransaction: event => { // Filter out mock express server transactions if (event.transaction.includes('/v1/messages')) { diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs index 524d19f4b995..b4ce44f3e91a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs @@ -7,8 +7,6 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: false, transport: loggingTransport, - // Filter out Anthropic integration to avoid duplicate spans with LangChain - integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'), beforeSendTransaction: event => { // Filter out mock express server transactions if (event.transaction.includes('/v1/messages')) { diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-openai-before-langchain.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-openai-before-langchain.mjs new file mode 100644 index 000000000000..f194acb1672b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-openai-before-langchain.mjs @@ -0,0 +1,95 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + res.json({ + id: 'msg_test123', + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: 'Mock response from Anthropic!', + }, + ], + model: req.body.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }); + }); + + 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 () => { + // EDGE CASE: Import and instantiate Anthropic client BEFORE LangChain is imported + // This simulates the timing issue where a user creates an Anthropic client in one file + // before importing LangChain in another file + const { default: Anthropic } = await import('@anthropic-ai/sdk'); + const anthropicClient = new Anthropic({ + apiKey: 'mock-api-key', + baseURL, + }); + + // Use the Anthropic client directly - this will be instrumented by the Anthropic integration + await anthropicClient.messages.create({ + model: 'claude-3-5-sonnet-20241022', + messages: [{ role: 'user', content: 'Direct Anthropic call' }], + temperature: 0.7, + max_tokens: 100, + }); + + // NOW import LangChain - at this point it will mark Anthropic to be skipped + // But the client created above is already instrumented + const { ChatAnthropic } = await import('@langchain/anthropic'); + + // Create a LangChain model - this uses Anthropic under the hood + const langchainModel = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0.7, + maxTokens: 100, + apiKey: 'mock-api-key', + clientOptions: { + baseURL, + }, + }); + + // Use LangChain - this will be instrumented by LangChain integration + await langchainModel.invoke('LangChain Anthropic call'); + + // Create ANOTHER Anthropic client after LangChain was imported + // This one should NOT be instrumented (skip mechanism works correctly) + const anthropicClient2 = new Anthropic({ + apiKey: 'mock-api-key', + baseURL, + }); + + await anthropicClient2.messages.create({ + model: 'claude-3-5-sonnet-20241022', + messages: [{ role: 'user', content: 'Second direct Anthropic call' }], + temperature: 0.7, + max_tokens: 100, + }); + }); + + 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 ff0e95b8f8ad..e75e0ec7f5da 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -245,4 +245,64 @@ describe('LangChain integration', () => { }); }, ); + + createEsmAndCjsTests( + __dirname, + 'scenario-openai-before-langchain.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('demonstrates timing issue with duplicate spans (ESM only)', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: event => { + // This test highlights the limitation: if a user creates an Anthropic client + // before importing LangChain, that client will still be instrumented and + // could cause duplicate spans when used alongside LangChain. + + const spans = event.spans || []; + + // First call: Direct Anthropic call made BEFORE LangChain import + // This should have Anthropic instrumentation (origin: 'auto.ai.anthropic') + const firstAnthropicSpan = spans.find( + span => + span.description === 'messages claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.anthropic', + ); + + // Second call: LangChain call + // This should have LangChain instrumentation (origin: 'auto.ai.langchain') + const langchainSpan = spans.find( + span => span.description === 'chat claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.langchain', + ); + + // Third call: Direct Anthropic call made AFTER LangChain import + // This should NOT have Anthropic instrumentation (skip works correctly) + // Count how many Anthropic spans we have - should be exactly 1 + const anthropicSpans = spans.filter( + span => + span.description === 'messages claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.anthropic', + ); + + // Verify the edge case limitation: + // - First Anthropic client (created before LangChain) IS instrumented + expect(firstAnthropicSpan).toBeDefined(); + expect(firstAnthropicSpan?.origin).toBe('auto.ai.anthropic'); + + // - LangChain call IS instrumented by LangChain + expect(langchainSpan).toBeDefined(); + expect(langchainSpan?.origin).toBe('auto.ai.langchain'); + + // - Second Anthropic client (created after LangChain) is NOT instrumented + // This demonstrates that the skip mechanism works for NEW clients + // We should only have ONE Anthropic span (the first one), not two + expect(anthropicSpans).toHaveLength(1); + }, + }) + .start() + .completed(); + }); + }, + // This test fails on CJS because we use dynamic imports to simulate importing LangChain after the Anthropic client is created + { failsOnCjs: true }, + ); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f3b29009b9ce..c11d69179bd3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -55,7 +55,12 @@ export { initAndBind, setCurrentClient } from './sdk'; export { createTransport } from './transports/base'; export { makeOfflineTransport } from './transports/offline'; export { makeMultiplexedTransport } from './transports/multiplexed'; -export { getIntegrationsToSetup, addIntegration, defineIntegration } from './integration'; +export { getIntegrationsToSetup, addIntegration, defineIntegration, installedIntegrations } from './integration'; +export { + _INTERNAL_skipAiProviderWrapping, + _INTERNAL_shouldSkipAiProviderWrapping, + _INTERNAL_clearAiProviderSkips, +} from './utils/ai/providerSkip'; export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent'; export { prepareEvent } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 5cba3ff3dfb8..892228476824 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -107,7 +107,7 @@ export function setupIntegration(client: Client, integration: Integration, integ integrationIndex[integration.name] = integration; // `setupOnce` is only called the first time - if (installedIntegrations.indexOf(integration.name) === -1 && typeof integration.setupOnce === 'function') { + if (!installedIntegrations.includes(integration.name) && typeof integration.setupOnce === 'function') { integration.setupOnce(); installedIntegrations.push(integration.name); } diff --git a/packages/core/src/utils/ai/providerSkip.ts b/packages/core/src/utils/ai/providerSkip.ts new file mode 100644 index 000000000000..0b7ca2a5c3bc --- /dev/null +++ b/packages/core/src/utils/ai/providerSkip.ts @@ -0,0 +1,64 @@ +import { DEBUG_BUILD } from '../../debug-build'; +import { debug } from '../debug-logger'; + +/** + * Registry tracking which AI provider modules should skip instrumentation wrapping. + * + * This prevents duplicate spans when a higher-level integration (like LangChain) + * already instruments AI providers at a higher abstraction level. + */ +const SKIPPED_AI_PROVIDERS = new Set(); + +/** + * Mark AI provider modules to skip instrumentation wrapping. + * + * This prevents duplicate spans when a higher-level integration (like LangChain) + * already instruments AI providers at a higher abstraction level. + * + * @internal + * @param modules - Array of npm module names to skip (e.g., '@anthropic-ai/sdk', 'openai') + * + * @example + * ```typescript + * // In LangChain integration + * _INTERNAL_skipAiProviderWrapping(['@anthropic-ai/sdk', 'openai', '@google/generative-ai']); + * ``` + */ +export function _INTERNAL_skipAiProviderWrapping(modules: string[]): void { + modules.forEach(module => { + SKIPPED_AI_PROVIDERS.add(module); + DEBUG_BUILD && debug.log(`AI provider "${module}" wrapping will be skipped`); + }); +} + +/** + * Check if an AI provider module should skip instrumentation wrapping. + * + * @internal + * @param module - The npm module name (e.g., '@anthropic-ai/sdk', 'openai') + * @returns true if wrapping should be skipped + * + * @example + * ```typescript + * // In AI provider instrumentation + * if (_INTERNAL_shouldSkipAiProviderWrapping('@anthropic-ai/sdk')) { + * return Reflect.construct(Original, args); // Don't instrument + * } + * ``` + */ +export function _INTERNAL_shouldSkipAiProviderWrapping(module: string): boolean { + return SKIPPED_AI_PROVIDERS.has(module); +} + +/** + * Clear all AI provider skip registrations. + * + * This is automatically called at the start of Sentry.init() to ensure a clean state + * between different client initializations. + * + * @internal + */ +export function _INTERNAL_clearAiProviderSkips(): void { + SKIPPED_AI_PROVIDERS.clear(); + DEBUG_BUILD && debug.log('Cleared AI provider skip registrations'); +} diff --git a/packages/core/test/lib/integration.test.ts b/packages/core/test/lib/integration.test.ts index 5b7554f261b1..75e13374daa7 100644 --- a/packages/core/test/lib/integration.test.ts +++ b/packages/core/test/lib/integration.test.ts @@ -3,7 +3,7 @@ import { getCurrentScope } from '../../src/currentScopes'; import { addIntegration, getIntegrationsToSetup, installedIntegrations, setupIntegration } from '../../src/integration'; import { setCurrentClient } from '../../src/sdk'; import type { Integration } from '../../src/types-hoist/integration'; -import type { Options } from '../../src/types-hoist/options'; +import type { CoreOptions } from '../../src/types-hoist/options'; import { debug } from '../../src/utils/debug-logger'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; @@ -32,8 +32,8 @@ class MockIntegration implements Integration { type TestCase = [ string, // test name - Options['defaultIntegrations'], // default integrations - Options['integrations'], // user-provided integrations + CoreOptions['defaultIntegrations'], // default integrations + CoreOptions['integrations'], // user-provided integrations Array, // expected results ]; diff --git a/packages/core/test/lib/utils/ai/providerSkip.test.ts b/packages/core/test/lib/utils/ai/providerSkip.test.ts new file mode 100644 index 000000000000..99ec76c970d6 --- /dev/null +++ b/packages/core/test/lib/utils/ai/providerSkip.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + _INTERNAL_clearAiProviderSkips, + _INTERNAL_shouldSkipAiProviderWrapping, + _INTERNAL_skipAiProviderWrapping, + ANTHROPIC_AI_INTEGRATION_NAME, + GOOGLE_GENAI_INTEGRATION_NAME, + OPENAI_INTEGRATION_NAME, +} from '../../../../src/index'; + +describe('AI Provider Skip', () => { + beforeEach(() => { + _INTERNAL_clearAiProviderSkips(); + }); + + describe('_INTERNAL_skipAiProviderWrapping', () => { + it('marks a single provider to be skipped', () => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(false); + }); + + it('marks multiple providers to be skipped', () => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME]); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(false); + }); + + it('is idempotent - can mark same provider multiple times', () => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + }); + }); + + describe('_INTERNAL_shouldSkipAiProviderWrapping', () => { + it('returns false for unmarked providers', () => { + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(false); + expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(false); + }); + + it('returns true after marking provider to be skipped', () => { + _INTERNAL_skipAiProviderWrapping([ANTHROPIC_AI_INTEGRATION_NAME]); + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); + }); + }); + + describe('_INTERNAL_clearAiProviderSkips', () => { + it('clears all skip registrations', () => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME]); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); + + _INTERNAL_clearAiProviderSkips(); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(false); + }); + + it('can be called multiple times safely', () => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + _INTERNAL_clearAiProviderSkips(); + _INTERNAL_clearAiProviderSkips(); + _INTERNAL_clearAiProviderSkips(); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + }); + }); +}); diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts index e631508c7392..efc144989421 100644 --- a/packages/node-core/src/sdk/client.ts +++ b/packages/node-core/src/sdk/client.ts @@ -4,7 +4,14 @@ import { trace } from '@opentelemetry/api'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import type { DynamicSamplingContext, Scope, ServerRuntimeClientOptions, TraceContext } from '@sentry/core'; -import { _INTERNAL_flushLogsBuffer, applySdkMetadata, debug, SDK_VERSION, ServerRuntimeClient } from '@sentry/core'; +import { + _INTERNAL_clearAiProviderSkips, + _INTERNAL_flushLogsBuffer, + applySdkMetadata, + debug, + SDK_VERSION, + ServerRuntimeClient, +} from '@sentry/core'; import { getTraceContextForScope } from '@sentry/opentelemetry'; import { isMainThread, threadId } from 'worker_threads'; import { DEBUG_BUILD } from '../debug-build'; @@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient { } } + /** @inheritDoc */ + protected _setupIntegrations(): void { + // Clear AI provider skip registrations before setting up integrations + // This ensures a clean state between different client initializations + // (e.g., when LangChain skips OpenAI in one client, but a subsequent client uses OpenAI standalone) + _INTERNAL_clearAiProviderSkips(); + super._setupIntegrations(); + } + /** Custom implementation for OTEL, so we can handle scope-span linking. */ protected _getTraceInfoFromScope( scope: Scope | undefined, diff --git a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts index d55689415aee..09cfd7e33713 100644 --- a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts @@ -5,7 +5,13 @@ import { InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; import type { AnthropicAiClient, AnthropicAiOptions } from '@sentry/core'; -import { getClient, instrumentAnthropicAiClient, SDK_VERSION } from '@sentry/core'; +import { + _INTERNAL_shouldSkipAiProviderWrapping, + ANTHROPIC_AI_INTEGRATION_NAME, + getClient, + instrumentAnthropicAiClient, + SDK_VERSION, +} from '@sentry/core'; const supportedVersions = ['>=0.19.2 <1.0.0']; @@ -48,6 +54,11 @@ export class SentryAnthropicAiInstrumentation extends InstrumentationBase=0.10.0 <2']; @@ -65,14 +72,17 @@ export class SentryGoogleGenAiInstrumentation extends InstrumentationBase instrumentTedious, instrumentGenericPool, instrumentAmqplib, + instrumentLangChain, instrumentVercelAi, instrumentOpenAi, instrumentPostgresJs, instrumentFirebase, instrumentAnthropicAi, instrumentGoogleGenAI, - instrumentLangChain, ]; } diff --git a/packages/node/src/integrations/tracing/langchain/index.ts b/packages/node/src/integrations/tracing/langchain/index.ts index e575691b930f..cbc7bae8c63d 100644 --- a/packages/node/src/integrations/tracing/langchain/index.ts +++ b/packages/node/src/integrations/tracing/langchain/index.ts @@ -25,6 +25,10 @@ const _langChainIntegration = ((options: LangChainOptions = {}) => { * When configured, this integration automatically instruments LangChain runnable instances * to capture telemetry data by injecting Sentry callback handlers into all LangChain calls. * + * **Important:** This integration automatically skips wrapping the OpenAI, Anthropic, and Google GenAI + * providers to prevent duplicate spans when using LangChain with these AI providers. + * LangChain handles the instrumentation for all underlying AI providers. + * * @example * ```javascript * import * as Sentry from '@sentry/node'; diff --git a/packages/node/src/integrations/tracing/langchain/instrumentation.ts b/packages/node/src/integrations/tracing/langchain/instrumentation.ts index f171a2dfb022..09c6002a4629 100644 --- a/packages/node/src/integrations/tracing/langchain/instrumentation.ts +++ b/packages/node/src/integrations/tracing/langchain/instrumentation.ts @@ -6,7 +6,15 @@ import { InstrumentationNodeModuleFile, } from '@opentelemetry/instrumentation'; import type { LangChainOptions } from '@sentry/core'; -import { createLangChainCallbackHandler, getClient, SDK_VERSION } from '@sentry/core'; +import { + _INTERNAL_skipAiProviderWrapping, + ANTHROPIC_AI_INTEGRATION_NAME, + createLangChainCallbackHandler, + getClient, + GOOGLE_GENAI_INTEGRATION_NAME, + OPENAI_INTEGRATION_NAME, + SDK_VERSION, +} from '@sentry/core'; const supportedVersions = ['>=0.1.0 <1.0.0']; @@ -143,14 +151,20 @@ export class SentryLangChainInstrumentation extends InstrumentationBase=4.0.0 <6']; @@ -56,6 +62,11 @@ export class SentryOpenAiInstrumentation extends InstrumentationBase(OPENAI_INTEGRATION_NAME); From 4f71c948e12d3f6a55f8d74c84766cce053a280c Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 12 Nov 2025 14:55:03 +0100 Subject: [PATCH 10/40] ref(core): Move ai integrations from utils to tracing (#18185) As discussed moving the AI integrations from core/utils to core/tracing. --- packages/core/src/index.ts | 30 +++++++++---------- .../ai/gen-ai-attributes.ts | 0 .../ai/messageTruncation.ts | 0 .../core/src/{utils => tracing}/ai/utils.ts | 0 .../anthropic-ai/constants.ts | 0 .../{utils => tracing}/anthropic-ai/index.ts | 2 +- .../anthropic-ai/streaming.ts | 0 .../{utils => tracing}/anthropic-ai/types.ts | 0 .../{utils => tracing}/anthropic-ai/utils.ts | 0 .../google-genai/constants.ts | 0 .../{utils => tracing}/google-genai/index.ts | 2 +- .../google-genai/streaming.ts | 0 .../{utils => tracing}/google-genai/types.ts | 0 .../{utils => tracing}/google-genai/utils.ts | 0 .../{utils => tracing}/langchain/constants.ts | 0 .../src/{utils => tracing}/langchain/index.ts | 0 .../src/{utils => tracing}/langchain/types.ts | 0 .../src/{utils => tracing}/langchain/utils.ts | 0 .../{utils => tracing}/openai/constants.ts | 0 .../src/{utils => tracing}/openai/index.ts | 0 .../{utils => tracing}/openai/streaming.ts | 0 .../src/{utils => tracing}/openai/types.ts | 0 .../src/{utils => tracing}/openai/utils.ts | 0 .../{utils => tracing}/vercel-ai/constants.ts | 0 .../src/{utils => tracing}/vercel-ai/index.ts | 2 +- .../src/{utils => tracing}/vercel-ai/types.ts | 0 .../src/{utils => tracing}/vercel-ai/utils.ts | 0 .../vercel-ai/vercel-ai-attributes.ts | 0 .../core/test/lib/utils/openai-utils.test.ts | 2 +- .../openai-integration-functions.test.ts | 2 +- 30 files changed, 20 insertions(+), 20 deletions(-) rename packages/core/src/{utils => tracing}/ai/gen-ai-attributes.ts (100%) rename packages/core/src/{utils => tracing}/ai/messageTruncation.ts (100%) rename packages/core/src/{utils => tracing}/ai/utils.ts (100%) rename packages/core/src/{utils => tracing}/anthropic-ai/constants.ts (100%) rename packages/core/src/{utils => tracing}/anthropic-ai/index.ts (99%) rename packages/core/src/{utils => tracing}/anthropic-ai/streaming.ts (100%) rename packages/core/src/{utils => tracing}/anthropic-ai/types.ts (100%) rename packages/core/src/{utils => tracing}/anthropic-ai/utils.ts (100%) rename packages/core/src/{utils => tracing}/google-genai/constants.ts (100%) rename packages/core/src/{utils => tracing}/google-genai/index.ts (99%) rename packages/core/src/{utils => tracing}/google-genai/streaming.ts (100%) rename packages/core/src/{utils => tracing}/google-genai/types.ts (100%) rename packages/core/src/{utils => tracing}/google-genai/utils.ts (100%) rename packages/core/src/{utils => tracing}/langchain/constants.ts (100%) rename packages/core/src/{utils => tracing}/langchain/index.ts (100%) rename packages/core/src/{utils => tracing}/langchain/types.ts (100%) rename packages/core/src/{utils => tracing}/langchain/utils.ts (100%) rename packages/core/src/{utils => tracing}/openai/constants.ts (100%) rename packages/core/src/{utils => tracing}/openai/index.ts (100%) rename packages/core/src/{utils => tracing}/openai/streaming.ts (100%) rename packages/core/src/{utils => tracing}/openai/types.ts (100%) rename packages/core/src/{utils => tracing}/openai/utils.ts (100%) rename packages/core/src/{utils => tracing}/vercel-ai/constants.ts (100%) rename packages/core/src/{utils => tracing}/vercel-ai/index.ts (99%) rename packages/core/src/{utils => tracing}/vercel-ai/types.ts (100%) rename packages/core/src/{utils => tracing}/vercel-ai/utils.ts (100%) rename packages/core/src/{utils => tracing}/vercel-ai/vercel-ai-attributes.ts (100%) rename packages/core/test/{utils => tracing}/openai-integration-functions.test.ts (98%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c11d69179bd3..504368cfb873 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -140,31 +140,31 @@ export { export * as metrics from './metrics/public-api'; export type { MetricOptions } from './metrics/public-api'; export { createConsolaReporter } from './integrations/consola'; -export { addVercelAiProcessors } from './utils/vercel-ai'; -export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './utils/vercel-ai/utils'; -export { instrumentOpenAiClient } from './utils/openai'; -export { OPENAI_INTEGRATION_NAME } from './utils/openai/constants'; -export { instrumentAnthropicAiClient } from './utils/anthropic-ai'; -export { ANTHROPIC_AI_INTEGRATION_NAME } from './utils/anthropic-ai/constants'; -export { instrumentGoogleGenAIClient } from './utils/google-genai'; -export { GOOGLE_GENAI_INTEGRATION_NAME } from './utils/google-genai/constants'; -export type { GoogleGenAIResponse } from './utils/google-genai/types'; -export { createLangChainCallbackHandler } from './utils/langchain'; -export { LANGCHAIN_INTEGRATION_NAME } from './utils/langchain/constants'; -export type { LangChainOptions, LangChainIntegration } from './utils/langchain/types'; -export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types'; +export { addVercelAiProcessors } from './tracing/vercel-ai'; +export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils'; +export { instrumentOpenAiClient } from './tracing/openai'; +export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants'; +export { instrumentAnthropicAiClient } from './tracing/anthropic-ai'; +export { ANTHROPIC_AI_INTEGRATION_NAME } from './tracing/anthropic-ai/constants'; +export { instrumentGoogleGenAIClient } from './tracing/google-genai'; +export { GOOGLE_GENAI_INTEGRATION_NAME } from './tracing/google-genai/constants'; +export type { GoogleGenAIResponse } from './tracing/google-genai/types'; +export { createLangChainCallbackHandler } from './tracing/langchain'; +export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants'; +export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types'; +export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types'; export type { AnthropicAiClient, AnthropicAiOptions, AnthropicAiInstrumentedMethod, AnthropicAiResponse, -} from './utils/anthropic-ai/types'; +} from './tracing/anthropic-ai/types'; export type { GoogleGenAIClient, GoogleGenAIChat, GoogleGenAIOptions, GoogleGenAIIstrumentedMethod, -} from './utils/google-genai/types'; +} from './tracing/google-genai/types'; export type { FeatureFlag } from './utils/featureFlags'; export { diff --git a/packages/core/src/utils/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts similarity index 100% rename from packages/core/src/utils/ai/gen-ai-attributes.ts rename to packages/core/src/tracing/ai/gen-ai-attributes.ts diff --git a/packages/core/src/utils/ai/messageTruncation.ts b/packages/core/src/tracing/ai/messageTruncation.ts similarity index 100% rename from packages/core/src/utils/ai/messageTruncation.ts rename to packages/core/src/tracing/ai/messageTruncation.ts diff --git a/packages/core/src/utils/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts similarity index 100% rename from packages/core/src/utils/ai/utils.ts rename to packages/core/src/tracing/ai/utils.ts diff --git a/packages/core/src/utils/anthropic-ai/constants.ts b/packages/core/src/tracing/anthropic-ai/constants.ts similarity index 100% rename from packages/core/src/utils/anthropic-ai/constants.ts rename to packages/core/src/tracing/anthropic-ai/constants.ts diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts similarity index 99% rename from packages/core/src/utils/anthropic-ai/index.ts rename to packages/core/src/tracing/anthropic-ai/index.ts index 669d8a61b068..aa48a1e4c21d 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -4,6 +4,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; +import { handleCallbackErrors } from '../../utils/handleCallbackErrors'; import { ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, @@ -30,7 +31,6 @@ import { getTruncatedJsonString, setTokenUsageAttributes, } from '../ai/utils'; -import { handleCallbackErrors } from '../handleCallbackErrors'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { AnthropicAiInstrumentedMethod, diff --git a/packages/core/src/utils/anthropic-ai/streaming.ts b/packages/core/src/tracing/anthropic-ai/streaming.ts similarity index 100% rename from packages/core/src/utils/anthropic-ai/streaming.ts rename to packages/core/src/tracing/anthropic-ai/streaming.ts diff --git a/packages/core/src/utils/anthropic-ai/types.ts b/packages/core/src/tracing/anthropic-ai/types.ts similarity index 100% rename from packages/core/src/utils/anthropic-ai/types.ts rename to packages/core/src/tracing/anthropic-ai/types.ts diff --git a/packages/core/src/utils/anthropic-ai/utils.ts b/packages/core/src/tracing/anthropic-ai/utils.ts similarity index 100% rename from packages/core/src/utils/anthropic-ai/utils.ts rename to packages/core/src/tracing/anthropic-ai/utils.ts diff --git a/packages/core/src/utils/google-genai/constants.ts b/packages/core/src/tracing/google-genai/constants.ts similarity index 100% rename from packages/core/src/utils/google-genai/constants.ts rename to packages/core/src/tracing/google-genai/constants.ts diff --git a/packages/core/src/utils/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts similarity index 99% rename from packages/core/src/utils/google-genai/index.ts rename to packages/core/src/tracing/google-genai/index.ts index 9639b1255d29..6ee3e9df8b5b 100644 --- a/packages/core/src/utils/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -4,6 +4,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; +import { handleCallbackErrors } from '../../utils/handleCallbackErrors'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, @@ -23,7 +24,6 @@ import { GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { buildMethodPath, getFinalOperationName, getSpanOperation, getTruncatedJsonString } from '../ai/utils'; -import { handleCallbackErrors } from '../handleCallbackErrors'; import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { diff --git a/packages/core/src/utils/google-genai/streaming.ts b/packages/core/src/tracing/google-genai/streaming.ts similarity index 100% rename from packages/core/src/utils/google-genai/streaming.ts rename to packages/core/src/tracing/google-genai/streaming.ts diff --git a/packages/core/src/utils/google-genai/types.ts b/packages/core/src/tracing/google-genai/types.ts similarity index 100% rename from packages/core/src/utils/google-genai/types.ts rename to packages/core/src/tracing/google-genai/types.ts diff --git a/packages/core/src/utils/google-genai/utils.ts b/packages/core/src/tracing/google-genai/utils.ts similarity index 100% rename from packages/core/src/utils/google-genai/utils.ts rename to packages/core/src/tracing/google-genai/utils.ts diff --git a/packages/core/src/utils/langchain/constants.ts b/packages/core/src/tracing/langchain/constants.ts similarity index 100% rename from packages/core/src/utils/langchain/constants.ts rename to packages/core/src/tracing/langchain/constants.ts diff --git a/packages/core/src/utils/langchain/index.ts b/packages/core/src/tracing/langchain/index.ts similarity index 100% rename from packages/core/src/utils/langchain/index.ts rename to packages/core/src/tracing/langchain/index.ts diff --git a/packages/core/src/utils/langchain/types.ts b/packages/core/src/tracing/langchain/types.ts similarity index 100% rename from packages/core/src/utils/langchain/types.ts rename to packages/core/src/tracing/langchain/types.ts diff --git a/packages/core/src/utils/langchain/utils.ts b/packages/core/src/tracing/langchain/utils.ts similarity index 100% rename from packages/core/src/utils/langchain/utils.ts rename to packages/core/src/tracing/langchain/utils.ts diff --git a/packages/core/src/utils/openai/constants.ts b/packages/core/src/tracing/openai/constants.ts similarity index 100% rename from packages/core/src/utils/openai/constants.ts rename to packages/core/src/tracing/openai/constants.ts diff --git a/packages/core/src/utils/openai/index.ts b/packages/core/src/tracing/openai/index.ts similarity index 100% rename from packages/core/src/utils/openai/index.ts rename to packages/core/src/tracing/openai/index.ts diff --git a/packages/core/src/utils/openai/streaming.ts b/packages/core/src/tracing/openai/streaming.ts similarity index 100% rename from packages/core/src/utils/openai/streaming.ts rename to packages/core/src/tracing/openai/streaming.ts diff --git a/packages/core/src/utils/openai/types.ts b/packages/core/src/tracing/openai/types.ts similarity index 100% rename from packages/core/src/utils/openai/types.ts rename to packages/core/src/tracing/openai/types.ts diff --git a/packages/core/src/utils/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts similarity index 100% rename from packages/core/src/utils/openai/utils.ts rename to packages/core/src/tracing/openai/utils.ts diff --git a/packages/core/src/utils/vercel-ai/constants.ts b/packages/core/src/tracing/vercel-ai/constants.ts similarity index 100% rename from packages/core/src/utils/vercel-ai/constants.ts rename to packages/core/src/tracing/vercel-ai/constants.ts diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts similarity index 99% rename from packages/core/src/utils/vercel-ai/index.ts rename to packages/core/src/tracing/vercel-ai/index.ts index 747a3c105449..8e802edc789f 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -2,12 +2,12 @@ import type { Client } from '../../client'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import type { Event } from '../../types-hoist/event'; import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON, SpanOrigin } from '../../types-hoist/span'; +import { spanToJSON } from '../../utils/spanUtils'; import { GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { getTruncatedJsonString } from '../ai/utils'; -import { spanToJSON } from '../spanUtils'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; import { accumulateTokensForParent, applyAccumulatedTokens } from './utils'; diff --git a/packages/core/src/utils/vercel-ai/types.ts b/packages/core/src/tracing/vercel-ai/types.ts similarity index 100% rename from packages/core/src/utils/vercel-ai/types.ts rename to packages/core/src/tracing/vercel-ai/types.ts diff --git a/packages/core/src/utils/vercel-ai/utils.ts b/packages/core/src/tracing/vercel-ai/utils.ts similarity index 100% rename from packages/core/src/utils/vercel-ai/utils.ts rename to packages/core/src/tracing/vercel-ai/utils.ts diff --git a/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts b/packages/core/src/tracing/vercel-ai/vercel-ai-attributes.ts similarity index 100% rename from packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts rename to packages/core/src/tracing/vercel-ai/vercel-ai-attributes.ts diff --git a/packages/core/test/lib/utils/openai-utils.test.ts b/packages/core/test/lib/utils/openai-utils.test.ts index 0076a617e219..c68a35e5becc 100644 --- a/packages/core/test/lib/utils/openai-utils.test.ts +++ b/packages/core/test/lib/utils/openai-utils.test.ts @@ -8,7 +8,7 @@ import { isResponsesApiResponse, isResponsesApiStreamEvent, shouldInstrument, -} from '../../../src/utils/openai/utils'; +} from '../../../src/tracing/openai/utils'; describe('openai-utils', () => { describe('getOperationName', () => { diff --git a/packages/core/test/utils/openai-integration-functions.test.ts b/packages/core/test/tracing/openai-integration-functions.test.ts similarity index 98% rename from packages/core/test/utils/openai-integration-functions.test.ts rename to packages/core/test/tracing/openai-integration-functions.test.ts index d032fb1e69ed..240ba14d429b 100644 --- a/packages/core/test/utils/openai-integration-functions.test.ts +++ b/packages/core/test/tracing/openai-integration-functions.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import type { OpenAiClient } from '../../src'; -import { instrumentOpenAiClient } from '../../src/utils/openai'; +import { instrumentOpenAiClient } from '../../src/tracing/openai'; interface FullOpenAIClient { chat: { From 2deb0001e9e3134596530f76280d8a6ea2dca985 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:45:56 +0100 Subject: [PATCH 11/40] ref(browserprofiling): Move and rename profiler class to UIProfiler (#18187) This PR renames and moves the profiler class as the class will be used for the `trace` and `manual` lifecycle in the future (prevents large git diffs). Part of https://github.com/getsentry/sentry-javascript/issues/17279 --- .../traceLifecycleProfiler.ts => UIProfiler.ts} | 8 ++++---- packages/browser/src/profiling/integration.ts | 4 ++-- ...{traceLifecycleProfiler.test.ts => UIProfiler.test.ts} | 0 3 files changed, 6 insertions(+), 6 deletions(-) rename packages/browser/src/profiling/{lifecycleMode/traceLifecycleProfiler.ts => UIProfiler.ts} (98%) rename packages/browser/test/profiling/{traceLifecycleProfiler.test.ts => UIProfiler.test.ts} (100%) diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts similarity index 98% rename from packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts rename to packages/browser/src/profiling/UIProfiler.ts index 3ce773fe01ff..fb7cd022ac7f 100644 --- a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -9,9 +9,9 @@ import { getSdkMetadataForEnvelopeHeader, uuid4, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; -import type { JSSelfProfiler } from '../jsSelfProfiling'; -import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from '../utils'; +import { DEBUG_BUILD } from './../debug-build'; +import type { JSSelfProfiler } from './jsSelfProfiling'; +import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from './utils'; const CHUNK_INTERVAL_MS = 60_000; // 1 minute // Maximum length for trace lifecycle profiling per root span (e.g. if spanEnd never fires) @@ -27,7 +27,7 @@ const MAX_ROOT_SPAN_PROFILE_MS = 300_000; // 5 minutes * - there are no more sampled root spans, or * - the 60s chunk timer elapses while profiling is running. */ -export class BrowserTraceLifecycleProfiler { +export class UIProfiler { private _client: Client | undefined; private _profiler: JSSelfProfiler | undefined; private _chunkTimer: ReturnType | undefined; diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 415282698d45..7cd1886e636d 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -3,8 +3,8 @@ import { debug, defineIntegration, getActiveSpan, getRootSpan, hasSpansEnabled } import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; -import { BrowserTraceLifecycleProfiler } from './lifecycleMode/traceLifecycleProfiler'; import { startProfileForSpan } from './startProfileForSpan'; +import { UIProfiler } from './UIProfiler'; import type { ProfiledEvent } from './utils'; import { addProfilesToEnvelope, @@ -65,7 +65,7 @@ const _browserProfilingIntegration = (() => { return; } - const traceLifecycleProfiler = new BrowserTraceLifecycleProfiler(); + const traceLifecycleProfiler = new UIProfiler(); traceLifecycleProfiler.initialize(client, sessionSampled); // If there is an active, sampled root span already, notify the profiler diff --git a/packages/browser/test/profiling/traceLifecycleProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts similarity index 100% rename from packages/browser/test/profiling/traceLifecycleProfiler.test.ts rename to packages/browser/test/profiling/UIProfiler.test.ts From 090a3e35a94014aad4dfd06a6ff3c361f0420009 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 12 Nov 2025 19:33:44 +0100 Subject: [PATCH 12/40] fix(node): `tracingChannel` export missing in older node versions (#18191) I guess this got through CI because we test latest 18 rather than 18.0.0. This breaks [some supported Electron versions](https://github.com/getsentry/sentry-electron/actions/runs/19306230917/job/55215745023) which are using >18.0.0 but <18.19.0. This wont have impacted almost anyone else because Otel requires 18.19.0! ``` [App] [ Main] App threw an error during load [App] [ Main] file:///home/runner/work/sentry-electron/sentry-electron/test/e2e/dist/error-after-ready/node_modules/@sentry/node-core/build/esm/integrations/pino.js:1 [App] [ Main] import { tracingChannel } from 'node:diagnostics_channel'; [App] [ Main] ^^^^^^^^^^^^^^ [App] [ Main] SyntaxError: The requested module 'node:diagnostics_channel' does not provide an export named 'tracingChannel' [App] [ Main] at ModuleJob._instantiate (node:internal/modules/esm/module_job:124:21) [App] [ Main] at async ModuleJob.run (node:internal/modules/esm/module_job:190:5) [App] [ Main] A JavaScript error occurred in the main process ``` --- packages/node-core/src/integrations/pino.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index 21eeff64769e..dda9693f90f2 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -1,4 +1,4 @@ -import { tracingChannel } from 'node:diagnostics_channel'; +import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { Integration, IntegrationFn, LogSeverityLevel } from '@sentry/core'; import { _INTERNAL_captureLog, @@ -122,8 +122,8 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial Date: Thu, 13 Nov 2025 11:35:12 +0100 Subject: [PATCH 13/40] feat(replay): Bump limit for minReplayDuration (#18190) With this PR users can set their min replay duration to max 50s, previously this was capped at 15s. We cannot bump this value further as this would lead to dropping buffered replays (we keep max. 60s in-memory at this point) closes https://github.com/getsentry/sentry-javascript/issues/18109 --------- Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com> --- .../replay/minReplayDurationLimit/init.js | 18 +++++++++++++++ .../minReplayDurationLimit/template.html | 12 ++++++++++ .../replay/minReplayDurationLimit/test.ts | 23 +++++++++++++++++++ packages/replay-internal/src/constants.ts | 8 +++++-- packages/replay-internal/src/types/replay.ts | 5 +++- 5 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/init.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/template.html create mode 100644 dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/test.ts diff --git a/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/init.js b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/init.js new file mode 100644 index 000000000000..58873b85f9ec --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + // Try to set to 60s - should be capped at 50s + minReplayDuration: 60000, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + integrations: [window.Replay], +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/template.html b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/template.html new file mode 100644 index 000000000000..06c44ed4bc9c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/template.html @@ -0,0 +1,12 @@ + + + + + Replay - minReplayDuration Limit + + +
+

Testing that minReplayDuration is capped at 50s max

+
+ + diff --git a/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/test.ts b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/test.ts new file mode 100644 index 000000000000..125af55a6985 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/test.ts @@ -0,0 +1,23 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipReplayTest } from '../../../utils/replayHelpers'; + +sentryTest('caps minReplayDuration to maximum of 50 seconds', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const actualMinReplayDuration = await page.evaluate(() => { + // @ts-expect-error - Replay is not typed on window + const replayIntegration = window.Replay; + const replay = replayIntegration._replay; + return replay.getOptions().minReplayDuration; + }); + + // Even though we configured it to 60s (60000ms), it should be capped to 50s + expect(actualMinReplayDuration).toBe(50_000); +}); diff --git a/packages/replay-internal/src/constants.ts b/packages/replay-internal/src/constants.ts index da253a68ec8f..ac24f64cddfe 100644 --- a/packages/replay-internal/src/constants.ts +++ b/packages/replay-internal/src/constants.ts @@ -45,8 +45,12 @@ export const REPLAY_MAX_EVENT_BUFFER_SIZE = 20_000_000; // ~20MB /** Replays must be min. 5s long before we send them. */ export const MIN_REPLAY_DURATION = 4_999; -/* The max. allowed value that the minReplayDuration can be set to. */ -export const MIN_REPLAY_DURATION_LIMIT = 15_000; + +/* +The max. allowed value that the minReplayDuration can be set to. +This needs to be below 60s, so we don't unintentionally drop buffered replays that are longer than 60s. +*/ +export const MIN_REPLAY_DURATION_LIMIT = 50_000; /** The max. length of a replay. */ export const MAX_REPLAY_DURATION = 3_600_000; // 60 minutes in ms; diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index a2c84d6c4bbe..75a2d35e64f2 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -180,7 +180,10 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { /** * The min. duration (in ms) a replay has to have before it is sent to Sentry. * Whenever attempting to flush a session that is shorter than this, it will not actually send it to Sentry. - * Note that this is capped at max. 15s. + * Note that this is capped at max. 50s, so we don't unintentionally drop buffered replays that are longer than 60s + * + * Warning: Setting this to a higher value can result in unintended drops of onError-sampled replays. + * */ minReplayDuration: number; From c8ca2864ca945f459f4c6bea68249e416997a982 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 13 Nov 2025 15:41:48 +0000 Subject: [PATCH 14/40] fix(node): Fix Spotlight configuration precedence to match specification (#18195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The Spotlight configuration logic had a precedence bug where when `spotlight: true` was set in config AND the `SENTRY_SPOTLIGHT` environment variable contained a URL string, the SDK would incorrectly use `true` instead of the URL from the environment variable. According to the [Spotlight specification](https://raw.githubusercontent.com/getsentry/sentry-docs/b38e3b307f900665a348f855559ac1d1c58914cc/develop-docs/sdk/expected-features/spotlight.mdx), when `spotlight: true` is set and the env var contains a URL, the URL from the env var should be used to allow developers to override the Spotlight URL via environment variables. **Previous behavior:** ```typescript // Config: spotlight: true // Env: SENTRY_SPOTLIGHT=http://custom:3000/stream // Result: spotlight = true ❌ (incorrect) ``` **Expected behavior per spec:** ```typescript // Config: spotlight: true // Env: SENTRY_SPOTLIGHT=http://custom:3000/stream // Result: spotlight = "http://custom:3000/stream" ✅ (correct) ``` ## Solution Fixed the precedence logic in `getClientOptions()` to properly implement the specification: 1. `spotlight: false` → Always disabled (overrides env var) 2. `spotlight: string` → Uses the config URL (ignores env var) 3. `spotlight: true` + env var URL → **Uses the env var URL** (the bug fix) 4. `spotlight: true` + env var truthy → Uses default URL 5. No config + env var → Parses and uses env var The implementation reuses the existing `envToBool()` utility to avoid code duplication. ## Changes - Fixed Spotlight precedence logic in `packages/node-core/src/sdk/index.ts` - Added 12 comprehensive test cases covering all precedence scenarios in `packages/node-core/test/sdk/init.test.ts` - Updated CHANGELOG.md ## Test Coverage The new tests cover: - ✅ Env var only: truthy values, falsy values, URL strings - ✅ Config only: `true`, `false`, URL string - ✅ Precedence: config `false` overrides env var (URL, truthy, falsy) - ✅ Precedence: config URL overrides env var - ✅ Precedence: config `true` + env var URL uses env var URL (the fix) - ✅ Precedence: config `true` + env var truthy uses default URL ## Related - Original Spotlight implementation: #13325 - Spotlight specification: https://spotlightjs.com/ --------- Co-authored-by: Cursor Agent --- .size-limit.js | 2 +- CHANGELOG.md | 2 +- packages/node-core/src/sdk/index.ts | 20 ++++- packages/node-core/test/sdk/init.test.ts | 108 +++++++++++++++++++++++ 4 files changed, 128 insertions(+), 4 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 2d07afde52ab..4e929875dad5 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '158 KB', + limit: '160 KB', }, { name: '@sentry/node - without tracing', diff --git a/CHANGELOG.md b/CHANGELOG.md index d5cf4d9b810f..09030a4dc82a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- fix(node): Fix Spotlight configuration precedence to match specification (#18195) ## 10.25.0 diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index d53f5d4faefb..0814ab401535 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -182,8 +182,24 @@ function getClientOptions( getDefaultIntegrationsImpl: (options: Options) => Integration[], ): NodeClientOptions { const release = getRelease(options.release); - const spotlight = - options.spotlight ?? envToBool(process.env.SENTRY_SPOTLIGHT, { strict: true }) ?? process.env.SENTRY_SPOTLIGHT; + + // Parse spotlight configuration with proper precedence per spec + let spotlight: boolean | string | undefined; + if (options.spotlight === false) { + spotlight = false; + } else if (typeof options.spotlight === 'string') { + spotlight = options.spotlight; + } else { + // options.spotlight is true or undefined + const envBool = envToBool(process.env.SENTRY_SPOTLIGHT, { strict: true }); + const envUrl = envBool === null && process.env.SENTRY_SPOTLIGHT ? process.env.SENTRY_SPOTLIGHT : undefined; + + spotlight = + options.spotlight === true + ? (envUrl ?? true) // true: use env URL if present, otherwise true + : (envBool ?? envUrl); // undefined: use env var (bool or URL) + } + const tracesSampleRate = getTracesSampleRate(options.tracesSampleRate); const mergedOptions = { diff --git a/packages/node-core/test/sdk/init.test.ts b/packages/node-core/test/sdk/init.test.ts index 4262bffb7cda..1332b89abcd6 100644 --- a/packages/node-core/test/sdk/init.test.ts +++ b/packages/node-core/test/sdk/init.test.ts @@ -216,6 +216,114 @@ describe('init()', () => { }), ); }); + + describe('spotlight configuration', () => { + afterEach(() => { + delete process.env.SENTRY_SPOTLIGHT; + }); + + it('enables spotlight with default URL from `SENTRY_SPOTLIGHT` env variable (truthy value)', () => { + process.env.SENTRY_SPOTLIGHT = 'true'; + + const client = init({ dsn: PUBLIC_DSN }); + + expect(client?.getOptions().spotlight).toBe(true); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(true); + }); + + it('disables spotlight from `SENTRY_SPOTLIGHT` env variable (falsy value)', () => { + process.env.SENTRY_SPOTLIGHT = 'false'; + + const client = init({ dsn: PUBLIC_DSN }); + + expect(client?.getOptions().spotlight).toBe(false); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(false); + }); + + it('enables spotlight with custom URL from `SENTRY_SPOTLIGHT` env variable', () => { + process.env.SENTRY_SPOTLIGHT = 'http://localhost:3000/stream'; + + const client = init({ dsn: PUBLIC_DSN }); + + expect(client?.getOptions().spotlight).toBe('http://localhost:3000/stream'); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(true); + }); + + it('enables spotlight with default URL from config `true`', () => { + const client = init({ dsn: PUBLIC_DSN, spotlight: true }); + + expect(client?.getOptions().spotlight).toBe(true); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(true); + }); + + it('disables spotlight from config `false`', () => { + const client = init({ dsn: PUBLIC_DSN, spotlight: false }); + + expect(client?.getOptions().spotlight).toBe(false); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(false); + }); + + it('enables spotlight with custom URL from config', () => { + const client = init({ dsn: PUBLIC_DSN, spotlight: 'http://custom:8888/stream' }); + + expect(client?.getOptions().spotlight).toBe('http://custom:8888/stream'); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(true); + }); + + it('config `false` overrides `SENTRY_SPOTLIGHT` env variable URL', () => { + process.env.SENTRY_SPOTLIGHT = 'http://localhost:3000/stream'; + + const client = init({ dsn: PUBLIC_DSN, spotlight: false }); + + expect(client?.getOptions().spotlight).toBe(false); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(false); + }); + + it('config `false` overrides `SENTRY_SPOTLIGHT` env variable truthy value', () => { + process.env.SENTRY_SPOTLIGHT = 'true'; + + const client = init({ dsn: PUBLIC_DSN, spotlight: false }); + + expect(client?.getOptions().spotlight).toBe(false); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(false); + }); + + it('config `false` with `SENTRY_SPOTLIGHT` env variable falsy value keeps spotlight disabled', () => { + process.env.SENTRY_SPOTLIGHT = 'false'; + + const client = init({ dsn: PUBLIC_DSN, spotlight: false }); + + expect(client?.getOptions().spotlight).toBe(false); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(false); + }); + + it('config URL overrides `SENTRY_SPOTLIGHT` env variable URL', () => { + process.env.SENTRY_SPOTLIGHT = 'http://env:3000/stream'; + + const client = init({ dsn: PUBLIC_DSN, spotlight: 'http://config:8888/stream' }); + + expect(client?.getOptions().spotlight).toBe('http://config:8888/stream'); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(true); + }); + + it('config `true` with env var URL uses env var URL', () => { + process.env.SENTRY_SPOTLIGHT = 'http://localhost:3000/stream'; + + const client = init({ dsn: PUBLIC_DSN, spotlight: true }); + + expect(client?.getOptions().spotlight).toBe('http://localhost:3000/stream'); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(true); + }); + + it('config `true` with env var truthy value uses default URL', () => { + process.env.SENTRY_SPOTLIGHT = 'true'; + + const client = init({ dsn: PUBLIC_DSN, spotlight: true }); + + expect(client?.getOptions().spotlight).toBe(true); + expect(client?.getOptions().integrations.some(integration => integration.name === 'Spotlight')).toBe(true); + }); + }); }); }); From b947281837b832c8744c86c3890ebe4c8073c7fb Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 14 Nov 2025 11:21:50 +0100 Subject: [PATCH 15/40] fix(core): Flatten gen_ai.request.available_tools in google-genai (#18194) While investigating [this ticket](https://linear.app/getsentry/issue/JS-657/available-tools-json-should-be-a-stringified-json-array-of-objects-not) I noticed that available tools are sent as a nested instead of a flat array in google genai, which seems like a bug to me. The format I would expect and how we do it in other integrations is: [{tool-definition}, {tool-definition}] What we actually send atm is: [[{tool-definition}], [{tool-definition}]] This PR fixes this to instead send a flat list of tool definitions. --- .../suites/tracing/google-genai/test.ts | 7 +++++-- packages/core/src/tracing/google-genai/index.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) 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 7507ca255ae0..8b2b04137fff 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 @@ -204,6 +204,9 @@ describe('Google GenAI integration', () => { }); }); + const EXPECTED_AVAILABLE_TOOLS_JSON = + '[{"name":"controlLight","parametersJsonSchema":{"type":"object","properties":{"brightness":{"type":"number"},"colorTemperature":{"type":"string"}},"required":["brightness","colorTemperature"]}}]'; + const EXPECTED_TRANSACTION_TOOLS = { transaction: 'main', spans: expect.arrayContaining([ @@ -215,7 +218,7 @@ describe('Google GenAI integration', () => { 'sentry.origin': 'auto.ai.google_genai', 'gen_ai.system': 'google_genai', 'gen_ai.request.model': 'gemini-2.0-flash-001', - 'gen_ai.request.available_tools': expect.any(String), // Should include tools + 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, 'gen_ai.request.messages': expect.any(String), // Should include contents 'gen_ai.response.text': expect.any(String), // Should include response text 'gen_ai.response.tool_calls': expect.any(String), // Should include tool calls @@ -236,7 +239,7 @@ describe('Google GenAI integration', () => { 'sentry.origin': 'auto.ai.google_genai', 'gen_ai.system': 'google_genai', 'gen_ai.request.model': 'gemini-2.0-flash-001', - 'gen_ai.request.available_tools': expect.any(String), // Should include tools + 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, 'gen_ai.request.messages': expect.any(String), // Should include contents 'gen_ai.response.streaming': true, 'gen_ai.response.text': expect.any(String), // Should include response text diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index 6ee3e9df8b5b..cc0226c5cb37 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -115,7 +115,7 @@ function extractRequestAttributes( // Extract available tools from config if ('tools' in config && Array.isArray(config.tools)) { - const functionDeclarations = config.tools.map( + const functionDeclarations = config.tools.flatMap( (tool: { functionDeclarations: unknown[] }) => tool.functionDeclarations, ); attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = JSON.stringify(functionDeclarations); From ea3183f701920d1a651e946032c16f80b69ee502 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 14 Nov 2025 12:22:00 +0100 Subject: [PATCH 16/40] fix(core): Stringify available tools sent from vercelai (#18197) [Linear Ticket](https://linear.app/getsentry/issue/JS-657/available-tools-json-should-be-a-stringified-json-array-of-objects-not) The available tools sent from our SDKs should generally be in the format of a stringified array of objects (where an object stores information about a particular tool). This is true for all AI SDKs except Vercel, where we send an array of strings. This PR fixes this by parsing the available tool array and converting the whole array into a proper string representation. --- .../suites/tracing/vercelai/test.ts | 5 ++++- .../suites/tracing/vercelai/v5/test.ts | 5 ++++- packages/core/src/tracing/vercel-ai/index.ts | 9 ++++++++- packages/core/src/tracing/vercel-ai/utils.ts | 17 +++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) 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 aac243eff11c..04ff4a0ac52c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -197,6 +197,9 @@ describe('Vercel AI integration', () => { ]), }; + const EXPECTED_AVAILABLE_TOOLS_JSON = + '[{"type":"function","name":"getWeather","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}]'; + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { transaction: 'main', spans: expect.arrayContaining([ @@ -358,7 +361,7 @@ describe('Vercel AI integration', () => { 'vercel.ai.prompt.format': expect.any(String), 'gen_ai.request.messages': expect.any(String), 'vercel.ai.prompt.toolChoice': expect.any(String), - 'gen_ai.request.available_tools': expect.any(Array), + 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, 'vercel.ai.response.finishReason': 'tool-calls', 'vercel.ai.response.id': expect.any(String), 'vercel.ai.response.model': 'mock-model-id', diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts index b5a2b8107326..56860af76925 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts @@ -193,6 +193,9 @@ describe('Vercel AI integration (V5)', () => { ]), }; + const EXPECTED_AVAILABLE_TOOLS_JSON = + '[{"type":"function","name":"getWeather","inputSchema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"location":{"type":"string"}},"required":["location"],"additionalProperties":false}}]'; + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { transaction: 'main', spans: expect.arrayContaining([ @@ -348,7 +351,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.pipeline.name': 'generateText.doGenerate', 'gen_ai.request.messages': expect.any(String), 'vercel.ai.prompt.toolChoice': expect.any(String), - 'gen_ai.request.available_tools': expect.any(Array), + 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, 'vercel.ai.response.finishReason': 'tool-calls', 'vercel.ai.response.id': expect.any(String), 'vercel.ai.response.model': 'mock-model-id', diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 8e802edc789f..f07244088ff9 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -10,7 +10,7 @@ import { import { getTruncatedJsonString } from '../ai/utils'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; -import { accumulateTokensForParent, applyAccumulatedTokens } from './utils'; +import { accumulateTokensForParent, applyAccumulatedTokens, convertAvailableToolsToJsonString } from './utils'; import type { ProviderMetadata } from './vercel-ai-attributes'; import { AI_MODEL_ID_ATTRIBUTE, @@ -123,6 +123,13 @@ function processEndedVercelAiSpan(span: SpanJSON): void { attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] + attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]; } + // Convert the available tools array to a JSON string + if (attributes[AI_PROMPT_TOOLS_ATTRIBUTE] && Array.isArray(attributes[AI_PROMPT_TOOLS_ATTRIBUTE])) { + attributes[AI_PROMPT_TOOLS_ATTRIBUTE] = convertAvailableToolsToJsonString( + attributes[AI_PROMPT_TOOLS_ATTRIBUTE] as unknown[], + ); + } + // Rename AI SDK attributes to standardized gen_ai attributes renameAttributeKey(attributes, AI_PROMPT_MESSAGES_ATTRIBUTE, 'gen_ai.request.messages'); renameAttributeKey(attributes, AI_RESPONSE_TEXT_ATTRIBUTE, 'gen_ai.response.text'); diff --git a/packages/core/src/tracing/vercel-ai/utils.ts b/packages/core/src/tracing/vercel-ai/utils.ts index e9df1a4a7f96..9a0b57eb16f7 100644 --- a/packages/core/src/tracing/vercel-ai/utils.ts +++ b/packages/core/src/tracing/vercel-ai/utils.ts @@ -70,3 +70,20 @@ export function _INTERNAL_getSpanForToolCallId(toolCallId: string): Span | undef export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void { toolCallSpanMap.delete(toolCallId); } + +/** + * Convert an array of tool strings to a JSON string + */ +export function convertAvailableToolsToJsonString(tools: unknown[]): string { + const toolObjects = tools.map(tool => { + if (typeof tool === 'string') { + try { + return JSON.parse(tool); + } catch { + return tool; + } + } + return tool; + }); + return JSON.stringify(toolObjects); +} From fcf35f6db6c826a124ccd7b81f13378bd7b9c706 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 14 Nov 2025 22:42:25 +0200 Subject: [PATCH 17/40] fix(core/vue): Detect and skip normalizing Vue `VNode` objects with high `normalizeDepth` (#18206) Fixes #18203 ### Problem When using `normalizeDepth: 10` with `captureConsoleIntegration` enabled, Vue VNodes in console arguments would trigger recursive warning spam. Accessing VNode properties during normalization would trigger Vue's reactive getters, which emit console warnings. These warnings would then be captured and normalized again, creating a recursive loop that could generate hundreds of warnings. Note that this only happens in `dev` mode ### Solution Changed `isVueViewModel()` to detect Vue 3 VNodes (`__v_isVNode: true`) in addition to Vue 2/3 ViewModels. VNodes are now identified early in the normalization process and stringified as `[VueVNode]` before their properties are accessed, preventing the recursive warning loop. Some of the properties on the `VNode` can also be reactive, so it can incorrectly add those to a watchEffect or a render function reactive dependencies when accessed by the normalizer. ### Changes - **`packages/core/src/utils/is.ts`**: Added `__v_isVNode` check to `isVueViewModel()`. - **`packages/core/src/utils/normalize.ts`**: Distinguish VNodes from ViewModels in output (`[VueVNode]` vs `[VueViewModel]`). - **Tests**: Added comprehensive unit tests for Vue object detection and integration test that verifies no property access occurs during VNode normalization. --- I couldn't reproduce this exactly in a test with a real vue component, but verified it fixes the reproduction example. The before and after of the captured logs: Before: CleanShot 2025-11-14 at 15 46 30 After: CleanShot 2025-11-14 at 15 45 15 As a Vue developer I don't think the loss of information here would help debug anything. --- packages/core/src/types-hoist/vue.ts | 18 +++++++ packages/core/src/utils/is.ts | 18 +++---- packages/core/src/utils/normalize.ts | 4 +- packages/core/src/utils/stacktrace.ts | 13 +++++ packages/core/src/utils/string.ts | 3 +- packages/core/test/lib/utils/is.test.ts | 37 ++++++++++++-- .../test/integration/VueIntegration.test.ts | 51 +++++++++++++++++++ 7 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/types-hoist/vue.ts diff --git a/packages/core/src/types-hoist/vue.ts b/packages/core/src/types-hoist/vue.ts new file mode 100644 index 000000000000..f83be3c5fd28 --- /dev/null +++ b/packages/core/src/types-hoist/vue.ts @@ -0,0 +1,18 @@ +/** + * Vue 2/3 VM type. + */ +export interface VueViewModel { + // Vue3 + __isVue?: boolean; + // Vue2 + _isVue?: boolean; +} + +/** + * Vue 3 VNode type. + */ +export interface VNode { + // Vue3 + // https://github.com/vuejs/core/blob/main/packages/runtime-core/src/vnode.ts#L168 + __v_isVNode?: boolean; +} diff --git a/packages/core/src/utils/is.ts b/packages/core/src/utils/is.ts index 9ec498983d4a..9abfab910099 100644 --- a/packages/core/src/utils/is.ts +++ b/packages/core/src/utils/is.ts @@ -3,6 +3,7 @@ import type { Primitive } from '../types-hoist/misc'; import type { ParameterizedString } from '../types-hoist/parameterize'; import type { PolymorphicEvent } from '../types-hoist/polymorphics'; +import type { VNode, VueViewModel } from '../types-hoist/vue'; // eslint-disable-next-line @typescript-eslint/unbound-method const objectToString = Object.prototype.toString; @@ -187,21 +188,20 @@ export function isInstanceOf(wat: any, base: any): boolean { } } -interface VueViewModel { - // Vue3 - __isVue?: boolean; - // Vue2 - _isVue?: boolean; -} /** - * Checks whether given value's type is a Vue ViewModel. + * Checks whether given value's type is a Vue ViewModel or a VNode. * * @param wat A value to be checked. * @returns A boolean representing the result. */ -export function isVueViewModel(wat: unknown): boolean { +export function isVueViewModel(wat: unknown): wat is VueViewModel | VNode { // Not using Object.prototype.toString because in Vue 3 it would read the instance's Symbol(Symbol.toStringTag) property. - return !!(typeof wat === 'object' && wat !== null && ((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue)); + // We also need to check for __v_isVNode because Vue 3 component render instances have an internal __v_isVNode property. + return !!( + typeof wat === 'object' && + wat !== null && + ((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue || (wat as { __v_isVNode?: boolean }).__v_isVNode) + ); } /** diff --git a/packages/core/src/utils/normalize.ts b/packages/core/src/utils/normalize.ts index ba78d0cdb043..d70033d65672 100644 --- a/packages/core/src/utils/normalize.ts +++ b/packages/core/src/utils/normalize.ts @@ -1,7 +1,7 @@ import type { Primitive } from '../types-hoist/misc'; import { isSyntheticEvent, isVueViewModel } from './is'; import { convertToPlainObject } from './object'; -import { getFunctionName } from './stacktrace'; +import { getFunctionName, getVueInternalName } from './stacktrace'; type Prototype = { constructor?: (...args: unknown[]) => unknown }; // This is a hack to placate TS, relying on the fact that technically, arrays are objects with integer keys. Normally we @@ -217,7 +217,7 @@ function stringifyValue( } if (isVueViewModel(value)) { - return '[VueViewModel]'; + return getVueInternalName(value); } // React's SyntheticEvent thingy diff --git a/packages/core/src/utils/stacktrace.ts b/packages/core/src/utils/stacktrace.ts index c7aab77bf3be..6b50caf48b30 100644 --- a/packages/core/src/utils/stacktrace.ts +++ b/packages/core/src/utils/stacktrace.ts @@ -1,6 +1,7 @@ import type { Event } from '../types-hoist/event'; import type { StackFrame } from '../types-hoist/stackframe'; import type { StackLineParser, StackParser } from '../types-hoist/stacktrace'; +import type { VNode, VueViewModel } from '../types-hoist/vue'; const STACKTRACE_FRAME_LIMIT = 50; export const UNKNOWN_FUNCTION = '?'; @@ -164,3 +165,15 @@ export function getFramesFromEvent(event: Event): StackFrame[] | undefined { } return undefined; } + +/** + * Get the internal name of an internal Vue value, to represent it in a stacktrace. + * + * @param value The value to get the internal name of. + */ +export function getVueInternalName(value: VueViewModel | VNode): string { + // Check if it's a VNode (has __v_isVNode) or a component instance (has _isVue/__isVue) + const isVNode = '__v_isVNode' in value && value.__v_isVNode; + + return isVNode ? '[VueVNode]' : '[VueViewModel]'; +} diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts index 34a1abd4eb46..b74f9559f9cf 100644 --- a/packages/core/src/utils/string.ts +++ b/packages/core/src/utils/string.ts @@ -1,4 +1,5 @@ import { isRegExp, isString, isVueViewModel } from './is'; +import { getVueInternalName } from './stacktrace'; export { escapeStringForRegex } from '../vendor/escapeStringForRegex'; @@ -81,7 +82,7 @@ export function safeJoin(input: unknown[], delimiter?: string): string { // Vue to issue another warning which repeats indefinitely. // see: https://github.com/getsentry/sentry-javascript/pull/8981 if (isVueViewModel(value)) { - output.push('[VueViewModel]'); + output.push(getVueInternalName(value)); } else { output.push(String(value)); } diff --git a/packages/core/test/lib/utils/is.test.ts b/packages/core/test/lib/utils/is.test.ts index 745cf275be06..092ffae2e3c3 100644 --- a/packages/core/test/lib/utils/is.test.ts +++ b/packages/core/test/lib/utils/is.test.ts @@ -121,11 +121,42 @@ describe('isInstanceOf()', () => { }); describe('isVueViewModel()', () => { - test('should work as advertised', () => { - expect(isVueViewModel({ _isVue: true })).toEqual(true); - expect(isVueViewModel({ __isVue: true })).toEqual(true); + test('detects Vue 2 component instances with _isVue', () => { + const vue2Component = { _isVue: true, $el: {}, $data: {} }; + expect(isVueViewModel(vue2Component)).toEqual(true); + }); + + test('detects Vue 3 component instances with __isVue', () => { + const vue3Component = { __isVue: true, $el: {}, $data: {} }; + expect(isVueViewModel(vue3Component)).toEqual(true); + }); + + test('detects Vue 3 VNodes with __v_isVNode', () => { + const vueVNode = { + __v_isVNode: true, + __v_skip: true, + type: {}, + props: {}, + children: null, + }; + expect(isVueViewModel(vueVNode)).toEqual(true); + }); + test('does not detect plain objects', () => { expect(isVueViewModel({ foo: true })).toEqual(false); + expect(isVueViewModel({ __v_skip: true })).toEqual(false); // __v_skip alone is not enough + expect(isVueViewModel({})).toEqual(false); + }); + + test('handles null and undefined', () => { + expect(isVueViewModel(null)).toEqual(false); + expect(isVueViewModel(undefined)).toEqual(false); + }); + + test('handles non-objects', () => { + expect(isVueViewModel('string')).toEqual(false); + expect(isVueViewModel(123)).toEqual(false); + expect(isVueViewModel(true)).toEqual(false); }); }); diff --git a/packages/vue/test/integration/VueIntegration.test.ts b/packages/vue/test/integration/VueIntegration.test.ts index 81eb22254917..62ff990d1f43 100644 --- a/packages/vue/test/integration/VueIntegration.test.ts +++ b/packages/vue/test/integration/VueIntegration.test.ts @@ -93,4 +93,55 @@ describe('Sentry.VueIntegration', () => { ]); expect(loggerWarnings).toEqual([]); }); + + it('does not trigger warning spam when normalizing Vue VNodes with high normalizeDepth', () => { + // This test reproduces the issue from https://github.com/getsentry/sentry-javascript/issues/18203 + // where VNodes in console arguments would trigger recursive warning spam with captureConsoleIntegration + + Sentry.init({ + dsn: PUBLIC_DSN, + defaultIntegrations: false, + normalizeDepth: 10, // High depth that would cause the issue + integrations: [Sentry.captureConsoleIntegration({ levels: ['warn'] })], + }); + + const initialWarningCount = warnings.length; + + // Create a mock VNode that simulates the problematic behavior from the original issue + // In the real scenario, accessing VNode properties during normalization would trigger Vue warnings + // which would then be captured and normalized again, creating a recursive loop + let propertyAccessCount = 0; + const mockVNode = { + __v_isVNode: true, + __v_skip: true, + type: {}, + get ctx() { + // Simulate Vue's behavior where accessing ctx triggers a warning + propertyAccessCount++; + // eslint-disable-next-line no-console + console.warn('[Vue warn]: compilerOptions warning triggered by property access'); + return { uid: 1 }; + }, + get props() { + propertyAccessCount++; + return {}; + }, + }; + + // Pass the mock VNode to console.warn, simulating what Vue does + // Without the fix, Sentry would try to normalize mockVNode, access its ctx property, + // which triggers another warning, which gets captured and normalized, creating infinite recursion + // eslint-disable-next-line no-console + console.warn('[Vue warn]: Original warning', mockVNode); + + // With the fix, Sentry detects the VNode early and stringifies it as [VueVNode] + // without accessing its properties, so propertyAccessCount stays at 0 + expect(propertyAccessCount).toBe(0); + + // Only 1 warning should be captured (the original one) + // Without the fix, the count would multiply as ctx getter warnings get recursively captured + const warningCountAfter = warnings.length; + const newWarnings = warningCountAfter - initialWarningCount; + expect(newWarnings).toBe(1); + }); }); From 74822de7db0996aa2faaf92d2970d6fb07093892 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 14 Nov 2025 22:27:38 +0100 Subject: [PATCH 18/40] ref(react-router): Deprecate ErrorBoundary exports (#18208) The ErrorBoundary exported in the SDK only works on the client and is not intended to be used. Use react router's error boundary instead: https://docs.sentry.io/platforms/javascript/guides/react-router/#report-errors-from-error-boundaries. --- packages/react-router/src/client/index.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index c19c3456e341..935df5fb7611 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -3,13 +3,16 @@ export * from '@sentry/browser'; export { init } from './sdk'; export { reactRouterTracingIntegration } from './tracingIntegration'; -export { - captureReactException, - reactErrorHandler, - Profiler, - withProfiler, - useProfiler, - ErrorBoundary, - withErrorBoundary, -} from '@sentry/react'; +export { captureReactException, reactErrorHandler, Profiler, withProfiler, useProfiler } from '@sentry/react'; + +/** + * @deprecated ErrorBoundary is deprecated, use react router's error boundary instead. + * See https://docs.sentry.io/platforms/javascript/guides/react-router/#report-errors-from-error-boundaries + */ +export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; + +/** + * @deprecated ErrorBoundaryProps and FallbackRender are deprecated, use react router's error boundary instead. + * See https://docs.sentry.io/platforms/javascript/guides/react-router/#report-errors-from-error-boundaries + */ export type { ErrorBoundaryProps, FallbackRender } from '@sentry/react'; From e403d893679475b5cb9a45b0631700383ad53749 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:35:55 +0100 Subject: [PATCH 19/40] fix(core): Ensure logs past `MAX_LOG_BUFFER_SIZE` are not swallowed (#18207) Looks like we swallowed the log that triggers a flush when `MAX_LOG_BUFFER_SIZE` is surpassed. Test demonstrating issue: [5697b7d](https://github.com/getsentry/sentry-javascript/pull/18207/commits/5697b7dd18110559c20eec9bbf6241c38ccc2ca0) Fix: [f7a4d8b](https://github.com/getsentry/sentry-javascript/pull/18207/commits/f7a4d8bf41f7116bd9ab8cf7edfd87b35345456a) Related metrics pr: #18212 v9 backport: #18213 --- packages/core/src/logs/internal.ts | 6 ++++-- packages/core/test/lib/logs/internal.test.ts | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 601d9be29cb6..819c51c7e3f1 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -91,14 +91,16 @@ function setLogAttribute( */ export function _INTERNAL_captureSerializedLog(client: Client, serializedLog: SerializedLog): void { const bufferMap = _getBufferMap(); - const logBuffer = _INTERNAL_getLogBuffer(client); + if (logBuffer === undefined) { bufferMap.set(client, [serializedLog]); } else { - bufferMap.set(client, [...logBuffer, serializedLog]); if (logBuffer.length >= MAX_LOG_BUFFER_SIZE) { _INTERNAL_flushLogsBuffer(client, logBuffer); + bufferMap.set(client, [serializedLog]); + } else { + bufferMap.set(client, [...logBuffer, serializedLog]); } } } diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index dbb2966dc076..563139aba36d 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -259,7 +259,10 @@ describe('_INTERNAL_captureLog', () => { // Add one more to trigger flush _INTERNAL_captureLog({ level: 'info', message: 'trigger flush' }, scope); - expect(_INTERNAL_getLogBuffer(client)).toEqual([]); + // After flushing the 100 logs, the new log starts a fresh buffer with 1 item + const buffer = _INTERNAL_getLogBuffer(client); + expect(buffer).toHaveLength(1); + expect(buffer?.[0]?.body).toBe('trigger flush'); }); it('does not flush logs buffer when it is empty', () => { From e3ef3f294c4d70a624df6ee642e8994d0cf7bd2c Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:50:11 +0100 Subject: [PATCH 20/40] fix(core): Ensure metrics past `MAX_METRIC_BUFFER_SIZE` are not swallowed (#18212) Looks like we swallowed the metric that triggers a flush when MAX_METRIC_BUFFER_SIZE is surpassed. Test demonstrating issue: [f0737fa](https://github.com/getsentry/sentry-javascript/pull/18212/commits/f0737fa81950e8690eb119749c943366ff898b29) Fix: [1a4e02a](https://github.com/getsentry/sentry-javascript/pull/18212/commits/1a4e02a6fc0ff44051171b63cf0e8c54083d436e) Related logs pr: #18207 --- packages/core/src/metrics/internal.ts | 6 ++++-- packages/core/test/lib/metrics/internal.test.ts | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 5371ecba8dfd..e94da7f36cd8 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -88,14 +88,16 @@ function setMetricAttribute( */ export function _INTERNAL_captureSerializedMetric(client: Client, serializedMetric: SerializedMetric): void { const bufferMap = _getBufferMap(); - const metricBuffer = _INTERNAL_getMetricBuffer(client); + if (metricBuffer === undefined) { bufferMap.set(client, [serializedMetric]); } else { - bufferMap.set(client, [...metricBuffer, serializedMetric]); if (metricBuffer.length >= MAX_METRIC_BUFFER_SIZE) { _INTERNAL_flushMetricsBuffer(client, metricBuffer); + bufferMap.set(client, [serializedMetric]); + } else { + bufferMap.set(client, [...metricBuffer, serializedMetric]); } } } diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts index d9d123b63e99..3e479e282a0c 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -256,7 +256,10 @@ describe('_INTERNAL_captureMetric', () => { // Add one more to trigger flush _INTERNAL_captureMetric({ type: 'counter', name: 'trigger.flush', value: 999 }, { scope }); - expect(_INTERNAL_getMetricBuffer(client)).toEqual([]); + // After flushing the 1000 metrics, the new metric starts a fresh buffer with 1 item + const buffer = _INTERNAL_getMetricBuffer(client); + expect(buffer).toHaveLength(1); + expect(buffer?.[0]?.name).toBe('trigger.flush'); }); it('does not flush metrics buffer when it is empty', () => { From 84ef78bbc8e5e28abb378df0b75c2e5006aef166 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:52:16 +0100 Subject: [PATCH 21/40] chore(react-router): Fix casing on deprecation notices (#18221) --- packages/react-router/src/client/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index 935df5fb7611..507f4172fe0b 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -6,13 +6,13 @@ export { reactRouterTracingIntegration } from './tracingIntegration'; export { captureReactException, reactErrorHandler, Profiler, withProfiler, useProfiler } from '@sentry/react'; /** - * @deprecated ErrorBoundary is deprecated, use react router's error boundary instead. + * @deprecated ErrorBoundary is deprecated, use React Router's error boundary instead. * See https://docs.sentry.io/platforms/javascript/guides/react-router/#report-errors-from-error-boundaries */ export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; /** - * @deprecated ErrorBoundaryProps and FallbackRender are deprecated, use react router's error boundary instead. + * @deprecated ErrorBoundaryProps and FallbackRender are deprecated, use React Router's error boundary instead. * See https://docs.sentry.io/platforms/javascript/guides/react-router/#report-errors-from-error-boundaries */ export type { ErrorBoundaryProps, FallbackRender } from '@sentry/react'; From ad0ce512ee369ad54e63f1ed439fa59e47c9bd9e Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:06:12 +0100 Subject: [PATCH 22/40] fix(core): Fix logs and metrics flush timeout starvation with continuous logging (#18211) The flush timeout was being reset on every incoming log, preventing flushes when logs arrived continuously. Now, the timer starts on the first log and won't get reset, ensuring logs flush within the configured interval. Fixes #18204, getsentry/sentry-react-native#5378 v9 backport: #18214 --- packages/core/src/client.ts | 13 +++++++-- packages/core/test/lib/client.test.ts | 42 ++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 53e0328965a4..f363e61becd7 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -94,7 +94,7 @@ function _isDoNotSendEventError(error: unknown): error is DoNotSendEventError { * This helper function encapsulates the common pattern of: * 1. Tracking accumulated weight of items * 2. Flushing when weight exceeds threshold (800KB) - * 3. Flushing after idle timeout if no new items arrive + * 3. Flushing after timeout period from the first item * * Uses closure variables to track weight and timeout state. */ @@ -112,11 +112,13 @@ function setupWeightBasedFlushing< // Track weight and timeout in closure variables let weight = 0; let flushTimeout: ReturnType | undefined; + let isTimerActive = false; // @ts-expect-error - TypeScript can't narrow generic hook types to match specific overloads, but we know this is type-safe client.on(flushHook, () => { weight = 0; clearTimeout(flushTimeout); + isTimerActive = false; }); // @ts-expect-error - TypeScript can't narrow generic hook types to match specific overloads, but we know this is type-safe @@ -127,10 +129,15 @@ function setupWeightBasedFlushing< // The weight is a rough estimate, so we flush way before the payload gets too big. if (weight >= 800_000) { flushFn(client); - } else { - clearTimeout(flushTimeout); + } else if (!isTimerActive) { + // Only start timer if one isn't already running. + // This prevents flushing being delayed by items that arrive close to the timeout limit + // and thus resetting the flushing timeout and delaying items being flushed. + isTimerActive = true; flushTimeout = setTimeout(() => { flushFn(client); + // Note: isTimerActive is reset by the flushHook handler above, not here, + // to avoid race conditions when new items arrive during the flush. }, DEFAULT_FLUSH_INTERVAL); } }); diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index acb4197cf4cf..c009d0e0c2a8 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -2772,7 +2772,7 @@ describe('Client', () => { expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); }); - it('resets idle timeout when new logs are captured', () => { + it('does not reset idle timeout when new logs are captured', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true, @@ -2783,26 +2783,52 @@ describe('Client', () => { const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); - // Add initial log + // Add initial log (starts the timer) _INTERNAL_captureLog({ message: 'test log 1', level: 'info' }, scope); // Fast forward part of the idle timeout vi.advanceTimersByTime(2500); - // Add another log which should reset the timeout + // Add another log which should NOT reset the timeout _INTERNAL_captureLog({ message: 'test log 2', level: 'info' }, scope); - // Fast forward the remaining time + // Fast forward the remaining time to reach the full timeout from the first log vi.advanceTimersByTime(2500); - // Should not have flushed yet since timeout was reset - expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + // Should have flushed both logs since timeout was not reset + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('starts new timer after timeout completes and flushes', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); - // Fast forward the full timeout + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + // First batch: Add a log and let it flush + _INTERNAL_captureLog({ message: 'test log 1', level: 'info' }, scope); + + // Fast forward to trigger the first flush vi.advanceTimersByTime(5000); - // Now should have flushed both logs expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + // Second batch: Add another log after the first flush completed + _INTERNAL_captureLog({ message: 'test log 2', level: 'info' }, scope); + + // Should not have flushed yet + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + // Fast forward to trigger the second flush + vi.advanceTimersByTime(5000); + + // Should have flushed the second log + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); }); it('flushes logs on flush event', () => { From 8efeeeb7d64c1ab1a95774bc6845c5d501650772 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 17 Nov 2025 10:57:42 +0100 Subject: [PATCH 23/40] fix(core): Emit processed metric (#18222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We were emitting the non-processed metric in the hook before – I changed this behaviour + added a test to verify. --- .../metrics/afterCaptureMetric/init.js | 25 ++++++++ .../metrics/afterCaptureMetric/subject.js | 14 +++++ .../metrics/afterCaptureMetric/test.ts | 59 +++++++++++++++++++ packages/core/src/metrics/internal.ts | 2 +- 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/init.js create mode 100644 dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/test.ts diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/init.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/init.js new file mode 100644 index 000000000000..5590fbb90547 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/init.js @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + integrations: integrations => { + return integrations.filter(integration => integration.name !== 'BrowserSession'); + }, + beforeSendMetric: metric => { + if (metric.name === 'test.counter') { + return { + ...metric, + attributes: { + ...metric.attributes, + modified: 'by-beforeSendMetric', + original: undefined, + }, + }; + } + return metric; + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/subject.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/subject.js new file mode 100644 index 000000000000..e7b9940c7f6f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/subject.js @@ -0,0 +1,14 @@ +// Store captured metrics from the afterCaptureMetric event +window.capturedMetrics = []; + +const client = Sentry.getClient(); + +client.on('afterCaptureMetric', metric => { + window.capturedMetrics.push(metric); +}); + +// Capture metrics - these should be processed by beforeSendMetric +Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test', original: 'value' } }); +Sentry.metrics.gauge('test.gauge', 42, { unit: 'millisecond', attributes: { server: 'test-1' } }); + +Sentry.flush(); diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/test.ts b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/test.ts new file mode 100644 index 000000000000..a89bdea81902 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/test.ts @@ -0,0 +1,59 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; + +sentryTest( + 'should emit afterCaptureMetric event with processed metric from beforeSendMetric', + async ({ getLocalTestUrl, page }) => { + const bundle = process.env.PW_BUNDLE || ''; + if (bundle.startsWith('bundle') || bundle.startsWith('loader')) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + await page.waitForFunction(() => { + return (window as any).capturedMetrics.length >= 2; + }); + + const capturedMetrics = await page.evaluate(() => { + return (window as any).capturedMetrics; + }); + + expect(capturedMetrics).toHaveLength(2); + + // Verify the counter metric was modified by beforeSendMetric + expect(capturedMetrics[0]).toMatchObject({ + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: '/api/test', + modified: 'by-beforeSendMetric', + 'sentry.release': '1.0.0', + 'sentry.environment': 'test', + 'sentry.sdk.name': 'sentry.javascript.browser', + }, + }); + + // Verify the 'original' attribute was removed by beforeSendMetric + expect(capturedMetrics[0].attributes.original).toBeUndefined(); + + // Verify the gauge metric was not modified (no beforeSendMetric processing) + expect(capturedMetrics[1]).toMatchObject({ + name: 'test.gauge', + type: 'gauge', + unit: 'millisecond', + value: 42, + attributes: { + server: 'test-1', + 'sentry.release': '1.0.0', + 'sentry.environment': 'test', + 'sentry.sdk.name': 'sentry.javascript.browser', + }, + }); + + expect(capturedMetrics[0].attributes['sentry.sdk.version']).toBeDefined(); + expect(capturedMetrics[1].attributes['sentry.sdk.version']).toBeDefined(); + }, +); diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index e94da7f36cd8..b38d61b5195c 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -243,7 +243,7 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal captureSerializedMetric(client, serializedMetric); - client.emit('afterCaptureMetric', enrichedMetric); + client.emit('afterCaptureMetric', processedMetric); } /** From f9e714fa24ebcbf73c276ab13b8e4a345d48cf33 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Mon, 17 Nov 2025 12:23:21 +0100 Subject: [PATCH 24/40] feat(core): Instrument LangGraph Agent (#18114) This PR adds official support for instrumenting LangGraph StateGraph operations in Node with Sentry tracing, following OpenTelemetry semantic conventions for Generative AI. ### Currently supported: Node.js - Both agent creation and invocation are instrumented in this PR ESM and CJS - Both module systems are supported The langGraphIntegration() accepts the following options: ``` // The integration respects your sendDefaultPii client option interface LangGraphOptions { recordInputs?: boolean; // Whether to record input messages recordOutputs?: boolean; // Whether to record response text and tool calls } ``` e.g ``` Sentry.init({ dsn: '__DSN__', sendDefaultPii: false, // Even with PII disabled globally integrations: [ Sentry.langGraphIntegration({ recordInputs: true, // Force recording input messages recordOutputs: true, // Force recording response text }), ], }); ``` ### Operations traced: - gen_ai.create_agent - Spans created when StateGraph.compile() is called - gen_ai.invoke_agent - Spans created when CompiledGraph.invoke() is called --- .../node-integration-tests/package.json | 1 + .../tracing/langgraph/instrument-with-pii.mjs | 10 + .../suites/tracing/langgraph/instrument.mjs | 10 + .../tracing/langgraph/scenario-tools.mjs | 164 ++++++++++++++ .../suites/tracing/langgraph/scenario.mjs | 52 +++++ .../suites/tracing/langgraph/test.ts | 208 ++++++++++++++++++ packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/core/src/index.ts | 3 + .../core/src/tracing/ai/gen-ai-attributes.ts | 15 ++ .../core/src/tracing/langgraph/constants.ts | 2 + packages/core/src/tracing/langgraph/index.ts | 157 +++++++++++++ packages/core/src/tracing/langgraph/types.ts | 85 +++++++ packages/core/src/tracing/langgraph/utils.ts | 187 ++++++++++++++++ packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 1 + .../node/src/integrations/tracing/index.ts | 3 + .../integrations/tracing/langgraph/index.ts | 88 ++++++++ .../tracing/langgraph/instrumentation.ts | 90 ++++++++ yarn.lock | 27 +++ 21 files changed, 1107 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-tools.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts create mode 100644 packages/core/src/tracing/langgraph/constants.ts create mode 100644 packages/core/src/tracing/langgraph/index.ts create mode 100644 packages/core/src/tracing/langgraph/types.ts create mode 100644 packages/core/src/tracing/langgraph/utils.ts create mode 100644 packages/node/src/integrations/tracing/langgraph/index.ts create mode 100644 packages/node/src/integrations/tracing/langgraph/instrumentation.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 79d933a3c525..0bc5b8b83b1c 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -31,6 +31,7 @@ "@hono/node-server": "^1.19.4", "@langchain/anthropic": "^0.3.10", "@langchain/core": "^0.3.28", + "@langchain/langgraph": "^0.2.32", "@nestjs/common": "^11", "@nestjs/core": "^11", "@nestjs/platform-express": "^11", diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-with-pii.mjs new file mode 100644 index 000000000000..be512ed2f773 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-with-pii.mjs @@ -0,0 +1,10 @@ +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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument.mjs new file mode 100644 index 000000000000..06cc1a32e93e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument.mjs @@ -0,0 +1,10 @@ +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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-tools.mjs new file mode 100644 index 000000000000..21110c337755 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-tools.mjs @@ -0,0 +1,164 @@ +import { tool } from '@langchain/core/tools'; +import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; +import { ToolNode } from '@langchain/langgraph/prebuilt'; +import * as Sentry from '@sentry/node'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'langgraph-tools-test' }, async () => { + // Define tools + const getWeatherTool = tool( + async ({ city }) => { + return JSON.stringify({ city, temperature: 72, condition: 'sunny' }); + }, + { + name: 'get_weather', + description: 'Get the current weather for a given city', + schema: z.object({ + city: z.string().describe('The city to get weather for'), + }), + }, + ); + + const getTimeTool = tool( + async () => { + return new Date().toISOString(); + }, + { + name: 'get_time', + description: 'Get the current time', + schema: z.object({}), + }, + ); + + const tools = [getWeatherTool, getTimeTool]; + const toolNode = new ToolNode(tools); + + // Define mock LLM function that returns without tool calls + const mockLlm = () => { + return { + messages: [ + { + role: 'assistant', + content: 'Response without calling tools', + response_metadata: { + model_name: 'gpt-4-0613', + finish_reason: 'stop', + tokenUsage: { + promptTokens: 25, + completionTokens: 15, + totalTokens: 40, + }, + }, + tool_calls: [], + }, + ], + }; + }; + + // Routing function - check if there are tool calls + const shouldContinue = state => { + const messages = state.messages; + const lastMessage = messages[messages.length - 1]; + + // If the last message has tool_calls, route to tools, otherwise end + if (lastMessage.tool_calls && lastMessage.tool_calls.length > 0) { + return 'tools'; + } + return END; + }; + + // Create graph with conditional edge to tools + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', mockLlm) + .addNode('tools', toolNode) + .addEdge(START, 'agent') + .addConditionalEdges('agent', shouldContinue, { + tools: 'tools', + [END]: END, + }) + .addEdge('tools', 'agent') + .compile({ name: 'tool_agent' }); + + // Simple invocation - won't call tools since mockLlm returns empty tool_calls + await graph.invoke({ + messages: [{ role: 'user', content: 'What is the weather?' }], + }); + + // Define mock LLM function that returns with tool calls + let callCount = 0; + const mockLlmWithTools = () => { + callCount++; + + // First call - return tool calls + if (callCount === 1) { + return { + messages: [ + { + role: 'assistant', + content: '', + response_metadata: { + model_name: 'gpt-4-0613', + finish_reason: 'tool_calls', + tokenUsage: { + promptTokens: 30, + completionTokens: 20, + totalTokens: 50, + }, + }, + tool_calls: [ + { + name: 'get_weather', + args: { city: 'San Francisco' }, + id: 'call_123', + type: 'tool_call', + }, + ], + }, + ], + }; + } + + // Second call - return final response after tool execution + return { + messages: [ + { + role: 'assistant', + content: 'Based on the weather data, it is sunny and 72 degrees in San Francisco.', + response_metadata: { + model_name: 'gpt-4-0613', + finish_reason: 'stop', + tokenUsage: { + promptTokens: 50, + completionTokens: 20, + totalTokens: 70, + }, + }, + tool_calls: [], + }, + ], + }; + }; + + // Create graph with tool calls enabled + const graphWithTools = new StateGraph(MessagesAnnotation) + .addNode('agent', mockLlmWithTools) + .addNode('tools', toolNode) + .addEdge(START, 'agent') + .addConditionalEdges('agent', shouldContinue, { + tools: 'tools', + [END]: END, + }) + .addEdge('tools', 'agent') + .compile({ name: 'tool_calling_agent' }); + + // Invocation that actually calls tools + await graphWithTools.invoke({ + messages: [{ role: 'user', content: 'What is the weather in San Francisco?' }], + }); + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario.mjs new file mode 100644 index 000000000000..d93c4b5491c7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario.mjs @@ -0,0 +1,52 @@ +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 () => { + // Define a simple mock LLM function + 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, + }, + }, + }, + ], + }; + }; + + // Create and compile the graph + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', mockLlm) + .addEdge(START, 'agent') + .addEdge('agent', END) + .compile({ name: 'weather_assistant' }); + + // Test: basic invocation + await graph.invoke({ + messages: [{ role: 'user', content: 'What is the weather today?' }], + }); + + // Test: invocation with multiple messages + await graph.invoke({ + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + { role: 'user', content: 'Tell me about the weather' }, + ], + }); + }); + + 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 new file mode 100644 index 000000000000..6a67b5cd1e86 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -0,0 +1,208 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('LangGraph integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'langgraph-test', + spans: expect.arrayContaining([ + // create_agent span + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'create_agent', + 'sentry.op': 'gen_ai.create_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'weather_assistant', + }, + description: 'create_agent weather_assistant', + op: 'gen_ai.create_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // First invoke_agent span + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'weather_assistant', + 'gen_ai.pipeline.name': 'weather_assistant', + }), + description: 'invoke_agent weather_assistant', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // Second invoke_agent span + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'weather_assistant', + 'gen_ai.pipeline.name': 'weather_assistant', + }), + description: 'invoke_agent weather_assistant', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'langgraph-test', + spans: expect.arrayContaining([ + // create_agent span (PII enabled doesn't affect this span) + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'create_agent', + 'sentry.op': 'gen_ai.create_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'weather_assistant', + }, + description: 'create_agent weather_assistant', + op: 'gen_ai.create_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // First invoke_agent span with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'weather_assistant', + 'gen_ai.pipeline.name': 'weather_assistant', + 'gen_ai.request.messages': expect.stringContaining('What is the weather today?'), + }), + description: 'invoke_agent weather_assistant', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // Second invoke_agent span with PII and multiple messages + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'weather_assistant', + 'gen_ai.pipeline.name': 'weather_assistant', + 'gen_ai.request.messages': expect.stringContaining('Tell me about the weather'), + }), + description: 'invoke_agent weather_assistant', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_WITH_TOOLS = { + transaction: 'langgraph-tools-test', + spans: expect.arrayContaining([ + // create_agent span for first graph (no tool calls) + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'create_agent', + 'sentry.op': 'gen_ai.create_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'tool_agent', + }, + description: 'create_agent tool_agent', + op: 'gen_ai.create_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // invoke_agent span with tools available but not called + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'tool_agent', + 'gen_ai.pipeline.name': 'tool_agent', + 'gen_ai.request.available_tools': expect.stringContaining('get_weather'), + 'gen_ai.request.messages': expect.stringContaining('What is the weather?'), + 'gen_ai.response.model': 'gpt-4-0613', + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.response.text': expect.stringContaining('Response without calling tools'), + 'gen_ai.usage.input_tokens': 25, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 40, + }), + description: 'invoke_agent tool_agent', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // create_agent span for second graph (with tool calls) + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'create_agent', + 'sentry.op': 'gen_ai.create_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'tool_calling_agent', + }, + description: 'create_agent tool_calling_agent', + op: 'gen_ai.create_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // invoke_agent span with tool calls and execution + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'tool_calling_agent', + 'gen_ai.pipeline.name': 'tool_calling_agent', + 'gen_ai.request.available_tools': expect.stringContaining('get_weather'), + 'gen_ai.request.messages': expect.stringContaining('San Francisco'), + 'gen_ai.response.model': 'gpt-4-0613', + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.response.text': expect.stringMatching(/"role":"tool"/), + // Verify tool_calls are captured + 'gen_ai.response.tool_calls': expect.stringContaining('get_weather'), + 'gen_ai.usage.input_tokens': 80, + 'gen_ai.usage.output_tokens': 40, + 'gen_ai.usage.total_tokens': 120, + }), + description: 'invoke_agent tool_calling_agent', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('should instrument LangGraph with default PII settings', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('should instrument LangGraph with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('should capture tools from LangGraph agent', { timeout: 30000 }, async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_WITH_TOOLS }).start().completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 1774f597af43..2913022c816b 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -95,6 +95,7 @@ export { onUnhandledRejectionIntegration, openAIIntegration, langChainIntegration, + langGraphIntegration, parameterize, pinoIntegration, postgresIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 586babab40ee..6b36930265ca 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -58,6 +58,7 @@ export { onUnhandledRejectionIntegration, openAIIntegration, langChainIntegration, + langGraphIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 813e087dc2d2..d3d266b4dfc1 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -79,6 +79,7 @@ export { onUnhandledRejectionIntegration, openAIIntegration, langChainIntegration, + langGraphIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 504368cfb873..ed3dbe4750d7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -152,6 +152,9 @@ export type { GoogleGenAIResponse } from './tracing/google-genai/types'; export { createLangChainCallbackHandler } from './tracing/langchain'; export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants'; export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types'; +export { instrumentStateGraphCompile } from './tracing/langgraph'; +export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants'; +export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types'; export type { AnthropicAiClient, diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index 84efb21c1822..b07aa63d306f 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -134,6 +134,16 @@ export const GEN_AI_RESPONSE_STREAMING_ATTRIBUTE = 'gen_ai.response.streaming'; */ export const GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE = 'gen_ai.response.tool_calls'; +/** + * The agent name + */ +export const GEN_AI_AGENT_NAME_ATTRIBUTE = 'gen_ai.agent.name'; + +/** + * The pipeline name + */ +export const GEN_AI_PIPELINE_NAME_ATTRIBUTE = 'gen_ai.pipeline.name'; + /** * The number of cache creation input tokens used */ @@ -154,6 +164,11 @@ export const GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE = 'gen_ai.usage.inp */ export const GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE = 'gen_ai.usage.input_tokens.cached'; +/** + * The span operation name for invoking an agent + */ +export const GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE = 'gen_ai.invoke_agent'; + // ============================================================================= // OPENAI-SPECIFIC ATTRIBUTES // ============================================================================= diff --git a/packages/core/src/tracing/langgraph/constants.ts b/packages/core/src/tracing/langgraph/constants.ts new file mode 100644 index 000000000000..add875f7b655 --- /dev/null +++ b/packages/core/src/tracing/langgraph/constants.ts @@ -0,0 +1,2 @@ +export const LANGGRAPH_INTEGRATION_NAME = 'LangGraph'; +export const LANGGRAPH_ORIGIN = 'auto.ai.langgraph'; diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts new file mode 100644 index 000000000000..65d315bf3f63 --- /dev/null +++ b/packages/core/src/tracing/langgraph/index.ts @@ -0,0 +1,157 @@ +import { captureException } from '../../exports'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { + GEN_AI_AGENT_NAME_ATTRIBUTE, + GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_PIPELINE_NAME_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { truncateGenAiMessages } from '../ai/messageTruncation'; +import type { LangChainMessage } from '../langchain/types'; +import { normalizeLangChainMessages } from '../langchain/utils'; +import { startSpan } from '../trace'; +import { LANGGRAPH_ORIGIN } from './constants'; +import type { CompiledGraph, LangGraphOptions } from './types'; +import { extractToolsFromCompiledGraph, setResponseAttributes } from './utils'; + +/** + * Instruments StateGraph's compile method to create spans for agent creation and invocation + * + * Wraps the compile() method to: + * - Create a `gen_ai.create_agent` span when compile() is called + * - Automatically wrap the invoke() method on the returned compiled graph with a `gen_ai.invoke_agent` span + * + */ +export function instrumentStateGraphCompile( + originalCompile: (...args: unknown[]) => CompiledGraph, + options: LangGraphOptions, +): (...args: unknown[]) => CompiledGraph { + return new Proxy(originalCompile, { + apply(target, thisArg, args: unknown[]): CompiledGraph { + return startSpan( + { + op: 'gen_ai.create_agent', + name: 'create_agent', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.create_agent', + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'create_agent', + }, + }, + span => { + try { + const compiledGraph = Reflect.apply(target, thisArg, args); + const compileOptions = args.length > 0 ? (args[0] as Record) : {}; + + // Extract graph name + if (compileOptions?.name && typeof compileOptions.name === 'string') { + span.setAttribute(GEN_AI_AGENT_NAME_ATTRIBUTE, compileOptions.name); + span.updateName(`create_agent ${compileOptions.name}`); + } + + // Instrument agent invoke method on the compiled graph + const originalInvoke = compiledGraph.invoke; + if (originalInvoke && typeof originalInvoke === 'function') { + compiledGraph.invoke = instrumentCompiledGraphInvoke( + originalInvoke.bind(compiledGraph) as (...args: unknown[]) => Promise, + compiledGraph, + compileOptions, + options, + ) as typeof originalInvoke; + } + + return compiledGraph; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.langgraph.error', + }, + }); + throw error; + } + }, + ); + }, + }) as (...args: unknown[]) => CompiledGraph; +} + +/** + * Instruments CompiledGraph's invoke method to create spans for agent invocation + * + * Creates a `gen_ai.invoke_agent` span when invoke() is called + */ +function instrumentCompiledGraphInvoke( + originalInvoke: (...args: unknown[]) => Promise, + graphInstance: CompiledGraph, + compileOptions: Record, + options: LangGraphOptions, +): (...args: unknown[]) => Promise { + return new Proxy(originalInvoke, { + apply(target, thisArg, args: unknown[]): Promise { + return startSpan( + { + op: 'gen_ai.invoke_agent', + name: 'invoke_agent', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', + }, + }, + async span => { + try { + const graphName = compileOptions?.name; + + if (graphName && typeof graphName === 'string') { + span.setAttribute(GEN_AI_PIPELINE_NAME_ATTRIBUTE, graphName); + span.setAttribute(GEN_AI_AGENT_NAME_ATTRIBUTE, graphName); + span.updateName(`invoke_agent ${graphName}`); + } + + // Extract available tools from the graph instance + const tools = extractToolsFromCompiledGraph(graphInstance); + if (tools) { + span.setAttribute(GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, JSON.stringify(tools)); + } + + // Parse input messages + const recordInputs = options.recordInputs; + const recordOutputs = options.recordOutputs; + const inputMessages = + args.length > 0 ? ((args[0] as { messages?: LangChainMessage[] }).messages ?? []) : []; + + if (inputMessages && recordInputs) { + const normalizedMessages = normalizeLangChainMessages(inputMessages); + const truncatedMessages = truncateGenAiMessages(normalizedMessages); + span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, JSON.stringify(truncatedMessages)); + } + + // Call original invoke + const result = await Reflect.apply(target, thisArg, args); + + // Set response attributes + if (recordOutputs) { + setResponseAttributes(span, inputMessages ?? null, result); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.langgraph.error', + }, + }); + throw error; + } + }, + ); + }, + }) as (...args: unknown[]) => Promise; +} diff --git a/packages/core/src/tracing/langgraph/types.ts b/packages/core/src/tracing/langgraph/types.ts new file mode 100644 index 000000000000..b16f9718c69e --- /dev/null +++ b/packages/core/src/tracing/langgraph/types.ts @@ -0,0 +1,85 @@ +export interface LangGraphOptions { + /** + * Enable or disable input recording. + */ + recordInputs?: boolean; + /** + * Enable or disable output recording. + */ + recordOutputs?: boolean; +} + +/** + * LangGraph Tool definition from lc_kwargs + */ +export interface LangGraphToolDefinition { + name?: string; + description?: string; + schema?: unknown; + func?: (...args: unknown[]) => unknown; +} + +/** + * LangGraph Tool object (DynamicTool, DynamicStructuredTool, etc.) + */ +export interface LangGraphTool { + [key: string]: unknown; + lc_kwargs?: LangGraphToolDefinition; + name?: string; + description?: string; +} + +/** + * LangGraph ToolNode with tools array + */ +export interface ToolNode { + [key: string]: unknown; + tools?: LangGraphTool[]; +} + +/** + * LangGraph PregelNode containing a ToolNode + */ +export interface PregelNode { + [key: string]: unknown; + runnable?: ToolNode; +} + +/** + * LangGraph StateGraph builder nodes + */ +export interface StateGraphNodes { + [key: string]: unknown; + tools?: PregelNode; +} + +/** + * LangGraph StateGraph builder + */ +export interface StateGraphBuilder { + [key: string]: unknown; + nodes?: StateGraphNodes; +} + +/** + * Basic interface for compiled graph + */ +export interface CompiledGraph { + [key: string]: unknown; + invoke?: (...args: unknown[]) => Promise; + name?: string; + graph_name?: string; + lc_kwargs?: { + [key: string]: unknown; + name?: string; + }; + builder?: StateGraphBuilder; +} + +/** + * LangGraph Integration interface for type safety + */ +export interface LangGraphIntegration { + name: string; + options: LangGraphOptions; +} diff --git a/packages/core/src/tracing/langgraph/utils.ts b/packages/core/src/tracing/langgraph/utils.ts new file mode 100644 index 000000000000..4b1990058924 --- /dev/null +++ b/packages/core/src/tracing/langgraph/utils.ts @@ -0,0 +1,187 @@ +import type { Span } from '../../types-hoist/span'; +import { + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import type { LangChainMessage } from '../langchain/types'; +import { normalizeLangChainMessages } from '../langchain/utils'; +import type { CompiledGraph, LangGraphTool } from './types'; + +/** + * Extract tool calls from messages + */ +export function extractToolCalls(messages: Array> | null): unknown[] | null { + if (!messages || messages.length === 0) { + return null; + } + + const toolCalls: unknown[] = []; + + for (const message of messages) { + if (message && typeof message === 'object') { + const msgToolCalls = message.tool_calls; + if (msgToolCalls && Array.isArray(msgToolCalls)) { + toolCalls.push(...msgToolCalls); + } + } + } + + return toolCalls.length > 0 ? toolCalls : null; +} + +/** + * Extract token usage from a message's usage_metadata or response_metadata + * Returns token counts without setting span attributes + */ +export function extractTokenUsageFromMessage(message: LangChainMessage): { + inputTokens: number; + outputTokens: number; + totalTokens: number; +} { + const msg = message as Record; + let inputTokens = 0; + let outputTokens = 0; + let totalTokens = 0; + + // Extract from usage_metadata (newer format) + if (msg.usage_metadata && typeof msg.usage_metadata === 'object') { + const usage = msg.usage_metadata as Record; + if (typeof usage.input_tokens === 'number') { + inputTokens = usage.input_tokens; + } + if (typeof usage.output_tokens === 'number') { + outputTokens = usage.output_tokens; + } + if (typeof usage.total_tokens === 'number') { + totalTokens = usage.total_tokens; + } + return { inputTokens, outputTokens, totalTokens }; + } + + // Fallback: Extract from response_metadata.tokenUsage + if (msg.response_metadata && typeof msg.response_metadata === 'object') { + const metadata = msg.response_metadata as Record; + if (metadata.tokenUsage && typeof metadata.tokenUsage === 'object') { + const tokenUsage = metadata.tokenUsage as Record; + if (typeof tokenUsage.promptTokens === 'number') { + inputTokens = tokenUsage.promptTokens; + } + if (typeof tokenUsage.completionTokens === 'number') { + outputTokens = tokenUsage.completionTokens; + } + if (typeof tokenUsage.totalTokens === 'number') { + totalTokens = tokenUsage.totalTokens; + } + } + } + + return { inputTokens, outputTokens, totalTokens }; +} + +/** + * Extract model and finish reason from a message's response_metadata + */ +export function extractModelMetadata(span: Span, message: LangChainMessage): void { + const msg = message as Record; + + if (msg.response_metadata && typeof msg.response_metadata === 'object') { + const metadata = msg.response_metadata as Record; + + if (metadata.model_name && typeof metadata.model_name === 'string') { + span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, metadata.model_name); + } + + if (metadata.finish_reason && typeof metadata.finish_reason === 'string') { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, [metadata.finish_reason]); + } + } +} + +/** + * Extract tools from compiled graph structure + * + * Tools are stored in: compiledGraph.builder.nodes.tools.runnable.tools + */ +export function extractToolsFromCompiledGraph(compiledGraph: CompiledGraph): unknown[] | null { + if (!compiledGraph.builder?.nodes?.tools?.runnable?.tools) { + return null; + } + + const tools = compiledGraph.builder?.nodes?.tools?.runnable?.tools; + + if (!tools || !Array.isArray(tools) || tools.length === 0) { + return null; + } + + // Extract name, description, and schema from each tool's lc_kwargs + return tools.map((tool: LangGraphTool) => ({ + name: tool.lc_kwargs?.name, + description: tool.lc_kwargs?.description, + schema: tool.lc_kwargs?.schema, + })); +} + +/** + * Set response attributes on the span + */ +export function setResponseAttributes(span: Span, inputMessages: LangChainMessage[] | null, result: unknown): void { + // Extract messages from result + const resultObj = result as { messages?: LangChainMessage[] } | undefined; + const outputMessages = resultObj?.messages; + + if (!outputMessages || !Array.isArray(outputMessages)) { + return; + } + + // Get new messages (delta between input and output) + const inputCount = inputMessages?.length ?? 0; + const newMessages = outputMessages.length > inputCount ? outputMessages.slice(inputCount) : []; + + if (newMessages.length === 0) { + return; + } + + // Extract and set tool calls from new messages BEFORE normalization + // (normalization strips tool_calls, so we need to extract them first) + const toolCalls = extractToolCalls(newMessages as Array>); + if (toolCalls) { + span.setAttribute(GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, JSON.stringify(toolCalls)); + } + + // Normalize the new messages + const normalizedNewMessages = normalizeLangChainMessages(newMessages); + span.setAttribute(GEN_AI_RESPONSE_TEXT_ATTRIBUTE, JSON.stringify(normalizedNewMessages)); + + // Accumulate token usage across all messages + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalTokens = 0; + + // Extract metadata from messages + for (const message of newMessages) { + // Accumulate token usage + const tokens = extractTokenUsageFromMessage(message); + totalInputTokens += tokens.inputTokens; + totalOutputTokens += tokens.outputTokens; + totalTokens += tokens.totalTokens; + + // Extract model metadata (last message's metadata wins for model/finish_reason) + extractModelMetadata(span, message); + } + + // Set accumulated token usage on span + if (totalInputTokens > 0) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, totalInputTokens); + } + if (totalOutputTokens > 0) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, totalOutputTokens); + } + if (totalTokens > 0) { + span.setAttribute(GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, totalTokens); + } +} diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 3aa7da1cbab9..d7cd08e5b14e 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -58,6 +58,7 @@ export { onUnhandledRejectionIntegration, openAIIntegration, langChainIntegration, + langGraphIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index e469fd75d2d2..a03b31619472 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -28,6 +28,7 @@ export { openAIIntegration } from './integrations/tracing/openai'; export { anthropicAIIntegration } from './integrations/tracing/anthropic-ai'; export { googleGenAIIntegration } from './integrations/tracing/google-genai'; export { langChainIntegration } from './integrations/tracing/langchain'; +export { langGraphIntegration } from './integrations/tracing/langgraph'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler, diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index b586941d6530..dcd2efa5595c 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -14,6 +14,7 @@ import { honoIntegration, instrumentHono } from './hono'; import { instrumentKafka, kafkaIntegration } from './kafka'; import { instrumentKoa, koaIntegration } from './koa'; import { instrumentLangChain, langChainIntegration } from './langchain'; +import { instrumentLangGraph, langGraphIntegration } from './langgraph'; import { instrumentLruMemoizer, lruMemoizerIntegration } from './lrumemoizer'; import { instrumentMongo, mongoIntegration } from './mongo'; import { instrumentMongoose, mongooseIntegration } from './mongoose'; @@ -54,6 +55,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { // AI providers // LangChain must come first to disable AI provider integrations before they instrument langChainIntegration(), + langGraphIntegration(), vercelAIIntegration(), openAIIntegration(), anthropicAIIntegration(), @@ -98,5 +100,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentFirebase, instrumentAnthropicAi, instrumentGoogleGenAI, + instrumentLangGraph, ]; } diff --git a/packages/node/src/integrations/tracing/langgraph/index.ts b/packages/node/src/integrations/tracing/langgraph/index.ts new file mode 100644 index 000000000000..c302582e5908 --- /dev/null +++ b/packages/node/src/integrations/tracing/langgraph/index.ts @@ -0,0 +1,88 @@ +import type { IntegrationFn, LangGraphOptions } from '@sentry/core'; +import { defineIntegration, LANGGRAPH_INTEGRATION_NAME } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; +import { SentryLangGraphInstrumentation } from './instrumentation'; + +export const instrumentLangGraph = generateInstrumentOnce( + LANGGRAPH_INTEGRATION_NAME, + options => new SentryLangGraphInstrumentation(options), +); + +const _langGraphIntegration = ((options: LangGraphOptions = {}) => { + return { + name: LANGGRAPH_INTEGRATION_NAME, + setupOnce() { + instrumentLangGraph(options); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for LangGraph. + * + * This integration is enabled by default. + * + * When configured, this integration automatically instruments LangGraph StateGraph and compiled graph instances + * to capture telemetry data following OpenTelemetry Semantic Conventions for Generative AI. + * + * @example + * ```javascript + * import * as Sentry from '@sentry/node'; + * + * Sentry.init({ + * integrations: [Sentry.langGraphIntegration()], + * }); + * ``` + * + * ## Options + * + * - `recordInputs`: Whether to record input messages (default: respects `sendDefaultPii` client option) + * - `recordOutputs`: Whether to record response text (default: respects `sendDefaultPii` client option) + * + * ### Default Behavior + * + * By default, the integration will: + * - Record inputs and outputs ONLY if `sendDefaultPii` is set to `true` in your Sentry client options + * - Otherwise, inputs and outputs are NOT recorded unless explicitly enabled + * + * @example + * ```javascript + * // Record inputs and outputs when sendDefaultPii is false + * Sentry.init({ + * integrations: [ + * Sentry.langGraphIntegration({ + * recordInputs: true, + * recordOutputs: true + * }) + * ], + * }); + * + * // Never record inputs/outputs regardless of sendDefaultPii + * Sentry.init({ + * sendDefaultPii: true, + * integrations: [ + * Sentry.langGraphIntegration({ + * recordInputs: false, + * recordOutputs: false + * }) + * ], + * }); + * ``` + * + * ## Captured Operations + * + * The integration captures the following LangGraph operations: + * - **Agent Creation** (`StateGraph.compile()`) - Creates a `gen_ai.create_agent` span + * - **Agent Invocation** (`CompiledGraph.invoke()`) - Creates a `gen_ai.invoke_agent` span + * + * ## Captured Data + * + * When `recordInputs` and `recordOutputs` are enabled, the integration captures: + * - Input messages from the graph state + * - Output messages and LLM responses + * - Tool calls made during agent execution + * - Agent and graph names + * - Available tools configured in the graph + * + */ +export const langGraphIntegration = defineIntegration(_langGraphIntegration); diff --git a/packages/node/src/integrations/tracing/langgraph/instrumentation.ts b/packages/node/src/integrations/tracing/langgraph/instrumentation.ts new file mode 100644 index 000000000000..ca1406e3e493 --- /dev/null +++ b/packages/node/src/integrations/tracing/langgraph/instrumentation.ts @@ -0,0 +1,90 @@ +import { + type InstrumentationConfig, + type InstrumentationModuleDefinition, + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, +} from '@opentelemetry/instrumentation'; +import type { CompiledGraph, LangGraphOptions } from '@sentry/core'; +import { getClient, instrumentStateGraphCompile, SDK_VERSION } from '@sentry/core'; + +const supportedVersions = ['>=0.0.0 <2.0.0']; + +type LangGraphInstrumentationOptions = InstrumentationConfig & LangGraphOptions; + +/** + * Represents the patched shape of the LangGraph module export. + */ +interface PatchedModuleExports { + [key: string]: unknown; + StateGraph?: abstract new (...args: unknown[]) => unknown; +} + +/** + * Sentry LangGraph instrumentation using OpenTelemetry. + */ +export class SentryLangGraphInstrumentation extends InstrumentationBase { + public constructor(config: LangGraphInstrumentationOptions = {}) { + super('@sentry/instrumentation-langgraph', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the modules to be patched. + */ + public init(): InstrumentationModuleDefinition { + const module = new InstrumentationNodeModuleDefinition( + '@langchain/langgraph', + supportedVersions, + this._patch.bind(this), + exports => exports, + [ + new InstrumentationNodeModuleFile( + /** + * In CJS, LangGraph packages re-export from dist/index.cjs files. + * Patching only the root module sometimes misses the real implementation or + * gets overwritten when that file is loaded. We add a file-level patch so that + * _patch runs again on the concrete implementation + */ + '@langchain/langgraph/dist/index.cjs', + supportedVersions, + this._patch.bind(this), + exports => exports, + ), + ], + ); + return module; + } + + /** + * Core patch logic applying instrumentation to the LangGraph module. + */ + private _patch(exports: PatchedModuleExports): PatchedModuleExports | void { + const client = getClient(); + const defaultPii = Boolean(client?.getOptions().sendDefaultPii); + + const config = this.getConfig(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const recordInputs = config.recordInputs ?? defaultPii; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const recordOutputs = config.recordOutputs ?? defaultPii; + + const options: LangGraphOptions = { + recordInputs, + recordOutputs, + }; + + // Patch StateGraph.compile to instrument both compile() and invoke() + if (exports.StateGraph && typeof exports.StateGraph === 'function') { + const StateGraph = exports.StateGraph as { + prototype: Record; + }; + + StateGraph.prototype.compile = instrumentStateGraphCompile( + StateGraph.prototype.compile as (...args: unknown[]) => CompiledGraph, + options, + ); + } + + return exports; + } +} diff --git a/yarn.lock b/yarn.lock index 99e434f5efff..0b2a0f0f31dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4931,6 +4931,33 @@ zod "^3.25.32" zod-to-json-schema "^3.22.3" +"@langchain/langgraph-checkpoint@~0.0.17": + version "0.0.18" + resolved "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.18.tgz#2f7a9cdeda948ccc8d312ba9463810709d71d0b8" + integrity sha512-IS7zJj36VgY+4pf8ZjsVuUWef7oTwt1y9ylvwu0aLuOn1d0fg05Om9DLm3v2GZ2Df6bhLV1kfWAM0IAl9O5rQQ== + dependencies: + uuid "^10.0.0" + +"@langchain/langgraph-sdk@~0.0.32": + version "0.0.112" + resolved "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.112.tgz#3186919b60e3381aa8aa32ea9b9c39df1f02a9fd" + integrity sha512-/9W5HSWCqYgwma6EoOspL4BGYxGxeJP6lIquPSF4FA0JlKopaUv58ucZC3vAgdJyCgg6sorCIV/qg7SGpEcCLw== + dependencies: + "@types/json-schema" "^7.0.15" + p-queue "^6.6.2" + p-retry "4" + uuid "^9.0.0" + +"@langchain/langgraph@^0.2.32": + version "0.2.74" + resolved "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.2.74.tgz#37367a1e8bafda3548037a91449a69a84f285def" + integrity sha512-oHpEi5sTZTPaeZX1UnzfM2OAJ21QGQrwReTV6+QnX7h8nDCBzhtipAw1cK616S+X8zpcVOjgOtJuaJhXa4mN8w== + dependencies: + "@langchain/langgraph-checkpoint" "~0.0.17" + "@langchain/langgraph-sdk" "~0.0.32" + uuid "^10.0.0" + zod "^3.23.8" + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" From 5a5e091365ea2a71fe8249288cf7fa8f25bcfd7e Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:12:44 +0100 Subject: [PATCH 25/40] ref(browser): Move trace lifecycle listeners to class function (#18231) This PR was factored out of another PR to make reviewing easier. The other PR: https://github.com/getsentry/sentry-javascript/pull/18189 Moved the `spanStart` and `spanEnd` listeners into an extra function (`_setupTraceLifecycleListeners`) to be able to only call it depending on the lifecycle (used in another PR). Part of https://github.com/getsentry/sentry-javascript/issues/17279 --- packages/browser/src/profiling/UIProfiler.ts | 138 +++++++++---------- 1 file changed, 68 insertions(+), 70 deletions(-) diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index fb7cd022ac7f..731684996d62 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -62,76 +62,7 @@ export class UIProfiler { this._client = client; this._sessionSampled = sessionSampled; - client.on('spanStart', span => { - if (!this._sessionSampled) { - DEBUG_BUILD && debug.log('[Profiling] Session not sampled because of negative sampling decision.'); - return; - } - if (span !== getRootSpan(span)) { - return; - } - // Only count sampled root spans - if (!span.isRecording()) { - DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); - return; - } - - // Matching root spans with profiles - getGlobalScope().setContext('profile', { - profiler_id: this._profilerId, - }); - - const spanId = span.spanContext().spanId; - if (!spanId) { - return; - } - if (this._activeRootSpanIds.has(spanId)) { - return; - } - - this._activeRootSpanIds.add(spanId); - const rootSpanCount = this._activeRootSpanIds.size; - - const timeout = setTimeout(() => { - this._onRootSpanTimeout(spanId); - }, MAX_ROOT_SPAN_PROFILE_MS); - this._rootSpanTimeouts.set(spanId, timeout); - - if (rootSpanCount === 1) { - DEBUG_BUILD && - debug.log( - `[Profiling] Root span with ID ${spanId} started. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, - ); - - this.start(); - } - }); - - client.on('spanEnd', span => { - if (!this._sessionSampled) { - return; - } - - const spanId = span.spanContext().spanId; - if (!spanId || !this._activeRootSpanIds.has(spanId)) { - return; - } - - this._activeRootSpanIds.delete(spanId); - const rootSpanCount = this._activeRootSpanIds.size; - - DEBUG_BUILD && - debug.log( - `[Profiling] Root span with ID ${spanId} ended. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, - ); - if (rootSpanCount === 0) { - this._collectCurrentChunk().catch(e => { - DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `spanEnd`:', e); - }); - - this.stop(); - } - }); + this._setupTraceLifecycleListeners(client); } /** @@ -170,6 +101,9 @@ export class UIProfiler { DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); + // Expose profiler_id to match root spans with profiles + getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); + this._startProfilerInstance(); if (!this._profiler) { @@ -203,6 +137,63 @@ export class UIProfiler { }); } + /** Trace-mode: attach spanStart/spanEnd listeners. */ + private _setupTraceLifecycleListeners(client: Client): void { + client.on('spanStart', span => { + if (!this._sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled because of negative sampling decision.'); + return; + } + if (span !== getRootSpan(span)) { + return; // only care about root spans + } + // Only count sampled root spans + if (!span.isRecording()) { + DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); + return; + } + + const spanId = span.spanContext().spanId; + if (!spanId || this._activeRootSpanIds.has(spanId)) { + return; + } + + this._registerTraceRootSpan(spanId); + + const rootSpanCount = this._activeRootSpanIds.size; + if (rootSpanCount === 1) { + DEBUG_BUILD && + debug.log( + `[Profiling] Root span ${spanId} started. Profiling active while there are active root spans (count=${rootSpanCount}).`, + ); + this.start(); + } + }); + + client.on('spanEnd', span => { + if (!this._sessionSampled) { + return; + } + const spanId = span.spanContext().spanId; + if (!spanId || !this._activeRootSpanIds.has(spanId)) { + return; + } + this._activeRootSpanIds.delete(spanId); + const rootSpanCount = this._activeRootSpanIds.size; + + DEBUG_BUILD && + debug.log( + `[Profiling] Root span with ID ${spanId} ended. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, + ); + if (rootSpanCount === 0) { + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on last `spanEnd`:', e); + }); + this.stop(); + } + }); + } + /** * Resets profiling information from scope and resets running state */ @@ -219,6 +210,13 @@ export class UIProfiler { this._rootSpanTimeouts.clear(); } + /** Register root span and schedule safeguard timeout (trace mode). */ + private _registerTraceRootSpan(spanId: string): void { + this._activeRootSpanIds.add(spanId); + const timeout = setTimeout(() => this._onRootSpanTimeout(spanId), MAX_ROOT_SPAN_PROFILE_MS); + this._rootSpanTimeouts.set(spanId, timeout); + } + /** * Start a profiler instance if needed. */ From fb67650bf316cfe723f4ab7003e06a28fb1b07f9 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:47:13 +0100 Subject: [PATCH 26/40] fix(core): Check `fetch` support with data URL (#18225) As Deno requires a valid URL when calling `new Request`, `example.com` was used but this caused problems. This PR changes it to a data URL as it does not rely on external dependencies and is a valid URL in Deno. Fixes: https://github.com/getsentry/sentry-javascript/issues/18218 Previous PR for that: https://github.com/getsentry/sentry-javascript/pull/5630 --- packages/core/src/utils/supports.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/supports.ts b/packages/core/src/utils/supports.ts index 5c7f173c3d6e..7ac3b4789765 100644 --- a/packages/core/src/utils/supports.ts +++ b/packages/core/src/utils/supports.ts @@ -80,7 +80,8 @@ function _isFetchSupported(): boolean { try { new Headers(); - new Request('http://www.example.com'); + // Deno requires a valid URL so '' cannot be used as an argument + new Request('data:,'); new Response(); return true; } catch { From 4ad4d926012238ca4e06e8d01d59ae21f8fe7266 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 17 Nov 2025 18:00:09 +0100 Subject: [PATCH 27/40] chore: move tip about prioritizing issues (#18071) Moves the prioritization hint to a dropdown to avoid users accidentally removing it --------- Co-authored-by: Lukas Stracke --- .github/ISSUE_TEMPLATE/bug.yml | 15 ++++++++------- .github/ISSUE_TEMPLATE/feature.yml | 16 ++++++++-------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 8acac6fd2709..c09984de5c3b 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -136,13 +136,14 @@ body: id: additional attributes: label: Additional Context - description: - Add any other context here. Please keep the pre-filled text, which helps us manage issue prioritization. - value: |- - Tip: React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding `+1` or `me too`, to help us triage it. + description: Add any other context here. validations: required: false - - type: markdown + - type: dropdown attributes: - value: |- - ## Thanks 🙏 + label: 'Priority' + description: Please keep the pre-filled option, which helps us manage issue prioritization. + default: 0 + options: + - React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding `+1` + or `me too`, to help us triage it. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 2859c10d2dc0..3809730ade4c 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -27,14 +27,14 @@ body: id: additional attributes: label: Additional Context - description: - Add any other context here. Please keep the pre-filled text, which helps us manage feature prioritization. - value: |- - Tip: React with 👍 to help prioritize this improvement. Please use comments to provide useful context, avoiding `+1` or `me too`, to help us triage it. + description: Add any other context here. validations: required: false - - type: markdown + - type: dropdown attributes: - value: |- - ## Thanks 🙏 - Check our [triage docs](https://open.sentry.io/triage/) for what to expect next. + label: 'Priority' + description: Please keep the pre-filled option, which helps us manage issue prioritization. + default: 0 + options: + - React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding `+1` + or `me too`, to help us triage it. From 1679d802742bd9ec5206f9663b37cf5c979170e8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 17 Nov 2025 20:04:15 +0200 Subject: [PATCH 28/40] fix(nextjs): Drop meta trace tags if rendered page is ISR (#18192) **Summary** ISR pages will have a `sentry-trace` and `baggage` meta tags rendered on them following the initial render or after the first invalidation causing a cached trace id to be present until the next invalidation. This happens in Next.js 15/16 and both on Turbopack and Webpack. **What I tried and didn't work** I Found no way to conditionally set/unset/change the values set by the `clientTraceMetadata` option, I found nothing useful on unit async storages, nor re-setting the propagation context works. The `clientTraceMetadata` gets called way earlier at the `app-render.tsx` level, which would call our `SentryPropagator.inject()` then. We cannot intercept it either because it runs before the page wrapper is called. The main issue is _timing_: - Suppressing the tracing wouldn't work either because it is too late. Ideally we want a way to tell Next to remove those attributes at runtime, or render them conditionally. - I also tried setting everything that has to do with `sentry-trace` or baggage to dummy values as some sort of "marker" for the SDK on the browser side to drop them, but again it is too late since `clientTraceMetadata` is picked up too early. **Implementation** so I figured a workaround, I decided to do it on the client side by: - Marking ISR page routes via the route manifest we already have. - In `Sentry.init` call we remove the tags before the browser integration has had a chance to grab the meta tags. Not the cleanest way, but I verified the issue by writing tests for it and observing page loads across multiple page visits having the same trace id. The meta deletion forces them to have new id for every visit which is what we want. --- .../nextjs-15/app/isr-test/[product]/page.tsx | 17 + .../nextjs-15/app/isr-test/static/page.tsx | 15 + .../app/non-isr-test/[item]/page.tsx | 11 + .../nextjs-15/instrumentation-client.ts | 3 +- .../nextjs-15/tests/isr-routes.test.ts | 94 +++++ .../nextjs-16/app/isr-test/[product]/page.tsx | 17 + .../nextjs-16/app/isr-test/static/page.tsx | 15 + .../app/non-isr-test/[item]/page.tsx | 11 + .../nextjs-16/tests/isr-routes.test.ts | 94 +++++ packages/nextjs/src/client/index.ts | 7 + .../src/client/routing/isrRoutingTracing.ts | 61 +++ .../src/client/routing/parameterization.ts | 3 +- .../config/manifest/createRouteManifest.ts | 42 +- packages/nextjs/src/config/manifest/types.ts | 5 + .../test/client/isrRoutingTracing.test.ts | 398 ++++++++++++++++++ .../suites/base-path/base-path.test.ts | 1 + .../catchall-at-root/catchall-at-root.test.ts | 1 + .../manifest/suites/catchall/catchall.test.ts | 1 + .../manifest/suites/dynamic/dynamic.test.ts | 1 + .../file-extensions/file-extensions.test.ts | 1 + .../app/articles/[category]/[slug]/page.tsx | 2 + .../manifest/suites/isr/app/blog/page.tsx | 2 + .../suites/isr/app/docs/[[...path]]/page.tsx | 2 + .../isr/app/guides/[...segments]/page.tsx | 2 + .../config/manifest/suites/isr/app/page.tsx | 2 + .../suites/isr/app/posts/[slug]/page.tsx | 2 + .../suites/isr/app/products/[id]/page.tsx | 2 + .../manifest/suites/isr/app/regular/page.tsx | 2 + .../isr/app/users/[id]/profile/page.tsx | 2 + .../config/manifest/suites/isr/isr.test.ts | 345 +++++++++++++++ .../suites/route-groups/route-groups.test.ts | 2 + .../manifest/suites/static/static.test.ts | 1 + 32 files changed, 1154 insertions(+), 10 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/non-isr-test/[item]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/non-isr-test/[item]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts create mode 100644 packages/nextjs/src/client/routing/isrRoutingTracing.ts create mode 100644 packages/nextjs/test/client/isrRoutingTracing.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/isr.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx new file mode 100644 index 000000000000..cd1e085e2763 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx @@ -0,0 +1,17 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams + +export async function generateStaticParams(): Promise> { + return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }]; +} + +export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) { + const { product } = await params; + + return ( +
+

ISR Product: {product}

+
{product}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx new file mode 100644 index 000000000000..f49605bd9da4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx @@ -0,0 +1,15 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; + +export async function generateStaticParams(): Promise { + return []; +} + +export default function ISRStaticPage() { + return ( +
+

ISR Static Page

+
static-isr
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/non-isr-test/[item]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/non-isr-test/[item]/page.tsx new file mode 100644 index 000000000000..e0bafdb24181 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/non-isr-test/[item]/page.tsx @@ -0,0 +1,11 @@ +// No generateStaticParams - this is NOT an ISR page +export default async function NonISRPage({ params }: { params: Promise<{ item: string }> }) { + const { item } = await params; + + return ( +
+

Non-ISR Dynamic Page: {item}

+
{item}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts index 4870c64e7959..0737d2043169 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts @@ -2,10 +2,11 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + dsn: 'https://username@domain/123', tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, sendDefaultPii: true, + debug: true, }); export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts new file mode 100644 index 000000000000..215f6cbb0bfc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { + // Navigate to ISR page + await page.goto('/isr-test/laptop'); + + // Wait for page to be fully loaded + await expect(page.locator('#isr-product-id')).toHaveText('laptop'); + + // Check that sentry-trace and baggage meta tags are removed for ISR pages + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => { + // Navigate to ISR static page + await page.goto('/isr-test/static'); + + // Wait for page to be fully loaded + await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); + + // Check that sentry-trace and baggage meta tags are removed for ISR pages + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should remove meta tags for different ISR dynamic route values', async ({ page }) => { + // Test with 'phone' (one of the pre-generated static params) + await page.goto('/isr-test/phone'); + await expect(page.locator('#isr-product-id')).toHaveText('phone'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + + // Test with 'tablet' + await page.goto('/isr-test/tablet'); + await expect(page.locator('#isr-product-id')).toHaveText('tablet'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should create unique transactions for ISR pages on each visit', async ({ page }) => { + const traceIds: string[] = []; + + // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed + for (let i = 0; i < 5; i++) { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return !!( + transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + if (i === 0) { + await page.goto('/isr-test/laptop'); + } else { + await page.reload(); + } + + const transaction = await transactionPromise; + const traceId = transaction.contexts?.trace?.trace_id; + + expect(traceId).toBeDefined(); + expect(traceId).toMatch(/[a-f0-9]{32}/); + traceIds.push(traceId!); + } + + // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags) + const uniqueTraceIds = new Set(traceIds); + expect(uniqueTraceIds.size).toBe(5); +}); + +test('ISR route should be identified correctly in the route manifest', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/isr-test/laptop'); + const transaction = await transactionPromise; + + // Verify the transaction is properly parameterized + expect(transaction).toMatchObject({ + transaction: '/isr-test/:product', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx new file mode 100644 index 000000000000..cd1e085e2763 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx @@ -0,0 +1,17 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams + +export async function generateStaticParams(): Promise> { + return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }]; +} + +export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) { + const { product } = await params; + + return ( +
+

ISR Product: {product}

+
{product}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx new file mode 100644 index 000000000000..f49605bd9da4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx @@ -0,0 +1,15 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; + +export async function generateStaticParams(): Promise { + return []; +} + +export default function ISRStaticPage() { + return ( +
+

ISR Static Page

+
static-isr
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/non-isr-test/[item]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/non-isr-test/[item]/page.tsx new file mode 100644 index 000000000000..e0bafdb24181 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/non-isr-test/[item]/page.tsx @@ -0,0 +1,11 @@ +// No generateStaticParams - this is NOT an ISR page +export default async function NonISRPage({ params }: { params: Promise<{ item: string }> }) { + const { item } = await params; + + return ( +
+

Non-ISR Dynamic Page: {item}

+
{item}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts new file mode 100644 index 000000000000..541cff9c064c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { + // Navigate to ISR page + await page.goto('/isr-test/laptop'); + + // Wait for page to be fully loaded + await expect(page.locator('#isr-product-id')).toHaveText('laptop'); + + // Check that sentry-trace and baggage meta tags are removed for ISR pages + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => { + // Navigate to ISR static page + await page.goto('/isr-test/static'); + + // Wait for page to be fully loaded + await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); + + // Check that sentry-trace and baggage meta tags are removed for ISR pages + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should remove meta tags for different ISR dynamic route values', async ({ page }) => { + // Test with 'phone' (one of the pre-generated static params) + await page.goto('/isr-test/phone'); + await expect(page.locator('#isr-product-id')).toHaveText('phone'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + + // Test with 'tablet' + await page.goto('/isr-test/tablet'); + await expect(page.locator('#isr-product-id')).toHaveText('tablet'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should create unique transactions for ISR pages on each visit', async ({ page }) => { + const traceIds: string[] = []; + + // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed + for (let i = 0; i < 5; i++) { + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return !!( + transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + if (i === 0) { + await page.goto('/isr-test/laptop'); + } else { + await page.reload(); + } + + const transaction = await transactionPromise; + const traceId = transaction.contexts?.trace?.trace_id; + + expect(traceId).toBeDefined(); + expect(traceId).toMatch(/[a-f0-9]{32}/); + traceIds.push(traceId!); + } + + // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags) + const uniqueTraceIds = new Set(traceIds); + expect(uniqueTraceIds.size).toBe(5); +}); + +test('ISR route should be identified correctly in the route manifest', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/isr-test/laptop'); + const transaction = await transactionPromise; + + // Verify the transaction is properly parameterized + expect(transaction).toMatchObject({ + transaction: '/isr-test/:product', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 4d09e7e2d170..a171652b7221 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -8,6 +8,7 @@ import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME } from './routing/appRouterRoutingInstrumentation'; +import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; @@ -41,6 +42,12 @@ export function init(options: BrowserOptions): Client | undefined { } clientIsInitialized = true; + // Remove cached trace meta tags for ISR/SSG pages before initializing + // This prevents the browser tracing integration from using stale trace IDs + if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { + removeIsrSsgTraceMetaTags(); + } + const opts = { environment: getVercelEnv(true) || process.env.NODE_ENV, defaultIntegrations: getDefaultIntegrations(options), diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts new file mode 100644 index 000000000000..567d30aa9852 --- /dev/null +++ b/packages/nextjs/src/client/routing/isrRoutingTracing.ts @@ -0,0 +1,61 @@ +import { WINDOW } from '@sentry/react'; +import { getManifest, maybeParameterizeRoute } from './parameterization'; + +/** + * Cache for ISR/SSG route checks. Exported for testing purposes. + * @internal + */ +export const IS_ISR_SSG_ROUTE_CACHE = new Map(); + +/** + * Check if the current page is an ISR/SSG route by checking the route manifest. + * @internal Exported for testing purposes. + */ +export function isIsrSsgRoute(pathname: string): boolean { + // Early parameterization to get the cache key + const parameterizedPath = maybeParameterizeRoute(pathname); + const pathToCheck = parameterizedPath || pathname; + + // Check cache using the parameterized path as the key + if (IS_ISR_SSG_ROUTE_CACHE.has(pathToCheck)) { + return IS_ISR_SSG_ROUTE_CACHE.get(pathToCheck) as boolean; + } + + // Cache miss get the manifest + const manifest = getManifest(); + if (!manifest?.isrRoutes || !Array.isArray(manifest.isrRoutes) || manifest.isrRoutes.length === 0) { + IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, false); + return false; + } + + const isIsrSsgRoute = manifest.isrRoutes.includes(pathToCheck); + IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, isIsrSsgRoute); + + return isIsrSsgRoute; +} + +/** + * Remove sentry-trace and baggage meta tags from the DOM if this is an ISR/SSG page. + * This prevents the browser tracing integration from using stale/cached trace IDs. + */ +export function removeIsrSsgTraceMetaTags(): void { + if (!WINDOW.document || !isIsrSsgRoute(WINDOW.location.pathname)) { + return; + } + + // Helper function to remove a meta tag + function removeMetaTag(metaName: string): void { + try { + const meta = WINDOW.document.querySelector(`meta[name="${metaName}"]`); + if (meta) { + meta.remove(); + } + } catch { + // ignore errors when removing the meta tag + } + } + + // Remove the meta tags so browserTracingIntegration won't pick them up + removeMetaTag('sentry-trace'); + removeMetaTag('baggage'); +} diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index d13097435f41..1bf6c22d5fe0 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -74,7 +74,7 @@ function getCompiledRegex(regexString: string): RegExp | null { * Get and cache the route manifest from the global object. * @returns The parsed route manifest or null if not available/invalid. */ -function getManifest(): RouteManifest | null { +export function getManifest(): RouteManifest | null { if ( !globalWithInjectedManifest?._sentryRouteManifest || typeof globalWithInjectedManifest._sentryRouteManifest !== 'string' @@ -96,6 +96,7 @@ function getManifest(): RouteManifest | null { let manifest: RouteManifest = { staticRoutes: [], dynamicRoutes: [], + isrRoutes: [], }; // Shallow check if the manifest is actually what we expect it to be diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index 5e2a99f66285..d37285983d31 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -115,23 +115,42 @@ function hasOptionalPrefix(paramNames: string[]): boolean { return firstParam === 'locale' || firstParam === 'lang' || firstParam === 'language'; } -function scanAppDirectory( - dir: string, - basePath: string = '', - includeRouteGroups: boolean = false, -): { dynamicRoutes: RouteInfo[]; staticRoutes: RouteInfo[] } { +/** + * Check if a page file exports generateStaticParams (ISR/SSG indicator) + */ +function checkForGenerateStaticParams(pageFilePath: string): boolean { + try { + const content = fs.readFileSync(pageFilePath, 'utf8'); + // check for generateStaticParams export + // the regex covers `export function generateStaticParams`, `export async function generateStaticParams`, `export const generateStaticParams` + return /export\s+(async\s+)?function\s+generateStaticParams|export\s+const\s+generateStaticParams/.test(content); + } catch { + return false; + } +} + +function scanAppDirectory(dir: string, basePath: string = '', includeRouteGroups: boolean = false): RouteManifest { const dynamicRoutes: RouteInfo[] = []; const staticRoutes: RouteInfo[] = []; + const isrRoutes: string[] = []; try { const entries = fs.readdirSync(dir, { withFileTypes: true }); - const pageFile = entries.some(entry => isPageFile(entry.name)); + const pageFile = entries.find(entry => isPageFile(entry.name)); if (pageFile) { // Conditionally normalize the path based on includeRouteGroups option const routePath = includeRouteGroups ? basePath || '/' : normalizeRoutePath(basePath || '/'); const isDynamic = routePath.includes(':'); + // Check if this page has generateStaticParams (ISR/SSG indicator) + const pageFilePath = path.join(dir, pageFile.name); + const hasGenerateStaticParams = checkForGenerateStaticParams(pageFilePath); + + if (hasGenerateStaticParams) { + isrRoutes.push(routePath); + } + if (isDynamic) { const { regex, paramNames, hasOptionalPrefix } = buildRegexForDynamicRoute(routePath); dynamicRoutes.push({ @@ -172,6 +191,7 @@ function scanAppDirectory( dynamicRoutes.push(...subRoutes.dynamicRoutes); staticRoutes.push(...subRoutes.staticRoutes); + isrRoutes.push(...subRoutes.isrRoutes); } } } catch (error) { @@ -179,7 +199,7 @@ function scanAppDirectory( console.warn('Error building route manifest:', error); } - return { dynamicRoutes, staticRoutes }; + return { dynamicRoutes, staticRoutes, isrRoutes }; } /** @@ -204,6 +224,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route if (!targetDir) { return { + isrRoutes: [], dynamicRoutes: [], staticRoutes: [], }; @@ -214,11 +235,16 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route return manifestCache; } - const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, options?.basePath, options?.includeRouteGroups); + const { dynamicRoutes, staticRoutes, isrRoutes } = scanAppDirectory( + targetDir, + options?.basePath, + options?.includeRouteGroups, + ); const manifest: RouteManifest = { dynamicRoutes, staticRoutes, + isrRoutes, }; // set cache diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts index 0a0946be70f7..99fc42fb27d7 100644 --- a/packages/nextjs/src/config/manifest/types.ts +++ b/packages/nextjs/src/config/manifest/types.ts @@ -34,4 +34,9 @@ export type RouteManifest = { * List of all static routes */ staticRoutes: RouteInfo[]; + + /** + * List of ISR/SSG routes (routes with generateStaticParams) + */ + isrRoutes: string[]; }; diff --git a/packages/nextjs/test/client/isrRoutingTracing.test.ts b/packages/nextjs/test/client/isrRoutingTracing.test.ts new file mode 100644 index 000000000000..be553e086449 --- /dev/null +++ b/packages/nextjs/test/client/isrRoutingTracing.test.ts @@ -0,0 +1,398 @@ +import { WINDOW } from '@sentry/react'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + IS_ISR_SSG_ROUTE_CACHE, + isIsrSsgRoute, + removeIsrSsgTraceMetaTags, +} from '../../src/client/routing/isrRoutingTracing'; +import type { RouteManifest } from '../../src/config/manifest/types'; + +const globalWithInjectedValues = WINDOW as typeof WINDOW & { + _sentryRouteManifest?: string; +}; + +describe('isrRoutingTracing', () => { + let dom: JSDOM; + + beforeEach(() => { + // Set up a fresh DOM environment for each test + dom = new JSDOM('', { + url: 'https://example.com/', + }); + Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); + Object.defineProperty(global, 'location', { value: dom.window.location, writable: true }); + + // Clear the injected manifest + delete globalWithInjectedValues._sentryRouteManifest; + }); + + afterEach(() => { + // Clean up + vi.clearAllMocks(); + }); + + describe('removeIsrSsgTraceMetaTags', () => { + const mockManifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/blog' }], + dynamicRoutes: [ + { + path: '/products/:id', + regex: '^/products/([^/]+?)(?:/)?$', + paramNames: ['id'], + hasOptionalPrefix: false, + }, + { + path: '/posts/:slug', + regex: '^/posts/([^/]+?)(?:/)?$', + paramNames: ['slug'], + hasOptionalPrefix: false, + }, + ], + isrRoutes: ['/', '/blog', '/products/:id', '/posts/:slug'], + }; + + it('should remove meta tags when on a static ISR route', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + const baggageMeta = dom.window.document.createElement('meta'); + baggageMeta.setAttribute('name', 'baggage'); + baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); + dom.window.document.head.appendChild(baggageMeta); + + // Set up route manifest (as stringified JSON, which is how it's injected in production) + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + + // Set location to an ISR route + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/blog' }, + writable: true, + }); + + // Call the function + removeIsrSsgTraceMetaTags(); + + // Verify meta tags were removed + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); + expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); + }); + + it('should remove meta tags when on a dynamic ISR route', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + const baggageMeta = dom.window.document.createElement('meta'); + baggageMeta.setAttribute('name', 'baggage'); + baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); + dom.window.document.head.appendChild(baggageMeta); + + // Set up route manifest + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + + // Set location to a dynamic ISR route + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/products/123' }, + writable: true, + }); + + // Call the function + removeIsrSsgTraceMetaTags(); + + // Verify meta tags were removed + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); + expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); + }); + + it('should NOT remove meta tags when on a non-ISR route', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + const baggageMeta = dom.window.document.createElement('meta'); + baggageMeta.setAttribute('name', 'baggage'); + baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); + dom.window.document.head.appendChild(baggageMeta); + + // Set up route manifest + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + + // Set location to a non-ISR route + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/regular-page' }, + writable: true, + }); + + // Call the function + removeIsrSsgTraceMetaTags(); + + // Verify meta tags were NOT removed + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); + expect(dom.window.document.querySelector('meta[name="baggage"]')).not.toBeNull(); + }); + + it('should handle missing manifest gracefully', () => { + // Clear cache to ensure fresh state + IS_ISR_SSG_ROUTE_CACHE.clear(); + + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + // No manifest set + // globalWithInjectedValues._sentryRouteManifest is undefined + + // Set location + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/blog' }, + writable: true, + }); + + // Call the function (should not throw) + expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); + + // Verify meta tags were NOT removed (no manifest means no ISR detection) + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); + }); + + it('should handle invalid JSON manifest gracefully', () => { + // Clear cache to ensure fresh state + IS_ISR_SSG_ROUTE_CACHE.clear(); + + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + // Set up invalid manifest + globalWithInjectedValues._sentryRouteManifest = 'invalid json {'; + + // Set location + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/blog' }, + writable: true, + }); + + // Call the function (should not throw) + expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); + + // Verify meta tags were NOT removed (invalid manifest means no ISR detection) + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); + }); + + it('should handle manifest with no ISR routes', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + // Set up manifest with no ISR routes + const manifestWithNoISR: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [], + isrRoutes: [], + }; + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(manifestWithNoISR); + + // Set location + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/' }, + writable: true, + }); + + // Call the function + removeIsrSsgTraceMetaTags(); + + // Verify meta tags were NOT removed (no ISR routes in manifest) + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); + }); + + it('should handle missing meta tags gracefully', () => { + // Set up DOM without meta tags + + // Set up route manifest + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + + // Set location to an ISR route + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/blog' }, + writable: true, + }); + + // Call the function (should not throw) + expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); + + // Verify no errors and still no meta tags + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); + expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); + }); + + it('should work with parameterized dynamic routes', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + const baggageMeta = dom.window.document.createElement('meta'); + baggageMeta.setAttribute('name', 'baggage'); + baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); + dom.window.document.head.appendChild(baggageMeta); + + // Set up route manifest + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + + // Set location to a different dynamic ISR route value + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/posts/my-awesome-post' }, + writable: true, + }); + + // Call the function + removeIsrSsgTraceMetaTags(); + + // Verify meta tags were removed (should match /posts/:slug) + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); + expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); + }); + }); + + describe('isIsrSsgRoute caching', () => { + const mockManifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/blog' }], + dynamicRoutes: [ + { + path: '/products/:id', + regex: '^/products/([^/]+?)(?:/)?$', + paramNames: ['id'], + hasOptionalPrefix: false, + }, + { + path: '/posts/:slug', + regex: '^/posts/([^/]+?)(?:/)?$', + paramNames: ['slug'], + hasOptionalPrefix: false, + }, + ], + isrRoutes: ['/', '/blog', '/products/:id', '/posts/:slug'], + }; + + beforeEach(() => { + // Clear cache before each test + IS_ISR_SSG_ROUTE_CACHE.clear(); + // Set up route manifest + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + }); + + it('should cache results by parameterized route, not concrete pathname', () => { + // First call with /products/123 + const result1 = isIsrSsgRoute('/products/123'); + expect(result1).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + + // Second call with different concrete path /products/456 + const result2 = isIsrSsgRoute('/products/456'); + expect(result2).toBe(true); + // Cache size should still be 1 - both paths map to same parameterized route + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + + // Third call with yet another path /products/999 + const result3 = isIsrSsgRoute('/products/999'); + expect(result3).toBe(true); + // Still just 1 cache entry + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + }); + + it('should use cached results on subsequent calls with same route pattern', () => { + // Clear cache + IS_ISR_SSG_ROUTE_CACHE.clear(); + + // First call - cache miss, will populate cache + isIsrSsgRoute('/products/1'); + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + + // Second call with different concrete path - cache hit + const result2 = isIsrSsgRoute('/products/2'); + expect(result2).toBe(true); + // Cache size unchanged - using cached result + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + + // Third call - still cache hit + const result3 = isIsrSsgRoute('/products/3'); + expect(result3).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + }); + + it('should cache false results for non-ISR routes', () => { + const result1 = isIsrSsgRoute('/not-an-isr-route'); + expect(result1).toBe(false); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/not-an-isr-route')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBe(false); + + // Second call should use cache + const result2 = isIsrSsgRoute('/not-an-isr-route'); + expect(result2).toBe(false); + }); + + it('should cache false results when manifest is invalid', () => { + IS_ISR_SSG_ROUTE_CACHE.clear(); + globalWithInjectedValues._sentryRouteManifest = 'invalid json'; + + const result = isIsrSsgRoute('/any-route'); + expect(result).toBe(false); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/any-route')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBe(false); + }); + + it('should cache static routes without parameterization', () => { + const result1 = isIsrSsgRoute('/blog'); + expect(result1).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/blog')).toBe(true); + + // Second call should use cache + const result2 = isIsrSsgRoute('/blog'); + expect(result2).toBe(true); + }); + + it('should maintain separate cache entries for different route patterns', () => { + // Check multiple different routes + isIsrSsgRoute('/products/1'); + isIsrSsgRoute('/posts/hello'); + isIsrSsgRoute('/blog'); + isIsrSsgRoute('/'); + + // Should have 4 cache entries (one for each unique route pattern) + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(4); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/posts/:slug')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/blog')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/')).toBe(true); + }); + + it('should efficiently handle multiple calls to same dynamic route with different params', () => { + // Simulate real-world scenario with many different product IDs + for (let i = 1; i <= 100; i++) { + isIsrSsgRoute(`/products/${i}`); + } + + // Should only have 1 cache entry despite 100 calls + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + }); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts index 097e3f603693..7a9a88033d85 100644 --- a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts +++ b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts @@ -19,6 +19,7 @@ describe('basePath', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts index 8d78f24a0986..4e8e62199592 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts @@ -16,6 +16,7 @@ describe('catchall', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index d259a1a38223..34b9334dba05 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -16,6 +16,7 @@ describe('catchall', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index 2ea4b4aca5d8..fb0111d941e5 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -34,6 +34,7 @@ describe('dynamic', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts index 2c898b1e8e96..79daf2d4d58c 100644 --- a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -16,6 +16,7 @@ describe('file-extensions', () => { { path: '/typescript' }, ], dynamicRoutes: [], + isrRoutes: [], }); }); }); diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx new file mode 100644 index 000000000000..24b26184353a --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx @@ -0,0 +1,2 @@ +// Nested dynamic ISR page +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx new file mode 100644 index 000000000000..027e160f91d3 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx @@ -0,0 +1,2 @@ +// Static ISR page +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx new file mode 100644 index 000000000000..f98be769d9c3 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx @@ -0,0 +1,2 @@ +// Optional catchall ISR page +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx new file mode 100644 index 000000000000..b22caba78d2f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx @@ -0,0 +1,2 @@ +// Required catchall ISR page +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx new file mode 100644 index 000000000000..b4aef2560f50 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx @@ -0,0 +1,2 @@ +// Static ISR page at root +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx new file mode 100644 index 000000000000..a68fb122c81d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx @@ -0,0 +1,2 @@ +// Dynamic ISR with async function +export const generateStaticParams = async (): Promise => {}; diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx new file mode 100644 index 000000000000..f18f341f1f43 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx @@ -0,0 +1,2 @@ +// Dynamic ISR page with generateStaticParams +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx new file mode 100644 index 000000000000..b46b50a71d44 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx @@ -0,0 +1,2 @@ +// Regular page without ISR (no generateStaticParams) +export {}; diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx new file mode 100644 index 000000000000..3774c141885d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx @@ -0,0 +1,2 @@ +// Mixed static-dynamic ISR page +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts b/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts new file mode 100644 index 000000000000..56d097df0180 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts @@ -0,0 +1,345 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; + +describe('ISR route detection and matching', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + describe('ISR detection', () => { + test('should detect static ISR pages with generateStaticParams', () => { + expect(manifest.isrRoutes).toContain('/'); + expect(manifest.isrRoutes).toContain('/blog'); + }); + + test('should detect dynamic ISR pages with generateStaticParams', () => { + expect(manifest.isrRoutes).toContain('/products/:id'); + expect(manifest.isrRoutes).toContain('/posts/:slug'); + }); + + test('should detect nested dynamic ISR pages', () => { + expect(manifest.isrRoutes).toContain('/articles/:category/:slug'); + }); + + test('should detect optional catchall ISR pages', () => { + expect(manifest.isrRoutes).toContain('/docs/:path*?'); + }); + + test('should detect required catchall ISR pages', () => { + expect(manifest.isrRoutes).toContain('/guides/:segments*'); + }); + + test('should detect mixed static-dynamic ISR pages', () => { + expect(manifest.isrRoutes).toContain('/users/:id/profile'); + }); + + test('should NOT detect pages without generateStaticParams as ISR', () => { + expect(manifest.isrRoutes).not.toContain('/regular'); + }); + + test('should detect both function and const generateStaticParams', () => { + // /blog uses function declaration + // /posts/[slug] uses const declaration + expect(manifest.isrRoutes).toContain('/blog'); + expect(manifest.isrRoutes).toContain('/posts/:slug'); + }); + + test('should detect async generateStaticParams', () => { + // Multiple pages use async - this should work + expect(manifest.isrRoutes).toContain('/products/:id'); + expect(manifest.isrRoutes).toContain('/posts/:slug'); + }); + }); + + describe('Route matching against pathnames', () => { + describe('single dynamic segment ISR routes', () => { + test('should match /products/:id against various product IDs', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match + expect(regex.test('/products/1')).toBe(true); + expect(regex.test('/products/123')).toBe(true); + expect(regex.test('/products/abc-def')).toBe(true); + expect(regex.test('/products/product-with-dashes')).toBe(true); + expect(regex.test('/products/UPPERCASE')).toBe(true); + + // Should NOT match + expect(regex.test('/products')).toBe(false); + expect(regex.test('/products/')).toBe(false); + expect(regex.test('/products/123/extra')).toBe(false); + expect(regex.test('/product/123')).toBe(false); // typo + }); + + test('should match /posts/:slug against various slugs', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/posts/:slug'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match + expect(regex.test('/posts/hello')).toBe(true); + expect(regex.test('/posts/world')).toBe(true); + expect(regex.test('/posts/my-awesome-post')).toBe(true); + expect(regex.test('/posts/post_with_underscores')).toBe(true); + + // Should NOT match + expect(regex.test('/posts')).toBe(false); + expect(regex.test('/posts/')).toBe(false); + expect(regex.test('/posts/hello/world')).toBe(false); + }); + }); + + describe('nested dynamic segments ISR routes', () => { + test('should match /articles/:category/:slug against various paths', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/articles/:category/:slug'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match + expect(regex.test('/articles/tech/nextjs-guide')).toBe(true); + expect(regex.test('/articles/tech/react-tips')).toBe(true); + expect(regex.test('/articles/programming/typescript-advanced')).toBe(true); + expect(regex.test('/articles/news/breaking-news-2024')).toBe(true); + + // Should NOT match + expect(regex.test('/articles')).toBe(false); + expect(regex.test('/articles/tech')).toBe(false); + expect(regex.test('/articles/tech/nextjs-guide/extra')).toBe(false); + + // Extract parameters + const match = '/articles/tech/nextjs-guide'.match(regex); + expect(match).toBeTruthy(); + expect(match?.[1]).toBe('tech'); + expect(match?.[2]).toBe('nextjs-guide'); + }); + }); + + describe('mixed static-dynamic ISR routes', () => { + test('should match /users/:id/profile against user profile paths', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/users/:id/profile'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match + expect(regex.test('/users/user1/profile')).toBe(true); + expect(regex.test('/users/user2/profile')).toBe(true); + expect(regex.test('/users/john-doe/profile')).toBe(true); + expect(regex.test('/users/123/profile')).toBe(true); + + // Should NOT match + expect(regex.test('/users/user1')).toBe(false); + expect(regex.test('/users/user1/profile/edit')).toBe(false); + expect(regex.test('/users/profile')).toBe(false); + expect(regex.test('/user/user1/profile')).toBe(false); // typo + + // Extract parameter + const match = '/users/john-doe/profile'.match(regex); + expect(match).toBeTruthy(); + expect(match?.[1]).toBe('john-doe'); + }); + }); + + describe('optional catchall ISR routes', () => { + test('should match /docs/:path*? against various documentation paths', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match - with paths + expect(regex.test('/docs/getting-started')).toBe(true); + expect(regex.test('/docs/api/reference')).toBe(true); + expect(regex.test('/docs/guides/installation/quick-start')).toBe(true); + expect(regex.test('/docs/a')).toBe(true); + expect(regex.test('/docs/a/b/c/d/e')).toBe(true); + + // Should match - without path (optional catchall) + expect(regex.test('/docs')).toBe(true); + + // Should NOT match + expect(regex.test('/doc')).toBe(false); // typo + expect(regex.test('/')).toBe(false); + expect(regex.test('/documents/test')).toBe(false); + + // Extract parameters + const matchWithPath = '/docs/api/reference'.match(regex); + expect(matchWithPath).toBeTruthy(); + expect(matchWithPath?.[1]).toBe('api/reference'); + + const matchNoPath = '/docs'.match(regex); + expect(matchNoPath).toBeTruthy(); + // Optional catchall without path + expect(matchNoPath?.[1]).toBeUndefined(); + }); + }); + + describe('required catchall ISR routes', () => { + test('should match /guides/:segments* against guide paths', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/guides/:segments*'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match - with paths (required) + expect(regex.test('/guides/intro')).toBe(true); + expect(regex.test('/guides/advanced/topics')).toBe(true); + expect(regex.test('/guides/getting-started/installation/setup')).toBe(true); + + // Should NOT match - without path (required catchall needs at least one segment) + expect(regex.test('/guides')).toBe(false); + expect(regex.test('/guides/')).toBe(false); + + // Should NOT match - wrong path + expect(regex.test('/guide/intro')).toBe(false); // typo + expect(regex.test('/')).toBe(false); + + // Extract parameters + const match = '/guides/advanced/topics'.match(regex); + expect(match).toBeTruthy(); + expect(match?.[1]).toBe('advanced/topics'); + }); + }); + + describe('real-world pathname simulations', () => { + test('should identify ISR pages from window.location.pathname examples', () => { + const testCases = [ + { pathname: '/', isISR: true, matchedRoute: '/' }, + { pathname: '/blog', isISR: true, matchedRoute: '/blog' }, + { pathname: '/products/123', isISR: true, matchedRoute: '/products/:id' }, + { pathname: '/products/gaming-laptop', isISR: true, matchedRoute: '/products/:id' }, + { pathname: '/posts/hello-world', isISR: true, matchedRoute: '/posts/:slug' }, + { pathname: '/articles/tech/nextjs-guide', isISR: true, matchedRoute: '/articles/:category/:slug' }, + { pathname: '/users/john/profile', isISR: true, matchedRoute: '/users/:id/profile' }, + { pathname: '/docs', isISR: true, matchedRoute: '/docs/:path*?' }, + { pathname: '/docs/getting-started', isISR: true, matchedRoute: '/docs/:path*?' }, + { pathname: '/docs/api/reference/advanced', isISR: true, matchedRoute: '/docs/:path*?' }, + { pathname: '/guides/intro', isISR: true, matchedRoute: '/guides/:segments*' }, + { pathname: '/guides/advanced/topics/performance', isISR: true, matchedRoute: '/guides/:segments*' }, + { pathname: '/regular', isISR: false, matchedRoute: null }, + ]; + + testCases.forEach(({ pathname, isISR, matchedRoute }) => { + // Check if pathname matches any ISR route + let foundMatch = false; + let foundRoute = null; + + // Check static ISR routes + if (manifest.isrRoutes.includes(pathname)) { + foundMatch = true; + foundRoute = pathname; + } + + // Check dynamic ISR routes + if (!foundMatch) { + for (const route of manifest.dynamicRoutes) { + if (manifest.isrRoutes.includes(route.path)) { + const regex = new RegExp(route.regex!); + if (regex.test(pathname)) { + foundMatch = true; + foundRoute = route.path; + break; + } + } + } + } + + expect(foundMatch).toBe(isISR); + if (matchedRoute) { + expect(foundRoute).toBe(matchedRoute); + } + }); + }); + }); + + describe('edge cases and special characters', () => { + test('should handle paths with special characters in dynamic segments', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); + const regex = new RegExp(route!.regex!); + + expect(regex.test('/products/product-123')).toBe(true); + expect(regex.test('/products/product_456')).toBe(true); + expect(regex.test('/products/PRODUCT-ABC')).toBe(true); + expect(regex.test('/products/2024-new-product')).toBe(true); + }); + + test('should handle deeply nested catchall paths', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); + const regex = new RegExp(route!.regex!); + + expect(regex.test('/docs/a/b/c/d/e/f/g/h/i/j')).toBe(true); + }); + + test('should not match paths with trailing slashes if route does not have them', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); + const regex = new RegExp(route!.regex!); + + // Most Next.js routes don't match trailing slashes + expect(regex.test('/products/123/')).toBe(false); + }); + }); + + describe('parameter extraction for ISR routes', () => { + test('should extract single parameter from ISR route', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); + const regex = new RegExp(route!.regex!); + + const match = '/products/gaming-laptop'.match(regex); + expect(match).toBeTruthy(); + expect(route?.paramNames).toEqual(['id']); + expect(match?.[1]).toBe('gaming-laptop'); + }); + + test('should extract multiple parameters from nested ISR route', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/articles/:category/:slug'); + const regex = new RegExp(route!.regex!); + + const match = '/articles/programming/typescript-advanced'.match(regex); + expect(match).toBeTruthy(); + expect(route?.paramNames).toEqual(['category', 'slug']); + expect(match?.[1]).toBe('programming'); + expect(match?.[2]).toBe('typescript-advanced'); + }); + + test('should extract catchall parameter from ISR route', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); + const regex = new RegExp(route!.regex!); + + const match = '/docs/api/reference/advanced'.match(regex); + expect(match).toBeTruthy(); + expect(route?.paramNames).toEqual(['path']); + expect(match?.[1]).toBe('api/reference/advanced'); + }); + }); + }); + + describe('complete manifest structure', () => { + test('should have correct structure with all route types', () => { + expect(manifest).toHaveProperty('staticRoutes'); + expect(manifest).toHaveProperty('dynamicRoutes'); + expect(manifest).toHaveProperty('isrRoutes'); + expect(Array.isArray(manifest.staticRoutes)).toBe(true); + expect(Array.isArray(manifest.dynamicRoutes)).toBe(true); + expect(Array.isArray(manifest.isrRoutes)).toBe(true); + }); + + test('should include both ISR and non-ISR routes in main route lists', () => { + // ISR static routes should be in staticRoutes + expect(manifest.staticRoutes.some(r => r.path === '/')).toBe(true); + expect(manifest.staticRoutes.some(r => r.path === '/blog')).toBe(true); + + // Non-ISR static routes should also be in staticRoutes + expect(manifest.staticRoutes.some(r => r.path === '/regular')).toBe(true); + + // ISR dynamic routes should be in dynamicRoutes + expect(manifest.dynamicRoutes.some(r => r.path === '/products/:id')).toBe(true); + }); + + test('should only include ISR routes in isrRoutes list', () => { + // ISR routes should be in the list + expect(manifest.isrRoutes).toContain('/'); + expect(manifest.isrRoutes).toContain('/blog'); + expect(manifest.isrRoutes).toContain('/products/:id'); + + // Non-ISR routes should NOT be in the list + expect(manifest.isrRoutes).not.toContain('/regular'); + }); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index 8e1fe463190e..c2d455361c4c 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -26,6 +26,7 @@ describe('route-groups', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); @@ -59,6 +60,7 @@ describe('route-groups', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts index a6f03f49b6fe..6701ef0875d4 100644 --- a/packages/nextjs/test/config/manifest/suites/static/static.test.ts +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -8,6 +8,7 @@ describe('static', () => { expect(manifest).toEqual({ staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], dynamicRoutes: [], + isrRoutes: [], }); }); }); From 60a198edcf89665fd48d9d9ed38e787ae430dbb6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 17 Nov 2025 20:25:07 +0200 Subject: [PATCH 29/40] fix(nextjs): use LRU map instead of map for ISR route cache (#18234) Make use of our existing `LRUMap` for the ISR route cache to avoid the map growing too big. --- .../src/client/routing/isrRoutingTracing.ts | 8 ++++--- .../test/client/isrRoutingTracing.test.ts | 22 +++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts index 567d30aa9852..de5308cbb7ef 100644 --- a/packages/nextjs/src/client/routing/isrRoutingTracing.ts +++ b/packages/nextjs/src/client/routing/isrRoutingTracing.ts @@ -1,3 +1,4 @@ +import { LRUMap } from '@sentry/core'; import { WINDOW } from '@sentry/react'; import { getManifest, maybeParameterizeRoute } from './parameterization'; @@ -5,7 +6,7 @@ import { getManifest, maybeParameterizeRoute } from './parameterization'; * Cache for ISR/SSG route checks. Exported for testing purposes. * @internal */ -export const IS_ISR_SSG_ROUTE_CACHE = new Map(); +export const IS_ISR_SSG_ROUTE_CACHE = new LRUMap(100); /** * Check if the current page is an ISR/SSG route by checking the route manifest. @@ -17,8 +18,9 @@ export function isIsrSsgRoute(pathname: string): boolean { const pathToCheck = parameterizedPath || pathname; // Check cache using the parameterized path as the key - if (IS_ISR_SSG_ROUTE_CACHE.has(pathToCheck)) { - return IS_ISR_SSG_ROUTE_CACHE.get(pathToCheck) as boolean; + const cachedResult = IS_ISR_SSG_ROUTE_CACHE.get(pathToCheck); + if (cachedResult !== undefined) { + return cachedResult; } // Cache miss get the manifest diff --git a/packages/nextjs/test/client/isrRoutingTracing.test.ts b/packages/nextjs/test/client/isrRoutingTracing.test.ts index be553e086449..cdca740727dd 100644 --- a/packages/nextjs/test/client/isrRoutingTracing.test.ts +++ b/packages/nextjs/test/client/isrRoutingTracing.test.ts @@ -301,14 +301,14 @@ describe('isrRoutingTracing', () => { const result1 = isIsrSsgRoute('/products/123'); expect(result1).toBe(true); expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); // Second call with different concrete path /products/456 const result2 = isIsrSsgRoute('/products/456'); expect(result2).toBe(true); // Cache size should still be 1 - both paths map to same parameterized route expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); // Third call with yet another path /products/999 const result3 = isIsrSsgRoute('/products/999'); @@ -324,7 +324,7 @@ describe('isrRoutingTracing', () => { // First call - cache miss, will populate cache isIsrSsgRoute('/products/1'); expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); // Second call with different concrete path - cache hit const result2 = isIsrSsgRoute('/products/2'); @@ -341,7 +341,7 @@ describe('isrRoutingTracing', () => { it('should cache false results for non-ISR routes', () => { const result1 = isIsrSsgRoute('/not-an-isr-route'); expect(result1).toBe(false); - expect(IS_ISR_SSG_ROUTE_CACHE.has('/not-an-isr-route')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBeDefined(); expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBe(false); // Second call should use cache @@ -355,14 +355,14 @@ describe('isrRoutingTracing', () => { const result = isIsrSsgRoute('/any-route'); expect(result).toBe(false); - expect(IS_ISR_SSG_ROUTE_CACHE.has('/any-route')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBeDefined(); expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBe(false); }); it('should cache static routes without parameterization', () => { const result1 = isIsrSsgRoute('/blog'); expect(result1).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.has('/blog')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/blog')).toBeDefined(); // Second call should use cache const result2 = isIsrSsgRoute('/blog'); @@ -378,10 +378,10 @@ describe('isrRoutingTracing', () => { // Should have 4 cache entries (one for each unique route pattern) expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(4); - expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.has('/posts/:slug')).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.has('/blog')).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.has('/')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/posts/:slug')).toBeDefined(); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/blog')).toBeDefined(); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/')).toBeDefined(); }); it('should efficiently handle multiple calls to same dynamic route with different params', () => { @@ -392,7 +392,7 @@ describe('isrRoutingTracing', () => { // Should only have 1 cache entry despite 100 calls expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); }); }); }); From ccfda39da64ecfe2496965a61b6f48c35eb50464 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:23:02 +0100 Subject: [PATCH 30/40] chore: Fix missing changelog quote we use for attribution placement (#18237) Restores the office quote in our changelog. This quote acts as a placement marker for attribution and is needed by our action. Alternatively, we could change our action but this is a long-standing quote and part of our culture :) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09030a4dc82a..30abc26e733c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott + - fix(node): Fix Spotlight configuration precedence to match specification (#18195) ## 10.25.0 From 9ce440c77d80841c7c82526817bce13a4a380b63 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 18 Nov 2025 09:24:59 +0000 Subject: [PATCH 31/40] fix(react): Prevent navigation span leaks for consecutive navigations (#18098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes an issue where consecutive navigations to different routes fail to create separate navigation spans, causing span leaks and missing transaction data. This came up in a React Router v6/v7 application where the pageload / navigation transactions take longer and there is a high `finalTimeout` set in config. When users navigate between different routes (e.g., `/users/:id` → `/projects/:projectId` → `/settings`). The SDK was incorrectly preventing new navigation spans from being created whenever an ongoing navigation span was active, regardless of whether the navigation was to a different route. This resulted in only the first navigation being tracked, with subsequent navigations being silently ignored. Also, the spans that needed to be a part of the subsequent navigation were recorded as a part of the previous one. The root cause was the `if (!isAlreadyInNavigationSpan)` check that we used to prevent cross-usage scenarios (multiple wrappers instrumenting the same navigation), which incorrectly blocked legitimate consecutive navigations to different routes. So, this fix changes the logic to check both navigation span state and the route name: `isSpanForSameRoute = isAlreadyInNavigationSpan && spanJson?.description === name`. This allows consecutive navigations to different routes while preventing duplicate spans for the same route. Also added tracking using `LAST_NAVIGATION_PER_CLIENT`. When multiple wrappers (e.g., `wrapCreateBrowserRouter` + `wrapUseRoutes`) instrument the same application, they may each trigger span creation for the same navigation event. We store the navigation key `${location.pathname}${location.search}${location.hash}` while the span is active and clear it when that span ends. If the same navigation key shows up again before the original span finishes, the second wrapper updates that span’s name if it has better parameterization instead of creating a duplicate, which keeps cross-usage covered. --- .../tests/transactions.test.ts | 109 ++++--- .../instrumentation.tsx | 209 ++++++++---- .../src/reactrouter-compat-utils/utils.ts | 93 +++--- .../test/reactrouter-cross-usage.test.tsx | 308 ++++++++++++++++-- .../reactrouter-descendant-routes.test.tsx | 2 + packages/react/test/reactrouterv6.test.tsx | 2 + 6 files changed, 538 insertions(+), 185 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index a57199c52633..e5b9f35042ed 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -87,7 +87,7 @@ test('Creates a navigation transaction inside a lazy route', async ({ page }) => }); test('Creates navigation transactions between two different lazy routes', async ({ page }) => { - // First, navigate to the "another-lazy" route + // Set up transaction listeners for both navigations const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { return ( !!transactionEvent?.transaction && @@ -96,6 +96,14 @@ test('Creates navigation transactions between two different lazy routes', async ); }); + const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + await page.goto('/'); // Navigate to another lazy route first @@ -115,14 +123,6 @@ test('Creates navigation transactions between two different lazy routes', async expect(firstEvent.contexts?.trace?.op).toBe('navigation'); // Now navigate from the first lazy route to the second lazy route - const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' - ); - }); - // Click the navigation link from within the first lazy route to the second lazy route const navigationToInnerFromDeep = page.locator('id=navigate-to-inner-from-deep'); await expect(navigationToInnerFromDeep).toBeVisible(); @@ -255,7 +255,7 @@ test('Does not send any duplicate navigation transaction names browsing between // Go to root page await page.goto('/'); - page.waitForTimeout(1000); + await page.waitForTimeout(1000); // Navigate to inner lazy route const navigationToInner = page.locator('id=navigation'); @@ -339,6 +339,7 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', const navigationToLongRunning = page.locator('id=navigation-to-long-running'); await expect(navigationToLongRunning).toBeVisible(); + // Set up transaction listeners for both navigations const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { return ( !!transactionEvent?.transaction && @@ -347,6 +348,14 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', ); }); + const backNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/' + ); + }); + await navigationToLongRunning.click(); const slowLoadingContent = page.locator('id=slow-loading-content'); @@ -359,14 +368,6 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', // Now navigate back using browser back button (POP event) // This should create a navigation transaction since pageload is complete - const backNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/' - ); - }); - await page.goBack(); // Verify we're back at home @@ -504,7 +505,7 @@ test('Updates navigation transaction name correctly when span is cancelled early test('Creates separate transactions for rapid consecutive navigations', async ({ page }) => { await page.goto('/'); - // First navigation: / -> /lazy/inner/:id/:anotherId/:someAnotherId + // Set up transaction listeners const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { return ( !!transactionEvent?.transaction && @@ -513,20 +514,6 @@ test('Creates separate transactions for rapid consecutive navigations', async ({ ); }); - const navigationToInner = page.locator('id=navigation'); - await expect(navigationToInner).toBeVisible(); - await navigationToInner.click(); - - const firstEvent = await firstTransactionPromise; - - // Verify first transaction - expect(firstEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(firstEvent.contexts?.trace?.op).toBe('navigation'); - expect(firstEvent.contexts?.trace?.status).toBe('ok'); - const firstTraceId = firstEvent.contexts?.trace?.trace_id; - const firstSpanId = firstEvent.contexts?.trace?.span_id; - - // Second navigation: /lazy/inner -> /another-lazy/sub/:id/:subId const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { return ( !!transactionEvent?.transaction && @@ -535,40 +522,54 @@ test('Creates separate transactions for rapid consecutive navigations', async ({ ); }); - const navigationToAnother = page.locator('id=navigate-to-another-from-inner'); - await expect(navigationToAnother).toBeVisible(); - await navigationToAnother.click(); + // Third navigation promise - using counter to match second occurrence of same route + let innerRouteMatchCount = 0; + const thirdTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + if ( + transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ) { + innerRouteMatchCount++; + return innerRouteMatchCount === 2; // Match the second occurrence + } + return false; + }); + + // Perform navigations + // First navigation: / -> /lazy/inner/:id/:anotherId/:someAnotherId + await page.locator('id=navigation').click(); + + const firstEvent = await firstTransactionPromise; + + // Second navigation: /lazy/inner -> /another-lazy/sub/:id/:subId + await page.locator('id=navigate-to-another-from-inner').click(); const secondEvent = await secondTransactionPromise; - // Verify second transaction + // Third navigation: /another-lazy -> /lazy/inner/:id/:anotherId/:someAnotherId (back to same route as first) + await page.locator('id=navigate-to-inner-from-deep').click(); + + const thirdEvent = await thirdTransactionPromise; + + // Verify transactions + expect(firstEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(firstEvent.contexts?.trace?.op).toBe('navigation'); + const firstTraceId = firstEvent.contexts?.trace?.trace_id; + const firstSpanId = firstEvent.contexts?.trace?.span_id; + expect(secondEvent.transaction).toBe('/another-lazy/sub/:id/:subId'); expect(secondEvent.contexts?.trace?.op).toBe('navigation'); expect(secondEvent.contexts?.trace?.status).toBe('ok'); + const secondTraceId = secondEvent.contexts?.trace?.trace_id; const secondSpanId = secondEvent.contexts?.trace?.span_id; - // Third navigation: /another-lazy -> /lazy/inner/:id/:anotherId/:someAnotherId (back to same route as first) - const thirdTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' && - // Ensure we're not matching the first transaction again - transactionEvent.contexts?.trace?.trace_id !== firstTraceId - ); - }); - - const navigationBackToInner = page.locator('id=navigate-to-inner-from-deep'); - await expect(navigationBackToInner).toBeVisible(); - await navigationBackToInner.click(); - - const thirdEvent = await thirdTransactionPromise; - // Verify third transaction expect(thirdEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); expect(thirdEvent.contexts?.trace?.op).toBe('navigation'); expect(thirdEvent.contexts?.trace?.status).toBe('ok'); + const thirdTraceId = thirdEvent.contexts?.trace?.trace_id; const thirdSpanId = thirdEvent.contexts?.trace?.span_id; diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index a6e55f1a967c..235b207ed9a0 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -53,9 +53,11 @@ let _enableAsyncRouteHandlers: boolean = false; const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); /** - * Adds resolved routes as children to the parent route. - * Prevents duplicate routes by checking if they already exist. + * Tracks last navigation per client to prevent duplicate spans in cross-usage scenarios. + * Entry persists until the navigation span ends, allowing cross-usage detection during delayed wrapper execution. */ +const LAST_NAVIGATION_PER_CLIENT = new WeakMap(); + export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void { const existingChildren = parentRoute.children || []; @@ -74,6 +76,26 @@ export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentR } } +/** + * Determines if a navigation should be handled based on router state. + * Only handles: + * - PUSH navigations (always) + * - POP navigations (only after initial pageload is complete) + * - When router state is 'idle' (not 'loading' or 'submitting') + * + * During 'loading' or 'submitting', state.location may still have the old pathname, + * which would cause us to create a span for the wrong route. + */ +function shouldHandleNavigation( + state: { historyAction: string; navigation: { state: string } }, + isInitialPageloadComplete: boolean, +): boolean { + return ( + (state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete)) && + state.navigation.state === 'idle' + ); +} + export interface ReactRouterOptions { useEffect: UseEffect; useLocation: UseLocation; @@ -275,27 +297,15 @@ export function createV6CompatibleWrapCreateBrowserRouter< // If we haven't seen a pageload span yet, keep waiting (don't mark as complete) } - const shouldHandleNavigation = - state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete); - - if (shouldHandleNavigation) { - const navigationHandler = (): void => { - handleNavigation({ - location: state.location, - routes, - navigationType: state.historyAction, - version, - basename, - allRoutes: Array.from(allRoutes), - }); - }; - - // Wait for the next render if loading an unsettled route - if (state.navigation.state !== 'idle') { - requestAnimationFrame(navigationHandler); - } else { - navigationHandler(); - } + if (shouldHandleNavigation(state, isInitialPageloadComplete)) { + handleNavigation({ + location: state.location, + routes, + navigationType: state.historyAction, + version, + basename, + allRoutes: Array.from(allRoutes), + }); } }); @@ -404,29 +414,15 @@ export function createV6CompatibleWrapCreateMemoryRouter< // If we haven't seen a pageload span yet, keep waiting (don't mark as complete) } - const location = state.location; - - const shouldHandleNavigation = - state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete); - - if (shouldHandleNavigation) { - const navigationHandler = (): void => { - handleNavigation({ - location, - routes, - navigationType: state.historyAction, - version, - basename, - allRoutes: Array.from(allRoutes), - }); - }; - - // Wait for the next render if loading an unsettled route - if (state.navigation.state !== 'idle') { - requestAnimationFrame(navigationHandler); - } else { - navigationHandler(); - } + if (shouldHandleNavigation(state, isInitialPageloadComplete)) { + handleNavigation({ + location: state.location, + routes, + navigationType: state.historyAction, + version, + basename, + allRoutes: Array.from(allRoutes), + }); } }); @@ -622,6 +618,71 @@ function wrapPatchRoutesOnNavigation( }; } +function getNavigationKey(location: Location): string { + return `${location.pathname}${location.search}${location.hash}`; +} + +function tryUpdateSpanName( + activeSpan: Span, + currentSpanName: string | undefined, + newName: string, + newSource: string, +): void { + // Check if the new name contains React Router parameter syntax (/:param/) + const isReactRouterParam = /\/:[a-zA-Z0-9_]+/.test(newName); + const isNewNameParameterized = newName !== currentSpanName && isReactRouterParam; + if (isNewNameParameterized) { + activeSpan.updateName(newName); + activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, newSource as 'route' | 'url' | 'custom'); + } +} + +function isDuplicateNavigation(client: Client, navigationKey: string): boolean { + const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client); + return lastKey === navigationKey; +} + +function createNavigationSpan(opts: { + client: Client; + name: string; + source: string; + version: string; + location: Location; + routes: RouteObject[]; + basename?: string; + allRoutes?: RouteObject[]; + navigationKey: string; +}): Span | undefined { + const { client, name, source, version, location, routes, basename, allRoutes, navigationKey } = opts; + + const navigationSpan = startBrowserTracingNavigationSpan(client, { + name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source as 'route' | 'url' | 'custom', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, + }, + }); + + if (navigationSpan) { + LAST_NAVIGATION_PER_CLIENT.set(client, navigationKey); + patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes); + + const unsubscribe = client.on('spanEnd', endedSpan => { + if (endedSpan === navigationSpan) { + // Clear key only if it's still our key (handles overlapping navigations) + const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client); + if (lastKey === navigationKey) { + LAST_NAVIGATION_PER_CLIENT.delete(client); + } + unsubscribe(); // Prevent memory leak + } + }); + } + + return navigationSpan; +} + export function handleNavigation(opts: { location: Location; routes: RouteObject[]; @@ -632,15 +693,13 @@ export function handleNavigation(opts: { allRoutes?: RouteObject[]; }): void { const { location, routes, navigationType, version, matches, basename, allRoutes } = opts; - const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename); + const branches = Array.isArray(matches) ? matches : _matchRoutes(allRoutes || routes, location, basename); const client = getClient(); if (!client || !CLIENTS_WITH_INSTRUMENT_NAVIGATION.has(client)) { return; } - // Avoid starting a navigation span on initial load when a pageload root span is active. - // This commonly happens when lazy routes resolve during the first render and React Router emits a POP. const activeRootSpan = getActiveRootSpan(); if (activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload' && navigationType === 'POP') { return; @@ -649,31 +708,39 @@ export function handleNavigation(opts: { if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) { const [name, source] = resolveRouteNameAndSource( location, - routes, + allRoutes || routes, allRoutes || routes, branches as RouteMatch[], basename, ); - const activeSpan = getActiveSpan(); - const spanJson = activeSpan && spanToJSON(activeSpan); - const isAlreadyInNavigationSpan = spanJson?.op === 'navigation'; + const currentNavigationKey = getNavigationKey(location); + const isNavDuplicate = isDuplicateNavigation(client, currentNavigationKey); - // Cross usage can result in multiple navigation spans being created without this check - if (!isAlreadyInNavigationSpan) { - const navigationSpan = startBrowserTracingNavigationSpan(client, { - name, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, - }, - }); + if (isNavDuplicate) { + // Cross-usage duplicate - update existing span name if better + const activeSpan = getActiveSpan(); + const spanJson = activeSpan && spanToJSON(activeSpan); + const isAlreadyInNavigationSpan = spanJson?.op === 'navigation'; - // Patch navigation span to handle early cancellation (e.g., document.hidden) - if (navigationSpan) { - patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes); + if (isAlreadyInNavigationSpan && activeSpan) { + tryUpdateSpanName(activeSpan, spanJson?.description, name, source); } + } else { + // Not a cross-usage duplicate - create new span + // This handles: different routes, same route with different params (/user/2 → /user/3) + // startBrowserTracingNavigationSpan will end any active navigation span + createNavigationSpan({ + client, + name, + source, + version, + location, + routes, + basename, + allRoutes, + navigationKey: currentNavigationKey, + }); } } } @@ -727,7 +794,13 @@ function updatePageloadTransaction({ : (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]); if (branches) { - const [name, source] = resolveRouteNameAndSource(location, routes, allRoutes || routes, branches, basename); + const [name, source] = resolveRouteNameAndSource( + location, + allRoutes || routes, + allRoutes || routes, + branches, + basename, + ); getCurrentScope().setTransactionName(name || '/'); @@ -780,7 +853,7 @@ function patchSpanEnd( if (branches) { const [name, source] = resolveRouteNameAndSource( location, - routes, + currentAllRoutes.length > 0 ? currentAllRoutes : routes, currentAllRoutes.length > 0 ? currentAllRoutes : routes, branches, basename, diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index d6501d0e4dbf..4cec7bd98dcd 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -171,6 +171,13 @@ export function locationIsInsideDescendantRoute(location: Location, routes: Rout return false; } +/** + * Returns a fallback transaction name from location pathname. + */ +function getFallbackTransactionName(location: Location, basename: string): string { + return _stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname || ''; +} + /** * Gets a normalized route name and transaction source from the current routes and location. */ @@ -184,53 +191,55 @@ export function getNormalizedName( return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url']; } + if (!branches) { + return [getFallbackTransactionName(location, basename), 'url']; + } + let pathBuilder = ''; - if (branches) { - for (const branch of branches) { - const route = branch.route; - if (route) { - // Early return if index route - if (route.index) { - return sendIndexPath(pathBuilder, branch.pathname, basename); - } - const path = route.path; - - // If path is not a wildcard and has no child routes, append the path - if (path && !pathIsWildcardAndHasChildren(path, branch)) { - const newPath = path[0] === '/' || pathBuilder[pathBuilder.length - 1] === '/' ? path : `/${path}`; - pathBuilder = trimSlash(pathBuilder) + prefixWithSlash(newPath); - - // If the path matches the current location, return the path - if (trimSlash(location.pathname) === trimSlash(basename + branch.pathname)) { - if ( - // If the route defined on the element is something like - // Product} /> - // We should check against the branch.pathname for the number of / separators - getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) && - // We should not count wildcard operators in the url segments calculation - !pathEndsWithWildcard(pathBuilder) - ) { - return [(_stripBasename ? '' : basename) + newPath, 'route']; - } - - // if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard - if (pathIsWildcardAndHasChildren(pathBuilder, branch)) { - pathBuilder = pathBuilder.slice(0, -1); - } - - return [(_stripBasename ? '' : basename) + pathBuilder, 'route']; - } - } - } + for (const branch of branches) { + const route = branch.route; + if (!route) { + continue; + } + + // Early return for index routes + if (route.index) { + return sendIndexPath(pathBuilder, branch.pathname, basename); } - } - const fallbackTransactionName = _stripBasename - ? stripBasenameFromPathname(location.pathname, basename) - : location.pathname || ''; + const path = route.path; + if (!path || pathIsWildcardAndHasChildren(path, branch)) { + continue; + } + + // Build the route path + const newPath = path[0] === '/' || pathBuilder[pathBuilder.length - 1] === '/' ? path : `/${path}`; + pathBuilder = trimSlash(pathBuilder) + prefixWithSlash(newPath); + + // Check if this path matches the current location + if (trimSlash(location.pathname) !== trimSlash(basename + branch.pathname)) { + continue; + } + + // Check if this is a parameterized route like /stores/:storeId/products/:productId + if ( + getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) && + !pathEndsWithWildcard(pathBuilder) + ) { + return [(_stripBasename ? '' : basename) + newPath, 'route']; + } + + // Handle wildcard routes with children - strip trailing wildcard + if (pathIsWildcardAndHasChildren(pathBuilder, branch)) { + pathBuilder = pathBuilder.slice(0, -1); + } + + return [(_stripBasename ? '' : basename) + pathBuilder, 'route']; + } - return [fallbackTransactionName, 'url']; + // Fallback when no matching route found + return [getFallbackTransactionName(location, basename), 'url']; } /** diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx index 77d8e3d95b2e..be71f4b838c5 100644 --- a/packages/react/test/reactrouter-cross-usage.test.tsx +++ b/packages/react/test/reactrouter-cross-usage.test.tsx @@ -9,7 +9,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCurrentClient, } from '@sentry/core'; -import { render } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import * as React from 'react'; import { createMemoryRouter, @@ -26,6 +26,7 @@ import { } from 'react-router-6'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../src'; +import { allRoutes } from '../src/reactrouter-compat-utils/instrumentation'; import { reactRouterV6BrowserTracingIntegration, withSentryReactRouterV6Routing, @@ -101,6 +102,7 @@ describe('React Router cross usage of wrappers', () => { beforeEach(() => { vi.clearAllMocks(); getCurrentScope().setClient(undefined); + allRoutes.clear(); }); describe('wrapCreateBrowserRouter and wrapUseRoutes', () => { @@ -218,16 +220,10 @@ describe('React Router cross usage of wrappers', () => { expect(container.innerHTML).toContain('Details'); - // It's called 1 time from the wrapped `MemoryRouter` expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/second-level/:id/third-level/:id', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); + // In cross-usage scenarios, the first wrapper creates the span and the second updates it + expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('/second-level/:id/third-level/:id'); + expect(mockNavigationSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); }); }); @@ -339,7 +335,6 @@ describe('React Router cross usage of wrappers', () => { expect(container.innerHTML).toContain('Details'); - // It's called 1 time from the wrapped `MemoryRouter` expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); }); }); @@ -465,17 +460,12 @@ describe('React Router cross usage of wrappers', () => { expect(container.innerHTML).toContain('Details'); - // It's called 1 time from the wrapped `createMemoryRouter` expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/second-level/:id/third-level/:id', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); + // Cross-usage deduplication: Span created once with initial route name + // With nested lazy routes, initial name may be raw path, updated to parameterized by later wrapper + expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('/second-level/:id/third-level/:id'); + expect(mockNavigationSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); }); }); @@ -597,14 +587,290 @@ describe('React Router cross usage of wrappers', () => { expect(container.innerHTML).toContain('Details'); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + // Cross-usage with all three wrappers: span created once, then updated + expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('/second-level/:id/third-level/:id'); + expect(mockNavigationSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + }); + + describe('consecutive navigations to different routes', () => { + it('should create separate transactions for consecutive navigations to different routes', async () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { path: '/users', element:
Users
}, + { path: '/settings', element:
Settings
}, + { path: '/profile', element:
Profile
}, + ], + }, + ], + { initialEntries: ['/users'] }, + ); + + render( + + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).not.toHaveBeenCalled(); + + await act(async () => { + router.navigate('/settings'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/second-level/:id/third-level/:id', + name: '/settings', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, }); + + await act(async () => { + router.navigate('/profile'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); + }); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + + const calls = mockStartBrowserTracingNavigationSpan.mock.calls; + expect(calls[0]![1].name).toBe('/settings'); + expect(calls[1]![1].name).toBe('/profile'); + expect(calls[0]![1].attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('navigation'); + expect(calls[1]![1].attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('navigation'); + }); + + it('should create separate transactions for rapid consecutive navigations', async () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { path: '/a', element:
A
}, + { path: '/b', element:
B
}, + { path: '/c', element:
C
}, + ], + }, + ], + { initialEntries: ['/a'] }, + ); + + render( + + + , + ); + + await act(async () => { + router.navigate('/b'); + router.navigate('/c'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); + }); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + + const calls = mockStartBrowserTracingNavigationSpan.mock.calls; + expect(calls[0]![1].name).toBe('/b'); + expect(calls[1]![1].name).toBe('/c'); + }); + + it('should create separate spans for same route with different params', async () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = createSentryMemoryRouter( + [ + { + children: [{ path: '/user/:id', element:
User
}], + }, + ], + { initialEntries: ['/user/1'] }, + ); + + render( + + + , + ); + + await act(async () => { + router.navigate('/user/2'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); + }); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledWith(expect.any(BrowserClient), { + name: '/user/:id', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + + await act(async () => { + router.navigate('/user/3'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); + }); + + // Should create 2 spans - different concrete paths are different user actions + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenNthCalledWith(2, expect.any(BrowserClient), { + name: '/user/:id', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('should handle mixed cross-usage and consecutive navigations correctly', async () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + const sentryUseRoutes = wrapUseRoutesV6(useRoutes); + + const UsersRoute: React.FC = () => sentryUseRoutes([{ path: '/', element:
Users
}]); + + const SettingsRoute: React.FC = () => sentryUseRoutes([{ path: '/', element:
Settings
}]); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { path: '/users/*', element: }, + { path: '/settings/*', element: }, + ], + }, + ], + { initialEntries: ['/users'] }, + ); + + render( + + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).not.toHaveBeenCalled(); + + await act(async () => { + router.navigate('/settings'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); + }); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/settings/*', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }), + }); + }); + + it('should not create duplicate spans for cross-usage on same route', async () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + const sentryUseRoutes = wrapUseRoutesV6(useRoutes); + + const NestedRoute: React.FC = () => sentryUseRoutes([{ path: '/', element:
Details
}]); + + const router = createSentryMemoryRouter( + [ + { + children: [{ path: '/details/*', element: }], + }, + ], + { initialEntries: ['/home'] }, + ); + + render( + + + , + ); + + await act(async () => { + router.navigate('/details'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalled()); + }); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledWith(expect.any(BrowserClient), { + name: '/details/*', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }), + }); }); }); }); diff --git a/packages/react/test/reactrouter-descendant-routes.test.tsx b/packages/react/test/reactrouter-descendant-routes.test.tsx index fe75bc81e858..a08893694a30 100644 --- a/packages/react/test/reactrouter-descendant-routes.test.tsx +++ b/packages/react/test/reactrouter-descendant-routes.test.tsx @@ -25,6 +25,7 @@ import { } from 'react-router-6'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../src'; +import { allRoutes } from '../src/reactrouter-compat-utils/instrumentation'; import { reactRouterV6BrowserTracingIntegration, withSentryReactRouterV6Routing, @@ -79,6 +80,7 @@ describe('React Router Descendant Routes', () => { beforeEach(() => { vi.clearAllMocks(); getCurrentScope().setClient(undefined); + allRoutes.clear(); }); describe('withSentryReactRouterV6Routing', () => { diff --git a/packages/react/test/reactrouterv6.test.tsx b/packages/react/test/reactrouterv6.test.tsx index 61fefdff9b63..fda5043d2e6a 100644 --- a/packages/react/test/reactrouterv6.test.tsx +++ b/packages/react/test/reactrouterv6.test.tsx @@ -28,6 +28,7 @@ import { } from 'react-router-6'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../src'; +import { allRoutes } from '../src/reactrouter-compat-utils/instrumentation'; import { reactRouterV6BrowserTracingIntegration, withSentryReactRouterV6Routing, @@ -83,6 +84,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { beforeEach(() => { vi.clearAllMocks(); getCurrentScope().setClient(undefined); + allRoutes.clear(); }); it('wrapCreateMemoryRouterV6 starts and updates a pageload transaction - single initialEntry', () => { From d729cdbd568425be29b9cfa72d31eede24b15949 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 18 Nov 2025 11:40:53 +0200 Subject: [PATCH 32/40] feat(nextjs): Add URL to server-side transaction events (#18230) URLs were missing from server-side transaction events (server components, generation functions) in Next.js. This was previously removed in #18113 because we tried to synchronously access `params` and `searchParams`, which cause builds to crash. This PR approach adds the URL at runtime using a `preprocessEvent` hook as suggested. **Implementation** 1. Reads `http.target` (actual request path) and `next.route` (parameterized route) from the transaction's trace data 2. Extracts headers from the captured isolation scope's SDK processing metadata 3. Builds the full URL using the existing `getSanitizedRequestUrl()` utility 4. Adds it to `normalizedRequest.url` so the `requestDataIntegration` includes it in the event This works uniformly for both Webpack and Turbopack across all of our supported Next.js versions (13~16), I added missing tests for this case in the versions that did not have it. Fixes #18115 --- .../tests/server/server-components.test.ts | 48 +++++++++++++++++++ .../nextjs-15/tests/server-components.test.ts | 48 +++++++++++++++++++ .../nextjs-16/tests/server-components.test.ts | 48 +++++++++++++++++++ .../tests/server-components.test.ts | 7 +-- .../tests/app-router/route-handlers.test.ts | 3 +- .../app-router/server-components.test.ts | 48 +++++++++++++++++++ .../common/utils/setUrlProcessingMetadata.ts | 42 ++++++++++++++++ packages/nextjs/src/edge/index.ts | 3 ++ packages/nextjs/src/server/index.ts | 3 ++ 9 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts create mode 100644 packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts new file mode 100644 index 000000000000..c9e3a6ff588c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-13', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts new file mode 100644 index 000000000000..2f3488976d28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-15', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts new file mode 100644 index 000000000000..d5b1a00b30d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts index 25d84cdc28e1..eedb702715de 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -34,11 +34,8 @@ test('Sends a transaction for a request to app router', async ({ page }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); - expect(transactionEvent.request).toEqual({ - cookies: {}, - headers: expect.objectContaining({ - 'user-agent': expect.any(String), - }), + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/server-component/parameter/1337/42'), }); // The transaction should not contain any spans with the same name as the transaction diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts index 544ba0084167..13f4f5fa4a58 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts @@ -66,8 +66,7 @@ test('Should record exceptions and transactions for faulty route handlers', asyn expect(routehandlerError.exception?.values?.[0].value).toBe('Dynamic route handler error'); expect(routehandlerError.request?.method).toBe('GET'); - // todo: make sure url is attached to request object - // expect(routehandlerError.request?.url).toContain('/route-handlers/boop/error'); + expect(routehandlerError.request?.url).toContain('/route-handlers/boop/error'); expect(routehandlerError.transaction).toBe('/route-handlers/[param]/error'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts new file mode 100644 index 000000000000..d3a6db69fcd5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-turbo', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); diff --git a/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts new file mode 100644 index 000000000000..0c7e0c3b33f2 --- /dev/null +++ b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts @@ -0,0 +1,42 @@ +import type { Event } from '@sentry/core'; +import { getClient } from '@sentry/core'; +import { getSanitizedRequestUrl } from './urls'; + +/** + * Sets the URL processing metadata for the event. + */ +export function setUrlProcessingMetadata(event: Event): void { + // Skip if not a server-side transaction + if (event.type !== 'transaction' || event.contexts?.trace?.op !== 'http.server' || !event.contexts?.trace?.data) { + return; + } + + // Only add URL if sendDefaultPii is enabled, as URLs may contain PII + const client = getClient(); + if (!client?.getOptions().sendDefaultPii) { + return; + } + + const traceData = event.contexts.trace.data; + + // Get the route from trace data + const componentRoute = traceData['next.route'] || traceData['http.route']; + const httpTarget = traceData['http.target'] as string | undefined; + + if (!componentRoute) { + return; + } + + // Extract headers + const isolationScopeData = event.sdkProcessingMetadata?.capturedSpanIsolationScope?.getScopeData(); + const headersDict = isolationScopeData?.sdkProcessingMetadata?.normalizedRequest?.headers; + + const url = getSanitizedRequestUrl(componentRoute, undefined, headersDict, httpTarget?.toString()); + + // Add URL to the isolation scope's normalizedRequest so requestDataIntegration picks it up + if (url && isolationScopeData?.sdkProcessingMetadata) { + isolationScopeData.sdkProcessingMetadata.normalizedRequest = + isolationScopeData.sdkProcessingMetadata.normalizedRequest || {}; + isolationScopeData.sdkProcessingMetadata.normalizedRequest.url = url; + } +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 6ee523fe72dc..5fd92707b912 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -22,6 +22,7 @@ import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-e import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; +import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export * from '@sentry/vercel-edge'; @@ -126,6 +127,8 @@ export function init(options: VercelEdgeOptions = {}): void { } } } + + setUrlProcessingMetadata(event); }); client?.on('spanEnd', span => { diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index aa6210c2ff6a..ce8ac7c56cea 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -39,6 +39,7 @@ import { } from '../common/span-attributes-with-logic-attached'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { isBuild } from '../common/utils/isBuild'; +import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export * from '@sentry/node'; @@ -391,6 +392,8 @@ export function init(options: NodeOptions): NodeClient | undefined { event.contexts.trace.parent_span_id = traceparentData.parentSpanId; } } + + setUrlProcessingMetadata(event); }); if (process.env.NODE_ENV === 'development') { From 9482a0274f79cd7ae71d18584aec3068787bde79 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 18 Nov 2025 11:41:34 +0100 Subject: [PATCH 33/40] fix(nextjs): Respect PORT variable for dev error symbolication (#18227) Next.js respects the PORT variable. If for some reason this is not sufficient for users we can ship a follow up with a config option, which I wanted to avoid in the first step. Also did a small refactor of the fetching code. closes https://github.com/getsentry/sentry-javascript/issues/18135 closes https://linear.app/getsentry/issue/JS-1139/handle-the-case-where-users-define-a-different-portprotocol --- .../nextjs-orpc/tests/orpc-error.test.ts | 7 +- .../devErrorSymbolicationEventProcessor.ts | 98 ++++---- ...evErrorSymbolicationEventProcessor.test.ts | 215 ++++++++++++++++++ 3 files changed, 268 insertions(+), 52 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts index a503533b6f00..a5f5494ee61c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts @@ -1,9 +1,12 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; -test('should capture orpc error', async ({ page }) => { +test('should capture server-side orpc error', async ({ page }) => { const orpcErrorPromise = waitForError('nextjs-orpc', errorEvent => { - return errorEvent.exception?.values?.[0]?.value === 'You are hitting an error'; + return ( + errorEvent.exception?.values?.[0]?.value === 'You are hitting an error' && + errorEvent.contexts?.['runtime']?.name === 'node' + ); }); await page.goto('/'); diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index 9ae0a5ee0bb2..3b02d92d80fb 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -15,6 +15,44 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryNextJsVersion: string | undefined; }; +/** + * Constructs the base URL for the Next.js dev server, including the port and base path. + * Returns only the base path when running in the browser (client-side) for relative URLs. + */ +function getDevServerBaseUrl(): string { + let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? ''; + + // Prefix the basepath with a slash if it doesn't have one + if (basePath !== '' && !basePath.match(/^\//)) { + basePath = `/${basePath}`; + } + + // eslint-disable-next-line no-restricted-globals + if (typeof window !== 'undefined') { + return basePath; + } + + const devServerPort = process.env.PORT || '3000'; + return `http://localhost:${devServerPort}${basePath}`; +} + +/** + * Fetches a URL with a 3-second timeout using AbortController. + */ +async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + + return suppressTracing(() => + fetch(url, { + ...options, + signal: controller.signal, + }).finally(() => { + clearTimeout(timer); + }), + ); +} + /** * Event processor that will symbolicate errors by using the webpack/nextjs dev server that is used to show stack traces * in the dev overlay. @@ -123,28 +161,8 @@ async function resolveStackFrame( params.append(key, (frame[key as keyof typeof frame] ?? '').toString()); }); - let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? ''; - - // Prefix the basepath with a slash if it doesn't have one - if (basePath !== '' && !basePath.match(/^\//)) { - basePath = `/${basePath}`; - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 3000); - const res = await suppressTracing(() => - fetch( - `${ - // eslint-disable-next-line no-restricted-globals - typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port - }${basePath}/__nextjs_original-stack-frame?${params.toString()}`, - { - signal: controller.signal, - }, - ).finally(() => { - clearTimeout(timer); - }), - ); + const baseUrl = getDevServerBaseUrl(); + const res = await fetchWithTimeout(`${baseUrl}/__nextjs_original-stack-frame?${params.toString()}`); if (!res.ok || res.status === 204) { return null; @@ -191,34 +209,14 @@ async function resolveStackFrames( isAppDirectory: true, }; - let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? ''; - - // Prefix the basepath with a slash if it doesn't have one - if (basePath !== '' && !basePath.match(/^\//)) { - basePath = `/${basePath}`; - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 3000); - - const res = await suppressTracing(() => - fetch( - `${ - // eslint-disable-next-line no-restricted-globals - typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port - }${basePath}/__nextjs_original-stack-frames`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - signal: controller.signal, - body: JSON.stringify(postBody), - }, - ).finally(() => { - clearTimeout(timer); - }), - ); + const baseUrl = getDevServerBaseUrl(); + const res = await fetchWithTimeout(`${baseUrl}/__nextjs_original-stack-frames`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(postBody), + }); if (!res.ok || res.status === 204) { return null; diff --git a/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts b/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts index 4305aad537a8..130f8ea685df 100644 --- a/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts +++ b/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts @@ -25,6 +25,7 @@ describe('devErrorSymbolicationEventProcessor', () => { vi.clearAllMocks(); delete (GLOBAL_OBJ as any)._sentryNextJsVersion; delete (GLOBAL_OBJ as any)._sentryBasePath; + delete process.env.PORT; }); describe('Next.js version handling', () => { @@ -258,4 +259,218 @@ describe('devErrorSymbolicationEventProcessor', () => { expect(result?.spans).toHaveLength(1); }); }); + + describe('dev server URL construction', () => { + it('should use default port 3000 when PORT env variable is not set (Next.js < 15.2)', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'webpack-internal:///./test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at webpack-internal:///./test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'webpack-internal:///./test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }), + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:3000/__nextjs_original-stack-frame'), + expect.any(Object), + ); + }); + + it('should use PORT env variable when set (Next.js < 15.2)', async () => { + process.env.PORT = '4000'; + + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'webpack-internal:///./test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at webpack-internal:///./test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'webpack-internal:///./test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }), + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:4000/__nextjs_original-stack-frame'), + expect.any(Object), + ); + }); + + it('should use default port 3000 when PORT env variable is not set (Next.js >= 15.2)', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'file:///test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at file:///test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '15.2.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'file:///test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [ + { + value: { + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }, + }, + ], + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:3000/__nextjs_original-stack-frames'), + expect.any(Object), + ); + }); + + it('should use PORT env variable when set (Next.js >= 15.2)', async () => { + process.env.PORT = '4000'; + + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'file:///test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at file:///test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '15.2.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'file:///test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [ + { + value: { + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }, + }, + ], + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:4000/__nextjs_original-stack-frames'), + expect.any(Object), + ); + }); + }); }); From 610ae69d36ec5b7d55b20aae3e5d9d62e9744a93 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 18 Nov 2025 14:51:29 +0200 Subject: [PATCH 34/40] feat(browser-utils): bump web-vitals to 5.1.0 (#18091) Bumps the vendored-in web vitals library to include the changes between `5.0.2` <-> `5.1.0` from upstream #### Changes from upstream - Remove `visibilitychange` event listeners when no longer required [#627](https://github.com/GoogleChrome/web-vitals/pull/627) - Register visibility-change early [#637](https://github.com/GoogleChrome/web-vitals/pull/637) - Only finalize LCP on user events (isTrusted=true) [#635](https://github.com/GoogleChrome/web-vitals/pull/635) - Fallback to default getSelector if custom function is null or undefined [#634](https://github.com/GoogleChrome/web-vitals/pull/634) #### Our own Changes - Added `addPageListener` and `removePageListener` utilities because the upstream package changed the listeners to be added on `window` instead of `document`, so I added those utilities to avoid having to check for window every time we try to add a listener. --- .size-limit.js | 6 +- .../src/metrics/web-vitals/README.md | 10 ++- .../src/metrics/web-vitals/getCLS.ts | 10 +-- .../src/metrics/web-vitals/getINP.ts | 9 +- .../src/metrics/web-vitals/getLCP.ts | 28 +++--- .../web-vitals/lib/getVisibilityWatcher.ts | 88 ++++++++++++------- .../metrics/web-vitals/lib/globalListeners.ts | 20 +++++ .../src/metrics/web-vitals/lib/onHidden.ts | 11 ++- .../web-vitals/lib/whenIdleOrHidden.ts | 9 +- .../src/metrics/web-vitals/types/base.ts | 2 +- .../src/metrics/web-vitals/types/cls.ts | 3 +- .../src/metrics/web-vitals/types/inp.ts | 3 +- .../src/metrics/web-vitals/types/lcp.ts | 3 +- 13 files changed, 135 insertions(+), 67 deletions(-) create mode 100644 packages/browser-utils/src/metrics/web-vitals/lib/globalListeners.ts diff --git a/.size-limit.js b/.size-limit.js index 4e929875dad5..184aad0698f4 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -38,7 +38,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '41.3 KB', + limit: '41.38 KB', }, { name: '@sentry/browser (incl. Tracing, Profiling)', @@ -127,7 +127,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '43.3 KB', + limit: '43.33 KB', }, // Vue SDK (ESM) { @@ -142,7 +142,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '43.1 KB', + limit: '43.2 KB', }, // Svelte SDK (ESM) { diff --git a/packages/browser-utils/src/metrics/web-vitals/README.md b/packages/browser-utils/src/metrics/web-vitals/README.md index a57937246cdd..1eeaf4df2420 100644 --- a/packages/browser-utils/src/metrics/web-vitals/README.md +++ b/packages/browser-utils/src/metrics/web-vitals/README.md @@ -2,10 +2,10 @@ > A modular library for measuring the [Web Vitals](https://web.dev/vitals/) metrics on real users. -This was vendored from: https://github.com/GoogleChrome/web-vitals: v5.0.2 +This was vendored from: https://github.com/GoogleChrome/web-vitals: v5.1.0 The commit SHA used is: -[463abbd425cda01ed65e0b5d18be9f559fe446cb](https://github.com/GoogleChrome/web-vitals/tree/463abbd425cda01ed65e0b5d18be9f559fe446cb) +[e22d23b22c1440e69c5fc25a2f373b1a425cc940](https://github.com/GoogleChrome/web-vitals/tree/e22d23b22c1440e69c5fc25a2f373b1a425cc940) Current vendored web vitals are: @@ -27,6 +27,12 @@ web-vitals only report once per pageload. ## CHANGELOG +- Bumped from Web Vitals 5.0.2 to 5.1.0 + - Remove `visibilitychange` event listeners when no longer required [#627](https://github.com/GoogleChrome/web-vitals/pull/627) + - Register visibility-change early [#637](https://github.com/GoogleChrome/web-vitals/pull/637) + - Only finalize LCP on user events (isTrusted=true) [#635](https://github.com/GoogleChrome/web-vitals/pull/635) + - Fallback to default getSelector if custom function is null or undefined [#634](https://github.com/GoogleChrome/web-vitals/pull/634) + https://github.com/getsentry/sentry-javascript/pull/17076 - Removed FID-related code with v10 of the SDK diff --git a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts index c40f993f8ca8..2e3f98c599e4 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts @@ -16,6 +16,7 @@ import { WINDOW } from '../../types'; import { bindReporter } from './lib/bindReporter'; +import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; import { initUnique } from './lib/initUnique'; import { LayoutShiftManager } from './lib/LayoutShiftManager'; @@ -55,6 +56,7 @@ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = runOnce(() => { const metric = initMetric('CLS', 0); let report: ReturnType; + const visibilityWatcher = getVisibilityWatcher(); const layoutShiftManager = initUnique(opts, LayoutShiftManager); @@ -76,11 +78,9 @@ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = if (po) { report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges); - WINDOW.document?.addEventListener('visibilitychange', () => { - if (WINDOW.document?.visibilityState === 'hidden') { - handleEntries(po.takeRecords() as CLSMetric['entries']); - report(true); - } + visibilityWatcher.onHidden(() => { + handleEntries(po.takeRecords() as CLSMetric['entries']); + report(true); }); // Queue a task to report (if nothing else triggers a report first). diff --git a/packages/browser-utils/src/metrics/web-vitals/getINP.ts b/packages/browser-utils/src/metrics/web-vitals/getINP.ts index f5efbcbc3afc..df8ac5e1c804 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getINP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getINP.ts @@ -15,11 +15,11 @@ */ import { bindReporter } from './lib/bindReporter'; +import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; import { initUnique } from './lib/initUnique'; import { InteractionManager } from './lib/InteractionManager'; import { observe } from './lib/observe'; -import { onHidden } from './lib/onHidden'; import { initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill'; import { whenActivated } from './lib/whenActivated'; import { whenIdleOrHidden } from './lib/whenIdleOrHidden'; @@ -67,6 +67,8 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts return; } + const visibilityWatcher = getVisibilityWatcher(); + whenActivated(() => { // TODO(philipwalton): remove once the polyfill is no longer needed. initInteractionCountPolyfill(); @@ -116,10 +118,7 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts // where the first interaction is less than the `durationThreshold`. po.observe({ type: 'first-input', buffered: true }); - // sentry: we use onHidden instead of directly listening to visibilitychange - // because some browsers we still support (Safari <14.4) don't fully support - // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. - onHidden(() => { + visibilityWatcher.onHidden(() => { handleEntries(po.takeRecords() as INPMetric['entries']); report(true); }); diff --git a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts index 6eafee698673..9de413c745c0 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { WINDOW } from '../../types'; import { bindReporter } from './lib/bindReporter'; import { getActivationStart } from './lib/getActivationStart'; import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; +import { addPageListener, removePageListener } from './lib/globalListeners'; import { initMetric } from './lib/initMetric'; import { initUnique } from './lib/initUnique'; import { LCPEntryManager } from './lib/LCPEntryManager'; @@ -88,20 +88,28 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = report(true); }); + // Need a separate wrapper to ensure the `runOnce` function above is + // common for all three functions + const stopListeningWrapper = (event: Event) => { + if (event.isTrusted) { + // Wrap the listener in an idle callback so it's run in a separate + // task to reduce potential INP impact. + // https://github.com/GoogleChrome/web-vitals/issues/383 + whenIdleOrHidden(stopListening); + removePageListener(event.type, stopListeningWrapper, { + capture: true, + }); + } + }; + // Stop listening after input or visibilitychange. // Note: while scrolling is an input that stops LCP observation, it's // unreliable since it can be programmatically generated. // See: https://github.com/GoogleChrome/web-vitals/issues/75 for (const type of ['keydown', 'click', 'visibilitychange']) { - // Wrap the listener in an idle callback so it's run in a separate - // task to reduce potential INP impact. - // https://github.com/GoogleChrome/web-vitals/issues/383 - if (WINDOW.document) { - addEventListener(type, () => whenIdleOrHidden(stopListening), { - capture: true, - once: true, - }); - } + addPageListener(type, stopListeningWrapper, { + capture: true, + }); } } }); diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts index 3a6c0a2e42a9..3eaea296a655 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts @@ -16,8 +16,10 @@ import { WINDOW } from '../../../types'; import { getActivationStart } from './getActivationStart'; +import { addPageListener, removePageListener } from './globalListeners'; let firstHiddenTime = -1; +const onHiddenFunctions: Set<() => void> = new Set(); const initHiddenTime = () => { // If the document is hidden when this code runs, assume it was always @@ -29,35 +31,34 @@ const initHiddenTime = () => { }; const onVisibilityUpdate = (event: Event) => { - // If the document is 'hidden' and no previous hidden timestamp has been - // set, update it based on the current event data. - if (WINDOW.document!.visibilityState === 'hidden' && firstHiddenTime > -1) { - // If the event is a 'visibilitychange' event, it means the page was - // visible prior to this change, so the event timestamp is the first - // hidden time. - // However, if the event is not a 'visibilitychange' event, then it must - // be a 'prerenderingchange' event, and the fact that the document is - // still 'hidden' from the above check means the tab was activated - // in a background state and so has always been hidden. - firstHiddenTime = event.type === 'visibilitychange' ? event.timeStamp : 0; + // Handle changes to hidden state + if (isPageHidden(event) && firstHiddenTime > -1) { + // Sentry-specific change: Also call onHidden callbacks for pagehide events + // to support older browsers (Safari <14.4) that don't properly fire visibilitychange + if (event.type === 'visibilitychange' || event.type === 'pagehide') { + for (const onHiddenFunction of onHiddenFunctions) { + onHiddenFunction(); + } + } - // Remove all listeners now that a `firstHiddenTime` value has been set. - removeChangeListeners(); - } -}; - -const addChangeListeners = () => { - addEventListener('visibilitychange', onVisibilityUpdate, true); - // IMPORTANT: when a page is prerendering, its `visibilityState` is - // 'hidden', so in order to account for cases where this module checks for - // visibility during prerendering, an additional check after prerendering - // completes is also required. - addEventListener('prerenderingchange', onVisibilityUpdate, true); -}; + // If the document is 'hidden' and no previous hidden timestamp has been + // set (so is infinity), update it based on the current event data. + if (!isFinite(firstHiddenTime)) { + // If the event is a 'visibilitychange' event, it means the page was + // visible prior to this change, so the event timestamp is the first + // hidden time. + // However, if the event is not a 'visibilitychange' event, then it must + // be a 'prerenderingchange' or 'pagehide' event, and the fact that the document is + // still 'hidden' from the above check means the tab was activated + // in a background state and so has always been hidden. + firstHiddenTime = event.type === 'visibilitychange' ? event.timeStamp : 0; -const removeChangeListeners = () => { - removeEventListener('visibilitychange', onVisibilityUpdate, true); - removeEventListener('prerenderingchange', onVisibilityUpdate, true); + // We no longer need the `prerenderingchange` event listener now we've + // set an initial init time so remove that + // (we'll keep the visibilitychange and pagehide ones for onHiddenFunction above) + removePageListener('prerenderingchange', onVisibilityUpdate, true); + } + } }; export const getVisibilityWatcher = () => { @@ -75,14 +76,39 @@ export const getVisibilityWatcher = () => { // a perfect heuristic, but it's the best we can do until the // `visibility-state` performance entry becomes available in all browsers. firstHiddenTime = firstVisibilityStateHiddenTime ?? initHiddenTime(); - // We're still going to listen to for changes so we can handle things like - // bfcache restores and/or prerender without having to examine individual - // timestamps in detail. - addChangeListeners(); + // Listen for visibility changes so we can handle things like bfcache + // restores and/or prerender without having to examine individual + // timestamps in detail and also for onHidden function calls. + addPageListener('visibilitychange', onVisibilityUpdate, true); + + // Sentry-specific change: Some browsers have buggy implementations of visibilitychange, + // so we use pagehide in addition, just to be safe. This is also required for older + // Safari versions (<14.4) that we still support. + addPageListener('pagehide', onVisibilityUpdate, true); + + // IMPORTANT: when a page is prerendering, its `visibilityState` is + // 'hidden', so in order to account for cases where this module checks for + // visibility during prerendering, an additional check after prerendering + // completes is also required. + addPageListener('prerenderingchange', onVisibilityUpdate, true); } + return { get firstHiddenTime() { return firstHiddenTime; }, + onHidden(cb: () => void) { + onHiddenFunctions.add(cb); + }, }; }; + +/** + * Check if the page is hidden, uses the `pagehide` event for older browsers support that we used to have in `onHidden` function. + * Some browsers we still support (Safari <14.4) don't fully support `visibilitychange` + * or have known bugs w.r.t the `visibilitychange` event. + * // TODO (v11): If we decide to drop support for Safari 14.4, we can use the logic from web-vitals 4.2.4 + */ +function isPageHidden(event: Event) { + return event.type === 'pagehide' || WINDOW.document?.visibilityState === 'hidden'; +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/globalListeners.ts b/packages/browser-utils/src/metrics/web-vitals/lib/globalListeners.ts new file mode 100644 index 000000000000..0e391cff17c2 --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/globalListeners.ts @@ -0,0 +1,20 @@ +import { WINDOW } from '../../../types'; + +/** + * web-vitals 5.1.0 switched listeners to be added on the window rather than the document. + * Instead of having to check for window/document every time we add a listener, we can use this function. + */ +export function addPageListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) { + if (WINDOW.document) { + WINDOW.addEventListener(type, listener, options); + } +} +/** + * web-vitals 5.1.0 switched listeners to be removed from the window rather than the document. + * Instead of having to check for window/document every time we remove a listener, we can use this function. + */ +export function removePageListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) { + if (WINDOW.document) { + WINDOW.removeEventListener(type, listener, options); + } +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts index 5a3c1b4fc810..d9dc2f6718ed 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts @@ -15,6 +15,7 @@ */ import { WINDOW } from '../../../types'; +import { addPageListener } from './globalListeners'; export interface OnHiddenCallback { (event: Event): void; @@ -37,10 +38,8 @@ export const onHidden = (cb: OnHiddenCallback) => { } }; - if (WINDOW.document) { - addEventListener('visibilitychange', onHiddenOrPageHide, true); - // Some browsers have buggy implementations of visibilitychange, - // so we use pagehide in addition, just to be safe. - addEventListener('pagehide', onHiddenOrPageHide, true); - } + addPageListener('visibilitychange', onHiddenOrPageHide, true); + // Some browsers have buggy implementations of visibilitychange, + // so we use pagehide in addition, just to be safe. + addPageListener('pagehide', onHiddenOrPageHide, true); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts index 32dae5f30f8b..008aac8dc4c2 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts @@ -15,6 +15,7 @@ */ import { WINDOW } from '../../../types.js'; +import { addPageListener, removePageListener } from './globalListeners.js'; import { onHidden } from './onHidden.js'; import { runOnce } from './runOnce.js'; @@ -32,7 +33,13 @@ export const whenIdleOrHidden = (cb: () => void) => { } else { // eslint-disable-next-line no-param-reassign cb = runOnce(cb); - rIC(cb); + addPageListener('visibilitychange', cb, { once: true, capture: true }); + rIC(() => { + cb(); + // Remove the above event listener since no longer required. + // See: https://github.com/GoogleChrome/web-vitals/issues/622 + removePageListener('visibilitychange', cb, { capture: true }); + }); // sentry: we use onHidden instead of directly listening to visibilitychange // because some browsers we still support (Safari <14.4) don't fully support // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. diff --git a/packages/browser-utils/src/metrics/web-vitals/types/base.ts b/packages/browser-utils/src/metrics/web-vitals/types/base.ts index 02cb566011ac..cac7fdac1d11 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/base.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/base.ts @@ -116,7 +116,7 @@ export interface ReportOpts { } export interface AttributionReportOpts extends ReportOpts { - generateTarget?: (el: Node | null) => string; + generateTarget?: (el: Node | null) => string | undefined; } /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/cls.ts b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts index 5acaaa27c9ab..6048c616e1f0 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/cls.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts @@ -34,7 +34,8 @@ export interface CLSAttribution { * By default, a selector identifying the first element (in document order) * that shifted when the single largest layout shift that contributed to the * page's CLS score occurred. If the `generateTarget` configuration option - * was passed, then this will instead be the return value of that function. + * was passed, then this will instead be the return value of that function, + * falling back to the default if that returns null or undefined. */ largestShiftTarget?: string; /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/inp.ts b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts index e73743866301..d2b2063c7d04 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/inp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts @@ -60,7 +60,8 @@ export interface INPAttribution { * occurred. If this value is an empty string, that generally means the * element was removed from the DOM after the interaction. If the * `generateTarget` configuration option was passed, then this will instead - * be the return value of that function. + * be the return value of that function, falling back to the default if that + * returns null or undefined. */ interactionTarget: string; /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts index 293531b3d45c..9de6b32a5f94 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts @@ -34,7 +34,8 @@ export interface LCPAttribution { * By default, a selector identifying the element corresponding to the * largest contentful paint for the page. If the `generateTarget` * configuration option was passed, then this will instead be the return - * value of that function. + * value of that function, falling back to the default if that returns null + * or undefined. */ target?: string; /** From 935ef55b1cfa4eb78bdcabe91c3ea4caf90e9820 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 18 Nov 2025 16:26:58 +0100 Subject: [PATCH 35/40] feat(core): Support OpenAI embeddings API (#18224) This adds instrumentation for the OpenAI Embeddings API. Specifically, we instrument [Create embeddings](https://platform.openai.com/docs/api-reference/embeddings/create), which is also the only endpoint in the embeddings API atm. Implementation generally follows the same flow we also have for the `completions` and `responses` APIs. To detect `embedding` requests we check whether the model name contains `embeddings`. The embedding results are currently not tracked, as we do not truncate outputs right now as far as I know and these can get large quite easily. For instance, [text-embedding-3 uses dimension 1536 (small) or 3072 (large) by default](https://platform.openai.com/docs/guides/embeddings#use-cases), resulting in single embeddings sizes of 6KB and 12KB, respectively. Test updates: - Added a new scenario-embeddings.mjs file, that covers the embeddings API tests (tried to put this in the main scenario.mjs, but the linter starts complaining about the file being too long). - Added a new scenario file to check that truncation works properly for the embeddings API. Also moved all truncation scenarios to a folder. --- .../{scenario.mjs => scenario-chat.mjs} | 0 .../tracing/openai/scenario-embeddings.mjs | 67 ++++++++ .../suites/tracing/openai/test.ts | 147 ++++++++++++++++-- ...cenario-message-truncation-completions.mjs | 0 ...scenario-message-truncation-embeddings.mjs | 66 ++++++++ .../scenario-message-truncation-responses.mjs | 0 .../core/src/tracing/ai/gen-ai-attributes.ts | 11 ++ packages/core/src/tracing/openai/constants.ts | 2 +- packages/core/src/tracing/openai/index.ts | 93 ++--------- packages/core/src/tracing/openai/types.ts | 24 ++- packages/core/src/tracing/openai/utils.ts | 116 ++++++++++++++ 11 files changed, 432 insertions(+), 94 deletions(-) rename dev-packages/node-integration-tests/suites/tracing/openai/{scenario.mjs => scenario-chat.mjs} (100%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs rename dev-packages/node-integration-tests/suites/tracing/openai/{ => truncation}/scenario-message-truncation-completions.mjs (100%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-embeddings.mjs rename dev-packages/node-integration-tests/suites/tracing/openai/{ => truncation}/scenario-message-truncation-responses.mjs (100%) diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/openai/scenario.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs new file mode 100644 index 000000000000..9cdb24a42da9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs @@ -0,0 +1,67 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.embeddings = { + create: async params => { + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + return { + object: 'list', + data: [ + { + object: 'embedding', + embedding: [0.1, 0.2, 0.3], + index: 0, + }, + ], + model: params.model, + usage: { + prompt_tokens: 10, + total_tokens: 10, + }, + }; + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // First test: embeddings API + await client.embeddings.create({ + input: 'Embedding test!', + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + + // Second test: embeddings API error model + try { + await client.embeddings.create({ + input: 'Error embedding test!', + model: 'error-model', + }); + } catch { + // Error is expected and handled + } + }); +} + +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 5cbb27df73bf..116c3a6208fa 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -6,7 +6,7 @@ describe('OpenAI integration', () => { cleanupChildProcesses(); }); - const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_CHAT = { transaction: 'main', spans: expect.arrayContaining([ // First span - basic chat completion without PII @@ -147,7 +147,7 @@ describe('OpenAI integration', () => { ]), }; - const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_CHAT = { transaction: 'main', spans: expect.arrayContaining([ // First span - basic chat completion with PII @@ -321,27 +321,27 @@ describe('OpenAI integration', () => { ]), }; - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument.mjs', (createRunner, test) => { test('creates openai related spans with sendDefaultPii: false', async () => { await createRunner() .ignore('event') - .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_CHAT }) .start() .completed(); }); }); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates openai related spans with sendDefaultPii: true', async () => { await createRunner() .ignore('event') - .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_CHAT }) .start() .completed(); }); }); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates openai related spans with custom options', async () => { await createRunner() .ignore('event') @@ -351,6 +351,109 @@ describe('OpenAI integration', () => { }); }); + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embeddings API + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'text-embedding-3-small', + 'gen_ai.request.encoding_format': 'float', + 'gen_ai.request.dimensions': 1536, + 'gen_ai.response.model': 'text-embedding-3-small', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.total_tokens': 10, + 'openai.response.model': 'text-embedding-3-small', + 'openai.usage.prompt_tokens': 10, + }, + description: 'embeddings text-embedding-3-small', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - embeddings API error model + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embeddings API with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'text-embedding-3-small', + 'gen_ai.request.encoding_format': 'float', + 'gen_ai.request.dimensions': 1536, + 'gen_ai.request.messages': 'Embedding test!', + 'gen_ai.response.model': 'text-embedding-3-small', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.total_tokens': 10, + 'openai.response.model': 'text-embedding-3-small', + 'openai.usage.prompt_tokens': 10, + }, + description: 'embeddings text-embedding-3-small', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - embeddings API error model with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': 'Error embedding test!', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS }) + .start() + .completed(); + }); + }); + createEsmAndCjsTests(__dirname, 'scenario-root-span.mjs', 'instrument.mjs', (createRunner, test) => { test('it works without a wrapping span', async () => { await createRunner() @@ -400,7 +503,7 @@ describe('OpenAI integration', () => { createEsmAndCjsTests( __dirname, - 'scenario-message-truncation-completions.mjs', + 'truncation/scenario-message-truncation-completions.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { @@ -436,7 +539,7 @@ describe('OpenAI integration', () => { createEsmAndCjsTests( __dirname, - 'scenario-message-truncation-responses.mjs', + 'truncation/scenario-message-truncation-responses.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('truncates string inputs when they exceed byte limit', async () => { @@ -469,4 +572,30 @@ describe('OpenAI integration', () => { }); }, ); + + createEsmAndCjsTests( + __dirname, + 'truncation/scenario-message-truncation-embeddings.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'embeddings', + }), + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-completions.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-completions.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-completions.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-completions.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-embeddings.mjs new file mode 100644 index 000000000000..b2e5cf3206fe --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-embeddings.mjs @@ -0,0 +1,66 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.embeddings = { + create: async params => { + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + object: 'list', + data: [ + { + object: 'embedding', + embedding: [0.1, 0.2, 0.3], + index: 0, + }, + ], + model: params.model, + usage: { + prompt_tokens: 10, + total_tokens: 10, + }, + }; + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // Create 1 large input that gets truncated to fit within the 20KB limit + const largeContent = 'A'.repeat(25000) + 'B'.repeat(25000); // ~50KB gets truncated to include only As + + await client.embeddings.create({ + input: largeContent, + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + + // Create 3 large inputs where: + // - First 2 inputs are very large (will be dropped) + // - Last input is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.embeddings.create({ + input: [largeContent1, largeContent2, largeContent3], + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-responses.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-responses.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-responses.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-responses.mjs diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index b07aa63d306f..e2808d5f2642 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -65,6 +65,16 @@ export const GEN_AI_REQUEST_TOP_K_ATTRIBUTE = 'gen_ai.request.top_k'; */ export const GEN_AI_REQUEST_STOP_SEQUENCES_ATTRIBUTE = 'gen_ai.request.stop_sequences'; +/** + * The encoding format for the model request + */ +export const GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE = 'gen_ai.request.encoding_format'; + +/** + * The dimensions for the model request + */ +export const GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE = 'gen_ai.request.dimensions'; + /** * Array of reasons why the model stopped generating tokens */ @@ -208,6 +218,7 @@ export const OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE = 'openai.usage.prompt_tokens' export const OPENAI_OPERATIONS = { CHAT: 'chat', RESPONSES: 'responses', + EMBEDDINGS: 'embeddings', } as const; // ============================================================================= diff --git a/packages/core/src/tracing/openai/constants.ts b/packages/core/src/tracing/openai/constants.ts index c4952b123b0f..e8b5c6ddc87f 100644 --- a/packages/core/src/tracing/openai/constants.ts +++ b/packages/core/src/tracing/openai/constants.ts @@ -2,7 +2,7 @@ export const OPENAI_INTEGRATION_NAME = 'OpenAI'; // https://platform.openai.com/docs/quickstart?api-mode=responses // https://platform.openai.com/docs/quickstart?api-mode=chat -export const INSTRUMENTED_METHODS = ['responses.create', 'chat.completions.create'] as const; +export const INSTRUMENTED_METHODS = ['responses.create', 'chat.completions.create', 'embeddings.create'] as const; export const RESPONSES_TOOL_CALL_EVENT_TYPES = [ 'response.output_item.added', 'response.function_call_arguments.delta', diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index bb099199772c..bba2ee0f5afd 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -7,6 +7,8 @@ import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, + GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE, + GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, @@ -14,9 +16,7 @@ import { GEN_AI_REQUEST_STREAM_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, GEN_AI_REQUEST_TOP_P_ATTRIBUTE, - GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, - GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { getTruncatedJsonString } from '../ai/utils'; @@ -25,22 +25,22 @@ import { instrumentStream } from './streaming'; import type { ChatCompletionChunk, InstrumentedMethod, - OpenAiChatCompletionObject, OpenAiIntegration, OpenAiOptions, OpenAiResponse, - OpenAIResponseObject, OpenAIStream, ResponseStreamingEvent, } from './types'; import { + addChatCompletionAttributes, + addEmbeddingsAttributes, + addResponsesApiAttributes, buildMethodPath, getOperationName, getSpanOperation, isChatCompletionResponse, + isEmbeddingsResponse, isResponsesApiResponse, - setCommonResponseAttributes, - setTokenUsageAttributes, shouldInstrument, } from './utils'; @@ -82,6 +82,8 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record choice.finish_reason) - .filter((reason): reason is string => reason !== null); - if (finishReasons.length > 0) { - span.setAttributes({ - [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(finishReasons), - }); - } - - // Extract tool calls from all choices (only if recordOutputs is true) - if (recordOutputs) { - const toolCalls = response.choices - .map(choice => choice.message?.tool_calls) - .filter(calls => Array.isArray(calls) && calls.length > 0) - .flat(); - - if (toolCalls.length > 0) { - span.setAttributes({ - [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls), - }); - } - } - } -} - -/** - * Add attributes for Responses API responses - */ -function addResponsesApiAttributes(span: Span, response: OpenAIResponseObject, recordOutputs?: boolean): void { - setCommonResponseAttributes(span, response.id, response.model, response.created_at); - if (response.status) { - span.setAttributes({ - [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify([response.status]), - }); - } - if (response.usage) { - setTokenUsageAttributes( - span, - response.usage.input_tokens, - response.usage.output_tokens, - response.usage.total_tokens, - ); - } - - // Extract function calls from output (only if recordOutputs is true) - if (recordOutputs) { - const responseWithOutput = response as OpenAIResponseObject & { output?: unknown[] }; - if (Array.isArray(responseWithOutput.output) && responseWithOutput.output.length > 0) { - // Filter for function_call type objects in the output array - const functionCalls = responseWithOutput.output.filter( - (item): unknown => - typeof item === 'object' && item !== null && (item as Record).type === 'function_call', - ); - - if (functionCalls.length > 0) { - span.setAttributes({ - [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(functionCalls), - }); - } - } - } -} - /** * Add response attributes to spans * This currently supports both Chat Completion and Responses API responses @@ -186,6 +111,8 @@ function addResponseAttributes(span: Span, result: unknown, recordOutputs?: bool if (recordOutputs && response.output_text) { span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.output_text }); } + } else if (isEmbeddingsResponse(response)) { + addEmbeddingsAttributes(span, response); } } diff --git a/packages/core/src/tracing/openai/types.ts b/packages/core/src/tracing/openai/types.ts index daa478db4ba6..6dcd644bfe17 100644 --- a/packages/core/src/tracing/openai/types.ts +++ b/packages/core/src/tracing/openai/types.ts @@ -131,7 +131,29 @@ export interface OpenAIResponseObject { metadata: Record; } -export type OpenAiResponse = OpenAiChatCompletionObject | OpenAIResponseObject; +/** + * @see https://platform.openai.com/docs/api-reference/embeddings/object + */ +export interface OpenAIEmbeddingsObject { + object: 'embedding'; + embedding: number[]; + index: number; +} + +/** + * @see https://platform.openai.com/docs/api-reference/embeddings/create + */ +export interface OpenAICreateEmbeddingsObject { + object: 'list'; + data: OpenAIEmbeddingsObject[]; + model: string; + usage: { + prompt_tokens: number; + total_tokens: number; + }; +} + +export type OpenAiResponse = OpenAiChatCompletionObject | OpenAIResponseObject | OpenAICreateEmbeddingsObject; /** * Streaming event types for the Responses API diff --git a/packages/core/src/tracing/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts index 17007693e739..4dff5b4fdbb8 100644 --- a/packages/core/src/tracing/openai/utils.ts +++ b/packages/core/src/tracing/openai/utils.ts @@ -1,7 +1,9 @@ import type { Span } from '../../types-hoist/span'; import { + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_ID_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, @@ -17,6 +19,7 @@ import type { ChatCompletionChunk, InstrumentedMethod, OpenAiChatCompletionObject, + OpenAICreateEmbeddingsObject, OpenAIResponseObject, ResponseStreamingEvent, } from './types'; @@ -31,6 +34,9 @@ export function getOperationName(methodPath: string): string { if (methodPath.includes('responses')) { return OPENAI_OPERATIONS.RESPONSES; } + if (methodPath.includes('embeddings')) { + return OPENAI_OPERATIONS.EMBEDDINGS; + } return methodPath.split('.').pop() || 'unknown'; } @@ -80,6 +86,21 @@ export function isResponsesApiResponse(response: unknown): response is OpenAIRes ); } +/** + * Check if response is an Embeddings API object + */ +export function isEmbeddingsResponse(response: unknown): response is OpenAICreateEmbeddingsObject { + if (response === null || typeof response !== 'object' || !('object' in response)) { + return false; + } + const responseObject = response as Record; + return ( + responseObject.object === 'list' && + typeof responseObject.model === 'string' && + responseObject.model.toLowerCase().includes('embedding') + ); +} + /** * Check if streaming event is from the Responses API */ @@ -105,6 +126,101 @@ export function isChatCompletionChunk(event: unknown): event is ChatCompletionCh ); } +/** + * Add attributes for Chat Completion responses + */ +export function addChatCompletionAttributes( + span: Span, + response: OpenAiChatCompletionObject, + recordOutputs?: boolean, +): void { + setCommonResponseAttributes(span, response.id, response.model, response.created); + if (response.usage) { + setTokenUsageAttributes( + span, + response.usage.prompt_tokens, + response.usage.completion_tokens, + response.usage.total_tokens, + ); + } + if (Array.isArray(response.choices)) { + const finishReasons = response.choices + .map(choice => choice.finish_reason) + .filter((reason): reason is string => reason !== null); + if (finishReasons.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(finishReasons), + }); + } + + // Extract tool calls from all choices (only if recordOutputs is true) + if (recordOutputs) { + const toolCalls = response.choices + .map(choice => choice.message?.tool_calls) + .filter(calls => Array.isArray(calls) && calls.length > 0) + .flat(); + + if (toolCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls), + }); + } + } + } +} + +/** + * Add attributes for Responses API responses + */ +export function addResponsesApiAttributes(span: Span, response: OpenAIResponseObject, recordOutputs?: boolean): void { + setCommonResponseAttributes(span, response.id, response.model, response.created_at); + if (response.status) { + span.setAttributes({ + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify([response.status]), + }); + } + if (response.usage) { + setTokenUsageAttributes( + span, + response.usage.input_tokens, + response.usage.output_tokens, + response.usage.total_tokens, + ); + } + + // Extract function calls from output (only if recordOutputs is true) + if (recordOutputs) { + const responseWithOutput = response as OpenAIResponseObject & { output?: unknown[] }; + if (Array.isArray(responseWithOutput.output) && responseWithOutput.output.length > 0) { + // Filter for function_call type objects in the output array + const functionCalls = responseWithOutput.output.filter( + (item): unknown => + typeof item === 'object' && item !== null && (item as Record).type === 'function_call', + ); + + if (functionCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(functionCalls), + }); + } + } + } +} + +/** + * Add attributes for Embeddings API responses + */ +export function addEmbeddingsAttributes(span: Span, response: OpenAICreateEmbeddingsObject): void { + span.setAttributes({ + [OPENAI_RESPONSE_MODEL_ATTRIBUTE]: response.model, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: response.model, + }); + + if (response.usage) { + setTokenUsageAttributes(span, response.usage.prompt_tokens, undefined, response.usage.total_tokens); + } +} + /** * Set token usage attributes * @param span - The span to add attributes to From d12ba2ef6bd2512e8b4576dfc381f9dae173c5b4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 18 Nov 2025 18:14:18 +0100 Subject: [PATCH 36/40] feat(metrics): Add default `server.address` attribute on server runtimes (#18242) Attaches a `server.address` attribute to all captured metrics on a `serverRuntimeClient` Did this by emitting a new `processMetric` hook in core, that we listen to in the `serverRuntimeClient`. This way we do not need to re-export all metrics functions from server runtime packages and still only get a minimal client bundle size bump. Added integration tests for node + cloudflare closes https://github.com/getsentry/sentry-javascript/issues/18240 closes https://linear.app/getsentry/issue/JS-1178/attach-serveraddress-as-a-default-attribute-to-metrics --------- Co-authored-by: Lukas Stracke --- .../metrics/server-address/index.ts | 21 ++++++ .../public-api/metrics/server-address/test.ts | 50 ++++++++++++++ .../metrics/server-address/wrangler.jsonc | 6 ++ .../metrics/server-address-option/scenario.ts | 19 ++++++ .../metrics/server-address-option/test.ts | 36 ++++++++++ .../metrics/server-address/scenario.ts | 18 +++++ .../public-api/metrics/server-address/test.ts | 36 ++++++++++ packages/core/src/client.ts | 14 ++++ packages/core/src/metrics/internal.ts | 2 + packages/core/src/server-runtime-client.ts | 16 +++++ .../test/lib/server-runtime-client.test.ts | 65 +++++++++++++++++++ 11 files changed, 283 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/wrangler.jsonc create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/server-address/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts new file mode 100644 index 000000000000..635fcfc8721e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + release: '1.0.0', + environment: 'test', + serverName: 'mi-servidor.com', + }), + { + async fetch(_request, _env, _ctx) { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + await Sentry.flush(); + return new Response('OK'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts new file mode 100644 index 000000000000..5ee5b0954e59 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -0,0 +1,50 @@ +import type { SerializedMetricContainer } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('should add server.address attribute to metrics when serverName is set', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const metric = envelope[1]?.[0]?.[1] as SerializedMetricContainer; + + expect(metric.items[0]).toEqual( + expect.objectContaining({ + name: 'test.counter', + type: 'counter', + value: 1, + span_id: expect.any(String), + timestamp: expect.any(Number), + trace_id: expect.any(String), + attributes: { + endpoint: { + type: 'string', + value: '/api/test', + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: expect.any(String), + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.cloudflare', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'server.address': { + type: 'string', + value: 'mi-servidor.com', + }, + }, + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/scenario.ts new file mode 100644 index 000000000000..1f6d60f91cac --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/scenario.ts @@ -0,0 +1,19 @@ +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.0', + environment: 'test', + serverName: 'mi-servidor.com', + transport: loggingTransport, +}); + +async function run(): Promise { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts new file mode 100644 index 000000000000..825d94f41624 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts @@ -0,0 +1,36 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('metrics server.address', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should add server.address attribute to metrics when serverName is set', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: { + items: [ + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/api/test', type: 'string' }, + 'server.address': { value: 'mi-servidor.com', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/scenario.ts new file mode 100644 index 000000000000..a985f2a0fce3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/scenario.ts @@ -0,0 +1,18 @@ +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.0', + environment: 'test', + transport: loggingTransport, +}); + +async function run(): Promise { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts new file mode 100644 index 000000000000..1ee4eda2de3e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -0,0 +1,36 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('metrics server.address', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should add server.address attribute to metrics when serverName is set', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: { + items: [ + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/api/test', type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index f363e61becd7..1c925d930036 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -783,6 +783,13 @@ export abstract class Client { */ public on(hook: 'flushMetrics', callback: () => void): () => void; + /** + * A hook that is called when a metric is processed before it is captured and before the `beforeSendMetric` callback is fired. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'processMetric', callback: (metric: Metric) => void): () => void; + /** * A hook that is called when a http server request is started. * This hook is called after request isolation, but before the request is processed. @@ -992,6 +999,13 @@ export abstract class Client { */ public emit(hook: 'flushMetrics'): void; + /** + * + * Emit a hook event for client to process a metric before it is captured. + * This hook is called before the `beforeSendMetric` callback is fired. + */ + public emit(hook: 'processMetric', metric: Metric): void; + /** * Emit a hook event for client when a http server request is started. * This hook is called after request isolation, but before the request is processed. diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index b38d61b5195c..7ac1372d1285 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -227,6 +227,8 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal // Enrich metric with contextual attributes const enrichedMetric = _enrichMetricAttributes(beforeMetric, client, currentScope); + client.emit('processMetric', enrichedMetric); + // todo(v11): Remove the experimental `beforeSendMetric` // eslint-disable-next-line deprecation/deprecation const beforeSendCallback = beforeSendMetric || _experiments?.beforeSendMetric; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 988e642d0a27..d1ae8e9063e6 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -40,6 +40,8 @@ export class ServerRuntimeClient< addUserAgentToTransportHeaders(options); super(options); + + this._setUpMetricsProcessing(); } /** @@ -176,6 +178,20 @@ export class ServerRuntimeClient< return super._prepareEvent(event, hint, currentScope, isolationScope); } + + /** + * Process a server-side metric before it is captured. + */ + private _setUpMetricsProcessing(): void { + this.on('processMetric', metric => { + if (this._options.serverName) { + metric.attributes = { + 'server.address': this._options.serverName, + ...metric.attributes, + }; + } + }); + } } function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void { diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index 3c5fe874af9f..24fb60d187ef 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, test, vi } from 'vitest'; import { applySdkMetadata, createTransport, Scope } from '../../src'; +import { _INTERNAL_captureMetric, _INTERNAL_getMetricBuffer } from '../../src/metrics/internal'; import type { ServerRuntimeClientOptions } from '../../src/server-runtime-client'; import { ServerRuntimeClient } from '../../src/server-runtime-client'; import type { Event, EventHint } from '../../src/types-hoist/event'; @@ -236,4 +237,68 @@ describe('ServerRuntimeClient', () => { }); }); }); + + describe('metrics processing', () => { + it('adds server.address attribute to metrics when serverName is set', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, serverName: 'my-server.example.com' }); + client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual( + expect.objectContaining({ + 'server.address': { + value: 'my-server.example.com', + type: 'string', + }, + }), + ); + }); + + it('does not add server.address attribute when serverName is not set', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).not.toEqual( + expect.objectContaining({ + 'server.address': expect.anything(), + }), + ); + }); + + it('does not overwrite existing server.address attribute', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, serverName: 'my-server.example.com' }); + client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: { 'server.address': 'existing-server.example.com' }, + }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual( + expect.objectContaining({ + 'server.address': { + value: 'existing-server.example.com', + type: 'string', + }, + }), + ); + }); + }); }); From 584d4bcf72cb2c98242c97841d53ae7c330d8da4 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Wed, 19 Nov 2025 10:00:32 +0100 Subject: [PATCH 37/40] feat(cloudflare/vercel-edge): Add manual instrumentation for LangGraph (#18112) This PR adds manual instrumentation support for LangGraph StateGraph operations in Cloudflare Workers and Vercel Edge environments. ``` import * as Sentry from '@sentry/cloudflare'; // or '@sentry/vercel-edge' import { StateGraph, START, END, MessagesAnnotation } from '@langchain/langgraph'; // Create and instrument the graph const graph = new StateGraph(MessagesAnnotation) .addNode('agent', agentFn) .addEdge(START, 'agent') .addEdge('agent', END); Sentry.instrumentLangGraph(graph, { recordInputs: true, recordOutputs: true, }); const compiled = graph.compile({ name: 'weather_assistant' }); await compiled.invoke({ messages: [{ role: 'user', content: 'What is the weather in SF?' }], }); ``` - [x] This PR depends on #18114 --- .../cloudflare-integration-tests/package.json | 3 +- .../suites/tracing/langgraph/index.ts | 66 +++++++++++++++++++ .../suites/tracing/langgraph/test.ts | 59 +++++++++++++++++ .../suites/tracing/langgraph/wrangler.jsonc | 6 ++ packages/cloudflare/src/index.ts | 1 + packages/core/src/index.ts | 2 +- packages/core/src/tracing/langgraph/index.ts | 35 ++++++++++ packages/vercel-edge/src/index.ts | 1 + yarn.lock | 25 +++++++ 9 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index aac6e9c96945..c791a224a2cc 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -13,7 +13,8 @@ "test:watch": "yarn test --watch" }, "dependencies": { - "@sentry/cloudflare": "10.25.0" + "@sentry/cloudflare": "10.25.0", + "@langchain/langgraph": "^1.0.1" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250922.0", diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts new file mode 100644 index 000000000000..6837a14be111 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts @@ -0,0 +1,66 @@ +import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + sendDefaultPii: true, + }), + { + async fetch(_request, _env, _ctx) { + // Define simple mock LLM function + const mockLlm = (): { + messages: { + role: string; + content: string; + response_metadata: { + model_name: string; + finish_reason: string; + tokenUsage: { promptTokens: number; completionTokens: number; totalTokens: number }; + }; + tool_calls: never[]; + }[]; + } => { + return { + messages: [ + { + role: 'assistant', + content: 'Mock response from LangGraph agent', + response_metadata: { + model_name: 'mock-model', + finish_reason: 'stop', + tokenUsage: { + promptTokens: 20, + completionTokens: 10, + totalTokens: 30, + }, + }, + tool_calls: [], + }, + ], + }; + }; + + // Create and instrument the graph + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', mockLlm) + .addEdge(START, 'agent') + .addEdge('agent', END); + + Sentry.instrumentLangGraph(graph, { recordInputs: true, recordOutputs: true }); + + const compiled = graph.compile({ name: 'weather_assistant' }); + + await compiled.invoke({ + messages: [{ role: 'user', content: 'What is the weather in SF?' }], + }); + + return new Response(JSON.stringify({ success: true })); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts new file mode 100644 index 000000000000..33023b30fa55 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts @@ -0,0 +1,59 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the node integration tests and we merely +// want to test that the instrumentation does not break in our +// cloudflare SDK. + +it('traces langgraph compile and invoke operations', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as any; + + expect(transactionEvent.transaction).toBe('GET /'); + + // Check create_agent span + const createAgentSpan = transactionEvent.spans.find((span: any) => span.op === 'gen_ai.create_agent'); + expect(createAgentSpan).toMatchObject({ + data: { + 'gen_ai.operation.name': 'create_agent', + 'sentry.op': 'gen_ai.create_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'weather_assistant', + }, + description: 'create_agent weather_assistant', + op: 'gen_ai.create_agent', + origin: 'auto.ai.langgraph', + }); + + // Check invoke_agent span + const invokeAgentSpan = transactionEvent.spans.find((span: any) => span.op === 'gen_ai.invoke_agent'); + expect(invokeAgentSpan).toMatchObject({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'weather_assistant', + 'gen_ai.pipeline.name': 'weather_assistant', + 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather in SF?"}]', + 'gen_ai.response.model': 'mock-model', + 'gen_ai.usage.input_tokens': 20, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.total_tokens': 30, + }), + description: 'invoke_agent weather_assistant', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + }); + + // Verify tools are captured + if (invokeAgentSpan.data['gen_ai.request.available_tools']) { + expect(invokeAgentSpan.data['gen_ai.request.available_tools']).toMatch(/get_weather/); + } + }) + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 36de54816030..827c45327689 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -102,6 +102,7 @@ export { growthbookIntegration, logger, metrics, + instrumentLangGraph, } from '@sentry/core'; export { withSentry } from './handler'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ed3dbe4750d7..014a411d0265 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -152,7 +152,7 @@ export type { GoogleGenAIResponse } from './tracing/google-genai/types'; export { createLangChainCallbackHandler } from './tracing/langchain'; export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants'; export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types'; -export { instrumentStateGraphCompile } from './tracing/langgraph'; +export { instrumentStateGraphCompile, instrumentLangGraph } from './tracing/langgraph'; export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants'; export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types'; diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index 65d315bf3f63..5601cddf458b 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -155,3 +155,38 @@ function instrumentCompiledGraphInvoke( }, }) as (...args: unknown[]) => Promise; } + +/** + * Directly instruments a StateGraph instance to add tracing spans + * + * This function can be used to manually instrument LangGraph StateGraph instances + * in environments where automatic instrumentation is not available or desired. + * + * @param stateGraph - The StateGraph instance to instrument + * @param options - Optional configuration for recording inputs/outputs + * + * @example + * ```typescript + * import { instrumentLangGraph } from '@sentry/cloudflare'; + * import { StateGraph } from '@langchain/langgraph'; + * + * const graph = new StateGraph(MessagesAnnotation) + * .addNode('agent', mockLlm) + * .addEdge(START, 'agent') + * .addEdge('agent', END); + * + * instrumentLangGraph(graph, { recordInputs: true, recordOutputs: true }); + * const compiled = graph.compile({ name: 'my_agent' }); + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function instrumentLangGraph any }>( + stateGraph: T, + options?: LangGraphOptions, +): T { + const _options: LangGraphOptions = options || {}; + + stateGraph.compile = instrumentStateGraphCompile(stateGraph.compile.bind(stateGraph), _options); + + return stateGraph; +} diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 4f5017bb8f6c..8ece38279732 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -71,6 +71,7 @@ export { // eslint-disable-next-line deprecation/deprecation inboundFiltersIntegration, instrumentOpenAiClient, + instrumentLangGraph, instrumentGoogleGenAIClient, instrumentAnthropicAiClient, eventFiltersIntegration, diff --git a/yarn.lock b/yarn.lock index 0b2a0f0f31dc..082e1a032283 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4931,6 +4931,13 @@ zod "^3.25.32" zod-to-json-schema "^3.22.3" +"@langchain/langgraph-checkpoint@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz#ece2ede439d0d0b0b532c4be7817fd5029afe4f8" + integrity sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A== + dependencies: + uuid "^10.0.0" + "@langchain/langgraph-checkpoint@~0.0.17": version "0.0.18" resolved "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.18.tgz#2f7a9cdeda948ccc8d312ba9463810709d71d0b8" @@ -4948,6 +4955,15 @@ p-retry "4" uuid "^9.0.0" +"@langchain/langgraph-sdk@~1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.0.0.tgz#16faca6cc426432dee9316428d0aecd94e5b7989" + integrity sha512-g25ti2W7Dl5wUPlNK+0uIGbeNFqf98imhHlbdVVKTTkDYLhi/pI1KTgsSSkzkeLuBIfvt2b0q6anQwCs7XBlbw== + dependencies: + p-queue "^6.6.2" + p-retry "4" + uuid "^9.0.0" + "@langchain/langgraph@^0.2.32": version "0.2.74" resolved "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.2.74.tgz#37367a1e8bafda3548037a91449a69a84f285def" @@ -4958,6 +4974,15 @@ uuid "^10.0.0" zod "^3.23.8" +"@langchain/langgraph@^1.0.1": + version "1.0.2" + resolved "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.0.2.tgz#62de931edac0dd850daf708bd6f8f3835cf25a5e" + integrity sha512-syxzzWTnmpCL+RhUEvalUeOXFoZy/KkzHa2Da2gKf18zsf9Dkbh3rfnRDrTyUGS1XSTejq07s4rg1qntdEDs2A== + dependencies: + "@langchain/langgraph-checkpoint" "^1.0.0" + "@langchain/langgraph-sdk" "~1.0.0" + uuid "^10.0.0" + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" From b3bf56dacea2d39da69a3239dc6cc2fb3dc1addd Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Wed, 19 Nov 2025 10:04:51 +0100 Subject: [PATCH 38/40] feat(node): Add OpenAI SDK v6 support and integration tests (#18244) Upgrades OpenAI instrumentation to support OpenAI SDK v6.0.0 and adds node integration tests to verify compatibility. ### Changes **Instrumentation:** - Bumped OpenAI SDK support to v6.0.0 ( { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + // If stream is requested, return an async generator + if (params.stream) { + return this._createChatCompletionStream(params); + } + + return { + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: params.model, + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello from OpenAI mock!', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }; + }, + }, + }; + + this.responses = { + create: async params => { + await new Promise(resolve => setTimeout(resolve, 10)); + + // If stream is requested, return an async generator + if (params.stream) { + return this._createResponsesApiStream(params); + } + + return { + id: 'resp_mock456', + object: 'response', + created_at: 1677652290, + model: params.model, + input_text: params.input, + output_text: `Response to: ${params.input}`, + status: 'completed', + usage: { + input_tokens: 5, + output_tokens: 8, + total_tokens: 13, + }, + }; + }, + }; + } + + // Create a mock streaming response for chat completions + async *_createChatCompletionStream(params) { + // First chunk with basic info + yield { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: params.model, + system_fingerprint: 'fp_stream_123', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + content: 'Hello', + }, + finish_reason: null, + }, + ], + }; + + // Second chunk with more content + yield { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: params.model, + system_fingerprint: 'fp_stream_123', + choices: [ + { + index: 0, + delta: { + content: ' from OpenAI streaming!', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 12, + completion_tokens: 18, + total_tokens: 30, + completion_tokens_details: { + accepted_prediction_tokens: 0, + audio_tokens: 0, + reasoning_tokens: 0, + rejected_prediction_tokens: 0, + }, + prompt_tokens_details: { + audio_tokens: 0, + cached_tokens: 0, + }, + }, + }; + } + + // Create a mock streaming response for responses API + async *_createResponsesApiStream(params) { + // Response created event + yield { + type: 'response.created', + response: { + id: 'resp_stream_456', + object: 'response', + created_at: 1677652310, + model: params.model, + status: 'in_progress', + error: null, + incomplete_details: null, + instructions: params.instructions, + max_output_tokens: 1000, + parallel_tool_calls: false, + previous_response_id: null, + reasoning: { + effort: null, + summary: null, + }, + store: false, + temperature: 0.7, + text: { + format: { + type: 'text', + }, + }, + tool_choice: 'auto', + top_p: 1.0, + truncation: 'disabled', + user: null, + metadata: {}, + output: [], + output_text: '', + usage: { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + }, + }, + sequence_number: 1, + }; + + // Response in progress with output text delta + yield { + type: 'response.output_text.delta', + delta: 'Streaming response to: ', + sequence_number: 2, + }; + + yield { + type: 'response.output_text.delta', + delta: params.input, + sequence_number: 3, + }; + + // Response completed event + yield { + type: 'response.completed', + response: { + id: 'resp_stream_456', + object: 'response', + created_at: 1677652310, + model: params.model, + status: 'completed', + error: null, + incomplete_details: null, + instructions: params.instructions, + max_output_tokens: 1000, + parallel_tool_calls: false, + previous_response_id: null, + reasoning: { + effort: null, + summary: null, + }, + store: false, + temperature: 0.7, + text: { + format: { + type: 'text', + }, + }, + tool_choice: 'auto', + top_p: 1.0, + truncation: 'disabled', + user: null, + metadata: {}, + output: [], + output_text: params.input, + usage: { + input_tokens: 6, + output_tokens: 10, + total_tokens: 16, + }, + }, + sequence_number: 4, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // First test: basic chat completion + await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + // Second test: responses API + await client.responses.create({ + model: 'gpt-3.5-turbo', + input: 'Translate this to French: Hello', + instructions: 'You are a translator', + }); + + // Third test: error handling in chat completions + try { + await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // Error is expected and handled + } + + // Fourth test: chat completions streaming + const stream1 = await client.chat.completions.create({ + model: 'gpt-4', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Tell me about streaming' }, + ], + stream: true, + temperature: 0.8, + }); + + // Consume the stream to trigger span instrumentation + for await (const chunk of stream1) { + // Stream chunks are processed automatically by instrumentation + void chunk; // Prevent unused variable warning + } + + // Fifth test: responses API streaming + const stream2 = await client.responses.create({ + model: 'gpt-4', + input: 'Test streaming responses API', + instructions: 'You are a streaming assistant', + stream: true, + }); + + for await (const chunk of stream2) { + void chunk; + } + + // Sixth test: error handling in streaming context + try { + const errorStream = await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + stream: true, + }); + + // Try to consume the stream (this should not execute) + for await (const chunk of errorStream) { + void chunk; + } + } catch { + // Error is expected and handled + } + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-embeddings.mjs new file mode 100644 index 000000000000..9cdb24a42da9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-embeddings.mjs @@ -0,0 +1,67 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.embeddings = { + create: async params => { + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + return { + object: 'list', + data: [ + { + object: 'embedding', + embedding: [0.1, 0.2, 0.3], + index: 0, + }, + ], + model: params.model, + usage: { + prompt_tokens: 10, + total_tokens: 10, + }, + }; + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // First test: embeddings API + await client.embeddings.create({ + input: 'Embedding test!', + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + + // Second test: embeddings API error model + try { + await client.embeddings.create({ + input: 'Error embedding test!', + model: 'error-model', + }); + } catch { + // Error is expected and handled + } + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-root-span.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-root-span.mjs new file mode 100644 index 000000000000..2aaca0700312 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-root-span.mjs @@ -0,0 +1,63 @@ +import express from 'express'; +import OpenAI from 'openai'; + +function startMockOpenAiServer() { + const app = express(); + app.use(express.json()); + + app.post('/openai/chat/completions', (req, res) => { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: req.body.model, + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello from OpenAI mock!', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }); + }); + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockOpenAiServer(); + + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + const response = await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + // eslint-disable-next-line no-console + console.log(JSON.stringify(response)); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts new file mode 100644 index 000000000000..053f3066a1b0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts @@ -0,0 +1,565 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('OpenAI integration (V6)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_CHAT = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - basic chat completion without PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'chatcmpl-mock123', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'openai.response.id': 'chatcmpl-mock123', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:28.000Z', + 'openai.usage.completion_tokens': 15, + 'openai.usage.prompt_tokens': 10, + }, + description: 'chat gpt-3.5-turbo', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - responses API + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'resp_mock456', + 'gen_ai.response.finish_reasons': '["completed"]', + 'gen_ai.usage.input_tokens': 5, + 'gen_ai.usage.output_tokens': 8, + 'gen_ai.usage.total_tokens': 13, + 'openai.response.id': 'resp_mock456', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:30.000Z', + 'openai.usage.completion_tokens': 8, + 'openai.usage.prompt_tokens': 5, + }, + description: 'responses gpt-3.5-turbo', + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Third span - error handling + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + }, + description: 'chat error-model', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + // Fourth span - chat completions streaming + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.stream': true, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'chatcmpl-stream-123', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 12, + 'gen_ai.usage.output_tokens': 18, + 'gen_ai.usage.total_tokens': 30, + 'openai.response.id': 'chatcmpl-stream-123', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:40.000Z', + 'openai.usage.completion_tokens': 18, + 'openai.usage.prompt_tokens': 12, + }, + description: 'chat gpt-4 stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Fifth span - responses API streaming + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.stream': true, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'resp_stream_456', + 'gen_ai.response.finish_reasons': '["in_progress","completed"]', + 'gen_ai.usage.input_tokens': 6, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.total_tokens': 16, + 'openai.response.id': 'resp_stream_456', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:50.000Z', + 'openai.usage.completion_tokens': 10, + 'openai.usage.prompt_tokens': 6, + }, + description: 'responses gpt-4 stream-response', + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Sixth span - error handling in streaming context + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.stream': true, + 'gen_ai.system': 'openai', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + }, + description: 'chat error-model stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_CHAT = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - basic chat completion with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages': + '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"What is the capital of France?"}]', + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'chatcmpl-mock123', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.response.text': '["Hello from OpenAI mock!"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'openai.response.id': 'chatcmpl-mock123', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:28.000Z', + 'openai.usage.completion_tokens': 15, + 'openai.usage.prompt_tokens': 10, + }, + description: 'chat gpt-3.5-turbo', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - responses API with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.request.messages': 'Translate this to French: Hello', + 'gen_ai.response.text': 'Response to: Translate this to French: Hello', + 'gen_ai.response.finish_reasons': '["completed"]', + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'resp_mock456', + 'gen_ai.usage.input_tokens': 5, + 'gen_ai.usage.output_tokens': 8, + 'gen_ai.usage.total_tokens': 13, + 'openai.response.id': 'resp_mock456', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:30.000Z', + 'openai.usage.completion_tokens': 8, + 'openai.usage.prompt_tokens': 5, + }, + description: 'responses gpt-3.5-turbo', + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Third span - error handling with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', + }, + description: 'chat error-model', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + // Fourth span - chat completions streaming with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.stream': true, + 'gen_ai.request.messages': + '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Tell me about streaming"}]', + 'gen_ai.response.text': 'Hello from OpenAI streaming!', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.response.id': 'chatcmpl-stream-123', + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.usage.input_tokens': 12, + 'gen_ai.usage.output_tokens': 18, + 'gen_ai.usage.total_tokens': 30, + 'openai.response.id': 'chatcmpl-stream-123', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:40.000Z', + 'openai.usage.completion_tokens': 18, + 'openai.usage.prompt_tokens': 12, + }), + description: 'chat gpt-4 stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Fifth span - responses API streaming with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.stream': true, + 'gen_ai.request.messages': 'Test streaming responses API', + 'gen_ai.response.text': 'Streaming response to: Test streaming responses APITest streaming responses API', + 'gen_ai.response.finish_reasons': '["in_progress","completed"]', + 'gen_ai.response.id': 'resp_stream_456', + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.usage.input_tokens': 6, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.total_tokens': 16, + 'openai.response.id': 'resp_stream_456', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:50.000Z', + 'openai.usage.completion_tokens': 10, + 'openai.usage.prompt_tokens': 6, + }), + description: 'responses gpt-4 stream-response', + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Sixth span - error handling in streaming context with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.stream': true, + 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', + 'gen_ai.system': 'openai', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + }, + description: 'chat error-model stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_WITH_OPTIONS = { + transaction: 'main', + spans: expect.arrayContaining([ + // Check that custom options are respected + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true + }), + }), + // Check that custom options are respected for streaming + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true + 'gen_ai.request.stream': true, // Should be marked as stream + }), + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embeddings API + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'text-embedding-3-small', + 'gen_ai.request.encoding_format': 'float', + 'gen_ai.request.dimensions': 1536, + 'gen_ai.response.model': 'text-embedding-3-small', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.total_tokens': 10, + 'openai.response.model': 'text-embedding-3-small', + 'openai.usage.prompt_tokens': 10, + }, + description: 'embeddings text-embedding-3-small', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - embeddings API error model + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embeddings API with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'text-embedding-3-small', + 'gen_ai.request.encoding_format': 'float', + 'gen_ai.request.dimensions': 1536, + 'gen_ai.request.messages': 'Embedding test!', + 'gen_ai.response.model': 'text-embedding-3-small', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.total_tokens': 10, + 'openai.response.model': 'text-embedding-3-small', + 'openai.usage.prompt_tokens': 10, + }, + description: 'embeddings text-embedding-3-small', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - embeddings API error model with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': 'Error embedding test!', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-chat.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: false (v6)', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_CHAT }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-chat.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: true (v6)', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_CHAT }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-chat.mjs', + 'instrument-with-options.mjs', + (createRunner, test) => { + test('creates openai related spans with custom options (v6)', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_WITH_OPTIONS }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-embeddings.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: false (v6)', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-embeddings.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: true (v6)', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-root-span.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('it works without a wrapping span (v6)', async () => { + await createRunner() + // First the span that our mock express server is emitting, unrelated to this test + .expect({ + transaction: { + transaction: 'POST /openai/chat/completions', + }, + }) + .expect({ + transaction: { + transaction: 'chat gpt-3.5-turbo', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'chatcmpl-mock123', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'openai.response.id': 'chatcmpl-mock123', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:28.000Z', + 'openai.usage.completion_tokens': 15, + 'openai.usage.prompt_tokens': 10, + }, + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }, + }, + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + express: 'latest', + }, + }, + ); +}); diff --git a/packages/node/src/integrations/tracing/openai/instrumentation.ts b/packages/node/src/integrations/tracing/openai/instrumentation.ts index d71c548395b0..e0682185ff0a 100644 --- a/packages/node/src/integrations/tracing/openai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/openai/instrumentation.ts @@ -13,7 +13,7 @@ import { SDK_VERSION, } from '@sentry/core'; -const supportedVersions = ['>=4.0.0 <6']; +const supportedVersions = ['>=4.0.0 <7']; export interface OpenAiIntegration extends Integration { options: OpenAiOptions; From be29c5617284e014e4fe5a3c0f75454d22036d0b Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 19 Nov 2025 10:36:08 +0100 Subject: [PATCH 39/40] chore(e2e): Bump zod in e2e tests (#18251) The lower version is currently breaking our ci, the version lacks v3 exports that are used by `zod-to-json`. --- .../e2e-tests/test-applications/node-express-v5/package.json | 2 +- .../e2e-tests/test-applications/node-express/package.json | 2 +- .../e2e-tests/test-applications/tsx-express/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/package.json b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json index b7caf4610712..0890fec0f10d 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json @@ -19,7 +19,7 @@ "@types/node": "^18.19.1", "express": "^5.1.0", "typescript": "~5.0.0", - "zod": "~3.24.3" + "zod": "~3.25.0" }, "devDependencies": { "@playwright/test": "~1.53.2", diff --git a/dev-packages/e2e-tests/test-applications/node-express/package.json b/dev-packages/e2e-tests/test-applications/node-express/package.json index 18be5221bd3f..d5bba8591164 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express/package.json @@ -19,7 +19,7 @@ "@types/node": "^18.19.1", "express": "^4.21.2", "typescript": "~5.0.0", - "zod": "~3.24.3" + "zod": "~3.25.0" }, "devDependencies": { "@playwright/test": "~1.53.2", diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/package.json b/dev-packages/e2e-tests/test-applications/tsx-express/package.json index 80dce608dbe5..d99a88bfa3b8 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/package.json +++ b/dev-packages/e2e-tests/test-applications/tsx-express/package.json @@ -19,7 +19,7 @@ "@types/node": "^18.19.1", "express": "^4.21.2", "typescript": "~5.0.0", - "zod": "~3.24.3" + "zod": "~3.25.0" }, "devDependencies": { "@playwright/test": "~1.50.0", From be12569677db0ea3455a7449f381973d6e842ff1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 19 Nov 2025 11:00:22 +0200 Subject: [PATCH 40/40] meta(changelog): Update changelog for 10.26.0 --- CHANGELOG.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30abc26e733c..5eba860932aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,101 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -- fix(node): Fix Spotlight configuration precedence to match specification (#18195) +## 10.26.0 + +### Important Changes + +- **feat(core): Instrument LangGraph Agent ([#18114](https://github.com/getsentry/sentry-javascript/pull/18114))** + +Adds support for instrumenting LangGraph StateGraph operations in Node. The LangGraph integration can be configured as follows: + +```js +Sentry.init({ + dsn: '__DSN__', + sendDefaultPii: false, // Even with PII disabled globally + integrations: [ + Sentry.langGraphIntegration({ + recordInputs: true, // Force recording input messages + recordOutputs: true, // Force recording response text + }), + ], +}); +``` + +- **feat(cloudflare/vercel-edge): Add manual instrumentation for LangGraph ([#18112](https://github.com/getsentry/sentry-javascript/pull/18112))** + +Instrumentation for LangGraph in Cloudflare Workers and Vercel Edge environments is supported by manually calling `instrumentLangGraph`: + +```js +import * as Sentry from '@sentry/cloudflare'; // or '@sentry/vercel-edge' +import { StateGraph, START, END, MessagesAnnotation } from '@langchain/langgraph'; + +// Create and instrument the graph +const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', agentFn) + .addEdge(START, 'agent') + .addEdge('agent', END); + +Sentry.instrumentLangGraph(graph, { + recordInputs: true, + recordOutputs: true, +}); + +const compiled = graph.compile({ name: 'weather_assistant' }); + +await compiled.invoke({ + messages: [{ role: 'user', content: 'What is the weather in SF?' }], +}); +``` + +- **feat(node): Add OpenAI SDK v6 support ([#18244](https://github.com/getsentry/sentry-javascript/pull/18244))** + +### Other Changes + +- feat(core): Support OpenAI embeddings API ([#18224](https://github.com/getsentry/sentry-javascript/pull/18224)) +- feat(browser-utils): bump web-vitals to 5.1.0 ([#18091](https://github.com/getsentry/sentry-javascript/pull/18091)) +- feat(core): Support truncation for LangChain integration request messages ([#18157](https://github.com/getsentry/sentry-javascript/pull/18157)) +- feat(metrics): Add default `server.address` attribute on server runtimes ([#18242](https://github.com/getsentry/sentry-javascript/pull/18242)) +- feat(nextjs): Add URL to server-side transaction events ([#18230](https://github.com/getsentry/sentry-javascript/pull/18230)) +- feat(node-core): Add mechanism to prevent wrapping ai providers multiple times([#17972](https://github.com/getsentry/sentry-javascript/pull/17972)) +- feat(replay): Bump limit for minReplayDuration ([#18190](https://github.com/getsentry/sentry-javascript/pull/18190)) +- fix(browser): Add `ok` status to successful `idleSpan`s ([#18139](https://github.com/getsentry/sentry-javascript/pull/18139)) +- fix(core): Check `fetch` support with data URL ([#18225](https://github.com/getsentry/sentry-javascript/pull/18225)) +- fix(core): Decrease number of Sentry stack frames for messages from `captureConsoleIntegration` ([#18096](https://github.com/getsentry/sentry-javascript/pull/18096)) +- fix(core): Emit processed metric ([#18222](https://github.com/getsentry/sentry-javascript/pull/18222)) +- fix(core): Ensure logs past `MAX_LOG_BUFFER_SIZE` are not swallowed ([#18207](https://github.com/getsentry/sentry-javascript/pull/18207)) +- fix(core): Ensure metrics past `MAX_METRIC_BUFFER_SIZE` are not swallowed ([#18212](https://github.com/getsentry/sentry-javascript/pull/18212)) +- fix(core): Fix logs and metrics flush timeout starvation with continuous logging ([#18211](https://github.com/getsentry/sentry-javascript/pull/18211)) +- fix(core): Flatten gen_ai.request.available_tools in google-genai ([#18194](https://github.com/getsentry/sentry-javascript/pull/18194)) +- fix(core): Stringify available tools sent from vercelai ([#18197](https://github.com/getsentry/sentry-javascript/pull/18197)) +- fix(core/vue): Detect and skip normalizing Vue `VNode` objects with high `normalizeDepth` ([#18206](https://github.com/getsentry/sentry-javascript/pull/18206)) +- fix(nextjs): Avoid wrapping middleware files when in standalone mode ([#18172](https://github.com/getsentry/sentry-javascript/pull/18172)) +- fix(nextjs): Drop meta trace tags if rendered page is ISR ([#18192](https://github.com/getsentry/sentry-javascript/pull/18192)) +- fix(nextjs): Respect PORT variable for dev error symbolication ([#18227](https://github.com/getsentry/sentry-javascript/pull/18227)) +- fix(nextjs): use LRU map instead of map for ISR route cache ([#18234](https://github.com/getsentry/sentry-javascript/pull/18234)) +- fix(node): `tracingChannel` export missing in older node versions ([#18191](https://github.com/getsentry/sentry-javascript/pull/18191)) +- fix(node): Fix Spotlight configuration precedence to match specification ([#18195](https://github.com/getsentry/sentry-javascript/pull/18195)) +- fix(react): Prevent navigation span leaks for consecutive navigations ([#18098](https://github.com/getsentry/sentry-javascript/pull/18098)) +- ref(react-router): Deprecate ErrorBoundary exports ([#18208](https://github.com/getsentry/sentry-javascript/pull/18208)) + +
+ Internal Changes + +- chore: Fix missing changelog quote we use for attribution placement ([#18237](https://github.com/getsentry/sentry-javascript/pull/18237)) +- chore: move tip about prioritizing issues ([#18071](https://github.com/getsentry/sentry-javascript/pull/18071)) +- chore(e2e): Pin `@embroider/addon-shim` to 1.10.0 for the e2e ember-embroider ([#18173](https://github.com/getsentry/sentry-javascript/pull/18173)) +- chore(react-router): Fix casing on deprecation notices ([#18221](https://github.com/getsentry/sentry-javascript/pull/18221)) +- chore(test): Use correct `testTimeout` field in bundler-tests vitest config +- chore(e2e): Bump zod in e2e tests ([#18251](https://github.com/getsentry/sentry-javascript/pull/18251)) +- test(browser-integration): Fix incorrect tag value assertions ([#18162](https://github.com/getsentry/sentry-javascript/pull/18162)) +- test(profiling): Add test utils to validate Profile Chunk envelope ([#18170](https://github.com/getsentry/sentry-javascript/pull/18170)) +- ref(e2e-ember): Remove `@embroider/addon-shim` override ([#18180](https://github.com/getsentry/sentry-javascript/pull/18180)) +- ref(browser): Move trace lifecycle listeners to class function ([#18231](https://github.com/getsentry/sentry-javascript/pull/18231)) +- ref(browserprofiling): Move and rename profiler class to UIProfiler ([#18187](https://github.com/getsentry/sentry-javascript/pull/18187)) +- ref(core): Move ai integrations from utils to tracing ([#18185](https://github.com/getsentry/sentry-javascript/pull/18185)) +- ref(core): Optimize `Scope.setTag` bundle size and adjust test ([#18182](https://github.com/getsentry/sentry-javascript/pull/18182)) + +
## 10.25.0