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. diff --git a/.size-limit.js b/.size-limit.js index 2d07afde52ab..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) { @@ -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..5eba860932aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,102 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 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 - feat(browser): Include Spotlight in development bundles ([#18078](https://github.com/getsentry/sentry-javascript/pull/18078)) 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, + }); }, ); 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/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..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,11 +3,24 @@ 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('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'); - expect(eventData.tags).toMatchObject({}); + + 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, 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/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/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/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/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/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" - } } } 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/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-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/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/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-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-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/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/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/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/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..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 @@ -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,10 +83,11 @@ 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 }) => { - // 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 && @@ -94,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 @@ -113,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(); @@ -253,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'); @@ -337,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 && @@ -345,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'); @@ -357,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 @@ -498,3 +501,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('/'); + + // Set up transaction listeners + 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 secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/another-lazy/sub/:id/:subId' + ); + }); + + // 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; + + // 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; + + // 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/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", 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/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/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/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-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/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 4de2f96b5dc5..e75e0ec7f5da 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,115 @@ 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(); + }); + }, + ); + + 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/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/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/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-options.mjs new file mode 100644 index 000000000000..35f97fd84093 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-options.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.openAIIntegration({ + recordInputs: true, + recordOutputs: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-pii.mjs new file mode 100644 index 000000000000..a53a13af7738 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-pii.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [Sentry.openAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument.mjs new file mode 100644 index 000000000000..f3fbac9d1274 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [Sentry.openAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-chat.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-chat.mjs new file mode 100644 index 000000000000..fde651c3c1ff --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-chat.mjs @@ -0,0 +1,318 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.chat = { + completions: { + create: async params => { + // 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/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/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/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; /** diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts similarity index 89% rename from packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts rename to packages/browser/src/profiling/UIProfiler.ts index 3ce773fe01ff..731684996d62 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; @@ -62,76 +62,7 @@ export class BrowserTraceLifecycleProfiler { 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 BrowserTraceLifecycleProfiler { 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 BrowserTraceLifecycleProfiler { }); } + /** 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 BrowserTraceLifecycleProfiler { 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. */ 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 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/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/client.ts b/packages/core/src/client.ts index 53e0328965a4..1c925d930036 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); } }); @@ -776,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. @@ -985,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/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/index.ts b/packages/core/src/index.ts index f3b29009b9ce..014a411d0265 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'; @@ -135,31 +140,34 @@ 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 { 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'; 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/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/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/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/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 5371ecba8dfd..7ac1372d1285 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]); } } } @@ -225,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; @@ -241,7 +245,7 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal captureSerializedMetric(client, serializedMetric); - client.emit('afterCaptureMetric', enrichedMetric); + client.emit('afterCaptureMetric', processedMetric); } /** diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 8b1e21acfb4a..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 }); } /** @@ -607,7 +605,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/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/src/utils/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts similarity index 90% rename from packages/core/src/utils/ai/gen-ai-attributes.ts rename to packages/core/src/tracing/ai/gen-ai-attributes.ts index 84efb21c1822..e2808d5f2642 100644 --- a/packages/core/src/utils/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 */ @@ -134,6 +144,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 +174,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 // ============================================================================= @@ -193,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/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..cc0226c5cb37 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 { @@ -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); 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/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); 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 99% rename from packages/core/src/utils/langchain/utils.ts rename to packages/core/src/tracing/langchain/utils.ts index 8464e71aecb0..caacf5059bdc 100644 --- a/packages/core/src/utils/langchain/utils.ts +++ b/packages/core/src/tracing/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; 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..5601cddf458b --- /dev/null +++ b/packages/core/src/tracing/langgraph/index.ts @@ -0,0 +1,192 @@ +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; +} + +/** + * 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/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/core/src/utils/openai/constants.ts b/packages/core/src/tracing/openai/constants.ts similarity index 93% rename from packages/core/src/utils/openai/constants.ts rename to packages/core/src/tracing/openai/constants.ts index c4952b123b0f..e8b5c6ddc87f 100644 --- a/packages/core/src/utils/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/utils/openai/index.ts b/packages/core/src/tracing/openai/index.ts similarity index 78% rename from packages/core/src/utils/openai/index.ts rename to packages/core/src/tracing/openai/index.ts index bb099199772c..bba2ee0f5afd 100644 --- a/packages/core/src/utils/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/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 93% rename from packages/core/src/utils/openai/types.ts rename to packages/core/src/tracing/openai/types.ts index daa478db4ba6..6dcd644bfe17 100644 --- a/packages/core/src/utils/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/utils/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts similarity index 55% rename from packages/core/src/utils/openai/utils.ts rename to packages/core/src/tracing/openai/utils.ts index 17007693e739..4dff5b4fdbb8 100644 --- a/packages/core/src/utils/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 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 96% rename from packages/core/src/utils/vercel-ai/index.ts rename to packages/core/src/tracing/vercel-ai/index.ts index 747a3c105449..f07244088ff9 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -2,15 +2,15 @@ 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'; +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/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 87% rename from packages/core/src/utils/vercel-ai/utils.ts rename to packages/core/src/tracing/vercel-ai/utils.ts index e9df1a4a7f96..9a0b57eb16f7 100644 --- a/packages/core/src/utils/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); +} 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/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/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/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/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 { 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', () => { 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/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", () => { 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', () => { 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', () => { 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', () => { 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', + }, + }), + ); + }); + }); }); 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/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/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: { 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/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..de5308cbb7ef --- /dev/null +++ b/packages/nextjs/src/client/routing/isrRoutingTracing.ts @@ -0,0 +1,63 @@ +import { LRUMap } from '@sentry/core'; +import { WINDOW } from '@sentry/react'; +import { getManifest, maybeParameterizeRoute } from './parameterization'; + +/** + * Cache for ISR/SSG route checks. Exported for testing purposes. + * @internal + */ +export const IS_ISR_SSG_ROUTE_CACHE = new LRUMap(100); + +/** + * Check if the current page is an ISR/SSG route by checking the route manifest. + * @internal Exported for testing purposes. + */ +export function isIsrSsgRoute(pathname: string): boolean { + // Early parameterization to get the cache key + const parameterizedPath = maybeParameterizeRoute(pathname); + const pathToCheck = parameterizedPath || pathname; + + // Check cache using the parameterized path as the key + const cachedResult = IS_ISR_SSG_ROUTE_CACHE.get(pathToCheck); + if (cachedResult !== undefined) { + return cachedResult; + } + + // Cache miss get the manifest + const manifest = getManifest(); + if (!manifest?.isrRoutes || !Array.isArray(manifest.isrRoutes) || manifest.isrRoutes.length === 0) { + IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, false); + return false; + } + + const isIsrSsgRoute = manifest.isrRoutes.includes(pathToCheck); + IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, isIsrSsgRoute); + + return isIsrSsgRoute; +} + +/** + * Remove sentry-trace and baggage meta tags from the DOM if this is an ISR/SSG page. + * This prevents the browser tracing integration from using stale/cached trace IDs. + */ +export function removeIsrSsgTraceMetaTags(): void { + if (!WINDOW.document || !isIsrSsgRoute(WINDOW.location.pathname)) { + return; + } + + // Helper function to remove a meta tag + function removeMetaTag(metaName: string): void { + try { + const meta = WINDOW.document.querySelector(`meta[name="${metaName}"]`); + if (meta) { + meta.remove(); + } + } catch { + // ignore errors when removing the meta tag + } + } + + // Remove the meta tags so browserTracingIntegration won't pick them up + removeMetaTag('sentry-trace'); + removeMetaTag('baggage'); +} diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index 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/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/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/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/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: [ 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') { diff --git a/packages/nextjs/test/client/isrRoutingTracing.test.ts b/packages/nextjs/test/client/isrRoutingTracing.test.ts new file mode 100644 index 000000000000..cdca740727dd --- /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.get('/products/:id')).toBeDefined(); + + // Second call with different concrete path /products/456 + const result2 = isIsrSsgRoute('/products/456'); + expect(result2).toBe(true); + // Cache size should still be 1 - both paths map to same parameterized route + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); + + // Third call with yet another path /products/999 + const result3 = isIsrSsgRoute('/products/999'); + expect(result3).toBe(true); + // Still just 1 cache entry + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + }); + + it('should use cached results on subsequent calls with same route pattern', () => { + // Clear cache + IS_ISR_SSG_ROUTE_CACHE.clear(); + + // First call - cache miss, will populate cache + isIsrSsgRoute('/products/1'); + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); + + // Second call with different concrete path - cache hit + const result2 = isIsrSsgRoute('/products/2'); + expect(result2).toBe(true); + // Cache size unchanged - using cached result + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + + // Third call - still cache hit + const result3 = isIsrSsgRoute('/products/3'); + expect(result3).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + }); + + it('should cache false results for non-ISR routes', () => { + const result1 = isIsrSsgRoute('/not-an-isr-route'); + expect(result1).toBe(false); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBeDefined(); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBe(false); + + // Second call should use cache + const result2 = isIsrSsgRoute('/not-an-isr-route'); + expect(result2).toBe(false); + }); + + it('should cache false results when manifest is invalid', () => { + IS_ISR_SSG_ROUTE_CACHE.clear(); + globalWithInjectedValues._sentryRouteManifest = 'invalid json'; + + const result = isIsrSsgRoute('/any-route'); + expect(result).toBe(false); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBeDefined(); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBe(false); + }); + + it('should cache static routes without parameterization', () => { + const result1 = isIsrSsgRoute('/blog'); + expect(result1).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/blog')).toBeDefined(); + + // Second call should use cache + const result2 = isIsrSsgRoute('/blog'); + expect(result2).toBe(true); + }); + + it('should maintain separate cache entries for different route patterns', () => { + // Check multiple different routes + isIsrSsgRoute('/products/1'); + isIsrSsgRoute('/posts/hello'); + isIsrSsgRoute('/blog'); + isIsrSsgRoute('/'); + + // Should have 4 cache entries (one for each unique route pattern) + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(4); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/posts/:slug')).toBeDefined(); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/blog')).toBeDefined(); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/')).toBeDefined(); + }); + + it('should efficiently handle multiple calls to same dynamic route with different params', () => { + // Simulate real-world scenario with many different product IDs + for (let i = 1; i <= 100; i++) { + isIsrSsgRoute(`/products/${i}`); + } + + // Should only have 1 cache entry despite 100 calls + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); + }); + }); +}); diff --git a/packages/nextjs/test/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), + ); + }); + }); }); 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: [], }); }); }); 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 { } } + /** @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-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); + }); + }); }); }); 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/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, + instrumentLangGraph, ]; } 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( + 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/packages/node/src/integrations/tracing/openai/instrumentation.ts b/packages/node/src/integrations/tracing/openai/instrumentation.ts index 23df5bb66c35..e0682185ff0a 100644 --- a/packages/node/src/integrations/tracing/openai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/openai/instrumentation.ts @@ -5,9 +5,15 @@ import { InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; import type { Integration, OpenAiClient, OpenAiOptions } from '@sentry/core'; -import { getClient, instrumentOpenAiClient, OPENAI_INTEGRATION_NAME, SDK_VERSION } from '@sentry/core'; +import { + _INTERNAL_shouldSkipAiProviderWrapping, + getClient, + instrumentOpenAiClient, + OPENAI_INTEGRATION_NAME, + SDK_VERSION, +} from '@sentry/core'; -const supportedVersions = ['>=4.0.0 <6']; +const supportedVersions = ['>=4.0.0 <7']; export interface OpenAiIntegration extends Integration { options: OpenAiOptions; @@ -56,6 +62,11 @@ export class SentryOpenAiInstrumentation extends InstrumentationBase(OPENAI_INTEGRATION_NAME); diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index c19c3456e341..507f4172fe0b 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'; 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', () => { 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; 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/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); + }); }); diff --git a/yarn.lock b/yarn.lock index 99e434f5efff..082e1a032283 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4931,6 +4931,58 @@ 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" + 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-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" + 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" + +"@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"