From 3e4ac6905b05c2eacad3d8671f3b51061f574780 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 25 Mar 2026 15:50:44 +0100 Subject: [PATCH 01/39] test(deno): Expand Deno E2E test coverage (#19957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 8 new E2E tests to the Deno test application (5 → 13 total), covering breadcrumbs, user/tag/extra context, scope isolation, outbound fetch, metrics, logs, and Vercel AI SDK integration (generateText spans + error-trace linking). Changes: - 8 new test files in tests/ - 8 new route handlers in src/app.ts - Added ai, zod dependencies + Deno import maps - Enabled sendDefaultPii and enableLogs in Sentry.init() AI tests follow the same MockLanguageModelV1 pattern used in the `nextjs-15/nextjs-16` E2E tests. --- .../test-applications/deno/deno.json | 5 +- .../test-applications/deno/package.json | 4 +- .../test-applications/deno/src/app.ts | 220 +++++++++++++++++- .../deno/tests/ai-error.test.ts | 36 +++ .../test-applications/deno/tests/ai.test.ts | 48 ++++ .../deno/tests/breadcrumbs.test.ts | 25 ++ .../deno/tests/context.test.ts | 34 +++ .../deno/tests/fetch.test.ts | 21 ++ .../test-applications/deno/tests/logs.test.ts | 16 ++ .../deno/tests/metrics.test.ts | 67 ++++++ .../deno/tests/scope.test.ts | 27 +++ 11 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno/tests/breadcrumbs.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno/tests/context.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno/tests/logs.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno/tests/metrics.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno/tests/scope.test.ts diff --git a/dev-packages/e2e-tests/test-applications/deno/deno.json b/dev-packages/e2e-tests/test-applications/deno/deno.json index c78a9bccb60a..35242c740171 100644 --- a/dev-packages/e2e-tests/test-applications/deno/deno.json +++ b/dev-packages/e2e-tests/test-applications/deno/deno.json @@ -2,7 +2,10 @@ "imports": { "@sentry/deno": "npm:@sentry/deno", "@sentry/core": "npm:@sentry/core", - "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0" + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", + "ai": "npm:ai@^3.0.0", + "ai/test": "npm:ai@^3.0.0/test", + "zod": "npm:zod@^3.22.4" }, "nodeModulesDir": "manual" } diff --git a/dev-packages/e2e-tests/test-applications/deno/package.json b/dev-packages/e2e-tests/test-applications/deno/package.json index 8ec92fbd3985..ff30a9304e53 100644 --- a/dev-packages/e2e-tests/test-applications/deno/package.json +++ b/dev-packages/e2e-tests/test-applications/deno/package.json @@ -11,7 +11,9 @@ }, "dependencies": { "@sentry/deno": "latest || *", - "@opentelemetry/api": "^1.9.0" + "@opentelemetry/api": "^1.9.0", + "ai": "^3.0.0", + "zod": "^3.22.4" }, "devDependencies": { "@playwright/test": "~1.56.0", diff --git a/dev-packages/e2e-tests/test-applications/deno/src/app.ts b/dev-packages/e2e-tests/test-applications/deno/src/app.ts index fb34053e29d7..9b19b4ba3ac7 100644 --- a/dev-packages/e2e-tests/test-applications/deno/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/deno/src/app.ts @@ -13,6 +13,9 @@ trace.setGlobalTracerProvider(fakeProvider as any); // Sentry.init() must call trace.disable() to clear the fake provider above import * as Sentry from '@sentry/deno'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; Sentry.init({ environment: 'qa', @@ -20,11 +23,13 @@ Sentry.init({ debug: !!Deno.env.get('DEBUG'), tunnel: 'http://localhost:3031/', tracesSampleRate: 1, + sendDefaultPii: true, + enableLogs: true, }); const port = 3030; -Deno.serve({ port }, (req: Request) => { +Deno.serve({ port }, async (req: Request) => { const url = new URL(req.url); if (url.pathname === '/test-success') { @@ -84,6 +89,219 @@ Deno.serve({ port }, (req: Request) => { }); } + // Test breadcrumbs: add a breadcrumb then capture an error + if (url.pathname === '/test-breadcrumb') { + Sentry.addBreadcrumb({ + message: 'test-breadcrumb', + category: 'custom', + level: 'info', + }); + const exceptionId = Sentry.captureException(new Error('breadcrumb-test')); + return new Response(JSON.stringify({ exceptionId }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test context: set user, tag, extra then capture an error + if (url.pathname === '/test-context') { + Sentry.setUser({ id: '123', email: 'test@sentry.io' }); + Sentry.setTag('deno-runtime', 'true'); + Sentry.setExtra('detail', { key: 'value' }); + const exceptionId = Sentry.captureException(new Error('context-test')); + return new Response(JSON.stringify({ exceptionId }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test scope isolation: tags inside withScope do not leak + if (url.pathname === '/test-scope-isolation') { + let insideId: string | undefined; + let outsideId: string | undefined; + + Sentry.withScope(scope => { + scope.setTag('isolated', 'yes'); + insideId = Sentry.captureException(new Error('inside-scope')); + }); + + outsideId = Sentry.captureException(new Error('outside-scope')); + + return new Response(JSON.stringify({ insideId, outsideId }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test outbound fetch instrumentation + if (url.pathname === '/test-outgoing-fetch') { + const response = await Sentry.startSpan({ name: 'test-outgoing-fetch' }, async () => { + const res = await fetch('http://localhost:3030/test-success'); + return res.json(); + }); + return new Response(JSON.stringify(response), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test AI: Vercel AI SDK generateText with mock model + if (url.pathname === '/test-ai') { + const results = await Sentry.startSpan({ op: 'function', name: 'ai-test' }, async () => { + // First call - telemetry enabled by default + const result1 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'First span here!', + }), + }), + prompt: 'Where is the first span?', + }); + + // Second call - explicitly enabled telemetry + const result2 = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Second span here!', + }), + }), + prompt: 'Where is the second span?', + }); + + // Third call - with tool calls + const result3 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async (args: { location: string }) => { + return `Weather in ${args.location}: Sunny, 72°F`; + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + + // Fourth call - explicitly disabled telemetry, should not be captured + const result4 = await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Should not be captured!', + }), + }), + prompt: 'Where is the disabled span?', + }); + + return { + result1: result1.text, + result2: result2.text, + result3: result3.text, + result4: result4.text, + }; + }); + + return new Response(JSON.stringify(results), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test AI error: tool call that throws + if (url.pathname === '/test-ai-error') { + try { + await Sentry.startSpan({ op: 'function', name: 'ai-error-test' }, async () => { + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async (_args: { location: string }) => { + throw new Error('Tool call failed'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + }); + } catch (e) { + Sentry.captureException(e); + } + + return new Response(JSON.stringify({ status: 'error-handled' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test metrics: emit counter, distribution, and gauge + if (url.pathname === '/test-metrics') { + Sentry.metrics.count('test.deno.count', 1, { + attributes: { + endpoint: '/test-metrics', + 'random.attribute': 'Apples', + }, + }); + Sentry.metrics.distribution('test.deno.distribution', 100, { + attributes: { + endpoint: '/test-metrics', + 'random.attribute': 'Bananas', + }, + }); + Sentry.metrics.gauge('test.deno.gauge', 200, { + attributes: { + endpoint: '/test-metrics', + 'random.attribute': 'Cherries', + }, + }); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test logs: emit a debug log via Sentry.logger + if (url.pathname === '/test-log') { + Sentry.logger.debug('Accessed /test-log route'); + return new Response(JSON.stringify({ message: 'Log sent' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response('Not found', { status: 404 }); }); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts new file mode 100644 index 000000000000..8cf82e56de15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; + +test('should link AI errors to the correct trace', async ({ baseURL }) => { + const aiTransactionPromise = waitForTransaction('deno', event => { + return event?.spans?.some(span => span.description === 'ai-error-test') ?? false; + }); + + const errorEventPromise = waitForError('deno', event => { + return event.exception?.values?.[0]?.value?.includes('Tool call failed') ?? false; + }); + + await fetch(`${baseURL}/test-ai-error`); + + const aiTransaction = await aiTransactionPromise; + const errorEvent = await errorEventPromise; + + expect(aiTransaction).toBeDefined(); + + const spans = aiTransaction.spans || []; + + // The parent span wrapping the AI call should exist + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'ai-error-test', + op: 'function', + }), + ]), + ); + + expect(errorEvent).toBeDefined(); + + // Verify error is linked to the same trace as the transaction + expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiTransaction.contexts?.trace?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts new file mode 100644 index 000000000000..102ef00c6cd1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create AI pipeline spans with Vercel AI SDK', async ({ baseURL }) => { + const aiTransactionPromise = waitForTransaction('deno', event => { + return event?.spans?.some(span => span.description === 'ai-test') ?? false; + }); + + await fetch(`${baseURL}/test-ai`); + + const aiTransaction = await aiTransactionPromise; + + expect(aiTransaction).toBeDefined(); + + const spans = aiTransaction.spans || []; + + // The parent span wrapping all AI calls should exist + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'ai-test', + op: 'function', + }), + ]), + ); + + // Vercel AI SDK emits OTel spans for generateText calls. + // Due to the AI SDK monkey-patching limitation (https://github.com/vercel/ai/pull/6716), + // only explicitly opted-in calls produce telemetry spans. + // The explicitly enabled call (experimental_telemetry: { isEnabled: true }) should produce spans. + const aiSpans = spans.filter( + (span: any) => + span.op === 'gen_ai.invoke_agent' || + span.op === 'gen_ai.generate_text' || + span.op === 'otel.span' || + span.description?.includes('ai.generateText'), + ); + + // We expect at least one AI-related span from the explicitly enabled call + expect(aiSpans.length).toBeGreaterThanOrEqual(1); + + // Verify the disabled call was not captured + const promptsInSpans = spans + .map((span: any) => span.data?.['vercel.ai.prompt']) + .filter((prompt: unknown): prompt is string => prompt !== undefined); + const hasDisabledPrompt = promptsInSpans.some((prompt: string) => prompt.includes('Where is the disabled span?')); + expect(hasDisabledPrompt).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/breadcrumbs.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/breadcrumbs.test.ts new file mode 100644 index 000000000000..b28c57e79ea1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/breadcrumbs.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends error event with breadcrumbs', async ({ baseURL }) => { + const errorEventPromise = waitForError('deno', event => { + return !event.type && event.exception?.values?.[0]?.value === 'breadcrumb-test'; + }); + + await fetch(`${baseURL}/test-breadcrumb`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('breadcrumb-test'); + + expect(errorEvent.breadcrumbs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'test-breadcrumb', + category: 'custom', + level: 'info', + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/context.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/context.test.ts new file mode 100644 index 000000000000..79f2043ca223 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/context.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends error event with user, tags, and extras', async ({ baseURL }) => { + const errorEventPromise = waitForError('deno', event => { + return !event.type && event.exception?.values?.[0]?.value === 'context-test'; + }); + + await fetch(`${baseURL}/test-context`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('context-test'); + + expect(errorEvent.user).toEqual( + expect.objectContaining({ + id: '123', + email: 'test@sentry.io', + }), + ); + + expect(errorEvent.tags).toEqual( + expect.objectContaining({ + 'deno-runtime': 'true', + }), + ); + + expect(errorEvent.extra).toEqual( + expect.objectContaining({ + detail: { key: 'value' }, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts new file mode 100644 index 000000000000..7a0dcb30c82e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Outbound fetch inside Sentry span creates transaction', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno', event => { + return event?.spans?.some(span => span.description === 'test-outgoing-fetch') ?? false; + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const transaction = await transactionPromise; + + expect(transaction.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'test-outgoing-fetch', + origin: 'manual', + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/logs.test.ts new file mode 100644 index 000000000000..7db4ff68fa9a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/logs.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; +import type { SerializedLogContainer } from '@sentry/core'; + +test('should send logs via Sentry.logger', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('deno', envelope => { + return envelope[0].type === 'log' && (envelope[1] as SerializedLogContainer).items[0]?.level === 'debug'; + }); + + await fetch(`${baseURL}/test-log`); + + const logEnvelope = await logEnvelopePromise; + const log = (logEnvelope[1] as SerializedLogContainer).items[0]; + expect(log?.level).toBe('debug'); + expect(log?.body).toBe('Accessed /test-log route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/metrics.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/metrics.test.ts new file mode 100644 index 000000000000..298f34ccad55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/metrics.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +test('Should emit counter, distribution, and gauge metrics', async ({ baseURL }) => { + const countPromise = waitForMetric('deno', metric => { + return metric.name === 'test.deno.count'; + }); + + const distributionPromise = waitForMetric('deno', metric => { + return metric.name === 'test.deno.distribution'; + }); + + const gaugePromise = waitForMetric('deno', metric => { + return metric.name === 'test.deno.gauge'; + }); + + await fetch(`${baseURL}/test-metrics`); + + const count = await countPromise; + const distribution = await distributionPromise; + const gauge = await gaugePromise; + + expect(count).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.deno.count', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/test-metrics', type: 'string' }, + 'random.attribute': { value: 'Apples', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(distribution).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.deno.distribution', + type: 'distribution', + value: 100, + attributes: { + endpoint: { value: '/test-metrics', type: 'string' }, + 'random.attribute': { value: 'Bananas', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(gauge).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.deno.gauge', + type: 'gauge', + value: 200, + attributes: { + endpoint: { value: '/test-metrics', type: 'string' }, + 'random.attribute': { value: 'Cherries', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/scope.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/scope.test.ts new file mode 100644 index 000000000000..50fc60940113 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/scope.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Scope isolation prevents tag leakage between scopes', async ({ baseURL }) => { + const insideErrorPromise = waitForError('deno', event => { + return !event.type && event.exception?.values?.[0]?.value === 'inside-scope'; + }); + + const outsideErrorPromise = waitForError('deno', event => { + return !event.type && event.exception?.values?.[0]?.value === 'outside-scope'; + }); + + await fetch(`${baseURL}/test-scope-isolation`); + + const insideError = await insideErrorPromise; + const outsideError = await outsideErrorPromise; + + // The error inside withScope should have the isolated tag + expect(insideError.tags).toEqual( + expect.objectContaining({ + isolated: 'yes', + }), + ); + + // The error outside withScope should NOT have the isolated tag + expect(outsideError.tags?.['isolated']).toBeUndefined(); +}); From 4ffea2da3ea6f7d4b5147a7f0ddf15e8a218cabd Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 25 Mar 2026 15:51:16 +0100 Subject: [PATCH 02/39] feat(node): Add `nodeRuntimeMetricsIntegration` (#19923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `nodeRuntimeMetricsIntegration` to `@sentry/node` and `@sentry/node-core`. When enabled, the integration periodically collects Node.js runtime health metrics and emits them to Sentry via the metrics pipeline. ### Usage ```ts import * as Sentry from '@sentry/node'; Sentry.init({ dsn: '...', integrations: [ Sentry.nodeRuntimeMetricsIntegration(), ], }); ``` ### Default metrics (8) Emitted every 30 seconds out of the box: | Metric | Type | Unit | Description | |---|---|---|---| | `node.runtime.mem.rss` | gauge | byte | Resident Set Size — actual process memory footprint | | `node.runtime.mem.heap_used` | gauge | byte | V8 heap currently in use — tracks GC pressure and leaks | | `node.runtime.mem.heap_total` | gauge | byte | Total V8 heap allocated — paired with `heap_used` to see headroom | | `node.runtime.cpu.utilization` | gauge | — | CPU time / wall-clock time ratio (can exceed 1.0 on multi-core) | | `node.runtime.event_loop.delay.p50` | gauge | second | Median event loop delay — baseline latency | | `node.runtime.event_loop.delay.p99` | gauge | second | 99th percentile event loop delay — tail latency / spikes | | `node.runtime.event_loop.utilization` | gauge | — | Fraction of time the event loop was active | | `node.runtime.process.uptime` | counter | second | Cumulative uptime — useful for detecting restarts / crashes | ### Opt-in metrics (off by default) ```ts Sentry.nodeRuntimeMetricsIntegration({ collect: { cpuTime: true, // node.runtime.cpu.user + node.runtime.cpu.system (raw seconds) memExternal: true, // node.runtime.mem.external + node.runtime.mem.array_buffers eventLoopDelayMin: true, eventLoopDelayMax: true, eventLoopDelayMean: true, eventLoopDelayP90: true, }, }) ``` Any default metric can also be turned off: ```ts Sentry.nodeRuntimeMetricsIntegration({ collect: { uptime: false, eventLoopDelayP50: false, }, }) ``` ### Collection interval ```ts Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 60_000, // default: 30_000 }) ``` ### Serverless (Next.js on Vercel, AWS Lambda, etc.) Works out of the box — no special configuration needed. Metrics are sent by the periodic collection interval and flushed by the existing SDK flush infrastructure (framework wrappers like SvelteKit, TanStack Start, and `@sentry/aws-serverless` already call `flushIfServerless` after each request handler). The interval is `unref()`-ed so it never prevents the process from exiting. ### Runtime compatibility This integration is Node.js only. Bun and Deno will be addressed in separate integrations that use their respective native APIs. Closes #19967 (added automatically) --------- Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 15 + .../scripts/consistentExports.ts | 3 + .../node-runtime-metrics/scenario-all.ts | 30 ++ .../node-runtime-metrics/scenario-opt-out.ts | 30 ++ .../suites/node-runtime-metrics/scenario.ts | 23 ++ .../suites/node-runtime-metrics/test.ts | 130 +++++++ packages/astro/src/index.server.ts | 2 + packages/aws-serverless/src/index.ts | 2 + packages/core/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 2 + packages/node-core/src/common-exports.ts | 1 + .../src/integrations/nodeRuntimeMetrics.ts | 243 +++++++++++++ .../integrations/nodeRuntimeMetrics.test.ts | 333 ++++++++++++++++++ packages/node/src/index.ts | 2 + 14 files changed, 817 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-all.ts create mode 100644 dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-opt-out.ts create mode 100644 dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/node-runtime-metrics/test.ts create mode 100644 packages/node-core/src/integrations/nodeRuntimeMetrics.ts create mode 100644 packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a6760d5d1ec..244de67b8086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ Work in this release was contributed by @roli-lpci. Thank you for your contributions! +### Important Changes + +- **feat(node): Add `nodeRuntimeMetricsIntegration` for automatic Node.js runtime metrics ([#19923](https://github.com/getsentry/sentry-javascript/pull/19923))** + + The new `nodeRuntimeMetricsIntegration` automatically collects Node.js runtime health metrics and sends them to Sentry. Eight metrics are emitted by default every 30 seconds: memory (RSS, heap used/total), CPU utilization, event loop delay (p50, p99), event loop utilization, and process uptime. Additional metrics are available as opt-in. + + ```ts + import * as Sentry from '@sentry/node'; + + Sentry.init({ + dsn: '...', + integrations: [Sentry.nodeRuntimeMetricsIntegration()], + }); + ``` + ## 10.45.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index f195206fb5b2..e762909c9173 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -55,6 +55,9 @@ const DEPENDENTS: Dependent[] = [ 'childProcessIntegration', 'systemErrorIntegration', 'pinoIntegration', + // Bun will get its own runtime metrics integration + 'nodeRuntimeMetricsIntegration', + 'NodeRuntimeMetricsOptions', ], }, { diff --git a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-all.ts b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-all.ts new file mode 100644 index 000000000000..e995482fafbf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-all.ts @@ -0,0 +1,30 @@ +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, + integrations: [ + Sentry.nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + collect: { + cpuTime: true, + memExternal: true, + eventLoopDelayMin: true, + eventLoopDelayMax: true, + eventLoopDelayMean: true, + eventLoopDelayP90: true, + }, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-opt-out.ts b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-opt-out.ts new file mode 100644 index 000000000000..423e478ed1f8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-opt-out.ts @@ -0,0 +1,30 @@ +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, + integrations: [ + Sentry.nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + collect: { + cpuUtilization: false, + cpuTime: false, + eventLoopDelayP50: false, + eventLoopDelayP99: false, + eventLoopUtilization: false, + uptime: false, + }, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario.ts b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario.ts new file mode 100644 index 000000000000..b862634c719a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, + integrations: [ + Sentry.nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + }), + ], +}); + +async function run(): Promise { + // Wait long enough for the collection interval to fire at least once. + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/node-runtime-metrics/test.ts b/dev-packages/node-integration-tests/suites/node-runtime-metrics/test.ts new file mode 100644 index 000000000000..42aa075e878c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/node-runtime-metrics/test.ts @@ -0,0 +1,130 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +const SENTRY_ATTRIBUTES = { + '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' }, + 'sentry.origin': { value: 'auto.node.runtime_metrics', type: 'string' }, +}; + +const gauge = (name: string, unit?: string) => ({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name, + type: 'gauge', + ...(unit ? { unit } : {}), + value: expect.any(Number), + attributes: expect.objectContaining(SENTRY_ATTRIBUTES), +}); + +const counter = (name: string, unit?: string) => ({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name, + type: 'counter', + ...(unit ? { unit } : {}), + value: expect.any(Number), + attributes: expect.objectContaining(SENTRY_ATTRIBUTES), +}); + +describe('nodeRuntimeMetricsIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('emits default runtime metrics with correct shape', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: { + items: expect.arrayContaining([ + gauge('node.runtime.mem.rss', 'byte'), + gauge('node.runtime.mem.heap_used', 'byte'), + gauge('node.runtime.mem.heap_total', 'byte'), + gauge('node.runtime.cpu.utilization'), + gauge('node.runtime.event_loop.delay.p50', 'second'), + gauge('node.runtime.event_loop.delay.p99', 'second'), + gauge('node.runtime.event_loop.utilization'), + counter('node.runtime.process.uptime', 'second'), + ]), + }, + }) + .start(); + + await runner.completed(); + }); + + test('does not emit opt-in metrics by default', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: (container: { items: Array<{ name: string }> }) => { + const names = container.items.map(item => item.name); + expect(names).not.toContain('node.runtime.cpu.user'); + expect(names).not.toContain('node.runtime.cpu.system'); + expect(names).not.toContain('node.runtime.mem.external'); + expect(names).not.toContain('node.runtime.mem.array_buffers'); + expect(names).not.toContain('node.runtime.event_loop.delay.min'); + expect(names).not.toContain('node.runtime.event_loop.delay.max'); + expect(names).not.toContain('node.runtime.event_loop.delay.mean'); + expect(names).not.toContain('node.runtime.event_loop.delay.p90'); + }, + }) + .start(); + + await runner.completed(); + }); + + test('emits all metrics when fully opted in', async () => { + const runner = createRunner(__dirname, 'scenario-all.ts') + .expect({ + trace_metric: { + items: expect.arrayContaining([ + gauge('node.runtime.mem.rss', 'byte'), + gauge('node.runtime.mem.heap_used', 'byte'), + gauge('node.runtime.mem.heap_total', 'byte'), + gauge('node.runtime.mem.external', 'byte'), + gauge('node.runtime.mem.array_buffers', 'byte'), + gauge('node.runtime.cpu.user', 'second'), + gauge('node.runtime.cpu.system', 'second'), + gauge('node.runtime.cpu.utilization'), + gauge('node.runtime.event_loop.delay.min', 'second'), + gauge('node.runtime.event_loop.delay.max', 'second'), + gauge('node.runtime.event_loop.delay.mean', 'second'), + gauge('node.runtime.event_loop.delay.p50', 'second'), + gauge('node.runtime.event_loop.delay.p90', 'second'), + gauge('node.runtime.event_loop.delay.p99', 'second'), + gauge('node.runtime.event_loop.utilization'), + counter('node.runtime.process.uptime', 'second'), + ]), + }, + }) + .start(); + + await runner.completed(); + }); + + test('respects opt-out: only memory metrics remain when cpu/event loop/uptime are disabled', async () => { + const runner = createRunner(__dirname, 'scenario-opt-out.ts') + .expect({ + trace_metric: (container: { items: Array<{ name: string }> }) => { + const names = container.items.map(item => item.name); + + // Memory metrics should still be present + expect(names).toContain('node.runtime.mem.rss'); + expect(names).toContain('node.runtime.mem.heap_used'); + expect(names).toContain('node.runtime.mem.heap_total'); + + // Everything else should be absent + expect(names).not.toContain('node.runtime.cpu.utilization'); + expect(names).not.toContain('node.runtime.event_loop.delay.p50'); + expect(names).not.toContain('node.runtime.event_loop.delay.p99'); + expect(names).not.toContain('node.runtime.event_loop.utilization'); + expect(names).not.toContain('node.runtime.process.uptime'); + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index ac01ff0647a7..f19f82391a5f 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -84,6 +84,8 @@ export { lruMemoizerIntegration, makeNodeTransport, modulesIntegration, + nodeRuntimeMetricsIntegration, + type NodeRuntimeMetricsOptions, mongoIntegration, mongooseIntegration, mysql2Integration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 1c980e4cae2d..00b14ed59235 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -61,6 +61,8 @@ export { langChainIntegration, langGraphIntegration, modulesIntegration, + nodeRuntimeMetricsIntegration, + type NodeRuntimeMetricsOptions, contextLinesIntegration, nodeContextIntegration, localVariablesIntegration, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 61865ea7ba3c..09a4f36ebdb2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -537,3 +537,4 @@ export { safeMathRandom as _INTERNAL_safeMathRandom, safeDateNow as _INTERNAL_safeDateNow, } from './utils/randomSafeContext'; +export { safeUnref as _INTERNAL_safeUnref } from './utils/timer'; diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 9478b98f5a58..004c785b6ca5 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -61,6 +61,8 @@ export { langChainIntegration, langGraphIntegration, modulesIntegration, + nodeRuntimeMetricsIntegration, + type NodeRuntimeMetricsOptions, contextLinesIntegration, nodeContextIntegration, localVariablesIntegration, diff --git a/packages/node-core/src/common-exports.ts b/packages/node-core/src/common-exports.ts index 3fff4100b352..d6d1e070ef85 100644 --- a/packages/node-core/src/common-exports.ts +++ b/packages/node-core/src/common-exports.ts @@ -12,6 +12,7 @@ import * as logger from './logs/exports'; // Node-core integrations (not OTel-dependent) export { nodeContextIntegration } from './integrations/context'; +export { nodeRuntimeMetricsIntegration, type NodeRuntimeMetricsOptions } from './integrations/nodeRuntimeMetrics'; export { contextLinesIntegration } from './integrations/contextlines'; export { localVariablesIntegration } from './integrations/local-variables'; export { modulesIntegration } from './integrations/modules'; diff --git a/packages/node-core/src/integrations/nodeRuntimeMetrics.ts b/packages/node-core/src/integrations/nodeRuntimeMetrics.ts new file mode 100644 index 000000000000..c2ae72f04f77 --- /dev/null +++ b/packages/node-core/src/integrations/nodeRuntimeMetrics.ts @@ -0,0 +1,243 @@ +import { monitorEventLoopDelay, performance } from 'perf_hooks'; +import { _INTERNAL_safeDateNow, _INTERNAL_safeUnref, defineIntegration, metrics } from '@sentry/core'; + +const INTEGRATION_NAME = 'NodeRuntimeMetrics'; +const DEFAULT_INTERVAL_MS = 30_000; +const EVENT_LOOP_DELAY_RESOLUTION_MS = 10; + +export interface NodeRuntimeMetricsOptions { + /** + * Which metrics to collect. + * + * Default on (8 metrics): + * - `cpuUtilization` — CPU utilization ratio + * - `memRss` — Resident Set Size (actual memory footprint) + * - `memHeapUsed` — V8 heap currently in use + * - `memHeapTotal` — total V8 heap allocated (headroom paired with `memHeapUsed`) + * - `eventLoopDelayP50` — median event loop delay (baseline latency) + * - `eventLoopDelayP99` — 99th percentile event loop delay (tail latency / spikes) + * - `eventLoopUtilization` — fraction of time the event loop was active + * - `uptime` — process uptime (detect restarts/crashes) + * + * Default off (opt-in): + * - `cpuTime` — raw user/system CPU time in seconds + * - `memExternal` — external/ArrayBuffer memory (relevant for native addons) + * - `eventLoopDelayMin` / `eventLoopDelayMax` / `eventLoopDelayMean` / `eventLoopDelayP90` + */ + collect?: { + // Default on + cpuUtilization?: boolean; + memHeapUsed?: boolean; + memRss?: boolean; + eventLoopDelayP99?: boolean; + eventLoopUtilization?: boolean; + uptime?: boolean; + // Default off + cpuTime?: boolean; + memHeapTotal?: boolean; + memExternal?: boolean; + eventLoopDelayMin?: boolean; + eventLoopDelayMax?: boolean; + eventLoopDelayMean?: boolean; + eventLoopDelayP50?: boolean; + eventLoopDelayP90?: boolean; + }; + /** + * How often to collect metrics, in milliseconds. + * @default 30000 + */ + collectionIntervalMs?: number; +} + +/** + * Automatically collects Node.js runtime metrics and emits them to Sentry. + * + * @example + * ```ts + * Sentry.init({ + * integrations: [ + * Sentry.nodeRuntimeMetricsIntegration(), + * ], + * }); + * ``` + */ +export const nodeRuntimeMetricsIntegration = defineIntegration((options: NodeRuntimeMetricsOptions = {}) => { + const collectionIntervalMs = options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS; + const collect = { + // Default on + cpuUtilization: true, + memHeapUsed: true, + memHeapTotal: true, + memRss: true, + eventLoopDelayP50: true, + eventLoopDelayP99: true, + eventLoopUtilization: true, + uptime: true, + // Default off + cpuTime: false, + memExternal: false, + eventLoopDelayMin: false, + eventLoopDelayMax: false, + eventLoopDelayMean: false, + eventLoopDelayP90: false, + ...options.collect, + }; + + const needsEventLoopDelay = + collect.eventLoopDelayP99 || + collect.eventLoopDelayMin || + collect.eventLoopDelayMax || + collect.eventLoopDelayMean || + collect.eventLoopDelayP50 || + collect.eventLoopDelayP90; + + const needsCpu = collect.cpuUtilization || collect.cpuTime; + + let intervalId: ReturnType | undefined; + let prevCpuUsage: NodeJS.CpuUsage | undefined; + let prevElu: ReturnType | undefined; + let prevFlushTime: number = 0; + let eventLoopDelayHistogram: ReturnType | undefined; + + const resolutionNs = EVENT_LOOP_DELAY_RESOLUTION_MS * 1e6; + const nsToS = (ns: number): number => Math.max(0, (ns - resolutionNs) / 1e9); + + const METRIC_ATTRIBUTES = { attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + const METRIC_ATTRIBUTES_BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + const METRIC_ATTRIBUTES_SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + + function collectMetrics(): void { + const now = _INTERNAL_safeDateNow(); + const elapsed = now - prevFlushTime; + + if (needsCpu && prevCpuUsage !== undefined) { + const delta = process.cpuUsage(prevCpuUsage); + + if (collect.cpuTime) { + metrics.gauge('node.runtime.cpu.user', delta.user / 1e6, METRIC_ATTRIBUTES_SECOND); + metrics.gauge('node.runtime.cpu.system', delta.system / 1e6, METRIC_ATTRIBUTES_SECOND); + } + if (collect.cpuUtilization && elapsed > 0) { + // Ratio of CPU time to wall-clock time. Can exceed 1.0 on multi-core systems. + // TODO: In cluster mode, add a runtime_id/process_id attribute to disambiguate per-worker metrics. + metrics.gauge( + 'node.runtime.cpu.utilization', + (delta.user + delta.system) / (elapsed * 1000), + METRIC_ATTRIBUTES, + ); + } + + prevCpuUsage = process.cpuUsage(); + } + + if (collect.memRss || collect.memHeapUsed || collect.memHeapTotal || collect.memExternal) { + const mem = process.memoryUsage(); + if (collect.memRss) { + metrics.gauge('node.runtime.mem.rss', mem.rss, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapUsed) { + metrics.gauge('node.runtime.mem.heap_used', mem.heapUsed, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapTotal) { + metrics.gauge('node.runtime.mem.heap_total', mem.heapTotal, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memExternal) { + metrics.gauge('node.runtime.mem.external', mem.external, METRIC_ATTRIBUTES_BYTE); + metrics.gauge('node.runtime.mem.array_buffers', mem.arrayBuffers, METRIC_ATTRIBUTES_BYTE); + } + } + + if (needsEventLoopDelay && eventLoopDelayHistogram) { + if (collect.eventLoopDelayMin) { + metrics.gauge( + 'node.runtime.event_loop.delay.min', + nsToS(eventLoopDelayHistogram.min), + METRIC_ATTRIBUTES_SECOND, + ); + } + if (collect.eventLoopDelayMax) { + metrics.gauge( + 'node.runtime.event_loop.delay.max', + nsToS(eventLoopDelayHistogram.max), + METRIC_ATTRIBUTES_SECOND, + ); + } + if (collect.eventLoopDelayMean) { + metrics.gauge( + 'node.runtime.event_loop.delay.mean', + nsToS(eventLoopDelayHistogram.mean), + METRIC_ATTRIBUTES_SECOND, + ); + } + if (collect.eventLoopDelayP50) { + metrics.gauge( + 'node.runtime.event_loop.delay.p50', + nsToS(eventLoopDelayHistogram.percentile(50)), + METRIC_ATTRIBUTES_SECOND, + ); + } + if (collect.eventLoopDelayP90) { + metrics.gauge( + 'node.runtime.event_loop.delay.p90', + nsToS(eventLoopDelayHistogram.percentile(90)), + METRIC_ATTRIBUTES_SECOND, + ); + } + if (collect.eventLoopDelayP99) { + metrics.gauge( + 'node.runtime.event_loop.delay.p99', + nsToS(eventLoopDelayHistogram.percentile(99)), + METRIC_ATTRIBUTES_SECOND, + ); + } + + eventLoopDelayHistogram.reset(); + } + + if (collect.eventLoopUtilization && prevElu !== undefined) { + const currentElu = performance.eventLoopUtilization(); + const delta = performance.eventLoopUtilization(currentElu, prevElu); + metrics.gauge('node.runtime.event_loop.utilization', delta.utilization, METRIC_ATTRIBUTES); + prevElu = currentElu; + } + + if (collect.uptime && elapsed > 0) { + metrics.count('node.runtime.process.uptime', elapsed / 1000, METRIC_ATTRIBUTES_SECOND); + } + + prevFlushTime = now; + } + + return { + name: INTEGRATION_NAME, + + setup(): void { + if (needsEventLoopDelay) { + // Disable any previous histogram before overwriting (prevents native resource leak on re-init). + eventLoopDelayHistogram?.disable(); + try { + eventLoopDelayHistogram = monitorEventLoopDelay({ resolution: EVENT_LOOP_DELAY_RESOLUTION_MS }); + eventLoopDelayHistogram.enable(); + } catch { + // Not available in all runtimes (e.g. Bun throws NotImplementedError). + eventLoopDelayHistogram = undefined; + } + } + + // Prime baselines before the first collection interval. + if (needsCpu) { + prevCpuUsage = process.cpuUsage(); + } + if (collect.eventLoopUtilization) { + prevElu = performance.eventLoopUtilization(); + } + prevFlushTime = _INTERNAL_safeDateNow(); + + // Guard against double setup (e.g. re-init). + if (intervalId) { + clearInterval(intervalId); + } + intervalId = _INTERNAL_safeUnref(setInterval(collectMetrics, collectionIntervalMs)); + }, + }; +}); diff --git a/packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts b/packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts new file mode 100644 index 000000000000..fe1de568304a --- /dev/null +++ b/packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts @@ -0,0 +1,333 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { metrics } from '@sentry/core'; +import { nodeRuntimeMetricsIntegration } from '../../src/integrations/nodeRuntimeMetrics'; + +const { mockHistogram, mockMonitorEventLoopDelay, mockPerformance } = vi.hoisted(() => { + const mockHistogram = { + min: 2_000_000, + max: 20_000_000, + mean: 10_000_000, + percentile: vi.fn((p: number) => { + if (p === 50) return 8_000_000; + if (p === 90) return 15_000_000; + if (p === 99) return 19_000_000; + return 0; + }), + enable: vi.fn(), + reset: vi.fn(), + disable: vi.fn(), + }; + + const mockMonitorEventLoopDelay = vi.fn(() => mockHistogram); + const mockElu = { idle: 700, active: 300, utilization: 0.3 }; + const mockEluDelta = { idle: 700, active: 300, utilization: 0.3 }; + const mockPerformance = { + eventLoopUtilization: vi.fn((curr?: object, _prev?: object) => { + if (curr) return mockEluDelta; + return mockElu; + }), + }; + + return { mockHistogram, mockMonitorEventLoopDelay, mockPerformance }; +}); + +vi.mock('perf_hooks', () => ({ + monitorEventLoopDelay: mockMonitorEventLoopDelay, + performance: mockPerformance, +})); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { ...actual }; +}); + +describe('nodeRuntimeMetricsIntegration', () => { + let gaugeSpy: ReturnType; + let countSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + gaugeSpy = vi.spyOn(metrics, 'gauge'); + countSpy = vi.spyOn(metrics, 'count'); + + vi.spyOn(process, 'cpuUsage').mockReturnValue({ user: 500_000, system: 200_000 }); + vi.spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 50_000_000, + heapTotal: 30_000_000, + heapUsed: 20_000_000, + external: 1_000_000, + arrayBuffers: 500_000, + }); + + mockHistogram.percentile.mockClear(); + mockHistogram.enable.mockClear(); + mockHistogram.reset.mockClear(); + mockMonitorEventLoopDelay.mockClear(); + mockPerformance.eventLoopUtilization.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('has the correct name', () => { + const integration = nodeRuntimeMetricsIntegration(); + expect(integration.name).toBe('NodeRuntimeMetrics'); + }); + + describe('setup', () => { + it('initializes event loop delay histogram with resolution 10', () => { + const integration = nodeRuntimeMetricsIntegration(); + integration.setup(); + + expect(mockMonitorEventLoopDelay).toHaveBeenCalledWith({ resolution: 10 }); + expect(mockHistogram.enable).toHaveBeenCalledOnce(); + }); + + it('does not throw if monitorEventLoopDelay is unavailable (e.g. Bun)', () => { + mockMonitorEventLoopDelay.mockImplementationOnce(() => { + throw new Error('NotImplementedError'); + }); + + const integration = nodeRuntimeMetricsIntegration(); + expect(() => integration.setup()).not.toThrow(); + }); + + it('starts a collection interval', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + + expect(gaugeSpy).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1_000); + expect(gaugeSpy).toHaveBeenCalled(); + }); + }); + + const ORIGIN = { attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + const BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + const SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + + describe('metric collection — defaults', () => { + it('emits cpu utilization (default on)', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.cpu.utilization', expect.any(Number), ORIGIN); + }); + + it('does not emit cpu.user / cpu.system by default (opt-in)', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.cpu.user', expect.anything(), expect.anything()); + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.cpu.system', expect.anything(), expect.anything()); + }); + + it('emits cpu.user / cpu.system when cpuTime is opted in', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { cpuTime: true }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.cpu.user', expect.any(Number), SECOND); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.cpu.system', expect.any(Number), SECOND); + }); + + it('emits mem.rss, mem.heap_used, mem.heap_total (default on)', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.mem.rss', 50_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.mem.heap_used', 20_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.mem.heap_total', 30_000_000, BYTE); + }); + + it('does not emit mem.external / mem.array_buffers by default (opt-in)', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.mem.external', expect.anything(), expect.anything()); + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.mem.array_buffers', expect.anything(), expect.anything()); + }); + + it('emits mem.external / mem.array_buffers when opted in', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memExternal: true }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.mem.external', 1_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.mem.array_buffers', 500_000, BYTE); + }); + + it('emits event_loop.delay.p50 and p99 (default on) and resets histogram', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.p50', expect.any(Number), SECOND); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.p99', expect.any(Number), SECOND); + expect(mockHistogram.reset).toHaveBeenCalledOnce(); + }); + + it('does not emit min/max/mean/p90 event loop delay by default (opt-in)', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + for (const suffix of ['min', 'max', 'mean', 'p90']) { + expect(gaugeSpy).not.toHaveBeenCalledWith( + `node.runtime.event_loop.delay.${suffix}`, + expect.anything(), + expect.anything(), + ); + } + }); + + it('emits all opt-in event loop delay percentiles when enabled', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { + eventLoopDelayMin: true, + eventLoopDelayMax: true, + eventLoopDelayMean: true, + eventLoopDelayP90: true, + }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + // min: (2_000_000 - 10_000_000) clamped to 0 → 0s + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.min', 0, SECOND); + // max: (20_000_000 - 10_000_000) / 1e9 → 0.01s + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.max', 0.01, SECOND); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.mean', 0, SECOND); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.p90', expect.any(Number), SECOND); + }); + + it('emits event loop utilization metric', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.utilization', 0.3, ORIGIN); + }); + + it('emits uptime counter', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(countSpy).toHaveBeenCalledWith('node.runtime.process.uptime', expect.any(Number), SECOND); + }); + + it('does not emit event loop delay metrics if monitorEventLoopDelay threw', () => { + mockMonitorEventLoopDelay.mockImplementationOnce(() => { + throw new Error('NotImplementedError'); + }); + + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'node.runtime.event_loop.delay.p99', + expect.anything(), + expect.anything(), + ); + }); + }); + + describe('opt-out', () => { + it('skips cpu.utilization when cpuUtilization is false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { cpuUtilization: false }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.cpu.utilization', expect.anything(), expect.anything()); + }); + + it('skips mem.rss when memRss is false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memRss: false }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.mem.rss', expect.anything(), expect.anything()); + }); + + it('skips event loop delay metrics when all delay flags are false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { eventLoopDelayP50: false, eventLoopDelayP99: false }, + }); + integration.setup(); + + expect(mockMonitorEventLoopDelay).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1_000); + for (const suffix of ['min', 'max', 'mean', 'p50', 'p90', 'p99']) { + expect(gaugeSpy).not.toHaveBeenCalledWith( + `node.runtime.event_loop.delay.${suffix}`, + expect.anything(), + expect.anything(), + ); + } + }); + + it('skips only p99 but still emits p50 when eventLoopDelayP99 is false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { eventLoopDelayP99: false }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'node.runtime.event_loop.delay.p99', + expect.anything(), + expect.anything(), + ); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.p50', expect.any(Number), SECOND); + }); + + it('skips event loop utilization when eventLoopUtilization is false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { eventLoopUtilization: false }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'node.runtime.event_loop.utilization', + expect.anything(), + expect.anything(), + ); + }); + + it('skips uptime when uptime is false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { uptime: false }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(countSpy).not.toHaveBeenCalledWith('node.runtime.process.uptime', expect.anything(), expect.anything()); + }); + }); +}); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8458dee5f6a7..67fe97e59300 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -188,6 +188,8 @@ export { spotlightIntegration, childProcessIntegration, processSessionIntegration, + nodeRuntimeMetricsIntegration, + type NodeRuntimeMetricsOptions, pinoIntegration, createSentryWinstonTransport, SentryContextManager, From c77eff1e271a655509b5c2e00a002bcf971893ac Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 25 Mar 2026 17:32:12 +0100 Subject: [PATCH 03/39] docs(release): Update publishing-a-release.md (#19982) Docs here seem slightly out of sync Closes #19983 (added automatically) --- docs/publishing-a-release.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/publishing-a-release.md b/docs/publishing-a-release.md index 282c8a6543bd..ae151a90a0af 100644 --- a/docs/publishing-a-release.md +++ b/docs/publishing-a-release.md @@ -2,9 +2,9 @@ _These steps are only relevant to Sentry employees when preparing and publishing a new SDK release._ -These have also been documented via [Cursor Rules](../.cursor/rules/publishing-release.mdc). +These have also been documented as a [skill](../.claude/skills/release/SKILL.md). -You can run a pre-configured command in cursor by just typing `/publish_release` into the chat window to automate the steps below. +You can run the `/release` skill in Claude Code or Cursor to automate the steps below. **If you want to release a new SDK for the first time, be sure to follow the [New SDK Release Checklist](./new-sdk-release-checklist.md)** @@ -20,9 +20,8 @@ You can run a pre-configured command in cursor by just typing `/publish_release` [Auto Prepare Release](https://github.com/getsentry/sentry-javascript/actions/workflows/auto-release.yml) on master. 7. A new issue should appear in https://github.com/getsentry/publish/issues. 8. Wait until the CI check runs have finished successfully (there is a link to them in the issue). -9. Once CI passes successfully, ask a member of the - [@getsentry/releases-approvers](https://github.com/orgs/getsentry/teams/release-approvers) to approve the release. a. - Once the release is completed, a sync from `master` ->` develop` will be automatically triggered +9. Once CI passes successfully, set the `accepted` label on the issue to approve the release. + Once the release is completed, a sync from `master` -> `develop` will be automatically triggered ## Publishing a release for previous majors or prerelease (alpha, beta) versions From a1df56d76a43c82152f99070b73b1a2c4de623cb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 25 Mar 2026 20:22:49 -0400 Subject: [PATCH 04/39] fix(opentelemetry): Convert seconds timestamps in span.end() to milliseconds (#19958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Patches OTel span's `end()` method to run numeric timestamps through `ensureTimestampInMilliseconds()` before reaching OTel's native implementation - `startTime` already had this conversion, but `span.end(timestamp)` passed values directly to OTel which expects milliseconds — passing seconds (the Sentry convention) produced garbage timestamps - Applied in all three span creation paths: both code paths in `_startSpan()` and `startInactiveSpan()` Closes #18697 Co-authored-by: Claude Opus 4.6 (1M context) --- packages/opentelemetry/src/trace.ts | 19 ++++- packages/opentelemetry/test/trace.test.ts | 88 +++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index a640841cd3d4..b651ea16ccab 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -1,4 +1,4 @@ -import type { Context, Span, SpanContext, SpanOptions, Tracer } from '@opentelemetry/api'; +import type { Context, Span, SpanContext, SpanOptions, TimeInput, Tracer } from '@opentelemetry/api'; import { context, SpanStatusCode, trace, TraceFlags } from '@opentelemetry/api'; import { isTracingSuppressed, suppressTracing } from '@opentelemetry/core'; import type { @@ -60,6 +60,7 @@ function _startSpan(options: OpenTelemetrySpanContext, callback: (span: Span) return context.with(suppressedCtx, () => { return tracer.startActiveSpan(name, spanOptions, suppressedCtx, span => { + patchSpanEnd(span); // Restore the original unsuppressed context for the callback execution // so that custom OpenTelemetry spans maintain the correct context. // We use activeCtx (not ctx) because ctx may be suppressed when onlyIfParent is true @@ -82,6 +83,7 @@ function _startSpan(options: OpenTelemetrySpanContext, callback: (span: Span) } return tracer.startActiveSpan(name, spanOptions, ctx, span => { + patchSpanEnd(span); return handleCallbackErrors( () => callback(span), () => { @@ -155,7 +157,9 @@ export function startInactiveSpan(options: OpenTelemetrySpanContext): Span { ctx = isTracingSuppressed(ctx) ? ctx : suppressTracing(ctx); } - return tracer.startSpan(name, spanOptions, ctx); + const span = tracer.startSpan(name, spanOptions, ctx); + patchSpanEnd(span); + return span; }); } @@ -202,6 +206,17 @@ function ensureTimestampInMilliseconds(timestamp: number): number { return isMs ? timestamp * 1000 : timestamp; } +/** + * Wraps the span's `end()` method so that numeric timestamps passed in seconds + * are converted to milliseconds before reaching OTel's native `Span.end()`. + */ +function patchSpanEnd(span: Span): void { + const originalEnd = span.end.bind(span); + span.end = (endTime?: TimeInput) => { + return originalEnd(typeof endTime === 'number' ? ensureTimestampInMilliseconds(endTime) : endTime); + }; +} + function getContext(scope: Scope | undefined, forceTransaction: boolean | undefined): Context { const ctx = getContextForScope(scope); const parentSpan = trace.getSpan(ctx); diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index 1bb1f2634839..aa8829341963 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -2005,6 +2005,94 @@ describe('suppressTracing', () => { }); }); +describe('span.end() timestamp conversion', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('converts seconds to milliseconds for startInactiveSpan', () => { + // Use a timestamp in seconds that is after the span start (i.e. in the future) + // OTel resets endTime to startTime if endTime < startTime + const nowSec = Math.floor(Date.now() / 1000) + 1; + const span = startInactiveSpan({ name: 'test' }); + span.end(nowSec); + + const endTime = getSpanEndTime(span); + // ensureTimestampInMilliseconds converts seconds (< 9999999999) to ms by * 1000 + // OTel then converts ms to HrTime [seconds, nanoseconds] + expect(endTime![0]).toBe(nowSec); + expect(endTime![1]).toBe(0); + }); + + it('keeps milliseconds as-is for startInactiveSpan', () => { + // Timestamp already in milliseconds (> 9999999999 threshold) + const nowMs = Date.now() + 1000; + const nowSec = Math.floor(nowMs / 1000); + const span = startInactiveSpan({ name: 'test' }); + span.end(nowMs); + + const endTime = getSpanEndTime(span); + expect(endTime![0]).toBe(nowSec); + }); + + it('handles Date input for startInactiveSpan', () => { + const nowMs = Date.now() + 1000; + const nowSec = Math.floor(nowMs / 1000); + const span = startInactiveSpan({ name: 'test' }); + span.end(new Date(nowMs)); + + const endTime = getSpanEndTime(span); + expect(endTime![0]).toBe(nowSec); + }); + + it('handles no-arg end for startInactiveSpan', () => { + const span = startInactiveSpan({ name: 'test' }); + span.end(); + + const endTime = getSpanEndTime(span); + expect(endTime).toBeDefined(); + expect(endTime![0]).not.toBe(0); + }); + + it('handles HrTime input for startInactiveSpan', () => { + const nowSec = Math.floor(Date.now() / 1000) + 1; + const span = startInactiveSpan({ name: 'test' }); + span.end([nowSec, 500000000] as [number, number]); + + const endTime = getSpanEndTime(span); + expect(endTime![0]).toBe(nowSec); + expect(endTime![1]).toBe(500000000); + }); + + it('converts seconds to milliseconds for startSpanManual callback span', () => { + const nowSec = Math.floor(Date.now() / 1000) + 1; + startSpanManual({ name: 'test' }, span => { + span.end(nowSec); + + const endTime = getSpanEndTime(span); + expect(endTime![0]).toBe(nowSec); + expect(endTime![1]).toBe(0); + }); + }); + + it('converts seconds to milliseconds for startSpan child span', () => { + const nowSec = Math.floor(Date.now() / 1000) + 1; + let capturedEndTime: [number, number] | undefined; + startSpan({ name: 'outer' }, () => { + const innerSpan = startInactiveSpan({ name: 'inner' }); + innerSpan.end(nowSec); + capturedEndTime = getSpanEndTime(innerSpan); + }); + + expect(capturedEndTime![0]).toBe(nowSec); + expect(capturedEndTime![1]).toBe(0); + }); +}); + function getSpanName(span: AbstractSpan): string | undefined { return spanHasName(span) ? span.name : undefined; } From c8d467750b4ccf4e87395fdab62c61af40c95824 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:34:58 +0100 Subject: [PATCH 05/39] feat(nuxt): Support parametrized SSR routes in Nuxt 5 (#19977) Nuxt 5 uses Nitro's `response` hook and changes the callback signature, while Nuxt 4 uses `beforeResponse`. This change keeps Sentry's server-side route naming working across both versions by separating the logic into two different plugins. Closes https://github.com/getsentry/sentry-javascript/issues/19976 --- .../nuxt-5/tests/tracing.test.ts | 3 +- packages/nuxt/src/module.ts | 2 + .../hooks/updateRouteBeforeResponse.ts | 16 +++- .../nuxt/src/runtime/plugins/sentry.server.ts | 3 - .../update-route-name-legacy.server.ts | 6 ++ .../plugins/update-route-name.server.ts | 8 ++ .../hooks/updateRouteBeforeResponse.test.ts | 87 +++++++++++++++++++ 7 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 packages/nuxt/src/runtime/plugins/update-route-name-legacy.server.ts create mode 100644 packages/nuxt/src/runtime/plugins/update-route-name.server.ts create mode 100644 packages/nuxt/test/runtime/hooks/updateRouteBeforeResponse.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts index e136d5635a29..9c456d50d85a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts @@ -68,8 +68,7 @@ test.describe('distributed tracing', () => { expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); - // TODO: Make test work with Nuxt 5 - test.skip('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction === '/test-param/user/:userId()'; }); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 0c1e43031742..077bda66745b 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -83,8 +83,10 @@ export default defineNuxtModule({ if (serverConfigFile) { if (isNitroV3) { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); } else { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name-legacy.server')); } addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); diff --git a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts index 9b0f8f4d05fe..becb367e178b 100644 --- a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts +++ b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts @@ -1,19 +1,29 @@ import { debug, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import type { H3Event } from 'h3'; +type MatchedRoute = { path?: string; route?: string }; +type EventWithMatchedRoute = Pick & Partial>; + +function getMatchedRoutePath(event: EventWithMatchedRoute): string | undefined { + const matchedRoute = (event.context as { matchedRoute?: MatchedRoute }).matchedRoute; + // Nuxt 4 with h3 v1 uses `path`, Nuxt 5 with h3 v2 uses `route` + return matchedRoute?.path ?? matchedRoute?.route; +} + /** * Update the root span (transaction) name for routes with parameters based on the matched route. */ -export function updateRouteBeforeResponse(event: H3Event): void { +export function updateRouteBeforeResponse(event: EventWithMatchedRoute): void { if (!event.context.matchedRoute) { return; } - const matchedRoutePath = event.context.matchedRoute.path; + const matchedRoutePath = getMatchedRoutePath(event); + const requestPath = event.path ?? event._path; // If the matched route path is defined and differs from the event's path, it indicates a parametrized route // Example: Matched route is "/users/:id" and the event's path is "/users/123", - if (matchedRoutePath && matchedRoutePath !== event._path) { + if (matchedRoutePath && matchedRoutePath !== requestPath) { if (matchedRoutePath === '/**') { // If page is server-side rendered, the whole path gets transformed to `/**` (Example : `/users/123` becomes `/**` instead of `/users/:id`). return; // Skip if the matched route is a catch-all route (handled in `route-detector.server.ts`) diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index 7ea91e36cf25..63b4f6c107be 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -3,12 +3,9 @@ import type { H3Event } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; -import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; export default (nitroApp => { - nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); - nitroApp.hooks.hook('error', sentryCaptureErrorHook); // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context diff --git a/packages/nuxt/src/runtime/plugins/update-route-name-legacy.server.ts b/packages/nuxt/src/runtime/plugins/update-route-name-legacy.server.ts new file mode 100644 index 000000000000..417c83062cb8 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/update-route-name-legacy.server.ts @@ -0,0 +1,6 @@ +import type { NitroAppPlugin } from 'nitropack'; +import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; + +export default (nitroApp => { + nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/update-route-name.server.ts b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts new file mode 100644 index 000000000000..72e3d9452e7e --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts @@ -0,0 +1,8 @@ +import type { NitroAppPlugin } from 'nitro/types'; +import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; +import type { H3Event } from 'h3'; + +export default (nitroApp => { + // @ts-expect-error Hook in Nuxt 5 (Nitro 3) is called 'response' https://nitro.build/docs/plugins#available-hooks + nitroApp.hooks.hook('response', (_response, event: H3Event) => updateRouteBeforeResponse(event)); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/test/runtime/hooks/updateRouteBeforeResponse.test.ts b/packages/nuxt/test/runtime/hooks/updateRouteBeforeResponse.test.ts new file mode 100644 index 000000000000..d311ee253121 --- /dev/null +++ b/packages/nuxt/test/runtime/hooks/updateRouteBeforeResponse.test.ts @@ -0,0 +1,87 @@ +import { + debug, + getActiveSpan, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + type Span, + type SpanAttributes, +} from '@sentry/core'; +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { updateRouteBeforeResponse } from '../../../src/runtime/hooks/updateRouteBeforeResponse'; + +vi.mock(import('@sentry/core'), async importOriginal => { + const mod = await importOriginal(); + + return { + ...mod, + debug: { + ...mod.debug, + log: vi.fn(), + }, + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + }; +}); + +describe('updateRouteBeforeResponse', () => { + const mockRootSpan = { + setAttributes: vi.fn(), + } as unknown as Pick; + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('updates the transaction name for Nitro v2 matched routes', () => { + (getActiveSpan as Mock).mockReturnValue({} as Span); + (getRootSpan as Mock).mockReturnValue(mockRootSpan); + + updateRouteBeforeResponse({ + _path: '/users/123', + context: { + matchedRoute: { + path: '/users/:id', + }, + params: { + id: '123', + }, + }, + } as never); + + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': '/users/:id', + } satisfies SpanAttributes); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + 'params.id': '123', + 'url.path.parameter.id': '123', + } satisfies SpanAttributes); + expect(debug.log).toHaveBeenCalledWith('Updated transaction name for parametrized route: /users/:id'); + }); + + it('updates the transaction name for Nitro v3 matched routes', () => { + (getActiveSpan as Mock).mockReturnValue({} as Span); + (getRootSpan as Mock).mockReturnValue(mockRootSpan); + + updateRouteBeforeResponse({ + path: '/users/123', + context: { + matchedRoute: { + route: '/users/:id', + }, + params: { + id: '123', + }, + }, + } as never); + + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': '/users/:id', + } satisfies SpanAttributes); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + 'params.id': '123', + 'url.path.parameter.id': '123', + } satisfies SpanAttributes); + }); +}); From 4a43db24d987f74a6f7c56c4eab34c035c6aa945 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 26 Mar 2026 10:39:58 +0100 Subject: [PATCH 06/39] ref(core): Consolidate getOperationName into one shared utility (#19971) We have this function in both the shared utilities (used by `google-genai` and `anthropic`) and in `openai` with slightly different names for no apparent reason. We also had a separate helper that just prepends `gen_ai` to the operation name in both cases, which seems unnecessary. Doing some cleanup here Closes #19978 (added automatically) --- .../core/src/tracing/ai/gen-ai-attributes.ts | 13 -------- packages/core/src/tracing/ai/utils.ts | 33 +++++++++---------- .../core/src/tracing/anthropic-ai/index.ts | 11 +++---- .../core/src/tracing/google-genai/index.ts | 16 +++------ packages/core/src/tracing/openai/index.ts | 8 ++--- packages/core/src/tracing/openai/utils.ts | 29 ---------------- .../core/test/lib/utils/openai-utils.test.ts | 12 +------ 7 files changed, 30 insertions(+), 92 deletions(-) diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index cae9a9353da9..5c740142b4f0 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -312,19 +312,6 @@ export const OPENAI_USAGE_COMPLETION_TOKENS_ATTRIBUTE = 'openai.usage.completion */ export const OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE = 'openai.usage.prompt_tokens'; -// ============================================================================= -// OPENAI OPERATIONS -// ============================================================================= - -/** - * OpenAI API operations following OpenTelemetry semantic conventions - * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#llm-request-spans - */ -export const OPENAI_OPERATIONS = { - CHAT: 'chat', - EMBEDDINGS: 'embeddings', -} as const; - // ============================================================================= // ANTHROPIC AI OPERATIONS // ============================================================================= diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index 38e4d831db3f..a454e5803c63 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -34,35 +34,34 @@ export function resolveAIRecordingOptions(options? * Maps AI method paths to OpenTelemetry semantic convention operation names * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#llm-request-spans */ -export function getFinalOperationName(methodPath: string): string { - if (methodPath.includes('messages')) { +export function getOperationName(methodPath: string): string { + // OpenAI: chat.completions.create, responses.create, conversations.create + // Anthropic: messages.create, messages.stream, completions.create + // Google GenAI: chats.create, chat.sendMessage, chat.sendMessageStream + if ( + methodPath.includes('completions') || + methodPath.includes('responses') || + methodPath.includes('conversations') || + methodPath.includes('messages') || + methodPath.includes('chat') + ) { return 'chat'; } - if (methodPath.includes('completions')) { - return 'text_completion'; + // OpenAI: embeddings.create + if (methodPath.includes('embeddings')) { + return 'embeddings'; } - // Google GenAI: models.generateContent* -> generate_content (actually generates AI responses) + // Google GenAI: models.generateContent, models.generateContentStream (must be before 'models' check) if (methodPath.includes('generateContent')) { return 'generate_content'; } - // Anthropic: models.get/retrieve -> models (metadata retrieval only) + // Anthropic: models.get, models.retrieve (metadata retrieval only) if (methodPath.includes('models')) { return 'models'; } - if (methodPath.includes('chat')) { - return 'chat'; - } return methodPath.split('.').pop() || 'unknown'; } -/** - * Get the span operation for AI methods - * Following Sentry's convention: "gen_ai.{operation_name}" - */ -export function getSpanOperation(methodPath: string): string { - return `gen_ai.${getFinalOperationName(methodPath)}`; -} - /** * Build method path from current traversal */ diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index 693ecbd23ff8..6217a08f3ffc 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -23,8 +23,7 @@ import { } from '../ai/gen-ai-attributes'; import { buildMethodPath, - getFinalOperationName, - getSpanOperation, + getOperationName, resolveAIRecordingOptions, setTokenUsageAttributes, wrapPromiseWithMethods, @@ -45,7 +44,7 @@ import { handleResponseError, messagesFromParams, setMessagesAttribute, shouldIn function extractRequestAttributes(args: unknown[], methodPath: string): Record { const attributes: Record = { [GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic', - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getFinalOperationName(methodPath), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getOperationName(methodPath), [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.anthropic', }; @@ -212,7 +211,7 @@ function handleStreamingRequest( const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; const spanConfig = { name: `${operationName} ${model}`, - op: getSpanOperation(methodPath), + op: `gen_ai.${operationName}`, attributes: requestAttributes as Record, }; @@ -272,7 +271,7 @@ function instrumentMethod( apply(target, thisArg, args: T): R | Promise { const requestAttributes = extractRequestAttributes(args, methodPath); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; - const operationName = getFinalOperationName(methodPath); + const operationName = getOperationName(methodPath); const params = typeof args[0] === 'object' ? (args[0] as Record) : undefined; const isStreamRequested = Boolean(params?.stream); @@ -299,7 +298,7 @@ function instrumentMethod( const instrumentedPromise = startSpan( { name: `${operationName} ${model}`, - op: getSpanOperation(methodPath), + op: `gen_ai.${operationName}`, attributes: requestAttributes as Record, }, span => { diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index e53b320a8503..6170163a626b 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -26,13 +26,7 @@ import { GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { truncateGenAiMessages } from '../ai/messageTruncation'; -import { - buildMethodPath, - extractSystemInstructions, - getFinalOperationName, - getSpanOperation, - resolveAIRecordingOptions, -} from '../ai/utils'; +import { buildMethodPath, extractSystemInstructions, getOperationName, resolveAIRecordingOptions } from '../ai/utils'; import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { @@ -111,7 +105,7 @@ function extractRequestAttributes( ): Record { const attributes: Record = { [GEN_AI_SYSTEM_ATTRIBUTE]: GOOGLE_GENAI_SYSTEM_NAME, - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getFinalOperationName(methodPath), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getOperationName(methodPath), [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', }; @@ -268,7 +262,7 @@ function instrumentMethod( const params = args[0] as Record | undefined; const requestAttributes = extractRequestAttributes(methodPath, params, context); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; - const operationName = getFinalOperationName(methodPath); + const operationName = getOperationName(methodPath); // Check if this is a streaming method if (isStreamingMethod(methodPath)) { @@ -276,7 +270,7 @@ function instrumentMethod( return startSpanManual( { name: `${operationName} ${model}`, - op: getSpanOperation(methodPath), + op: `gen_ai.${operationName}`, attributes: requestAttributes, }, async (span: Span) => { @@ -305,7 +299,7 @@ function instrumentMethod( return startSpan( { name: isSyncCreate ? `${operationName} ${model} create` : `${operationName} ${model}`, - op: getSpanOperation(methodPath), + op: `gen_ai.${operationName}`, attributes: requestAttributes, }, (span: Span) => { diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index d5ee3f53af86..fc8e67115e87 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -15,10 +15,10 @@ import { GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, - OPENAI_OPERATIONS, } from '../ai/gen-ai-attributes'; import { extractSystemInstructions, + getOperationName, getTruncatedJsonString, resolveAIRecordingOptions, wrapPromiseWithMethods, @@ -39,8 +39,6 @@ import { addEmbeddingsAttributes, addResponsesApiAttributes, extractRequestParameters, - getOperationName, - getSpanOperation, isChatCompletionResponse, isConversationResponse, isEmbeddingsResponse, @@ -127,7 +125,7 @@ function addResponseAttributes(span: Span, result: unknown, recordOutputs?: bool // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. function addRequestAttributes(span: Span, params: Record, operationName: string): void { // Store embeddings input on a separate attribute and do not truncate it - if (operationName === OPENAI_OPERATIONS.EMBEDDINGS && 'input' in params) { + if (operationName === 'embeddings' && 'input' in params) { const input = params.input; // No input provided @@ -197,7 +195,7 @@ function instrumentMethod( const spanConfig = { name: `${operationName} ${model}`, - op: getSpanOperation(methodPath), + op: `gen_ai.${operationName}`, attributes: requestAttributes as Record, }; diff --git a/packages/core/src/tracing/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts index f89b786b5a3c..2ccaaeb3264a 100644 --- a/packages/core/src/tracing/openai/utils.ts +++ b/packages/core/src/tracing/openai/utils.ts @@ -16,7 +16,6 @@ import { GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, - OPENAI_OPERATIONS, OPENAI_RESPONSE_ID_ATTRIBUTE, OPENAI_RESPONSE_MODEL_ATTRIBUTE, OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE, @@ -34,34 +33,6 @@ import type { ResponseStreamingEvent, } from './types'; -/** - * Maps OpenAI method paths to OpenTelemetry semantic convention operation names - * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#llm-request-spans - */ -export function getOperationName(methodPath: string): string { - if (methodPath.includes('chat.completions')) { - return OPENAI_OPERATIONS.CHAT; - } - if (methodPath.includes('responses')) { - return OPENAI_OPERATIONS.CHAT; - } - if (methodPath.includes('embeddings')) { - return OPENAI_OPERATIONS.EMBEDDINGS; - } - if (methodPath.includes('conversations')) { - return OPENAI_OPERATIONS.CHAT; - } - return methodPath.split('.').pop() || 'unknown'; -} - -/** - * Get the span operation for OpenAI methods - * Following Sentry's convention: "gen_ai.{operation_name}" - */ -export function getSpanOperation(methodPath: string): string { - return `gen_ai.${getOperationName(methodPath)}`; -} - /** * Check if a method path should be instrumented */ diff --git a/packages/core/test/lib/utils/openai-utils.test.ts b/packages/core/test/lib/utils/openai-utils.test.ts index 65a55bcc9ef6..ca0aa08ca0f7 100644 --- a/packages/core/test/lib/utils/openai-utils.test.ts +++ b/packages/core/test/lib/utils/openai-utils.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildMethodPath } from '../../../src/tracing/ai/utils'; +import { buildMethodPath, getOperationName } from '../../../src/tracing/ai/utils'; import { - getOperationName, - getSpanOperation, isChatCompletionChunk, isChatCompletionResponse, isConversationResponse, @@ -38,14 +36,6 @@ describe('openai-utils', () => { }); }); - describe('getSpanOperation', () => { - it('should prefix operation with gen_ai', () => { - expect(getSpanOperation('chat.completions.create')).toBe('gen_ai.chat'); - expect(getSpanOperation('responses.create')).toBe('gen_ai.chat'); - expect(getSpanOperation('some.custom.operation')).toBe('gen_ai.operation'); - }); - }); - describe('shouldInstrument', () => { it('should return true for instrumented methods', () => { expect(shouldInstrument('responses.create')).toBe(true); From f685a859673fcb95f7e45194860ce72c907f7569 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 26 Mar 2026 12:37:26 -0400 Subject: [PATCH 07/39] fix(e2e): Pin @opentelemetry/api to 1.9.0 in ts3.8 test app (#19992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `@opentelemetry/api@1.9.1` was released on Mar 25 and introduced `export { Foo, type Bar }` syntax (inline type modifiers) in its `.d.ts` files, which requires TypeScript 4.5+ - The `generic-ts3.8` E2E test runs with `skipLibCheck: false` and TypeScript 3.8, so it tries to parse OTel's types and fails - This pins `@opentelemetry/api` to `1.9.0` in the ts3.8 test app via `pnpm.overrides` - We can't pin repo-wide in published packages because OTel uses a global singleton pattern — version mismatches with `@opentelemetry/sdk-trace-base` cause the tracer to become a no-op - Our published `.d.ts` files are unaffected — only OTel's own types use the incompatible syntax ## Test plan - [x] Verified locally: `yarn test:run generic-ts3.8` passes with the pin - [ ] CI `E2E generic-ts3.8 Test` should go green 🤖 Generated with [Claude Code](https://claude.com/claude-code) Closes #19998 (added automatically) Co-authored-by: Claude Opus 4.6 (1M context) --- .../e2e-tests/test-applications/generic-ts3.8/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/generic-ts3.8/package.json b/dev-packages/e2e-tests/test-applications/generic-ts3.8/package.json index fbd40cebcb07..fe0e0f6ec5f0 100644 --- a/dev-packages/e2e-tests/test-applications/generic-ts3.8/package.json +++ b/dev-packages/e2e-tests/test-applications/generic-ts3.8/package.json @@ -20,6 +20,11 @@ "@sentry-internal/replay": "latest || *", "@sentry/wasm": "latest || *" }, + "pnpm": { + "overrides": { + "@opentelemetry/api": "1.9.0" + } + }, "volta": { "extends": "../../package.json" } From 91709f0e769e3c553ec31c427a8e63bfafce0526 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 26 Mar 2026 13:52:31 -0400 Subject: [PATCH 08/39] feat(browser): Replace element timing spans with metrics (#19869) Removes element timing span creation from `browserTracingIntegration` (deprecates `enableElementTiming` option, introduces a new standalone `elementTimingIntegration` that emits Element Timing API data as **Sentry distribution metrics** instead of spans. Emits `element_timing.render_time` and `element_timing.load_time` metrics with `element.identifier` and `element.paint_type` attributes. I believe users can query by the element identifier if they are interested in metrics for a specific element. Me and Lukas think this is a safe change because it was never documented, even then I made sure to export NO-OP replacement functions to stub them out. ## Reasoning for the change Element Timing values (`renderTime`, `loadTime`) are point-in-time timestamps, not durations. Modeling them as spans required awkward workarounds (zero-duration spans, arbitrary start times) that didn't produce meaningful trace data. Metrics are the correct abstraction here. See discussion in #19261 for full context. ## Usage ```js Sentry.init({ integrations: [ Sentry.browserTracingIntegration(), Sentry.elementTimingIntegration(), ], }); ``` closes #19260 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .size-limit.js | 4 +- .../tracing/metrics/element-timing/init.js | 2 +- .../tracing/metrics/element-timing/test.ts | 318 +++++------- packages/browser-utils/src/index.ts | 2 +- .../src/metrics/elementTiming.ts | 192 ++++--- .../test/metrics/elementTiming.test.ts | 468 +++++------------- packages/browser/src/index.bundle.feedback.ts | 2 + .../browser/src/index.bundle.logs.metrics.ts | 2 + .../src/index.bundle.replay.feedback.ts | 2 + .../src/index.bundle.replay.logs.metrics.ts | 2 + packages/browser/src/index.bundle.replay.ts | 2 + .../src/index.bundle.tracing.logs.metrics.ts | 1 + ...le.tracing.replay.feedback.logs.metrics.ts | 1 + .../index.bundle.tracing.replay.feedback.ts | 7 +- ...ndex.bundle.tracing.replay.logs.metrics.ts | 1 + .../src/index.bundle.tracing.replay.ts | 8 +- packages/browser/src/index.bundle.tracing.ts | 2 + packages/browser/src/index.bundle.ts | 2 + packages/browser/src/index.ts | 1 + .../src/tracing/browserTracingIntegration.ts | 25 +- .../integration-shims/src/ElementTiming.ts | 17 + packages/integration-shims/src/index.ts | 1 + 22 files changed, 426 insertions(+), 636 deletions(-) create mode 100644 packages/integration-shims/src/ElementTiming.ts diff --git a/.size-limit.js b/.size-limit.js index 3e0902c0a57c..fcc455808948 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -248,7 +248,7 @@ module.exports = [ path: createCDNPath('bundle.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '86 KB', + limit: '88 KB', }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed', @@ -262,7 +262,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '210 KB', + limit: '211 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js index 5a4cb2dff8b7..40253c296af1 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js @@ -5,6 +5,6 @@ window.Sentry = Sentry; Sentry.init({ debug: true, dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration()], + integrations: [Sentry.browserTracingIntegration(), Sentry.elementTimingIntegration()], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts index d5dabb5d0ca5..bbff70505c0a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts @@ -1,218 +1,174 @@ -import type { Page, Route } from '@playwright/test'; +import type { Page, Request, Route } from '@playwright/test'; import { expect } from '@playwright/test'; +import type { Envelope } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; +import { + properFullEnvelopeRequestParser, + shouldSkipMetricsTest, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +type MetricItem = Record & { + name: string; + type: string; + value: number; + unit?: string; + attributes: Record; +}; + +function extractMetricsFromRequest(req: Request): MetricItem[] { + try { + const envelope = properFullEnvelopeRequestParser(req); + const items = envelope[1]; + const metrics: MetricItem[] = []; + for (const item of items) { + const [header] = item; + if (header.type === 'trace_metric') { + const payload = item[1] as { items?: MetricItem[] }; + if (payload.items) { + metrics.push(...payload.items); + } + } + } + return metrics; + } catch { + return []; + } +} + +/** + * Collects element timing metrics from envelope requests on the page. + * Returns a function to get all collected metrics so far and a function + * that waits until all expected identifiers have been seen in render_time metrics. + */ +function createMetricCollector(page: Page) { + const collectedRequests: Request[] = []; + + page.on('request', req => { + if (!req.url().includes('/api/1337/envelope/')) return; + const metrics = extractMetricsFromRequest(req); + if (metrics.some(m => m.name.startsWith('ui.element.'))) { + collectedRequests.push(req); + } + }); + + function getAll(): MetricItem[] { + return collectedRequests.flatMap(req => extractMetricsFromRequest(req)); + } + + async function waitForIdentifiers(identifiers: string[], timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const all = getAll().filter(m => m.name === 'ui.element.render_time'); + const seen = new Set(all.map(m => m.attributes['ui.element.identifier']?.value)); + if (identifiers.every(id => seen.has(id))) { + return; + } + await page.waitForTimeout(500); + } + // Final check with assertion for clear error message + const all = getAll().filter(m => m.name === 'ui.element.render_time'); + const seen = all.map(m => m.attributes['ui.element.identifier']?.value); + for (const id of identifiers) { + expect(seen).toContain(id); + } + } + + function reset(): void { + collectedRequests.length = 0; + } + + return { getAll, waitForIdentifiers, reset }; +} sentryTest( - 'adds element timing spans to pageload span tree for elements rendered during pageload', + 'emits element timing metrics for elements rendered during pageload', async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipTracingTest() || browserName === 'webkit') { + if (shouldSkipTracingTest() || shouldSkipMetricsTest() || browserName === 'webkit') { sentryTest.skip(); } - const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); - serveAssets(page); const url = await getLocalTestUrl({ testDir: __dirname }); + const collector = createMetricCollector(page); await page.goto(url); - const eventData = envelopeRequestParser(await pageloadEventPromise); - - const elementTimingSpans = eventData.spans?.filter(({ op }) => op === 'ui.elementtiming'); - - expect(elementTimingSpans?.length).toEqual(8); - - // Check image-fast span (this is served with a 100ms delay) - const imageFastSpan = elementTimingSpans?.find(({ description }) => description === 'element[image-fast]'); - const imageFastRenderTime = imageFastSpan?.data['element.render_time']; - const imageFastLoadTime = imageFastSpan?.data['element.load_time']; - const duration = imageFastSpan!.timestamp! - imageFastSpan!.start_timestamp; - - expect(imageFastSpan).toBeDefined(); - expect(imageFastSpan?.data).toEqual({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'load-time', - 'element.id': 'image-fast-id', - 'element.identifier': 'image-fast', - 'element.type': 'img', - 'element.size': '600x179', - 'element.url': 'https://sentry-test-site.example/path/to/image-fast.png', - 'element.render_time': expect.any(Number), - 'element.load_time': expect.any(Number), - 'element.paint_type': 'image-paint', - 'sentry.transaction_name': '/index.html', - }); - expect(imageFastRenderTime).toBeGreaterThan(90); - expect(imageFastRenderTime).toBeLessThan(400); - expect(imageFastLoadTime).toBeGreaterThan(90); - expect(imageFastLoadTime).toBeLessThan(400); - expect(imageFastRenderTime).toBeGreaterThan(imageFastLoadTime as number); - expect(duration).toBeGreaterThan(0); - expect(duration).toBeLessThan(20); - - // Check text1 span - const text1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'text1'); - const text1RenderTime = text1Span?.data['element.render_time']; - const text1LoadTime = text1Span?.data['element.load_time']; - const text1Duration = text1Span!.timestamp! - text1Span!.start_timestamp; - expect(text1Span).toBeDefined(); - expect(text1Span?.data).toEqual({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'render-time', - 'element.id': 'text1-id', - 'element.identifier': 'text1', - 'element.type': 'p', - 'element.render_time': expect.any(Number), - 'element.load_time': expect.any(Number), - 'element.paint_type': 'text-paint', - 'sentry.transaction_name': '/index.html', - }); - expect(text1RenderTime).toBeGreaterThan(0); - expect(text1RenderTime).toBeLessThan(300); - expect(text1LoadTime).toBe(0); - expect(text1RenderTime).toBeGreaterThan(text1LoadTime as number); - expect(text1Duration).toBe(0); - - // Check button1 span (no need for a full assertion) - const button1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'button1'); - expect(button1Span).toBeDefined(); - expect(button1Span?.data).toMatchObject({ - 'element.identifier': 'button1', - 'element.type': 'button', - 'element.paint_type': 'text-paint', - 'sentry.transaction_name': '/index.html', + // Wait until all expected element identifiers have been flushed as metrics + await collector.waitForIdentifiers(['image-fast', 'text1', 'button1', 'image-slow', 'lazy-image', 'lazy-text']); + + const allMetrics = collector.getAll().filter(m => m.name.startsWith('ui.element.')); + const renderTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.render_time'); + const loadTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.load_time'); + + const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); + const loadIdentifiers = loadTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); + + // All text and image elements should have render_time + expect(renderIdentifiers).toContain('image-fast'); + expect(renderIdentifiers).toContain('text1'); + expect(renderIdentifiers).toContain('button1'); + expect(renderIdentifiers).toContain('image-slow'); + expect(renderIdentifiers).toContain('lazy-image'); + expect(renderIdentifiers).toContain('lazy-text'); + + // Image elements should also have load_time + expect(loadIdentifiers).toContain('image-fast'); + expect(loadIdentifiers).toContain('image-slow'); + expect(loadIdentifiers).toContain('lazy-image'); + + // Text elements should NOT have load_time (loadTime is 0 for text-paint) + expect(loadIdentifiers).not.toContain('text1'); + expect(loadIdentifiers).not.toContain('button1'); + expect(loadIdentifiers).not.toContain('lazy-text'); + + // Validate metric structure for image-fast + const imageFastRender = renderTimeMetrics.find(m => m.attributes['ui.element.identifier']?.value === 'image-fast'); + expect(imageFastRender).toMatchObject({ + name: 'ui.element.render_time', + type: 'distribution', + unit: 'millisecond', + value: expect.any(Number), }); + expect(imageFastRender!.attributes['ui.element.paint_type']?.value).toBe('image-paint'); - // Check image-slow span - const imageSlowSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'image-slow'); - expect(imageSlowSpan).toBeDefined(); - expect(imageSlowSpan?.data).toEqual({ - 'element.id': '', - 'element.identifier': 'image-slow', - 'element.type': 'img', - 'element.size': '600x179', - 'element.url': 'https://sentry-test-site.example/path/to/image-slow.png', - 'element.paint_type': 'image-paint', - 'element.render_time': expect.any(Number), - 'element.load_time': expect.any(Number), - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'load-time', - 'sentry.transaction_name': '/index.html', - }); - const imageSlowRenderTime = imageSlowSpan?.data['element.render_time']; - const imageSlowLoadTime = imageSlowSpan?.data['element.load_time']; - const imageSlowDuration = imageSlowSpan!.timestamp! - imageSlowSpan!.start_timestamp; - expect(imageSlowRenderTime).toBeGreaterThan(1400); - expect(imageSlowRenderTime).toBeLessThan(2000); - expect(imageSlowLoadTime).toBeGreaterThan(1400); - expect(imageSlowLoadTime).toBeLessThan(2000); - expect(imageSlowDuration).toBeGreaterThan(0); - expect(imageSlowDuration).toBeLessThan(20); - - // Check lazy-image span - const lazyImageSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-image'); - expect(lazyImageSpan).toBeDefined(); - expect(lazyImageSpan?.data).toEqual({ - 'element.id': '', - 'element.identifier': 'lazy-image', - 'element.type': 'img', - 'element.size': '600x179', - 'element.url': 'https://sentry-test-site.example/path/to/image-lazy.png', - 'element.paint_type': 'image-paint', - 'element.render_time': expect.any(Number), - 'element.load_time': expect.any(Number), - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'load-time', - 'sentry.transaction_name': '/index.html', - }); - const lazyImageRenderTime = lazyImageSpan?.data['element.render_time']; - const lazyImageLoadTime = lazyImageSpan?.data['element.load_time']; - const lazyImageDuration = lazyImageSpan!.timestamp! - lazyImageSpan!.start_timestamp; - expect(lazyImageRenderTime).toBeGreaterThan(1000); - expect(lazyImageRenderTime).toBeLessThan(1500); - expect(lazyImageLoadTime).toBeGreaterThan(1000); - expect(lazyImageLoadTime).toBeLessThan(1500); - expect(lazyImageDuration).toBeGreaterThan(0); - expect(lazyImageDuration).toBeLessThan(20); - - // Check lazy-text span - const lazyTextSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-text'); - expect(lazyTextSpan?.data).toMatchObject({ - 'element.id': '', - 'element.identifier': 'lazy-text', - 'element.type': 'p', - 'sentry.transaction_name': '/index.html', - }); - const lazyTextRenderTime = lazyTextSpan?.data['element.render_time']; - const lazyTextLoadTime = lazyTextSpan?.data['element.load_time']; - const lazyTextDuration = lazyTextSpan!.timestamp! - lazyTextSpan!.start_timestamp; - expect(lazyTextRenderTime).toBeGreaterThan(1000); - expect(lazyTextRenderTime).toBeLessThan(1500); - expect(lazyTextLoadTime).toBe(0); - expect(lazyTextDuration).toBe(0); - - // the div1 entry does not emit an elementTiming entry because it's neither a text nor an image - expect(elementTimingSpans?.find(({ description }) => description === 'element[div1]')).toBeUndefined(); + // Validate text-paint metric + const text1Render = renderTimeMetrics.find(m => m.attributes['ui.element.identifier']?.value === 'text1'); + expect(text1Render!.attributes['ui.element.paint_type']?.value).toBe('text-paint'); }, ); -sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipTracingTest() || browserName === 'webkit') { +sentryTest('emits element timing metrics after navigation', async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipTracingTest() || shouldSkipMetricsTest() || browserName === 'webkit') { sentryTest.skip(); } serveAssets(page); const url = await getLocalTestUrl({ testDir: __dirname }); + const collector = createMetricCollector(page); await page.goto(url); - const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); - - const navigationEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + // Wait for pageload element timing metrics to arrive before navigating + await collector.waitForIdentifiers(['image-fast', 'text1']); - await pageloadEventPromise; + // Reset so we only capture post-navigation metrics + collector.reset(); + // Trigger navigation await page.locator('#button1').click(); - const navigationTransactionEvent = envelopeRequestParser(await navigationEventPromise); - const pageloadTransactionEvent = envelopeRequestParser(await pageloadEventPromise); - - const navigationElementTimingSpans = navigationTransactionEvent.spans?.filter(({ op }) => op === 'ui.elementtiming'); - - expect(navigationElementTimingSpans?.length).toEqual(2); - - const navigationStartTime = navigationTransactionEvent.start_timestamp!; - const pageloadStartTime = pageloadTransactionEvent.start_timestamp!; - - const imageSpan = navigationElementTimingSpans?.find( - ({ description }) => description === 'element[navigation-image]', - ); - const textSpan = navigationElementTimingSpans?.find(({ description }) => description === 'element[navigation-text]'); + // Wait for navigation element timing metrics + await collector.waitForIdentifiers(['navigation-image', 'navigation-text']); - // Image started loading after navigation, but render-time and load-time still start from the time origin - // of the pageload. This is somewhat a limitation (though by design according to the ElementTiming spec) - expect((imageSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( - navigationStartTime, - ); - expect((imageSpan!.data['element.load_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( - navigationStartTime, - ); + const allMetrics = collector.getAll(); + const renderTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.render_time'); + const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); - expect(textSpan?.data['element.load_time']).toBe(0); - expect((textSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( - navigationStartTime, - ); + expect(renderIdentifiers).toContain('navigation-image'); + expect(renderIdentifiers).toContain('navigation-text'); }); function serveAssets(page: Page) { diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index a4d0960b1ccb..2b2d4b7f9397 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -16,7 +16,7 @@ export { registerInpInteractionListener, } from './metrics/browserMetrics'; -export { startTrackingElementTiming } from './metrics/elementTiming'; +export { elementTimingIntegration, startTrackingElementTiming } from './metrics/elementTiming'; export { extractNetworkProtocol } from './metrics/utils'; diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/metrics/elementTiming.ts index f746b16645af..16aced700844 100644 --- a/packages/browser-utils/src/metrics/elementTiming.ts +++ b/packages/browser-utils/src/metrics/elementTiming.ts @@ -1,18 +1,7 @@ -import type { SpanAttributes } from '@sentry/core'; -import { - browserPerformanceTimeOrigin, - getActiveSpan, - getCurrentScope, - getRootSpan, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - spanToJSON, - startSpan, - timestampInSeconds, -} from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; +import { browserPerformanceTimeOrigin, defineIntegration, metrics } from '@sentry/core'; import { addPerformanceInstrumentationHandler } from './instrument'; -import { getBrowserPerformanceAPI, msToSec } from './utils'; +import { getBrowserPerformanceAPI } from './utils'; // ElementTiming interface based on the W3C spec interface PerformanceElementTiming extends PerformanceEntry { @@ -27,95 +16,96 @@ interface PerformanceElementTiming extends PerformanceEntry { url?: string; } +const INTEGRATION_NAME = 'ElementTiming'; + +const _elementTimingIntegration = (() => { + return { + name: INTEGRATION_NAME, + setup() { + const performance = getBrowserPerformanceAPI(); + if (!performance || !browserPerformanceTimeOrigin()) { + return; + } + + addPerformanceInstrumentationHandler('element', ({ entries }) => { + for (const entry of entries) { + const elementEntry = entry as PerformanceElementTiming; + + if (!elementEntry.identifier) { + continue; + } + + const identifier = elementEntry.identifier; + const paintType = elementEntry.name as 'image-paint' | 'text-paint' | undefined; + const renderTime = elementEntry.renderTime; + const loadTime = elementEntry.loadTime; + + const metricAttributes: Record = { + 'sentry.origin': 'auto.ui.browser.element_timing', + 'ui.element.identifier': identifier, + }; + + if (paintType) { + metricAttributes['ui.element.paint_type'] = paintType; + } + + if (elementEntry.id) { + metricAttributes['ui.element.id'] = elementEntry.id; + } + + if (elementEntry.element) { + metricAttributes['ui.element.type'] = elementEntry.element.tagName.toLowerCase(); + } + + if (elementEntry.url) { + metricAttributes['ui.element.url'] = elementEntry.url; + } + + if (elementEntry.naturalWidth) { + metricAttributes['ui.element.width'] = elementEntry.naturalWidth; + } + + if (elementEntry.naturalHeight) { + metricAttributes['ui.element.height'] = elementEntry.naturalHeight; + } + + if (renderTime > 0) { + metrics.distribution(`ui.element.render_time`, renderTime, { + unit: 'millisecond', + attributes: metricAttributes, + }); + } + + if (loadTime > 0) { + metrics.distribution(`ui.element.load_time`, loadTime, { + unit: 'millisecond', + attributes: metricAttributes, + }); + } + } + }); + }, + }; +}) satisfies IntegrationFn; + /** - * Start tracking ElementTiming performance entries. + * Captures [Element Timing API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming) + * data as Sentry metrics. + * + * To mark an element for tracking, add the `elementtiming` HTML attribute: + * ```html + * + *

Welcome!

+ * ``` + * + * This emits `ui.element.render_time` and `ui.element.load_time` (for images) + * as distribution metrics, tagged with the element's identifier and paint type. */ -export function startTrackingElementTiming(): () => void { - const performance = getBrowserPerformanceAPI(); - if (performance && browserPerformanceTimeOrigin()) { - return addPerformanceInstrumentationHandler('element', _onElementTiming); - } - - return () => undefined; -} +export const elementTimingIntegration = defineIntegration(_elementTimingIntegration); /** - * exported only for testing + * @deprecated Use `elementTimingIntegration` instead. This function is a no-op and will be removed in a future version. */ -export const _onElementTiming = ({ entries }: { entries: PerformanceEntry[] }): void => { - const activeSpan = getActiveSpan(); - const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - const transactionName = rootSpan - ? spanToJSON(rootSpan).description - : getCurrentScope().getScopeData().transactionName; - - entries.forEach(entry => { - const elementEntry = entry as PerformanceElementTiming; - - // Skip entries without identifier (elementtiming attribute) - if (!elementEntry.identifier) { - return; - } - - // `name` contains the type of the element paint. Can be `'image-paint'` or `'text-paint'`. - // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming#instance_properties - const paintType = elementEntry.name as 'image-paint' | 'text-paint' | undefined; - - const renderTime = elementEntry.renderTime; - const loadTime = elementEntry.loadTime; - - // starting the span at: - // - `loadTime` if available (should be available for all "image-paint" entries, 0 otherwise) - // - `renderTime` if available (available for all entries, except 3rd party images, but these should be covered by `loadTime`, 0 otherwise) - // - `timestampInSeconds()` as a safeguard - // see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming/renderTime#cross-origin_image_render_time - const [spanStartTime, spanStartTimeSource] = loadTime - ? [msToSec(loadTime), 'load-time'] - : renderTime - ? [msToSec(renderTime), 'render-time'] - : [timestampInSeconds(), 'entry-emission']; - - const duration = - paintType === 'image-paint' - ? // for image paints, we can acually get a duration because image-paint entries also have a `loadTime` - // and `renderTime`. `loadTime` is the time when the image finished loading and `renderTime` is the - // time when the image finished rendering. - msToSec(Math.max(0, (renderTime ?? 0) - (loadTime ?? 0))) - : // for `'text-paint'` entries, we can't get a duration because the `loadTime` is always zero. - 0; - - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.elementtiming', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.elementtiming', - // name must be user-entered, so we can assume low cardinality - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - // recording the source of the span start time, as it varies depending on available data - 'sentry.span_start_time_source': spanStartTimeSource, - 'sentry.transaction_name': transactionName, - 'element.id': elementEntry.id, - 'element.type': elementEntry.element?.tagName?.toLowerCase() || 'unknown', - 'element.size': - elementEntry.naturalWidth && elementEntry.naturalHeight - ? `${elementEntry.naturalWidth}x${elementEntry.naturalHeight}` - : undefined, - 'element.render_time': renderTime, - 'element.load_time': loadTime, - // `url` is `0`(number) for text paints (hence we fall back to undefined) - 'element.url': elementEntry.url || undefined, - 'element.identifier': elementEntry.identifier, - 'element.paint_type': paintType, - }; - - startSpan( - { - name: `element[${elementEntry.identifier}]`, - attributes, - startTime: spanStartTime, - onlyIfParent: true, - }, - span => { - span.end(spanStartTime + duration); - }, - ); - }); -}; +export function startTrackingElementTiming(): () => void { + return () => undefined; +} diff --git a/packages/browser-utils/test/metrics/elementTiming.test.ts b/packages/browser-utils/test/metrics/elementTiming.test.ts index 14431415873b..c58a4faf6d45 100644 --- a/packages/browser-utils/test/metrics/elementTiming.test.ts +++ b/packages/browser-utils/test/metrics/elementTiming.test.ts @@ -1,369 +1,165 @@ import * as sentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { _onElementTiming, startTrackingElementTiming } from '../../src/metrics/elementTiming'; +import { elementTimingIntegration, startTrackingElementTiming } from '../../src/metrics/elementTiming'; import * as browserMetricsInstrumentation from '../../src/metrics/instrument'; import * as browserMetricsUtils from '../../src/metrics/utils'; -describe('_onElementTiming', () => { - const spanEndSpy = vi.fn(); - const startSpanSpy = vi.spyOn(sentryCore, 'startSpan').mockImplementation((opts, cb) => { - // @ts-expect-error - only passing a partial span. This is fine for the test. - cb({ - end: spanEndSpy, - }); - }); +describe('elementTimingIntegration', () => { + const distributionSpy = vi.spyOn(sentryCore.metrics, 'distribution'); - beforeEach(() => { - startSpanSpy.mockClear(); - spanEndSpy.mockClear(); - }); + let elementHandler: (data: { entries: PerformanceEntry[] }) => void; - it('does nothing if the ET entry has no identifier', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - } as Partial; + beforeEach(() => { + distributionSpy.mockClear(); - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); + vi.spyOn(browserMetricsUtils, 'getBrowserPerformanceAPI').mockReturnValue({ + getEntriesByType: vi.fn().mockReturnValue([]), + } as unknown as Performance); - expect(startSpanSpy).not.toHaveBeenCalled(); + vi.spyOn(browserMetricsInstrumentation, 'addPerformanceInstrumentationHandler').mockImplementation( + (type, handler) => { + if (type === 'element') { + elementHandler = handler; + } + return () => undefined; + }, + ); }); - describe('span start time', () => { - it('uses the load time as span start time if available', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - loadTime: 50, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: 0.05, - attributes: expect.objectContaining({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'load-time', - 'element.render_time': 100, - 'element.load_time': 50, - 'element.identifier': 'test-element', - 'element.paint_type': 'image-paint', - }), - }), - expect.any(Function), - ); + function setupIntegration(): void { + const integration = elementTimingIntegration(); + integration?.setup?.({} as sentryCore.Client); + } + + it('skips entries without an identifier', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + } as unknown as PerformanceEntry, + ], }); - it('uses the render time as span start time if load time is not available', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); + expect(distributionSpy).not.toHaveBeenCalled(); + }); - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: 0.1, - attributes: expect.objectContaining({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'render-time', - 'element.render_time': 100, - 'element.load_time': undefined, - 'element.identifier': 'test-element', - 'element.paint_type': 'image-paint', - }), - }), - expect.any(Function), - ); + it('emits render_time metric for text-paint entries', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'text-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 150, + loadTime: 0, + identifier: 'hero-text', + id: 'hero', + element: { tagName: 'P' }, + naturalWidth: 0, + naturalHeight: 0, + } as unknown as PerformanceEntry, + ], }); - it('falls back to the time of handling the entry if load and render time are not available', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: expect.any(Number), - attributes: expect.objectContaining({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'entry-emission', - 'element.render_time': undefined, - 'element.load_time': undefined, - 'element.identifier': 'test-element', - 'element.paint_type': 'image-paint', - }), - }), - expect.any(Function), - ); + expect(distributionSpy).toHaveBeenCalledTimes(1); + expect(distributionSpy).toHaveBeenCalledWith('ui.element.render_time', 150, { + unit: 'millisecond', + attributes: { + 'sentry.origin': 'auto.ui.browser.element_timing', + 'ui.element.identifier': 'hero-text', + 'ui.element.paint_type': 'text-paint', + 'ui.element.id': 'hero', + 'ui.element.type': 'p', + }, }); }); - describe('span duration', () => { - it('uses (render-load) time as duration for image paints', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 1505, - loadTime: 1500, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: 1.5, - attributes: expect.objectContaining({ - 'element.render_time': 1505, - 'element.load_time': 1500, - 'element.paint_type': 'image-paint', - }), - }), - expect.any(Function), - ); - - expect(spanEndSpy).toHaveBeenCalledWith(1.505); + it('emits both render_time and load_time metrics for image-paint entries', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 200, + loadTime: 150, + identifier: 'hero-image', + id: 'img1', + element: { tagName: 'IMG' }, + url: 'https://example.com/hero.jpg', + naturalWidth: 1920, + naturalHeight: 1080, + } as unknown as PerformanceEntry, + ], }); - it('uses 0 as duration for text paints', () => { - const entry = { - name: 'text-paint', - entryType: 'element', - startTime: 0, - duration: 0, - loadTime: 0, - renderTime: 1600, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: 1.6, - attributes: expect.objectContaining({ - 'element.paint_type': 'text-paint', - 'element.render_time': 1600, - 'element.load_time': 0, - }), - }), - expect.any(Function), - ); - - expect(spanEndSpy).toHaveBeenCalledWith(1.6); + expect(distributionSpy).toHaveBeenCalledTimes(2); + const expectedAttributes = { + 'sentry.origin': 'auto.ui.browser.element_timing', + 'ui.element.identifier': 'hero-image', + 'ui.element.paint_type': 'image-paint', + 'ui.element.id': 'img1', + 'ui.element.type': 'img', + 'ui.element.url': 'https://example.com/hero.jpg', + 'ui.element.width': 1920, + 'ui.element.height': 1080, + }; + expect(distributionSpy).toHaveBeenCalledWith('ui.element.render_time', 200, { + unit: 'millisecond', + attributes: expectedAttributes, }); - - // per spec, no other kinds are supported but let's make sure we're defensive - it('uses 0 as duration for other kinds of entries', () => { - const entry = { - name: 'somethingelse', - entryType: 'element', - startTime: 0, - duration: 0, - loadTime: 0, - renderTime: 1700, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: 1.7, - attributes: expect.objectContaining({ - 'element.paint_type': 'somethingelse', - 'element.render_time': 1700, - 'element.load_time': 0, - }), - }), - expect.any(Function), - ); - - expect(spanEndSpy).toHaveBeenCalledWith(1.7); + expect(distributionSpy).toHaveBeenCalledWith('ui.element.load_time', 150, { + unit: 'millisecond', + attributes: expectedAttributes, }); }); - describe('span attributes', () => { - it('sets element type, identifier, paint type, load and render time', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - identifier: 'my-image', - element: { - tagName: 'IMG', - }, - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: expect.objectContaining({ - 'element.type': 'img', - 'element.identifier': 'my-image', - 'element.paint_type': 'image-paint', - 'element.render_time': 100, - 'element.load_time': undefined, - 'element.size': undefined, - 'element.url': undefined, - }), - }), - expect.any(Function), - ); + it('handles multiple entries in a single batch', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'text-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + loadTime: 0, + identifier: 'heading', + } as unknown as PerformanceEntry, + { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 300, + loadTime: 250, + identifier: 'banner', + } as unknown as PerformanceEntry, + ], }); - it('sets element size if available', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - naturalWidth: 512, - naturalHeight: 256, - identifier: 'my-image', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: expect.objectContaining({ - 'element.size': '512x256', - 'element.identifier': 'my-image', - }), - }), - expect.any(Function), - ); - }); - - it('sets element url if available', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - url: 'https://santry.com/image.png', - identifier: 'my-image', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: expect.objectContaining({ - 'element.identifier': 'my-image', - 'element.url': 'https://santry.com/image.png', - }), - }), - expect.any(Function), - ); - }); - - it('sets sentry attributes', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - identifier: 'my-image', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: expect.objectContaining({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'render-time', - 'sentry.transaction_name': undefined, - }), - }), - expect.any(Function), - ); - }); + // heading: 1 render_time, banner: 1 render_time + 1 load_time + expect(distributionSpy).toHaveBeenCalledTimes(3); }); }); describe('startTrackingElementTiming', () => { - const addInstrumentationHandlerSpy = vi.spyOn(browserMetricsInstrumentation, 'addPerformanceInstrumentationHandler'); - - beforeEach(() => { - addInstrumentationHandlerSpy.mockClear(); - }); - - it('returns a function that does nothing if the browser does not support the performance API', () => { - vi.spyOn(browserMetricsUtils, 'getBrowserPerformanceAPI').mockReturnValue(undefined); - expect(typeof startTrackingElementTiming()).toBe('function'); - - expect(addInstrumentationHandlerSpy).not.toHaveBeenCalled(); - }); - - it('adds an instrumentation handler for elementtiming entries, if the browser supports the performance API', () => { - vi.spyOn(browserMetricsUtils, 'getBrowserPerformanceAPI').mockReturnValue({ - getEntriesByType: vi.fn().mockReturnValue([]), - } as unknown as Performance); - - const addInstrumentationHandlerSpy = vi.spyOn( - browserMetricsInstrumentation, - 'addPerformanceInstrumentationHandler', - ); - - const stopTracking = startTrackingElementTiming(); - - expect(typeof stopTracking).toBe('function'); - - expect(addInstrumentationHandlerSpy).toHaveBeenCalledWith('element', expect.any(Function)); + it('is a deprecated no-op that returns a cleanup function', () => { + const cleanup = startTrackingElementTiming(); + expect(typeof cleanup).toBe('function'); }); }); diff --git a/packages/browser/src/index.bundle.feedback.ts b/packages/browser/src/index.bundle.feedback.ts index 7f8e663bfd0a..f8d2dfd14014 100644 --- a/packages/browser/src/index.bundle.feedback.ts +++ b/packages/browser/src/index.bundle.feedback.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, loggerShim, replayIntegrationShim, } from '@sentry-internal/integration-shims'; @@ -15,6 +16,7 @@ export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackAsyncIntegration as feedbackAsyncIntegration, feedbackAsyncIntegration as feedbackIntegration, replayIntegrationShim as replayIntegration, diff --git a/packages/browser/src/index.bundle.logs.metrics.ts b/packages/browser/src/index.bundle.logs.metrics.ts index 461362830e7b..f03371dc40b8 100644 --- a/packages/browser/src/index.bundle.logs.metrics.ts +++ b/packages/browser/src/index.bundle.logs.metrics.ts @@ -9,6 +9,8 @@ export * from './index.bundle.base'; // TODO(v11): Export metrics here once we remove it from the base bundle. export { logger, consoleLoggingIntegration } from '@sentry/core'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; + export { browserTracingIntegrationShim as browserTracingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, diff --git a/packages/browser/src/index.bundle.replay.feedback.ts b/packages/browser/src/index.bundle.replay.feedback.ts index 60c2a0e2ac4b..da307df3a951 100644 --- a/packages/browser/src/index.bundle.replay.feedback.ts +++ b/packages/browser/src/index.bundle.replay.feedback.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, loggerShim, } from '@sentry-internal/integration-shims'; import { feedbackAsyncIntegration } from './feedbackAsync'; @@ -14,6 +15,7 @@ export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackAsyncIntegration as feedbackAsyncIntegration, feedbackAsyncIntegration as feedbackIntegration, }; diff --git a/packages/browser/src/index.bundle.replay.logs.metrics.ts b/packages/browser/src/index.bundle.replay.logs.metrics.ts index ce4f3334e21a..6ceb7623d77f 100644 --- a/packages/browser/src/index.bundle.replay.logs.metrics.ts +++ b/packages/browser/src/index.bundle.replay.logs.metrics.ts @@ -7,6 +7,8 @@ export { logger, consoleLoggingIntegration } from '@sentry/core'; export { replayIntegration, getReplay } from '@sentry-internal/replay'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; + export { browserTracingIntegrationShim as browserTracingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, diff --git a/packages/browser/src/index.bundle.replay.ts b/packages/browser/src/index.bundle.replay.ts index 9a370ae51b81..e305596f190c 100644 --- a/packages/browser/src/index.bundle.replay.ts +++ b/packages/browser/src/index.bundle.replay.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, feedbackIntegrationShim, loggerShim, } from '@sentry-internal/integration-shims'; @@ -14,6 +15,7 @@ export { replayIntegration, getReplay } from '@sentry-internal/replay'; export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration, }; diff --git a/packages/browser/src/index.bundle.tracing.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.logs.metrics.ts index ce6a65061385..d10bfea67687 100644 --- a/packages/browser/src/index.bundle.tracing.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.logs.metrics.ts @@ -25,6 +25,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts index 9fb81d9a4750..6caef09459ae 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts @@ -25,6 +25,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index b6b298189aef..dbff7b4dd7b3 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -1,5 +1,9 @@ import { registerSpanErrorInstrumentation } from '@sentry/core'; -import { consoleLoggingIntegrationShim, loggerShim } from '@sentry-internal/integration-shims'; +import { + consoleLoggingIntegrationShim, + elementTimingIntegrationShim, + loggerShim, +} from '@sentry-internal/integration-shims'; import { feedbackAsyncIntegration } from './feedbackAsync'; registerSpanErrorInstrumentation(); @@ -26,6 +30,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegrationShim as elementTimingIntegration }; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts index 6b856e7a37cc..9972cd85ca8a 100644 --- a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts @@ -25,6 +25,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index a20a7b8388f1..f95e3d6cdcc9 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -1,5 +1,10 @@ import { registerSpanErrorInstrumentation } from '@sentry/core'; -import { consoleLoggingIntegrationShim, feedbackIntegrationShim, loggerShim } from '@sentry-internal/integration-shims'; +import { + consoleLoggingIntegrationShim, + elementTimingIntegrationShim, + feedbackIntegrationShim, + loggerShim, +} from '@sentry-internal/integration-shims'; registerSpanErrorInstrumentation(); @@ -25,6 +30,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegrationShim as elementTimingIntegration }; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index c3cb0a85cf1d..38186b3aded2 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -1,6 +1,7 @@ import { registerSpanErrorInstrumentation } from '@sentry/core'; import { consoleLoggingIntegrationShim, + elementTimingIntegrationShim, feedbackIntegrationShim, loggerShim, replayIntegrationShim, @@ -30,6 +31,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegrationShim as elementTimingIntegration }; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.bundle.ts b/packages/browser/src/index.bundle.ts index cd7de6dd80c8..7dfcd30ad2ef 100644 --- a/packages/browser/src/index.bundle.ts +++ b/packages/browser/src/index.bundle.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, feedbackIntegrationShim, loggerShim, replayIntegrationShim, @@ -13,6 +14,7 @@ export { consoleLoggingIntegrationShim as consoleLoggingIntegration, loggerShim export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration, replayIntegrationShim as replayIntegration, diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 70a6595d07d9..dbf39482e3e2 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -39,6 +39,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index b6dc8b2e92b8..7eb87cd1d833 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -11,6 +11,7 @@ import type { import { addNonEnumerableProperty, browserPerformanceTimeOrigin, + consoleSandbox, dateTimestampInSeconds, debug, generateSpanId, @@ -39,7 +40,6 @@ import { addHistoryInstrumentationHandler, addPerformanceEntries, registerInpInteractionListener, - startTrackingElementTiming, startTrackingINP, startTrackingInteractions, startTrackingLongAnimationFrames, @@ -146,12 +146,10 @@ export interface BrowserTracingOptions { enableInp: boolean; /** - * If true, Sentry will capture [element timing](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming) - * information and add it to the corresponding transaction. - * - * Default: true + * @deprecated This option is no longer used. Element timing is now tracked via the standalone + * `elementTimingIntegration`. Add it to your `integrations` array to collect element timing metrics. */ - enableElementTiming: boolean; + enableElementTiming?: boolean; /** * Flag to disable patching all together for fetch requests. @@ -337,7 +335,6 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { enableLongTask: true, enableLongAnimationFrame: true, enableInp: true, - enableElementTiming: true, ignoreResourceSpans: [], ignorePerformanceApiSpans: [], detectRedirects: true, @@ -358,6 +355,15 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { * We explicitly export the proper type here, as this has to be extended in some cases. */ export const browserTracingIntegration = ((options: Partial = {}) => { + if ('enableElementTiming' in options) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + '[Sentry] `enableElementTiming` is deprecated and no longer has any effect. Use the standalone `elementTimingIntegration` instead.', + ); + }); + } + const latestRoute: RouteInfo = { name: undefined, source: undefined, @@ -371,7 +377,6 @@ export const browserTracingIntegration = ((options: Partial { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('You are using elementTimingIntegration() even though this bundle does not include element timing.'); + }); + + return { + name: 'ElementTiming', + }; +}); diff --git a/packages/integration-shims/src/index.ts b/packages/integration-shims/src/index.ts index 1d535b6da35d..4cabb8a5e36f 100644 --- a/packages/integration-shims/src/index.ts +++ b/packages/integration-shims/src/index.ts @@ -2,4 +2,5 @@ export { feedbackIntegrationShim } from './Feedback'; export { replayIntegrationShim } from './Replay'; export { browserTracingIntegrationShim } from './BrowserTracing'; export { launchDarklyIntegrationShim, buildLaunchDarklyFlagUsedHandlerShim } from './launchDarkly'; +export { elementTimingIntegrationShim } from './ElementTiming'; export { loggerShim, consoleLoggingIntegrationShim } from './logs'; From e3bdbed752a0b5667dd40b6590e0132476014c13 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 26 Mar 2026 19:05:57 +0100 Subject: [PATCH 09/39] ref(core): Introduce instrumented method registry for AI integrations (#19981) Replace the shared `getOperationName()` function with per-provider method registries that map API paths to their operation name and streaming behavior. This explicitly couples the instrumented methods and necessary metadata in one place instead of having convoluted substring matching in multiple places that can be quite hard to reason about. Closes #19987 (added automatically) --- packages/core/src/tracing/ai/utils.ts | 50 +++++++------------ .../src/tracing/anthropic-ai/constants.ts | 20 ++++---- .../core/src/tracing/anthropic-ai/index.ts | 37 +++++++------- .../core/src/tracing/anthropic-ai/types.ts | 7 ++- .../core/src/tracing/anthropic-ai/utils.ts | 10 +--- .../src/tracing/google-genai/constants.ts | 17 ++++--- .../core/src/tracing/google-genai/index.ts | 49 ++++++++++-------- .../core/src/tracing/google-genai/types.ts | 7 ++- .../core/src/tracing/google-genai/utils.ts | 24 --------- packages/core/src/tracing/openai/constants.ts | 14 +++--- packages/core/src/tracing/openai/index.ts | 39 ++++++++------- packages/core/src/tracing/openai/types.ts | 7 ++- packages/core/src/tracing/openai/utils.ts | 9 ---- .../test/lib/utils/anthropic-utils.test.ts | 11 ---- .../test/lib/utils/google-genai-utils.test.ts | 22 +------- .../core/test/lib/utils/openai-utils.test.ts | 42 +--------------- 16 files changed, 134 insertions(+), 231 deletions(-) diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index a454e5803c63..d9628e3c75e2 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -17,6 +17,24 @@ export interface AIRecordingOptions { recordOutputs?: boolean; } +/** + * A method registry entry describes a single instrumented method: + * which gen_ai operation it maps to and whether it is intrinsically streaming. + */ +export interface InstrumentedMethodEntry { + /** Operation name (e.g. 'chat', 'embeddings', 'generate_content') */ + operation: string; + /** True if the method itself is always streaming (not param-based) */ + streaming?: boolean; +} + +/** + * Maps method paths to their registry entries. + * Used by proxy-based AI client instrumentations to determine which methods + * to instrument, what operation name to use, and whether they stream. + */ +export type InstrumentedMethodRegistry = Record; + /** * Resolves AI recording options by falling back to the client's `sendDefaultPii` setting. * Precedence: explicit option > sendDefaultPii > false @@ -30,38 +48,6 @@ export function resolveAIRecordingOptions(options? } as T & Required; } -/** - * Maps AI method paths to OpenTelemetry semantic convention operation names - * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#llm-request-spans - */ -export function getOperationName(methodPath: string): string { - // OpenAI: chat.completions.create, responses.create, conversations.create - // Anthropic: messages.create, messages.stream, completions.create - // Google GenAI: chats.create, chat.sendMessage, chat.sendMessageStream - if ( - methodPath.includes('completions') || - methodPath.includes('responses') || - methodPath.includes('conversations') || - methodPath.includes('messages') || - methodPath.includes('chat') - ) { - return 'chat'; - } - // OpenAI: embeddings.create - if (methodPath.includes('embeddings')) { - return 'embeddings'; - } - // Google GenAI: models.generateContent, models.generateContentStream (must be before 'models' check) - if (methodPath.includes('generateContent')) { - return 'generate_content'; - } - // Anthropic: models.get, models.retrieve (metadata retrieval only) - if (methodPath.includes('models')) { - return 'models'; - } - return methodPath.split('.').pop() || 'unknown'; -} - /** * Build method path from current traversal */ diff --git a/packages/core/src/tracing/anthropic-ai/constants.ts b/packages/core/src/tracing/anthropic-ai/constants.ts index 7e6c66196a82..4441a32b98ca 100644 --- a/packages/core/src/tracing/anthropic-ai/constants.ts +++ b/packages/core/src/tracing/anthropic-ai/constants.ts @@ -1,13 +1,15 @@ +import type { InstrumentedMethodRegistry } from '../ai/utils'; + export const ANTHROPIC_AI_INTEGRATION_NAME = 'Anthropic_AI'; // https://docs.anthropic.com/en/api/messages // https://docs.anthropic.com/en/api/models-list -export const ANTHROPIC_AI_INSTRUMENTED_METHODS = [ - 'messages.create', - 'messages.stream', - 'messages.countTokens', - 'models.get', - 'completions.create', - 'models.retrieve', - 'beta.messages.create', -] as const; +export const ANTHROPIC_METHOD_REGISTRY = { + 'messages.create': { operation: 'chat' }, + 'messages.stream': { operation: 'chat', streaming: true }, + 'messages.countTokens': { operation: 'chat' }, + 'models.get': { operation: 'models' }, + 'completions.create': { operation: 'chat' }, + 'models.retrieve': { operation: 'models' }, + 'beta.messages.create': { operation: 'chat' }, +} as const satisfies InstrumentedMethodRegistry; diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index 6217a08f3ffc..226aa8e1d544 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -21,30 +21,25 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import type { InstrumentedMethodEntry } from '../ai/utils'; import { buildMethodPath, - getOperationName, resolveAIRecordingOptions, setTokenUsageAttributes, wrapPromiseWithMethods, } from '../ai/utils'; +import { ANTHROPIC_METHOD_REGISTRY } from './constants'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; -import type { - AnthropicAiInstrumentedMethod, - AnthropicAiOptions, - AnthropicAiResponse, - AnthropicAiStreamingEvent, - ContentBlock, -} from './types'; -import { handleResponseError, messagesFromParams, setMessagesAttribute, shouldInstrument } from './utils'; +import type { AnthropicAiOptions, AnthropicAiResponse, AnthropicAiStreamingEvent, ContentBlock } from './types'; +import { handleResponseError, messagesFromParams, setMessagesAttribute } from './utils'; /** * Extract request attributes from method arguments */ -function extractRequestAttributes(args: unknown[], methodPath: string): Record { +function extractRequestAttributes(args: unknown[], methodPath: string, operationName: string): Record { const attributes: Record = { [GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic', - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getOperationName(methodPath), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: operationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.anthropic', }; @@ -263,19 +258,20 @@ function handleStreamingRequest( */ function instrumentMethod( originalMethod: (...args: T) => R | Promise, - methodPath: AnthropicAiInstrumentedMethod, + methodPath: string, + instrumentedMethod: InstrumentedMethodEntry, context: unknown, options: AnthropicAiOptions, ): (...args: T) => R | Promise { return new Proxy(originalMethod, { apply(target, thisArg, args: T): R | Promise { - const requestAttributes = extractRequestAttributes(args, methodPath); + const operationName = instrumentedMethod.operation; + const requestAttributes = extractRequestAttributes(args, methodPath, operationName); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; - const operationName = getOperationName(methodPath); const params = typeof args[0] === 'object' ? (args[0] as Record) : undefined; const isStreamRequested = Boolean(params?.stream); - const isStreamingMethod = methodPath === 'messages.stream'; + const isStreamingMethod = instrumentedMethod.streaming === true; if (isStreamRequested || isStreamingMethod) { return handleStreamingRequest( @@ -343,8 +339,15 @@ function createDeepProxy(target: T, currentPath = '', options: const value = (obj as Record)[prop]; const methodPath = buildMethodPath(currentPath, String(prop)); - if (typeof value === 'function' && shouldInstrument(methodPath)) { - return instrumentMethod(value as (...args: unknown[]) => unknown | Promise, methodPath, obj, options); + const instrumentedMethod = ANTHROPIC_METHOD_REGISTRY[methodPath as keyof typeof ANTHROPIC_METHOD_REGISTRY]; + if (typeof value === 'function' && instrumentedMethod) { + return instrumentMethod( + value as (...args: unknown[]) => unknown | Promise, + methodPath, + instrumentedMethod, + obj, + options, + ); } if (typeof value === 'function') { diff --git a/packages/core/src/tracing/anthropic-ai/types.ts b/packages/core/src/tracing/anthropic-ai/types.ts index 124b7c7f73be..ba281ef82a0d 100644 --- a/packages/core/src/tracing/anthropic-ai/types.ts +++ b/packages/core/src/tracing/anthropic-ai/types.ts @@ -1,4 +1,4 @@ -import type { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; +import type { ANTHROPIC_METHOD_REGISTRY } from './constants'; export interface AnthropicAiOptions { /** @@ -84,7 +84,10 @@ export interface AnthropicAiIntegration { options: AnthropicAiOptions; } -export type AnthropicAiInstrumentedMethod = (typeof ANTHROPIC_AI_INSTRUMENTED_METHODS)[number]; +/** + * @deprecated This type is no longer used and will be removed in the next major version. + */ +export type AnthropicAiInstrumentedMethod = keyof typeof ANTHROPIC_METHOD_REGISTRY; /** * Message type for Anthropic AI diff --git a/packages/core/src/tracing/anthropic-ai/utils.ts b/packages/core/src/tracing/anthropic-ai/utils.ts index e2cadcec331b..b70d9adcfa67 100644 --- a/packages/core/src/tracing/anthropic-ai/utils.ts +++ b/packages/core/src/tracing/anthropic-ai/utils.ts @@ -8,15 +8,7 @@ import { GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils'; -import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; -import type { AnthropicAiInstrumentedMethod, AnthropicAiResponse } from './types'; - -/** - * Check if a method path should be instrumented - */ -export function shouldInstrument(methodPath: string): methodPath is AnthropicAiInstrumentedMethod { - return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod); -} +import type { AnthropicAiResponse } from './types'; /** * Set the messages and messages original length attributes. diff --git a/packages/core/src/tracing/google-genai/constants.ts b/packages/core/src/tracing/google-genai/constants.ts index b06e46e18755..6b9f2db990f1 100644 --- a/packages/core/src/tracing/google-genai/constants.ts +++ b/packages/core/src/tracing/google-genai/constants.ts @@ -1,16 +1,19 @@ +import type { InstrumentedMethodRegistry } from '../ai/utils'; + export const GOOGLE_GENAI_INTEGRATION_NAME = 'Google_GenAI'; // https://ai.google.dev/api/rest/v1/models/generateContent // https://ai.google.dev/api/rest/v1/chats/sendMessage // https://googleapis.github.io/js-genai/release_docs/classes/models.Models.html#generatecontentstream // https://googleapis.github.io/js-genai/release_docs/classes/chats.Chat.html#sendmessagestream -export const GOOGLE_GENAI_INSTRUMENTED_METHODS = [ - 'models.generateContent', - 'models.generateContentStream', - 'chats.create', - 'sendMessage', - 'sendMessageStream', -] as const; +export const GOOGLE_GENAI_METHOD_REGISTRY = { + 'models.generateContent': { operation: 'generate_content' }, + 'models.generateContentStream': { operation: 'generate_content', streaming: true }, + 'chats.create': { operation: 'chat' }, + // chat.* paths are built by createDeepProxy when it proxies the chat instance with CHAT_PATH as base + 'chat.sendMessage': { operation: 'chat' }, + 'chat.sendMessageStream': { operation: 'chat', streaming: true }, +} as const satisfies InstrumentedMethodRegistry; // Constants for internal use export const GOOGLE_GENAI_SYSTEM_NAME = 'google_genai'; diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index 6170163a626b..a6ce47eedbd8 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -26,18 +26,13 @@ import { GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { truncateGenAiMessages } from '../ai/messageTruncation'; -import { buildMethodPath, extractSystemInstructions, getOperationName, resolveAIRecordingOptions } from '../ai/utils'; -import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; +import type { InstrumentedMethodEntry } from '../ai/utils'; +import { buildMethodPath, extractSystemInstructions, resolveAIRecordingOptions } from '../ai/utils'; +import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_METHOD_REGISTRY, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; -import type { - Candidate, - ContentPart, - GoogleGenAIIstrumentedMethod, - GoogleGenAIOptions, - GoogleGenAIResponse, -} from './types'; +import type { Candidate, ContentPart, GoogleGenAIOptions, GoogleGenAIResponse } from './types'; import type { ContentListUnion, ContentUnion, Message, PartListUnion } from './utils'; -import { contentUnionToMessages, isStreamingMethod, shouldInstrument } from './utils'; +import { contentUnionToMessages } from './utils'; /** * Extract model from parameters or chat context object @@ -99,13 +94,13 @@ function extractConfigAttributes(config: Record): Record, context?: unknown, ): Record { const attributes: Record = { [GEN_AI_SYSTEM_ATTRIBUTE]: GOOGLE_GENAI_SYSTEM_NAME, - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getOperationName(methodPath), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: operationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', }; @@ -251,7 +246,8 @@ function addResponseAttributes(span: Span, response: GoogleGenAIResponse, record */ function instrumentMethod( originalMethod: (...args: T) => R | Promise, - methodPath: GoogleGenAIIstrumentedMethod, + methodPath: string, + instrumentedMethod: InstrumentedMethodEntry, context: unknown, options: GoogleGenAIOptions, ): (...args: T) => R | Promise { @@ -259,13 +255,13 @@ function instrumentMethod( return new Proxy(originalMethod, { apply(target, _, args: T): R | Promise { + const operationName = instrumentedMethod.operation; const params = args[0] as Record | undefined; - const requestAttributes = extractRequestAttributes(methodPath, params, context); + const requestAttributes = extractRequestAttributes(operationName, params, context); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; - const operationName = getOperationName(methodPath); // Check if this is a streaming method - if (isStreamingMethod(methodPath)) { + if (instrumentedMethod.streaming) { // Use startSpanManual for streaming methods to control span lifecycle return startSpanManual( { @@ -338,12 +334,19 @@ function createDeepProxy(target: T, currentPath = '', options: const value = Reflect.get(t, prop, receiver); const methodPath = buildMethodPath(currentPath, String(prop)); - if (typeof value === 'function' && shouldInstrument(methodPath)) { + const instrumentedMethod = GOOGLE_GENAI_METHOD_REGISTRY[methodPath as keyof typeof GOOGLE_GENAI_METHOD_REGISTRY]; + if (typeof value === 'function' && instrumentedMethod) { // Special case: chats.create is synchronous but needs both instrumentation AND result proxying if (methodPath === CHATS_CREATE_METHOD) { - const instrumentedMethod = instrumentMethod(value as (...args: unknown[]) => unknown, methodPath, t, options); + const wrappedMethod = instrumentMethod( + value as (...args: unknown[]) => unknown, + methodPath, + instrumentedMethod, + t, + options, + ); return function instrumentedAndProxiedCreate(...args: unknown[]): unknown { - const result = instrumentedMethod(...args); + const result = wrappedMethod(...args); // If the result is an object (like a chat instance), proxy it too if (result && typeof result === 'object') { return createDeepProxy(result, CHAT_PATH, options); @@ -352,7 +355,13 @@ function createDeepProxy(target: T, currentPath = '', options: }; } - return instrumentMethod(value as (...args: unknown[]) => Promise, methodPath, t, options); + return instrumentMethod( + value as (...args: unknown[]) => Promise, + methodPath, + instrumentedMethod, + t, + options, + ); } if (typeof value === 'function') { diff --git a/packages/core/src/tracing/google-genai/types.ts b/packages/core/src/tracing/google-genai/types.ts index 9a2138a7843d..948f57f81bf5 100644 --- a/packages/core/src/tracing/google-genai/types.ts +++ b/packages/core/src/tracing/google-genai/types.ts @@ -1,4 +1,4 @@ -import type { GOOGLE_GENAI_INSTRUMENTED_METHODS } from './constants'; +import type { GOOGLE_GENAI_METHOD_REGISTRY } from './constants'; export interface GoogleGenAIOptions { /** @@ -179,7 +179,10 @@ export interface GoogleGenAIChat { sendMessageStream: (...args: unknown[]) => Promise>; } -export type GoogleGenAIIstrumentedMethod = (typeof GOOGLE_GENAI_INSTRUMENTED_METHODS)[number]; +/** + * @deprecated This type is no longer used and will be removed in the next major version. + */ +export type GoogleGenAIIstrumentedMethod = keyof typeof GOOGLE_GENAI_METHOD_REGISTRY; // Export the response type for use in instrumentation export type GoogleGenAIResponse = GenerateContentResponse; diff --git a/packages/core/src/tracing/google-genai/utils.ts b/packages/core/src/tracing/google-genai/utils.ts index 4280957ce43f..9286822fa60d 100644 --- a/packages/core/src/tracing/google-genai/utils.ts +++ b/packages/core/src/tracing/google-genai/utils.ts @@ -1,27 +1,3 @@ -import { GOOGLE_GENAI_INSTRUMENTED_METHODS } from './constants'; -import type { GoogleGenAIIstrumentedMethod } from './types'; - -/** - * Check if a method path should be instrumented - */ -export function shouldInstrument(methodPath: string): methodPath is GoogleGenAIIstrumentedMethod { - // Check for exact matches first (like 'models.generateContent') - if (GOOGLE_GENAI_INSTRUMENTED_METHODS.includes(methodPath as GoogleGenAIIstrumentedMethod)) { - return true; - } - - // Check for method name matches (like 'sendMessage' from chat instances) - const methodName = methodPath.split('.').pop(); - return GOOGLE_GENAI_INSTRUMENTED_METHODS.includes(methodName as GoogleGenAIIstrumentedMethod); -} - -/** - * Check if a method is a streaming method - */ -export function isStreamingMethod(methodPath: string): boolean { - return methodPath.includes('Stream'); -} - // Copied from https://googleapis.github.io/js-genai/release_docs/index.html export type ContentListUnion = Content | Content[] | PartListUnion; export type ContentUnion = Content | PartUnion[] | PartUnion; diff --git a/packages/core/src/tracing/openai/constants.ts b/packages/core/src/tracing/openai/constants.ts index 426cda443680..f28571aae09b 100644 --- a/packages/core/src/tracing/openai/constants.ts +++ b/packages/core/src/tracing/openai/constants.ts @@ -1,16 +1,18 @@ +import type { InstrumentedMethodRegistry } from '../ai/utils'; + export const OPENAI_INTEGRATION_NAME = 'OpenAI'; // https://platform.openai.com/docs/quickstart?api-mode=responses // https://platform.openai.com/docs/quickstart?api-mode=chat // https://platform.openai.com/docs/api-reference/conversations -export const INSTRUMENTED_METHODS = [ - 'responses.create', - 'chat.completions.create', - 'embeddings.create', +export const OPENAI_METHOD_REGISTRY = { + 'responses.create': { operation: 'chat' }, + 'chat.completions.create': { operation: 'chat' }, + 'embeddings.create': { operation: 'embeddings' }, // Conversations API - for conversation state management // https://platform.openai.com/docs/guides/conversation-state - 'conversations.create', -] as const; + 'conversations.create': { operation: 'chat' }, +} as const satisfies InstrumentedMethodRegistry; export const RESPONSES_TOOL_CALL_EVENT_TYPES = [ 'response.output_item.added', 'response.function_call_arguments.delta', diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index fc8e67115e87..0f83bf2cd3eb 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -16,23 +16,17 @@ import { GEN_AI_SYSTEM_ATTRIBUTE, GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import type { InstrumentedMethodEntry } from '../ai/utils'; import { + buildMethodPath, extractSystemInstructions, - getOperationName, getTruncatedJsonString, resolveAIRecordingOptions, wrapPromiseWithMethods, - buildMethodPath, } from '../ai/utils'; +import { OPENAI_METHOD_REGISTRY } from './constants'; import { instrumentStream } from './streaming'; -import type { - ChatCompletionChunk, - InstrumentedMethod, - OpenAiOptions, - OpenAiResponse, - OpenAIStream, - ResponseStreamingEvent, -} from './types'; +import type { ChatCompletionChunk, OpenAiOptions, OpenAiResponse, OpenAIStream, ResponseStreamingEvent } from './types'; import { addChatCompletionAttributes, addConversationAttributes, @@ -43,7 +37,6 @@ import { isConversationResponse, isEmbeddingsResponse, isResponsesApiResponse, - shouldInstrument, } from './utils'; /** @@ -72,10 +65,10 @@ function extractAvailableTools(params: Record): string | undefi /** * Extract request attributes from method arguments */ -function extractRequestAttributes(args: unknown[], methodPath: string): Record { +function extractRequestAttributes(args: unknown[], operationName: string): Record { const attributes: Record = { [GEN_AI_SYSTEM_ATTRIBUTE]: 'openai', - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getOperationName(methodPath), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: operationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.openai', }; @@ -181,14 +174,15 @@ function addRequestAttributes(span: Span, params: Record, opera */ function instrumentMethod( originalMethod: (...args: T) => Promise, - methodPath: InstrumentedMethod, + methodPath: string, + instrumentedMethod: InstrumentedMethodEntry, context: unknown, options: OpenAiOptions, ): (...args: T) => Promise { - return function instrumentedMethod(...args: T): Promise { - const requestAttributes = extractRequestAttributes(args, methodPath); + return function instrumentedCall(...args: T): Promise { + const operationName = instrumentedMethod.operation; + const requestAttributes = extractRequestAttributes(args, operationName); const model = (requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] as string) || 'unknown'; - const operationName = getOperationName(methodPath); const params = args[0] as Record | undefined; const isStreamRequested = params && typeof params === 'object' && params.stream === true; @@ -278,8 +272,15 @@ function createDeepProxy(target: T, currentPath = '', options: const value = (obj as Record)[prop]; const methodPath = buildMethodPath(currentPath, String(prop)); - if (typeof value === 'function' && shouldInstrument(methodPath)) { - return instrumentMethod(value as (...args: unknown[]) => Promise, methodPath, obj, options); + const instrumentedMethod = OPENAI_METHOD_REGISTRY[methodPath as keyof typeof OPENAI_METHOD_REGISTRY]; + if (typeof value === 'function' && instrumentedMethod) { + return instrumentMethod( + value as (...args: unknown[]) => Promise, + methodPath, + instrumentedMethod, + obj, + options, + ); } if (typeof value === 'function') { diff --git a/packages/core/src/tracing/openai/types.ts b/packages/core/src/tracing/openai/types.ts index 94809041d94e..dd6872bb691b 100644 --- a/packages/core/src/tracing/openai/types.ts +++ b/packages/core/src/tracing/openai/types.ts @@ -1,4 +1,4 @@ -import type { INSTRUMENTED_METHODS } from './constants'; +import type { OPENAI_METHOD_REGISTRY } from './constants'; /** * Attribute values may be any non-nullish primitive value except an object. @@ -360,4 +360,7 @@ export interface OpenAiIntegration { options: OpenAiOptions; } -export type InstrumentedMethod = (typeof INSTRUMENTED_METHODS)[number]; +/** + * @deprecated This type is no longer used and will be removed in the next major version. + */ +export type InstrumentedMethod = keyof typeof OPENAI_METHOD_REGISTRY; diff --git a/packages/core/src/tracing/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts index 2ccaaeb3264a..e7ad110e00bb 100644 --- a/packages/core/src/tracing/openai/utils.ts +++ b/packages/core/src/tracing/openai/utils.ts @@ -22,10 +22,8 @@ import { OPENAI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { INSTRUMENTED_METHODS } from './constants'; import type { ChatCompletionChunk, - InstrumentedMethod, OpenAiChatCompletionObject, OpenAIConversationObject, OpenAICreateEmbeddingsObject, @@ -33,13 +31,6 @@ import type { ResponseStreamingEvent, } from './types'; -/** - * Check if a method path should be instrumented - */ -export function shouldInstrument(methodPath: string): methodPath is InstrumentedMethod { - return INSTRUMENTED_METHODS.includes(methodPath as InstrumentedMethod); -} - /** * Check if response is a Chat Completion object */ diff --git a/packages/core/test/lib/utils/anthropic-utils.test.ts b/packages/core/test/lib/utils/anthropic-utils.test.ts index b912af40e35e..012ff9e6ccb6 100644 --- a/packages/core/test/lib/utils/anthropic-utils.test.ts +++ b/packages/core/test/lib/utils/anthropic-utils.test.ts @@ -3,7 +3,6 @@ import { mapAnthropicErrorToStatusMessage, messagesFromParams, setMessagesAttribute, - shouldInstrument, } from '../../../src/tracing/anthropic-ai/utils'; import type { Span } from '../../../src/types-hoist/span'; @@ -29,16 +28,6 @@ describe('anthropic-ai-utils', () => { }); }); - describe('shouldInstrument', () => { - it('should instrument known methods', () => { - expect(shouldInstrument('models.get')).toBe(true); - }); - - it('should not instrument unknown methods', () => { - expect(shouldInstrument('models.unknown.thing')).toBe(false); - }); - }); - describe('messagesFromParams', () => { it('includes system message in messages list', () => { expect( diff --git a/packages/core/test/lib/utils/google-genai-utils.test.ts b/packages/core/test/lib/utils/google-genai-utils.test.ts index 7b9c6d80c773..93d7750994f1 100644 --- a/packages/core/test/lib/utils/google-genai-utils.test.ts +++ b/packages/core/test/lib/utils/google-genai-utils.test.ts @@ -1,26 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { ContentListUnion } from '../../../src/tracing/google-genai/utils'; -import { contentUnionToMessages, isStreamingMethod, shouldInstrument } from '../../../src/tracing/google-genai/utils'; - -describe('isStreamingMethod', () => { - it('detects streaming methods', () => { - expect(isStreamingMethod('messageStreamBlah')).toBe(true); - expect(isStreamingMethod('blahblahblah generateContentStream')).toBe(true); - expect(isStreamingMethod('blahblahblah sendMessageStream')).toBe(true); - expect(isStreamingMethod('blahblahblah generateContentStream')).toBe(true); - expect(isStreamingMethod('blahblahblah sendMessageStream')).toBe(true); - expect(isStreamingMethod('blahblahblah generateContent')).toBe(false); - expect(isStreamingMethod('blahblahblah sendMessage')).toBe(false); - }); -}); - -describe('shouldInstrument', () => { - it('detects which methods to instrument', () => { - expect(shouldInstrument('models.generateContent')).toBe(true); - expect(shouldInstrument('some.path.to.sendMessage')).toBe(true); - expect(shouldInstrument('unknown')).toBe(false); - }); -}); +import { contentUnionToMessages } from '../../../src/tracing/google-genai/utils'; describe('convert google-genai messages to consistent message', () => { it('converts strings to messages', () => { diff --git a/packages/core/test/lib/utils/openai-utils.test.ts b/packages/core/test/lib/utils/openai-utils.test.ts index ca0aa08ca0f7..3f8fd0045f2e 100644 --- a/packages/core/test/lib/utils/openai-utils.test.ts +++ b/packages/core/test/lib/utils/openai-utils.test.ts @@ -1,54 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { buildMethodPath, getOperationName } from '../../../src/tracing/ai/utils'; +import { buildMethodPath } from '../../../src/tracing/ai/utils'; import { isChatCompletionChunk, isChatCompletionResponse, isConversationResponse, isResponsesApiResponse, isResponsesApiStreamEvent, - shouldInstrument, } from '../../../src/tracing/openai/utils'; describe('openai-utils', () => { - describe('getOperationName', () => { - it('should return chat for chat.completions methods', () => { - expect(getOperationName('chat.completions.create')).toBe('chat'); - expect(getOperationName('some.path.chat.completions.method')).toBe('chat'); - }); - - it('should return chat for responses methods', () => { - expect(getOperationName('responses.create')).toBe('chat'); - expect(getOperationName('some.path.responses.method')).toBe('chat'); - }); - - it('should return chat for conversations methods', () => { - expect(getOperationName('conversations.create')).toBe('chat'); - expect(getOperationName('some.path.conversations.method')).toBe('chat'); - }); - - it('should return the last part of path for unknown methods', () => { - expect(getOperationName('some.unknown.method')).toBe('method'); - expect(getOperationName('create')).toBe('create'); - }); - - it('should return unknown for empty path', () => { - expect(getOperationName('')).toBe('unknown'); - }); - }); - - describe('shouldInstrument', () => { - it('should return true for instrumented methods', () => { - expect(shouldInstrument('responses.create')).toBe(true); - expect(shouldInstrument('chat.completions.create')).toBe(true); - expect(shouldInstrument('conversations.create')).toBe(true); - }); - - it('should return false for non-instrumented methods', () => { - expect(shouldInstrument('unknown.method')).toBe(false); - expect(shouldInstrument('')).toBe(false); - }); - }); - describe('buildMethodPath', () => { it('should build method path correctly', () => { expect(buildMethodPath('', 'chat')).toBe('chat'); From c0d52df9dbd0cd8fac8eea10e009a744d8839ee1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 26 Mar 2026 14:56:57 -0400 Subject: [PATCH 10/39] fix(node): Ensure startNewTrace propagates traceId in OTel environments (#19963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add OTel-aware `startNewTrace` implementation that injects the new traceId as a remote span context into the OTel context - Add `startNewTrace` to the `AsyncContextStrategy` interface so OTel can override the default behavior - Register the new implementation in the OTel async context strategy ### Root Cause `startNewTrace` set a new `traceId` on the Sentry scope's propagation context but only called `withActiveSpan(null, callback)`, which in OTel translates to `trace.deleteSpan(context.active())`. This removed the active span but did **not** inject the new traceId into the OTel context. Each subsequent `startInactiveSpan` call created a root span with a fresh random traceId from OTel's tracer. The fix follows the same pattern as `continueTrace` — injecting the traceId as a remote span context via `trace.setSpanContext()` so all spans in the callback inherit it. Closes #19952 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- packages/core/src/asyncContext/types.ts | 4 + packages/core/src/tracing/trace.ts | 5 + .../opentelemetry/src/asyncContextStrategy.ts | 3 +- packages/opentelemetry/src/trace.ts | 33 +++++++ packages/opentelemetry/test/trace.test.ts | 95 ++++++++++++++++++- 5 files changed, 138 insertions(+), 2 deletions(-) diff --git a/packages/core/src/asyncContext/types.ts b/packages/core/src/asyncContext/types.ts index 97af0af1b88a..be1ea92a7736 100644 --- a/packages/core/src/asyncContext/types.ts +++ b/packages/core/src/asyncContext/types.ts @@ -3,6 +3,7 @@ import type { getTraceData } from '../utils/traceData'; import type { continueTrace, startInactiveSpan, + startNewTrace, startSpan, startSpanManual, suppressTracing, @@ -76,4 +77,7 @@ export interface AsyncContextStrategy { * and `` HTML tags. */ continueTrace?: typeof continueTrace; + + /** Start a new trace, ensuring all spans in the callback share the same traceId. */ + startNewTrace?: typeof startNewTrace; } diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 28a5bccd4147..d3043808260f 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -291,6 +291,11 @@ export function suppressTracing(callback: () => T): T { * or page will automatically create a new trace. */ export function startNewTrace(callback: () => T): T { + const acs = getAcs(); + if (acs.startNewTrace) { + return acs.startNewTrace(callback); + } + return withScope(scope => { scope.setPropagationContext({ traceId: generateTraceId(), diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index 9f7b38d0b43d..7cb8dc0f54eb 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -6,7 +6,7 @@ import { SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, } from './constants'; -import { continueTrace, startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from './trace'; +import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual, withActiveSpan } from './trace'; import type { CurrentScopes } from './types'; import { getContextFromScope, getScopesFromContext } from './utils/contextData'; import { getActiveSpan } from './utils/getActiveSpan'; @@ -104,6 +104,7 @@ export function setOpenTelemetryContextAsyncContextStrategy(): void { suppressTracing, getTraceData, continueTrace, + startNewTrace, // The types here don't fully align, because our own `Span` type is narrower // than the OTEL one - but this is OK for here, as we now we'll only have OTEL spans passed around withActiveSpan: withActiveSpan as typeof defaultWithActiveSpan, diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index b651ea16ccab..7c9d09a169b9 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -10,6 +10,9 @@ import type { TraceContext, } from '@sentry/core'; import { + _INTERNAL_safeMathRandom, + generateSpanId, + generateTraceId, getClient, getCurrentScope, getDynamicSamplingContextFromScope, @@ -291,6 +294,36 @@ export function continueTrace(options: Parameters[0 return continueTraceAsRemoteSpan(context.active(), options, callback); } +/** + * Start a new trace with a unique traceId, ensuring all spans created within the callback + * share the same traceId. + * + * This is a custom version of `startNewTrace` for OTEL-powered environments. + * It injects the new traceId as a remote span context into the OTEL context, so that + * `startInactiveSpan` and `startSpan` pick it up correctly. + */ +export function startNewTrace(callback: () => T): T { + const traceId = generateTraceId(); + const spanId = generateSpanId(); + + const spanContext: SpanContext = { + traceId, + spanId, + isRemote: true, + traceFlags: TraceFlags.NONE, + }; + + const ctxWithTrace = trace.setSpanContext(context.active(), spanContext); + + return context.with(ctxWithTrace, () => { + getCurrentScope().setPropagationContext({ + traceId, + sampleRand: _INTERNAL_safeMathRandom(), + }); + return callback(); + }); +} + /** * Get the trace context for a given scope. * We have a custom implementation here because we need an OTEL-specific way to get the span from a scope. diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index aa8829341963..a6a7f35ab76a 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -21,7 +21,7 @@ import { } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getParentSpanId } from '../../../packages/opentelemetry/src/utils/getParentSpanId'; -import { continueTrace, startInactiveSpan, startSpan, startSpanManual } from '../src/trace'; +import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual } from '../src/trace'; import type { AbstractSpan } from '../src/types'; import { getActiveSpan } from '../src/utils/getActiveSpan'; import { getSamplingDecision } from '../src/utils/getSamplingDecision'; @@ -2093,6 +2093,99 @@ describe('span.end() timestamp conversion', () => { }); }); +describe('startNewTrace', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('sequential startInactiveSpan calls share the same traceId', () => { + startNewTrace(() => { + const propagationContext = getCurrentScope().getPropagationContext(); + + const span1 = startInactiveSpan({ name: 'span-1' }); + const span2 = startInactiveSpan({ name: 'span-2' }); + const span3 = startInactiveSpan({ name: 'span-3' }); + + const traceId1 = span1.spanContext().traceId; + const traceId2 = span2.spanContext().traceId; + const traceId3 = span3.spanContext().traceId; + + expect(traceId1).toBe(propagationContext.traceId); + expect(traceId2).toBe(propagationContext.traceId); + expect(traceId3).toBe(propagationContext.traceId); + + span1.end(); + span2.end(); + span3.end(); + }); + }); + + it('startSpan inside startNewTrace uses the correct traceId', () => { + startNewTrace(() => { + const propagationContext = getCurrentScope().getPropagationContext(); + + startSpan({ name: 'parent-span' }, parentSpan => { + const parentTraceId = parentSpan.spanContext().traceId; + expect(parentTraceId).toBe(propagationContext.traceId); + + const child = startInactiveSpan({ name: 'child-span' }); + expect(child.spanContext().traceId).toBe(propagationContext.traceId); + child.end(); + }); + }); + }); + + it('generates a different traceId than the outer trace', () => { + startSpan({ name: 'outer-span' }, outerSpan => { + const outerTraceId = outerSpan.spanContext().traceId; + + startNewTrace(() => { + const innerSpan = startInactiveSpan({ name: 'inner-span' }); + const innerTraceId = innerSpan.spanContext().traceId; + + expect(innerTraceId).not.toBe(outerTraceId); + + const propagationContext = getCurrentScope().getPropagationContext(); + expect(innerTraceId).toBe(propagationContext.traceId); + + innerSpan.end(); + }); + }); + }); + + it('allows spans to be sampled based on tracesSampleRate', () => { + startNewTrace(() => { + const span = startInactiveSpan({ name: 'sampled-span' }); + // tracesSampleRate is 1 in mockSdkInit, so spans should be sampled + // This verifies that TraceFlags.NONE on the remote span context does not + // cause the sampler to inherit a "not sampled" decision from the parent + expect(spanIsSampled(span)).toBe(true); + span.end(); + }); + }); + + it('does not leak the new traceId to the outer scope', () => { + const outerScope = getCurrentScope(); + const outerTraceId = outerScope.getPropagationContext().traceId; + + startNewTrace(() => { + // Manually set a known traceId on the inner scope to verify it doesn't leak + getCurrentScope().setPropagationContext({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + sampleRand: 0.5, + }); + }); + + const afterTraceId = outerScope.getPropagationContext().traceId; + expect(afterTraceId).toBe(outerTraceId); + expect(afterTraceId).not.toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + }); +}); + function getSpanName(span: AbstractSpan): string | undefined { return spanHasName(span) ? span.name : undefined; } From fa74db58b321684d248fca097fb7e8748c19954a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 27 Mar 2026 10:01:26 +0100 Subject: [PATCH 11/39] feat(core): Support embedding APIs in google-genai (#19797) Add instrumentation support for the Google GenAI embeddings API (`models.embedContent`). Docs: https://ai.google.dev/gemini-api/docs/embeddings Closes https://github.com/getsentry/sentry-javascript/issues/19535 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 3 + .../ai-providers/google-genai/mocks.js | 18 +++ .../ai-providers/google-genai/subject.js | 8 ++ .../tracing/ai-providers/google-genai/test.ts | 23 ++++ .../suites/tracing/google-genai/index.ts | 8 +- .../suites/tracing/google-genai/mocks.ts | 15 +++ .../suites/tracing/google-genai/test.ts | 13 ++ .../google-genai/scenario-embeddings.mjs | 77 +++++++++++ .../suites/tracing/google-genai/test.ts | 121 ++++++++++++++++++ .../src/tracing/google-genai/constants.ts | 1 + .../core/src/tracing/google-genai/index.ts | 24 +++- .../core/src/tracing/google-genai/types.ts | 2 + 12 files changed, 307 insertions(+), 6 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-embeddings.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f1d7232266..c34aea56bd79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Unreleased - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- feat(core): Support embedding APIs in google-genai ([#19797](https://github.com/getsentry/sentry-javascript/pull/19797)) + + Adds instrumentation for the Google GenAI [`embedContent`](https://ai.google.dev/gemini-api/docs/embeddings) API, creating `gen_ai.embeddings` spans. ## 10.46.0 diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js index 8aab37fb3a1e..d33f5dfbb285 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js @@ -39,6 +39,24 @@ export class MockGoogleGenAI { }, }; }, + embedContent: async (...args) => { + const params = args[0]; + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + throw error; + } + + return { + embeddings: [ + { + values: [0.1, 0.2, 0.3, 0.4, 0.5], + }, + ], + }; + }, generateContentStream: async () => { // Return a promise that resolves to an async generator return (async function* () { diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js index 14b95f2b6942..b506ec52195b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js @@ -30,3 +30,11 @@ const response = await chat.sendMessage({ }); console.log('Received response', response); + +// Test embedContent +const embedResponse = await client.models.embedContent({ + model: 'text-embedding-004', + contents: 'Hello world', +}); + +console.log('Received embed response', embedResponse); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts index 6774129f183e..c5c269d435e3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts @@ -29,3 +29,26 @@ sentryTest('manual Google GenAI instrumentation sends gen_ai transactions', asyn 'gen_ai.request.model': 'gemini-1.5-pro', }); }); + +sentryTest('manual Google GenAI instrumentation sends embeddings transactions', async ({ getLocalTestUrl, page }) => { + const transactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('text-embedding-004'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const req = await transactionPromise; + + const eventData = envelopeRequestParser(req); + + // Verify it's a gen_ai embeddings transaction + expect(eventData.transaction).toBe('embeddings text-embedding-004'); + expect(eventData.contexts?.trace?.op).toBe('gen_ai.embeddings'); + expect(eventData.contexts?.trace?.origin).toBe('auto.ai.google_genai'); + expect(eventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'embeddings', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'text-embedding-004', + }); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts index 4759ec9a107b..88328768c6f2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts @@ -55,7 +55,13 @@ export default Sentry.withSentry( ], }); - return new Response(JSON.stringify({ chatResponse, modelResponse })); + // Test 3: models.embedContent + const embedResponse = await client.models.embedContent({ + model: 'text-embedding-004', + contents: 'Hello world', + }); + + return new Response(JSON.stringify({ chatResponse, modelResponse, embedResponse })); }, }, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts index fc475234ef5c..c3eb23d7d5f5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts @@ -4,6 +4,7 @@ export class MockGoogleGenAI implements GoogleGenAIClient { public models: { generateContent: (...args: unknown[]) => Promise; generateContentStream: (...args: unknown[]) => Promise>; + embedContent: (...args: unknown[]) => Promise<{ embeddings: { values: number[] }[] }>; }; public chats: { create: (...args: unknown[]) => GoogleGenAIChat; @@ -49,6 +50,20 @@ export class MockGoogleGenAI implements GoogleGenAIClient { }, }; }, + embedContent: async (...args: unknown[]) => { + const params = args[0] as { model: string; contents?: unknown }; + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + (error as unknown as { status: number }).status = 404; + throw error; + } + + return { + embeddings: [{ values: [0.1, 0.2, 0.3, 0.4, 0.5] }], + }; + }, generateContentStream: async () => { // Return a promise that resolves to an async generator return (async function* (): AsyncGenerator { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts index d2657f55b1ed..7172366a54c5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts @@ -78,6 +78,19 @@ it('traces Google GenAI chat creation and message sending', async () => { op: 'gen_ai.generate_content', origin: 'auto.ai.google_genai', }), + // Fourth span - models.embedContent + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'text-embedding-004', + }), + description: 'embeddings text-embedding-004', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + }), ]), ); }) diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-embeddings.mjs new file mode 100644 index 000000000000..166e741cf199 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-embeddings.mjs @@ -0,0 +1,77 @@ +import { GoogleGenAI } from '@google/genai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockGoogleGenAIServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1beta/models/:model\\:batchEmbedContents', (req, res) => { + const model = req.params.model; + + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + res.send({ + embeddings: [ + { + values: [0.1, 0.2, 0.3, 0.4, 0.5], + }, + ], + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockGoogleGenAIServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new GoogleGenAI({ + apiKey: 'mock-api-key', + httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, + }); + + // Test 1: Basic embedContent with string contents + await client.models.embedContent({ + model: 'text-embedding-004', + contents: 'What is the capital of France?', + }); + + // Test 2: Error handling + try { + await client.models.embedContent({ + model: 'error-model', + contents: 'This will fail', + }); + } catch { + // Expected error + } + + // Test 3: embedContent with array contents + await client.models.embedContent({ + model: 'text-embedding-004', + contents: [ + { + role: 'user', + parts: [{ text: 'First input text' }], + }, + { + role: 'user', + parts: [{ text: 'Second input text' }], + }, + ], + }); + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 993984cc6b3d..91784a2de0e5 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 @@ -1,6 +1,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { + GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, @@ -601,4 +602,124 @@ describe('Google GenAI integration', () => { }); }, ); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embedContent with string contents + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'text-embedding-004', + }, + description: 'embeddings text-embedding-004', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Second span - embedContent error model + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'error-model', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'internal_error', + }), + // Third span - embedContent with array contents + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'text-embedding-004', + }, + description: 'embeddings text-embedding-004', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embedContent with PII + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'text-embedding-004', + [GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE]: 'What is the capital of France?', + }, + description: 'embeddings text-embedding-004', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Second span - embedContent error model with PII + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'error-model', + [GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE]: 'This will fail', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'internal_error', + }), + // Third span - embedContent with array contents and PII + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'text-embedding-004', + [GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE]: + '[{"role":"user","parts":[{"text":"First input text"}]},{"role":"user","parts":[{"text":"Second input text"}]}]', + }, + description: 'embeddings text-embedding-004', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates google genai embeddings 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 google genai embeddings spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS }) + .start() + .completed(); + }); + }); }); diff --git a/packages/core/src/tracing/google-genai/constants.ts b/packages/core/src/tracing/google-genai/constants.ts index 6b9f2db990f1..b23f870f49b7 100644 --- a/packages/core/src/tracing/google-genai/constants.ts +++ b/packages/core/src/tracing/google-genai/constants.ts @@ -9,6 +9,7 @@ export const GOOGLE_GENAI_INTEGRATION_NAME = 'Google_GenAI'; export const GOOGLE_GENAI_METHOD_REGISTRY = { 'models.generateContent': { operation: 'generate_content' }, 'models.generateContentStream': { operation: 'generate_content', streaming: true }, + 'models.embedContent': { operation: 'embeddings' }, 'chats.create': { operation: 'chat' }, // chat.* paths are built by createDeepProxy when it proxies the chat instance with CHAT_PATH as base 'chat.sendMessage': { operation: 'chat' }, diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index a6ce47eedbd8..b754a8c874c6 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; @@ -5,6 +6,7 @@ import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { handleCallbackErrors } from '../../utils/handleCallbackErrors'; import { + GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, @@ -132,7 +134,18 @@ function extractRequestAttributes( * This is only recorded if recordInputs is true. * Handles different parameter formats for different Google GenAI methods. */ -function addPrivateRequestAttributes(span: Span, params: Record): void { +function addPrivateRequestAttributes(span: Span, params: Record, isEmbeddings: boolean): void { + if (isEmbeddings) { + const contents = params.contents; + if (contents != null) { + span.setAttribute( + GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE, + typeof contents === 'string' ? contents : JSON.stringify(contents), + ); + } + return; + } + const messages: Message[] = []; // config.systemInstruction: ContentUnion @@ -252,6 +265,7 @@ function instrumentMethod( options: GoogleGenAIOptions, ): (...args: T) => R | Promise { const isSyncCreate = methodPath === CHATS_CREATE_METHOD; + const isEmbeddings = instrumentedMethod.operation === 'embeddings'; return new Proxy(originalMethod, { apply(target, _, args: T): R | Promise { @@ -272,7 +286,7 @@ function instrumentMethod( async (span: Span) => { try { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); + addPrivateRequestAttributes(span, params, isEmbeddings); } const stream = await target.apply(context, args); return instrumentStream(stream, span, Boolean(options.recordOutputs)) as R; @@ -300,7 +314,7 @@ function instrumentMethod( }, (span: Span) => { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); + addPrivateRequestAttributes(span, params, isEmbeddings); } return handleCallbackErrors( @@ -312,8 +326,8 @@ function instrumentMethod( }, () => {}, result => { - // Only add response attributes for content-producing methods, not for chats.create - if (!isSyncCreate) { + // Only add response attributes for content-producing methods, not for chats.create or embeddings + if (!isSyncCreate && !isEmbeddings) { addResponseAttributes(span, result, options.recordOutputs); } }, diff --git a/packages/core/src/tracing/google-genai/types.ts b/packages/core/src/tracing/google-genai/types.ts index 948f57f81bf5..abfb8141ce31 100644 --- a/packages/core/src/tracing/google-genai/types.ts +++ b/packages/core/src/tracing/google-genai/types.ts @@ -163,6 +163,8 @@ export interface GoogleGenAIClient { // https://googleapis.github.io/js-genai/release_docs/classes/models.Models.html#generatecontentstream // eslint-disable-next-line @typescript-eslint/no-explicit-any generateContentStream: (...args: unknown[]) => Promise>; + // https://googleapis.github.io/js-genai/release_docs/classes/models.Models.html#embedcontent + embedContent: (...args: unknown[]) => Promise; }; chats: { create: (...args: unknown[]) => GoogleGenAIChat; From cd6d8cc16743481450b7e0c08781085ba336e042 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:33:23 +0100 Subject: [PATCH 12/39] feat(deps): Bump handlebars from 4.7.7 to 4.7.9 (#20008) Bumps [handlebars](https://github.com/handlebars-lang/handlebars.js) from 4.7.7 to 4.7.9.
Release notes

Sourced from handlebars's releases.

v4.7.9

Commits

v4.7.8

  • Make library compatible with workers (#1894) - 3d3796c
  • Don't rely on Node.js global object (#1776) - 2954e7e
  • Fix compiling of each block params in strict mode (#1855) - 30dbf04
  • Fix rollup warning when importing Handlebars as ESM - 03d387b
  • Fix bundler issue with webpack 5 (#1862) - c6c6bbb
  • Use https instead of git for mustache submodule - 88ac068

Commits

Changelog

Sourced from handlebars's changelog.

v4.7.9 - March 26th, 2026

  • fix: enable shell mode for spawn to resolve Windows EINVAL issue - e0137c2
  • fix type "RuntimeOptions" also accepting string partials - eab1d14
  • feat(types): set hash to be a Record<string, any> - de4414d
  • fix non-contiguous program indices - 4512766
  • refactor: rename i to startPartIndex - e497a35
  • security: fix security issues - 68d8df5

Commits

v4.7.8 - July 27th, 2023

  • Make library compatible with workers (#1894) - 3d3796c
  • Don't rely on Node.js global object (#1776) - 2954e7e
  • Fix compiling of each block params in strict mode (#1855) - 30dbf04
  • Fix rollup warning when importing Handlebars as ESM - 03d387b
  • Fix bundler issue with webpack 5 (#1862) - c6c6bbb
  • Use https instead of git for mustache submodule - 88ac068

Commits

Commits
  • dce542c v4.7.9
  • 8a41389 Update release notes
  • 68d8df5 Fix security issues
  • b2a0831 Fix browser tests
  • 9f98c16 Fix release script
  • 45443b4 Revert "Improve partial indenting performance"
  • 8841a5f Fix CI errors with linting
  • e0137c2 fix: enable shell mode for spawn to resolve Windows EINVAL issue
  • e914d60 Improve rendering performance
  • 7de4b41 Upgrade GitHub Actions checkout and setup-node on 4.x branch
  • Additional commits viewable in compare view
Maintainer changes

This version was pushed to npm by jaylinski, a new releaser for handlebars since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=handlebars&package-manager=npm_and_yarn&previous-version=4.7.7&new-version=4.7.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index b4340f8ab7d3..bb492345f7ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18549,12 +18549,12 @@ handle-thing@^2.0.0: integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== handlebars@^4.0.4, handlebars@^4.3.1, handlebars@^4.7.3: - version "4.7.7" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" - integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + version "4.7.9" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f" + integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== dependencies: minimist "^1.2.5" - neo-async "^2.6.0" + neo-async "^2.6.2" source-map "^0.6.1" wordwrap "^1.0.0" optionalDependencies: @@ -22767,7 +22767,7 @@ negotiator@^1.0.0: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== -neo-async@^2.6.0, neo-async@^2.6.2: +neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== From 819240d34b955b2faffc0f6c6b5dcb3a55026a4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:34:20 +0100 Subject: [PATCH 13/39] chore(deps): Bump @apollo/server from 5.4.0 to 5.5.0 (#20007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@apollo/server](https://github.com/apollographql/apollo-server/tree/HEAD/packages/server) from 5.4.0 to 5.5.0.
Release notes

Sourced from @​apollo/server's releases.

@​apollo/server-integration-testsuite@​5.5.0

Minor Changes

  • #8191 ada1200 - ⚠️ SECURITY @apollo/server/standalone:

    Apollo Server now rejects GraphQL GET requests which contain a Content-Type header other than application/json (with optional parameters such as ; charset=utf-8). Any other value is now rejected with a 415 status code.

    (GraphQL GET requests without a Content-Type header are still allowed, though they do still need to contain a non-empty X-Apollo-Operation-Name or Apollo-Require-Preflight header to be processed if the default CSRF prevention feature is enabled.)

    This improvement makes Apollo Server's CSRF more resistant to browsers which implement CORS in non-spec-compliant ways. Apollo is aware of one browser which as of March 2026 has a bug which allows an attacker to circumvent Apollo Server's CSRF prevention feature to carry out read-only XS-Search-style CSRF attacks. The browser vendor is in the process of patching this vulnerability; upgrading Apollo Server to v5.5.0 mitigates this vulnerability.

    If your server uses cookies (or HTTP Basic Auth) for authentication, Apollo encourages you to upgrade to v5.5.0.

    This is technically a backwards-incompatible change. Apollo is not aware of any GraphQL clients which provide non-empty Content-Type headers with GET requests with types other than application/json. If your use case requires such requests, please file an issue and we may add more configurability in a follow-up release.

    See advisory GHSA-9q82-xgwf-vj6h for more details.

Patch Changes

  • Updated dependencies [ada1200]:
    • @​apollo/server@​5.5.0

@​apollo/server@​5.5.0

Minor Changes

  • #8191 ada1200 Thanks @​glasser! - ⚠️ SECURITY @apollo/server/standalone:

    Apollo Server now rejects GraphQL GET requests which contain a Content-Type header other than application/json (with optional parameters such as ; charset=utf-8). Any other value is now rejected with a 415 status code.

    (GraphQL GET requests without a Content-Type header are still allowed, though they do still need to contain a non-empty X-Apollo-Operation-Name or Apollo-Require-Preflight header to be processed if the default CSRF prevention feature is enabled.)

    This improvement makes Apollo Server's CSRF more resistant to browsers which implement CORS in non-spec-compliant ways. Apollo is aware of one browser which as of March 2026 has a bug which allows an attacker to circumvent Apollo Server's CSRF prevention feature to carry out read-only XS-Search-style CSRF attacks. The browser vendor is in the process of patching this vulnerability; upgrading Apollo Server to v5.5.0 mitigates this vulnerability.

    If your server uses cookies (or HTTP Basic Auth) for authentication, Apollo encourages you to upgrade to v5.5.0.

    This is technically a backwards-incompatible change. Apollo is not aware of any GraphQL clients which provide non-empty Content-Type headers with GET requests with types other than application/json. If your use case requires such requests, please file an issue and we may add more configurability in a follow-up release.

    See advisory GHSA-9q82-xgwf-vj6h for more details.

Changelog

Sourced from @​apollo/server's changelog.

5.5.0

Minor Changes

  • #8191 ada1200 Thanks @​glasser! - ⚠️ SECURITY @apollo/server/standalone:

    Apollo Server now rejects GraphQL GET requests which contain a Content-Type header other than application/json (with optional parameters such as ; charset=utf-8). Any other value is now rejected with a 415 status code.

    (GraphQL GET requests without a Content-Type header are still allowed, though they do still need to contain a non-empty X-Apollo-Operation-Name or Apollo-Require-Preflight header to be processed if the default CSRF prevention feature is enabled.)

    This improvement makes Apollo Server's CSRF more resistant to browsers which implement CORS in non-spec-compliant ways. Apollo is aware of one browser which as of March 2026 has a bug which allows an attacker to circumvent Apollo Server's CSRF prevention feature to carry out read-only XS-Search-style CSRF attacks. The browser vendor is in the process of patching this vulnerability; upgrading Apollo Server to v5.5.0 mitigates this vulnerability.

    If your server uses cookies (or HTTP Basic Auth) for authentication, Apollo encourages you to upgrade to v5.5.0.

    This is technically a backwards-incompatible change. Apollo is not aware of any GraphQL clients which provide non-empty Content-Type headers with GET requests with types other than application/json. If your use case requires such requests, please file an issue and we may add more configurability in a follow-up release.

    See advisory GHSA-9q82-xgwf-vj6h for more details.

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@apollo/server&package-manager=npm_and_yarn&previous-version=5.4.0&new-version=5.5.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/node-integration-tests/package.json | 2 +- yarn.lock | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 1b46351cec78..2f75ecdb5f71 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "0.63.0", - "@apollo/server": "^5.4.0", + "@apollo/server": "^5.5.0", "@aws-sdk/client-s3": "^3.993.0", "@google/genai": "^1.20.0", "@growthbook/growthbook": "^1.6.1", diff --git a/yarn.lock b/yarn.lock index bb492345f7ab..0b6473d8d8ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -464,10 +464,10 @@ "@apollo/utils.keyvaluecache" "^4.0.0" "@apollo/utils.logger" "^3.0.0" -"@apollo/server@^5.4.0": - version "5.4.0" - resolved "https://registry.yarnpkg.com/@apollo/server/-/server-5.4.0.tgz#ad161a6e8b14f5227027205e0970a91667351e49" - integrity sha512-E0/2C5Rqp7bWCjaDh4NzYuEPDZ+dltTf2c0FI6GCKJA6GBetVferX3h1//1rS4+NxD36wrJsGGJK+xyT/M3ysg== +"@apollo/server@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@apollo/server/-/server-5.5.0.tgz#7918e45af53879b11baea04772fc2968fe64492c" + integrity sha512-vWtodBOK/SZwBTJzItECOmLfL8E8pn/IdvP7pnxN5g2tny9iW4+9sxdajE798wV1H2+PYp/rRcl/soSHIBKMPw== dependencies: "@apollo/cache-control-types" "^1.0.3" "@apollo/server-gateway-interface" "^2.0.0" @@ -28513,6 +28513,7 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" + uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From 5ba0bd359b5ebefaee73cbae15d55755592cbb01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:38:26 +0100 Subject: [PATCH 14/39] chore(deps): Bump srvx from 0.11.12 to 0.11.13 (#20001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [srvx](https://github.com/h3js/srvx) from 0.11.12 to 0.11.13.
Release notes

Sourced from srvx's releases.

v0.11.13

compare changes

🩹 Fixes

  • url: Deopt absolute URIs in FastURL (de0d699)
Changelog

Sourced from srvx's changelog.

v0.11.13

compare changes

🩹 Fixes

  • url: Deopt absolute URIs in FastURL (de0d699)

🏡 Chore

❤️ Contributors

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=srvx&package-manager=npm_and_yarn&previous-version=0.11.12&new-version=0.11.13)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0b6473d8d8ce..f46f4df85bb4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28054,9 +28054,9 @@ sqlstring@2.3.1: integrity sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A= srvx@^0.11.12, srvx@^0.11.2, srvx@^0.11.9: - version "0.11.12" - resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.12.tgz#ed59866cd0cec580b119e161ead3fecd2a546fee" - integrity sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA== + version "0.11.13" + resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.13.tgz#cc77a98cb9a459c34f75ee4345bd0eef9f613a54" + integrity sha512-oknN6qduuMPafxKtHucUeG32Q963pjriA5g3/Bl05cwEsUe5VVbIU4qR9LrALHbipSCyBe+VmfDGGydqazDRkw== ssri@^9.0.0: version "9.0.1" From 64cdce138f4d470a66a5d53a695c2f5e21509192 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:36:15 +0100 Subject: [PATCH 15/39] feat(deps): Bump babel-loader from 10.0.0 to 10.1.1 (#19997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [babel-loader](https://github.com/babel/babel-loader) from 10.0.0 to 10.1.1.
Release notes

Sourced from babel-loader's releases.

v10.1.1

What's Changed

Full Changelog: https://github.com/babel/babel-loader/compare/v10.1.0...v10.1.1

v10.1.0

What's Changed

New Contributors

Full Changelog: https://github.com/babel/babel-loader/compare/v10.0.0...v10.1.0

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/browser-integration-tests/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index aebcefb3cd68..13fa11441031 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -63,7 +63,7 @@ "@sentry/browser": "10.46.0", "@supabase/supabase-js": "2.49.3", "axios": "^1.12.2", - "babel-loader": "^10.0.0", + "babel-loader": "^10.1.1", "fflate": "0.8.2", "html-webpack-plugin": "^5.5.0", "webpack": "^5.95.0" diff --git a/yarn.lock b/yarn.lock index f46f4df85bb4..136b3c48a5fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11886,10 +11886,10 @@ babel-loader@8.2.5, babel-loader@^8.0.6: make-dir "^3.1.0" schema-utils "^2.6.5" -babel-loader@^10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-10.0.0.tgz#b9743714c0e1e084b3e4adef3cd5faee33089977" - integrity sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA== +babel-loader@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-10.1.1.tgz#ce9748e85b7071eb88006e3cfa9e6cf14eeb97c5" + integrity sha512-JwKSzk2kjIe7mgPK+/lyZ2QAaJcpahNAdM+hgR2HI8D0OJVkdj8Rl6J3kaLYki9pwF7P2iWnD8qVv80Lq1ABtg== dependencies: find-up "^5.0.0" From 032dc48f1e7f1e6413b0cb5dfc785261d6126ce9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:36:49 +0100 Subject: [PATCH 16/39] ci(deps): Bump actions/upload-artifact from 6 to 7 (#19569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
Release notes

Sourced from actions/upload-artifact's releases.

v7.0.0

v7 What's new

Direct Uploads

Adds support for uploading single files directly (unzipped). Callers can set the new archive parameter to false to skip zipping the file during upload. Right now, we only support single files. The action will fail if the glob passed resolves to multiple files. The name parameter is also ignored with this setting. Instead, the name of the artifact will be the name of the uploaded file.

ESM

To support new versions of the @actions/* packages, we've upgraded the package to ESM.

What's Changed

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v6...v7.0.0

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 14 +++++++------- .github/workflows/flaky-test-detector.yml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dea6b4802dc5..1fc85176963d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -130,7 +130,7 @@ jobs: run: yarn build - name: Upload build artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: build-output path: ${{ env.CACHED_BUILD_PATHS }} @@ -351,7 +351,7 @@ jobs: run: yarn build:tarball - name: Archive artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ github.sha }} retention-days: 90 @@ -588,7 +588,7 @@ jobs: format(' --shard={0}/{1}', matrix.shard, matrix.shards) || '' }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: @@ -654,7 +654,7 @@ jobs: yarn test:loader - name: Upload Playwright Traces - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: playwright-traces-job_browser_loader_tests-${{ matrix.bundle}} @@ -1030,7 +1030,7 @@ jobs: SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: playwright-traces-job_e2e_playwright_tests-${{ matrix.test-application}} @@ -1044,7 +1044,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) @@ -1157,7 +1157,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index 6afed7df214b..aa2c33336bd2 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -71,7 +71,7 @@ jobs: TEST_RUN_COUNT: 'AUTO' - name: Upload Playwright Traces - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() && steps.test.outcome == 'failure' with: name: playwright-test-results From d7eb65991d11711fc2fb822246224510776f56d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:44:34 +0000 Subject: [PATCH 17/39] chore(deps-dev): Bump yaml from 2.8.2 to 2.8.3 (#19985) Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.2 to 2.8.3.
Release notes

Sourced from yaml's releases.

v2.8.3

  • Add trailingComma ToString option for multiline flow formatting (#670)
  • Catch stack overflow during node composition (1e84ebb)
Commits
  • ce14587 2.8.3
  • 1e84ebb fix: Catch stack overflow during node composition
  • 6b24090 ci: Include Prettier check in lint action
  • 9424dee chore: Refresh lockfile
  • d1aca82 Add trailingComma ToString option for multiline flow formatting (#670)
  • 4321509 ci: Drop the branch filter from GitHub PR actions
  • 47207d0 chore: Update docs-slate
  • 5212fae chore: Update docs-slate
  • See full diff in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/e2e-tests/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 42db5f05ceb2..c8de88c64869 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -28,7 +28,7 @@ "glob": "^13.0.6", "rimraf": "^6.1.3", "ts-node": "10.9.2", - "yaml": "2.8.2" + "yaml": "2.8.3" }, "volta": { "extends": "../../package.json" diff --git a/yarn.lock b/yarn.lock index 136b3c48a5fa..335f991c1ae7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31298,10 +31298,10 @@ yam@^1.0.0: fs-extra "^4.0.2" lodash.merge "^4.6.0" -yaml@2.8.2, yaml@^2.6.0, yaml@^2.8.0: - version "2.8.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5" - integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== +yaml@2.8.3, yaml@^2.6.0, yaml@^2.8.0: + version "2.8.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" + integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== yaml@^1.10.0: version "1.10.2" From a8e14f91bafb5453c4d18c2ed5862c4cc2267fd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:51:52 +0100 Subject: [PATCH 18/39] chore(deps): Bump amqplib from 0.10.7 to 0.10.9 (#20000) Bumps [amqplib](https://github.com/amqp-node/amqplib) from 0.10.7 to 0.10.9.
Changelog

Sourced from amqplib's changelog.

v0.10.9

  • Add support for IPv6 urls

v0.10.8

  • Updated README
Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/node-integration-tests/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 2f75ecdb5f71..1d7abdc225e3 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -46,7 +46,7 @@ "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", "ai": "^4.3.16", - "amqplib": "^0.10.7", + "amqplib": "^0.10.9", "body-parser": "^2.2.2", "connect": "^3.7.0", "consola": "^3.2.3", diff --git a/yarn.lock b/yarn.lock index 335f991c1ae7..fd7e7cf04a42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11153,10 +11153,10 @@ amdefine@>=0.0.4: resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= -amqplib@^0.10.7: - version "0.10.7" - resolved "https://registry.yarnpkg.com/amqplib/-/amqplib-0.10.7.tgz#d28586805169bedb03a2efe6e09a3e43148eaa0f" - integrity sha512-7xPSYKSX2kj/bT6iHZ3MlctzxdCW1Ds9xyN0EmuRi2DZxHztwwoG1YkZrgmLyuPNjfxlRiMdWJPQscmoa3Vgdg== +amqplib@^0.10.9: + version "0.10.9" + resolved "https://registry.yarnpkg.com/amqplib/-/amqplib-0.10.9.tgz#5b744c21d624f9307d0399e4d339b7354675831c" + integrity sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA== dependencies: buffer-more-ints "~1.0.0" url-parse "~1.5.10" From 08fb122ace31cb8e18297acf29a50410e0e7bc14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:56:12 +0100 Subject: [PATCH 19/39] chore(deps-dev): Bump node-forge from 1.3.2 to 1.4.0 (#20012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.2 to 1.4.0.
Changelog

Sourced from node-forge's changelog.

1.4.0 - 2026-03-24

Security

  • HIGH: Denial of Service in BigInteger.modInverse()
    • A Denial of Service (DoS) vulnerability exists due to an infinite loop in the BigInteger.modInverse() function (inherited from the bundled jsbn library). When modInverse() is called with a zero value as input, the internal Extended Euclidean Algorithm enters an unreachable exit condition, causing the process to hang indefinitely and consume 100% CPU.
    • Reported by Kr0emer.
    • CVE ID: CVE-2026-33891
    • GHSA ID: GHSA-5gfm-wpxj-wjgq
  • HIGH: Signature forgery in RSA-PKCS due to ASN.1 extra field.
    • RSASSA PKCS#1 v1.5 signature verification accepts forged signatures for low public exponent keys (e=3). Attackers can forge signatures by stuffing "garbage" bytes within the ASN.1 structure in order to construct a signature that passes verification, enabling Bleichenbacher style forgery. This issue is similar to CVE-2022-24771, but adds bytes in an addition field within the ASN.1 structure, rather than outside of it.
    • Additionally, forge does not validate that signatures include a minimum of 8 bytes of padding as defined by the specification, providing attackers additional space to construct Bleichenbacher forgeries.
    • Reported as part of a U.C. Berkeley security research project by:
      • Austin Chu, Sohee Kim, and Corban Villa.
    • CVE ID: CVE-2026-33894
    • GHSA ID: GHSA-ppp5-5v6c-4jwp
  • HIGH: Signature forgery in Ed25519 due to missing S < L check.
    • Ed25519 signature verification accepts forged non-canonical signatures where the scalar S is not reduced modulo the group order (S >= L). A valid signature and its S + L variant both verify in forge, while Node.js crypto.verify (OpenSSL-backed) rejects the S + L variant, as defined by the specification. This class of signature malleability has been exploited in practice to bypass authentication and authorization logic (see CVE-2026-25793, CVE-2022-35961). Applications relying on signature uniqueness (i.e., dedup by signature bytes, replay tracking, signed-object canonicalization checks) may be bypassed.
    • Reported as part of a U.C. Berkeley security research project by:
      • Austin Chu, Sohee Kim, and Corban Villa.
    • CVE ID: CVE-2026-33895
    • GHSA ID: GHSA-q67f-28xg-22rw
  • HIGH: basicConstraints bypass in certificate chain verification.
    • pki.verifyCertificateChain() does not enforce RFC 5280 basicConstraints requirements when an intermediate certificate lacks both the basicConstraints and keyUsage extensions. This allows any leaf certificate (without these extensions) to act as a CA and sign other certificates, which node-forge will accept as valid.
    • Reported by Doruk Tan Ozturk (@​peaktwilight) - doruk.ch
    • CVE ID: CVE-2026-33896
    • GHSA ID: GHSA-2328-f5f3-gj25

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=node-forge&package-manager=npm_and_yarn&previous-version=1.3.2&new-version=1.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Lukas Stracke --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index fd7e7cf04a42..0963b2267409 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23014,9 +23014,9 @@ node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@^2.6.9: whatwg-url "^5.0.0" node-forge@^1, node-forge@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.2.tgz#d0d2659a26eef778bf84d73e7f55c08144ee7750" - integrity sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw== + version "1.4.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" + integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ== node-gyp-build@^4.2.2: version "4.6.0" From 9eb3d7432406c32b2159e859595a84a5ce063fc7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 27 Mar 2026 13:00:59 +0100 Subject: [PATCH 20/39] test(e2e): Add e2e tests for `nodeRuntimeMetricsIntegration` (#19989) Add E2E tests for `nodeRuntimeMetricsIntegration` (#19923) in two test applications: - **node-express-v5**: Enables the integration in the Express app's Sentry init and adds 4 tests verifying all 8 default runtime metrics are emitted with correct shape - **nextjs-16**: Enables the integration in the server config and adds the same 4 tests, verifying metrics flow through the Next.js server runtime Both test suites use `waitForMetric` from `@sentry-internal/test-utils` and validate metric type, unit, value, and attributes (including `sentry.origin: 'auto.node.runtime_metrics'`). The collection interval is set to 1 second to keep tests fast. Refs #19923 Co-authored-by: Claude Opus 4.6 (1M context) --- .../nextjs-16/sentry.server.config.ts | 2 +- .../tests/node-runtime-metrics.test.ts | 148 ++++++++++++++++++ .../node-express-v5/src/app.ts | 1 + .../tests/node-runtime-metrics.test.ts | 148 ++++++++++++++++++ 4 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-v5/tests/node-runtime-metrics.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts index 8f0b4d0f7800..d7015bce4a30 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts @@ -8,7 +8,7 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, // debug: true, - integrations: [Sentry.vercelAIIntegration()], + integrations: [Sentry.vercelAIIntegration(), Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 })], // Verify Log type is available beforeSendLog(log: Log) { return log; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts new file mode 100644 index 000000000000..0efd0d8f7d79 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +const EXPECTED_ATTRIBUTES = { + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.origin': { value: 'auto.node.runtime_metrics', type: 'string' }, +}; + +test('Should emit node runtime memory metrics', async ({ request }) => { + const rssPromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.mem.rss'; + }); + + const heapUsedPromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.mem.heap_used'; + }); + + const heapTotalPromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.mem.heap_total'; + }); + + // Trigger a request to ensure the server is running and metrics start being collected + await request.get('/'); + + const rss = await rssPromise; + const heapUsed = await heapUsedPromise; + const heapTotal = await heapTotalPromise; + + expect(rss).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.rss', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(heapUsed).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.heap_used', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(heapTotal).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.heap_total', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime CPU utilization metric', async ({ request }) => { + const cpuUtilPromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.cpu.utilization'; + }); + + await request.get('/'); + + const cpuUtil = await cpuUtilPromise; + + expect(cpuUtil).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.cpu.utilization', + type: 'gauge', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime event loop metrics', async ({ request }) => { + const elDelayP50Promise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.event_loop.delay.p50'; + }); + + const elDelayP99Promise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.event_loop.delay.p99'; + }); + + const elUtilPromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.event_loop.utilization'; + }); + + await request.get('/'); + + const elDelayP50 = await elDelayP50Promise; + const elDelayP99 = await elDelayP99Promise; + const elUtil = await elUtilPromise; + + expect(elDelayP50).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.delay.p50', + type: 'gauge', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(elDelayP99).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.delay.p99', + type: 'gauge', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(elUtil).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.utilization', + type: 'gauge', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime uptime counter', async ({ request }) => { + const uptimePromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.process.uptime'; + }); + + await request.get('/'); + + const uptime = await uptimePromise; + + expect(uptime).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.process.uptime', + type: 'counter', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts index 20dfa5bf84c5..9a7f6f07d8bc 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts @@ -14,6 +14,7 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, enableLogs: true, + integrations: [Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 })], }); import { TRPCError, initTRPC } from '@trpc/server'; diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/node-runtime-metrics.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/node-runtime-metrics.test.ts new file mode 100644 index 000000000000..e8e0aef3be17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/node-runtime-metrics.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +const EXPECTED_ATTRIBUTES = { + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.origin': { value: 'auto.node.runtime_metrics', type: 'string' }, +}; + +test('Should emit node runtime memory metrics', async ({ baseURL }) => { + const rssPromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.mem.rss'; + }); + + const heapUsedPromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.mem.heap_used'; + }); + + const heapTotalPromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.mem.heap_total'; + }); + + // Trigger a request to ensure the server is running and metrics start being collected + await fetch(`${baseURL}/test-success`); + + const rss = await rssPromise; + const heapUsed = await heapUsedPromise; + const heapTotal = await heapTotalPromise; + + expect(rss).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.rss', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(heapUsed).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.heap_used', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(heapTotal).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.heap_total', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime CPU utilization metric', async ({ baseURL }) => { + const cpuUtilPromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.cpu.utilization'; + }); + + await fetch(`${baseURL}/test-success`); + + const cpuUtil = await cpuUtilPromise; + + expect(cpuUtil).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.cpu.utilization', + type: 'gauge', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime event loop metrics', async ({ baseURL }) => { + const elDelayP50Promise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.event_loop.delay.p50'; + }); + + const elDelayP99Promise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.event_loop.delay.p99'; + }); + + const elUtilPromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.event_loop.utilization'; + }); + + await fetch(`${baseURL}/test-success`); + + const elDelayP50 = await elDelayP50Promise; + const elDelayP99 = await elDelayP99Promise; + const elUtil = await elUtilPromise; + + expect(elDelayP50).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.delay.p50', + type: 'gauge', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(elDelayP99).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.delay.p99', + type: 'gauge', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(elUtil).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.utilization', + type: 'gauge', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime uptime counter', async ({ baseURL }) => { + const uptimePromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.process.uptime'; + }); + + await fetch(`${baseURL}/test-success`); + + const uptime = await uptimePromise; + + expect(uptime).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.process.uptime', + type: 'counter', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); From b93ef5677499637d7f7a8f3451f3db9c425f7b9c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 27 Mar 2026 14:51:26 +0100 Subject: [PATCH 21/39] feat(bun): Add `bunRuntimeMetricsIntegration` (#19979) Adds a new `bunRuntimeMetricsIntegration` that collects runtime metrics on a configurable interval using `process.memoryUsage()`, `process.cpuUsage()`, `performance.eventLoopUtilization()`, and `process.uptime()`. **Default metrics** (`bun.runtime.*` prefix): - `mem.rss`, `mem.heap_used`, `mem.heap_total` - `cpu.utilization` - `event_loop.utilization` - `process.uptime` **Opt-in:** `cpuTime` (`cpu.user`, `cpu.system`), `memExternal` (`mem.external`, `mem.array_buffers`) **vs. `nodeRuntimeMetricsIntegration`:** No event loop delay histogram metrics (`monitorEventLoopDelay` is unavailable in Bun). ELU is guarded with try/catch for older Bun versions. Uses `bun.runtime.*` prefix and `auto.bun.runtime_metrics` origin. Includes unit tests (`bun:test`) and integration tests. closes https://linear.app/getsentry/issue/JS-1956/runtime-metrics-bun-support --------- Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 24 ++ .../bun-runtime-metrics/scenario-all.ts | 27 +++ .../bun-runtime-metrics/scenario-opt-out.ts | 29 +++ .../suites/bun-runtime-metrics/scenario.ts | 23 ++ .../suites/bun-runtime-metrics/test.ts | 116 ++++++++++ packages/bun/src/index.ts | 1 + .../bun/src/integrations/bunRuntimeMetrics.ts | 166 ++++++++++++++ .../integrations/bunRuntimeMetrics.test.ts | 215 ++++++++++++++++++ 8 files changed, 601 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts create mode 100644 dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts create mode 100644 dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts create mode 100644 packages/bun/src/integrations/bunRuntimeMetrics.ts create mode 100644 packages/bun/test/integrations/bunRuntimeMetrics.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c34aea56bd79..6a3960f3fa34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## Unreleased +### Important Changes + +- **feat(node, bun): Add runtime metrics integrations for Node.js and Bun ([#19923](https://github.com/getsentry/sentry-javascript/pull/19923), [#19979](https://github.com/getsentry/sentry-javascript/pull/19979))** + + New `nodeRuntimeMetricsIntegration` and `bunRuntimeMetricsIntegration` automatically collect runtime health metrics and send them to Sentry on a configurable interval (default: 30s). Collected metrics include memory (RSS, heap used/total), CPU utilization, event loop utilization, and process uptime. Node additionally collects event loop delay percentiles (p50, p99). Extra metrics like CPU time and external memory are available as opt-in. + + ```ts + // Node.js + import * as Sentry from '@sentry/node'; + + Sentry.init({ + dsn: '...', + integrations: [Sentry.nodeRuntimeMetricsIntegration()], + }); + + // Bun + import * as Sentry from '@sentry/bun'; + + Sentry.init({ + dsn: '...', + integrations: [Sentry.bunRuntimeMetricsIntegration()], + }); + ``` + - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott - feat(core): Support embedding APIs in google-genai ([#19797](https://github.com/getsentry/sentry-javascript/pull/19797)) diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts new file mode 100644 index 000000000000..f82385c4c16e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +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, + integrations: [ + bunRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + collect: { + cpuTime: true, + memExternal: true, + }, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts new file mode 100644 index 000000000000..d3aa0f309893 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/node'; +import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +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, + integrations: [ + bunRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + collect: { + cpuUtilization: false, + cpuTime: false, + eventLoopUtilization: false, + uptime: false, + }, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts new file mode 100644 index 000000000000..1948ddfa6c23 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node'; +import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +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, + integrations: [ + bunRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts new file mode 100644 index 000000000000..78638b8b02cb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts @@ -0,0 +1,116 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +const SENTRY_ATTRIBUTES = { + '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' }, + 'sentry.origin': { value: 'auto.bun.runtime_metrics', type: 'string' }, +}; + +const gauge = (name: string, unit?: string) => ({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name, + type: 'gauge', + ...(unit ? { unit } : {}), + value: expect.any(Number), + attributes: expect.objectContaining(SENTRY_ATTRIBUTES), +}); + +const counter = (name: string, unit?: string) => ({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name, + type: 'counter', + ...(unit ? { unit } : {}), + value: expect.any(Number), + attributes: expect.objectContaining(SENTRY_ATTRIBUTES), +}); + +describe('bunRuntimeMetricsIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('emits default runtime metrics with correct shape', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: { + items: expect.arrayContaining([ + gauge('bun.runtime.mem.rss', 'byte'), + gauge('bun.runtime.mem.heap_used', 'byte'), + gauge('bun.runtime.mem.heap_total', 'byte'), + gauge('bun.runtime.cpu.utilization'), + gauge('bun.runtime.event_loop.utilization'), + counter('bun.runtime.process.uptime', 'second'), + ]), + }, + }) + .start(); + + await runner.completed(); + }); + + test('does not emit opt-in metrics by default', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: (container: { items: Array<{ name: string }> }) => { + const names = container.items.map(item => item.name); + expect(names).not.toContain('bun.runtime.cpu.user'); + expect(names).not.toContain('bun.runtime.cpu.system'); + expect(names).not.toContain('bun.runtime.mem.external'); + expect(names).not.toContain('bun.runtime.mem.array_buffers'); + }, + }) + .start(); + + await runner.completed(); + }); + + test('emits all metrics when fully opted in', async () => { + const runner = createRunner(__dirname, 'scenario-all.ts') + .expect({ + trace_metric: { + items: expect.arrayContaining([ + gauge('bun.runtime.mem.rss', 'byte'), + gauge('bun.runtime.mem.heap_used', 'byte'), + gauge('bun.runtime.mem.heap_total', 'byte'), + gauge('bun.runtime.mem.external', 'byte'), + gauge('bun.runtime.mem.array_buffers', 'byte'), + gauge('bun.runtime.cpu.user', 'second'), + gauge('bun.runtime.cpu.system', 'second'), + gauge('bun.runtime.cpu.utilization'), + gauge('bun.runtime.event_loop.utilization'), + counter('bun.runtime.process.uptime', 'second'), + ]), + }, + }) + .start(); + + await runner.completed(); + }); + + test('respects opt-out: only memory metrics remain when cpu/event loop/uptime are disabled', async () => { + const runner = createRunner(__dirname, 'scenario-opt-out.ts') + .expect({ + trace_metric: (container: { items: Array<{ name: string }> }) => { + const names = container.items.map(item => item.name); + + // Memory metrics should still be present + expect(names).toContain('bun.runtime.mem.rss'); + expect(names).toContain('bun.runtime.mem.heap_used'); + expect(names).toContain('bun.runtime.mem.heap_total'); + + // Everything else should be absent + expect(names).not.toContain('bun.runtime.cpu.utilization'); + expect(names).not.toContain('bun.runtime.event_loop.utilization'); + expect(names).not.toContain('bun.runtime.process.uptime'); + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c2990e6262a7..41f5b3cf5c52 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -192,4 +192,5 @@ export type { BunOptions } from './types'; export { BunClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { bunServerIntegration } from './integrations/bunserver'; +export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics'; export { makeFetchTransport } from './transports'; diff --git a/packages/bun/src/integrations/bunRuntimeMetrics.ts b/packages/bun/src/integrations/bunRuntimeMetrics.ts new file mode 100644 index 000000000000..7646eb23568b --- /dev/null +++ b/packages/bun/src/integrations/bunRuntimeMetrics.ts @@ -0,0 +1,166 @@ +import { performance } from 'perf_hooks'; +import { _INTERNAL_safeDateNow, _INTERNAL_safeUnref, defineIntegration, metrics } from '@sentry/core'; +import type { NodeRuntimeMetricsOptions } from '@sentry/node'; + +const INTEGRATION_NAME = 'BunRuntimeMetrics'; +const DEFAULT_INTERVAL_MS = 30_000; + +/** + * Which metrics to collect in the Bun runtime metrics integration. + * Explicitly picks the metrics available in Bun from `NodeRuntimeMetricsOptions['collect']`. + * Event loop delay percentiles are excluded because `monitorEventLoopDelay` is unavailable in Bun. + */ +type BunCollectOptions = Pick< + NonNullable, + | 'cpuUtilization' + | 'cpuTime' + | 'memHeapUsed' + | 'memHeapTotal' + | 'memRss' + | 'memExternal' + | 'eventLoopUtilization' + | 'uptime' +>; + +export interface BunRuntimeMetricsOptions { + /** + * Which metrics to collect. + * + * Default on (6 metrics): + * - `cpuUtilization` — CPU utilization ratio + * - `memRss` — Resident Set Size (actual memory footprint) + * - `memHeapUsed` — V8 heap currently in use + * - `memHeapTotal` — total V8 heap allocated + * - `eventLoopUtilization` — fraction of time the event loop was active + * - `uptime` — process uptime (detect restarts/crashes) + * + * Default off (opt-in): + * - `cpuTime` — raw user/system CPU time in seconds + * - `memExternal` — external/ArrayBuffer memory (relevant for native addons) + * + * Note: event loop delay percentiles (p50, p99, etc.) are not available in Bun + * because `monitorEventLoopDelay` from `perf_hooks` is not implemented. + */ + collect?: BunCollectOptions; + /** + * How often to collect metrics, in milliseconds. + * @default 30000 + */ + collectionIntervalMs?: number; +} + +/** + * Automatically collects Bun runtime metrics and emits them to Sentry. + * + * @example + * ```ts + * Sentry.init({ + * integrations: [ + * Sentry.bunRuntimeMetricsIntegration(), + * ], + * }); + * ``` + */ +export const bunRuntimeMetricsIntegration = defineIntegration((options: BunRuntimeMetricsOptions = {}) => { + const collectionIntervalMs = options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS; + const collect = { + // Default on + cpuUtilization: true, + memHeapUsed: true, + memHeapTotal: true, + memRss: true, + eventLoopUtilization: true, + uptime: true, + // Default off + cpuTime: false, + memExternal: false, + ...options.collect, + }; + + const needsCpu = collect.cpuUtilization || collect.cpuTime; + + let intervalId: ReturnType | undefined; + let prevCpuUsage: NodeJS.CpuUsage | undefined; + let prevElu: ReturnType | undefined; + let prevFlushTime: number = 0; + let eluAvailable = false; + + const METRIC_ATTRIBUTES = { attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const METRIC_ATTRIBUTES_BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const METRIC_ATTRIBUTES_SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + + function collectMetrics(): void { + const now = _INTERNAL_safeDateNow(); + const elapsed = now - prevFlushTime; + + if (needsCpu && prevCpuUsage !== undefined) { + const delta = process.cpuUsage(prevCpuUsage); + + if (collect.cpuTime) { + metrics.gauge('bun.runtime.cpu.user', delta.user / 1e6, METRIC_ATTRIBUTES_SECOND); + metrics.gauge('bun.runtime.cpu.system', delta.system / 1e6, METRIC_ATTRIBUTES_SECOND); + } + if (collect.cpuUtilization && elapsed > 0) { + metrics.gauge('bun.runtime.cpu.utilization', (delta.user + delta.system) / (elapsed * 1000), METRIC_ATTRIBUTES); + } + + prevCpuUsage = process.cpuUsage(); + } + + if (collect.memRss || collect.memHeapUsed || collect.memHeapTotal || collect.memExternal) { + const mem = process.memoryUsage(); + if (collect.memRss) { + metrics.gauge('bun.runtime.mem.rss', mem.rss, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapUsed) { + metrics.gauge('bun.runtime.mem.heap_used', mem.heapUsed, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapTotal) { + metrics.gauge('bun.runtime.mem.heap_total', mem.heapTotal, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memExternal) { + metrics.gauge('bun.runtime.mem.external', mem.external, METRIC_ATTRIBUTES_BYTE); + metrics.gauge('bun.runtime.mem.array_buffers', mem.arrayBuffers, METRIC_ATTRIBUTES_BYTE); + } + } + + if (collect.eventLoopUtilization && eluAvailable && prevElu !== undefined) { + const currentElu = performance.eventLoopUtilization(); + const delta = performance.eventLoopUtilization(currentElu, prevElu); + metrics.gauge('bun.runtime.event_loop.utilization', delta.utilization, METRIC_ATTRIBUTES); + prevElu = currentElu; + } + + if (collect.uptime && elapsed > 0) { + metrics.count('bun.runtime.process.uptime', elapsed / 1000, METRIC_ATTRIBUTES_SECOND); + } + + prevFlushTime = now; + } + + return { + name: INTEGRATION_NAME, + + setup(): void { + // Prime baselines before the first collection interval. + if (needsCpu) { + prevCpuUsage = process.cpuUsage(); + } + if (collect.eventLoopUtilization) { + try { + prevElu = performance.eventLoopUtilization(); + eluAvailable = true; + } catch { + // Not available in all Bun versions. + } + } + prevFlushTime = _INTERNAL_safeDateNow(); + + // Guard against double setup (e.g. re-init). + if (intervalId) { + clearInterval(intervalId); + } + intervalId = _INTERNAL_safeUnref(setInterval(collectMetrics, collectionIntervalMs)); + }, + }; +}); diff --git a/packages/bun/test/integrations/bunRuntimeMetrics.test.ts b/packages/bun/test/integrations/bunRuntimeMetrics.test.ts new file mode 100644 index 000000000000..6264905db41e --- /dev/null +++ b/packages/bun/test/integrations/bunRuntimeMetrics.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; +import { metrics } from '@sentry/core'; + +const mockElu = { idle: 700, active: 300, utilization: 0.3 }; +const mockEluDelta = { idle: 700, active: 300, utilization: 0.3 }; +const mockEventLoopUtilization = jest.fn((curr?: object, _prev?: object) => { + if (curr) return mockEluDelta; + return mockElu; +}); + +mock.module('perf_hooks', () => ({ + performance: { eventLoopUtilization: mockEventLoopUtilization }, +})); + +const { bunRuntimeMetricsIntegration } = await import('../../src/integrations/bunRuntimeMetrics'); + +describe('bunRuntimeMetricsIntegration', () => { + let gaugeSpy: ReturnType; + let countSpy: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + gaugeSpy = spyOn(metrics, 'gauge').mockImplementation(() => undefined); + countSpy = spyOn(metrics, 'count').mockImplementation(() => undefined); + + spyOn(process, 'cpuUsage').mockReturnValue({ user: 500_000, system: 200_000 }); + spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 50_000_000, + heapTotal: 30_000_000, + heapUsed: 20_000_000, + external: 1_000_000, + arrayBuffers: 500_000, + }); + + mockEventLoopUtilization.mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('has the correct name', () => { + const integration = bunRuntimeMetricsIntegration(); + expect(integration.name).toBe('BunRuntimeMetrics'); + }); + + describe('setup', () => { + it('starts a collection interval', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + + expect(gaugeSpy).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1_000); + expect(gaugeSpy).toHaveBeenCalled(); + }); + + it('does not throw if performance.eventLoopUtilization is unavailable', () => { + mockEventLoopUtilization.mockImplementationOnce(() => { + throw new Error('Not implemented'); + }); + + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + expect(() => integration.setup()).not.toThrow(); + }); + }); + + const ORIGIN = { attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + + describe('metric collection — defaults', () => { + it('emits cpu utilization (default on)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.cpu.utilization', expect.any(Number), ORIGIN); + }); + + it('does not emit cpu.user / cpu.system by default (opt-in)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.cpu.user', expect.anything(), expect.anything()); + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.cpu.system', expect.anything(), expect.anything()); + }); + + it('emits cpu.user / cpu.system when cpuTime is opted in', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { cpuTime: true }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.cpu.user', expect.any(Number), SECOND); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.cpu.system', expect.any(Number), SECOND); + }); + + it('emits mem.rss, mem.heap_used, mem.heap_total (default on)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.rss', 50_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.heap_used', 20_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.heap_total', 30_000_000, BYTE); + }); + + it('does not emit mem.external / mem.array_buffers by default (opt-in)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.mem.external', expect.anything(), expect.anything()); + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.mem.array_buffers', expect.anything(), expect.anything()); + }); + + it('emits mem.external / mem.array_buffers when opted in', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memExternal: true }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.external', 1_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.array_buffers', 500_000, BYTE); + }); + + it('emits event loop utilization metric', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.event_loop.utilization', 0.3, ORIGIN); + }); + + it('does not emit event loop utilization if performance.eventLoopUtilization threw during setup', () => { + mockEventLoopUtilization.mockImplementationOnce(() => { + throw new Error('Not implemented'); + }); + + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'bun.runtime.event_loop.utilization', + expect.anything(), + expect.anything(), + ); + }); + + it('emits uptime counter', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(countSpy).toHaveBeenCalledWith('bun.runtime.process.uptime', expect.any(Number), SECOND); + }); + }); + + describe('opt-out', () => { + it('skips cpu.utilization when cpuUtilization is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { cpuUtilization: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.cpu.utilization', expect.anything(), expect.anything()); + }); + + it('skips mem.rss when memRss is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memRss: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.mem.rss', expect.anything(), expect.anything()); + }); + + it('skips event loop utilization when eventLoopUtilization is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { eventLoopUtilization: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'bun.runtime.event_loop.utilization', + expect.anything(), + expect.anything(), + ); + }); + + it('skips uptime when uptime is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { uptime: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(countSpy).not.toHaveBeenCalledWith('bun.runtime.process.uptime', expect.anything(), expect.anything()); + }); + }); +}); From 02c9cd898832f59cf04ca1c4ef1d3c9d919ff3d4 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 27 Mar 2026 10:56:19 -0700 Subject: [PATCH 22/39] chore(deps-dev): remove esbuild override in astro-5-cf-workers E2E test --- .../test-applications/astro-5-cf-workers/package.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json index b74b36c9d314..400ab6144248 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json @@ -20,11 +20,6 @@ "devDependencies": { "wrangler": "^4.63.0" }, - "pnpm": { - "overrides": { - "esbuild": "0.24.0" - } - }, "volta": { "extends": "../../package.json" } From 73f03bbe04a7471f440cc682990bb175b007cede Mon Sep 17 00:00:00 2001 From: Stephanie Anderson Date: Fri, 27 Mar 2026 20:48:49 +0100 Subject: [PATCH 23/39] chore: Add shared validate-pr composite action (#20025) Add the PR validation workflow using the shared composite action from getsentry/github-workflows#153. Validates non-maintainer PRs against contribution guidelines and enforces draft status on all new PRs. #skip-changelog Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../create-issue-for-unreferenced-prs.yml | 130 ------------------ .github/workflows/validate-pr.yml | 16 +++ 2 files changed, 16 insertions(+), 130 deletions(-) delete mode 100644 .github/workflows/create-issue-for-unreferenced-prs.yml create mode 100644 .github/workflows/validate-pr.yml diff --git a/.github/workflows/create-issue-for-unreferenced-prs.yml b/.github/workflows/create-issue-for-unreferenced-prs.yml deleted file mode 100644 index 0a833715d854..000000000000 --- a/.github/workflows/create-issue-for-unreferenced-prs.yml +++ /dev/null @@ -1,130 +0,0 @@ -# This GitHub Action workflow checks if a new or updated pull request -# references a GitHub issue in its title or body. If no reference is found, -# it automatically creates a new issue. This helps ensure all work is -# tracked, especially when syncing with tools like Linear. - -name: Create issue for unreferenced PR - -# This action triggers on pull request events -on: - pull_request: - types: [opened, edited, reopened, synchronize, ready_for_review] - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - check_for_issue_reference: - runs-on: ubuntu-latest - if: | - (github.event.pull_request.base.ref == 'develop' || github.event.pull_request.base.ref == 'master') - && !contains(github.event.pull_request.labels.*.name, 'Dev: Gitflow') - && !startsWith(github.event.pull_request.head.ref, 'external-contributor/') - && !startsWith(github.event.pull_request.head.ref, 'prepare-release/') - && !startsWith(github.event.pull_request.head.ref, 'dependabot/') - steps: - - name: Check PR Body and Title for Issue Reference - uses: actions/github-script@v8 - with: - script: | - const pr = context.payload.pull_request; - if (!pr) { - core.setFailed('Could not get PR from context.'); - return; - } - - // Don't create an issue for draft PRs - if (pr.draft) { - console.log(`PR #${pr.number} is a draft, skipping issue creation.`); - return; - } - - // Bail if this edit was made by the GitHub Actions bot (this workflow) - // This prevents infinite loops when we update the PR body with the new issue reference - // We check login specifically to not skip edits from other legitimate bots - if (context.payload.sender && context.payload.sender.login === 'github-actions[bot]') { - console.log(`PR #${pr.number} was edited by github-actions[bot] (this workflow), skipping.`); - return; - } - - // Check if the PR is already approved - const reviewsResponse = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - - if (reviewsResponse.data.some(review => review.state === 'APPROVED')) { - console.log(`PR #${pr.number} is already approved, skipping issue creation.`); - return; - } - - const prBody = pr.body || ''; - const prTitle = pr.title || ''; - const prAuthor = pr.user.login; - const prUrl = pr.html_url; - const prNumber = pr.number; - - // Regex for GitHub issue references (e.g., #123, fixes #456) - // https://regex101.com/r/eDiGrQ/1 - const issueRegexGitHub = /(?:(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):?\s*)?(#\d+|https:\/\/github\.com\/getsentry\/[\w-]+\/issues\/\d+)/i; - - // Regex for Linear issue references (e.g., ENG-123, resolves ENG-456) - const issueRegexLinear = /(?:(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):?\s*)?[A-Z]+-\d+/i; - - const contentToCheck = `${prTitle} ${prBody}`; - const hasIssueReference = issueRegexGitHub.test(contentToCheck) || issueRegexLinear.test(contentToCheck); - - if (hasIssueReference) { - console.log(`PR #${prNumber} contains a valid issue reference.`); - return; - } - - // Check if there's already an issue created by this automation for this PR - // Search for issues that mention this PR and were created by github-actions bot - const existingIssuesResponse = await github.rest.search.issuesAndPullRequests({ - q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open author:app/github-actions "${prUrl}" in:title in:body`, - }); - - if (existingIssuesResponse.data.total_count > 0) { - const existingIssue = existingIssuesResponse.data.items[0]; - console.log(`An issue (#${existingIssue.number}) already exists for PR #${prNumber}, skipping creation.`); - return; - } - - core.warning(`PR #${prNumber} does not have an issue reference. Creating a new issue so it can be tracked in Linear.`); - - // Construct the title and body for the new issue - const issueTitle = `${prTitle}`; - const issueBody = `> [!NOTE] - > The pull request "[${prTitle}](${prUrl})" was created by @${prAuthor} but did not reference an issue. Therefore this issue was created for better visibility in external tools like Linear. - - ${prBody} - `; - - // Create the issue using the GitHub API - const newIssue = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: issueTitle, - body: issueBody, - assignees: [prAuthor] - }); - - const issueID = newIssue.data.number; - console.log(`Created issue #${issueID}.`); - - // Update the PR body to reference the new issue - const updatedPrBody = `${prBody}\n\nCloses #${issueID} (added automatically)`; - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - body: updatedPrBody - }); - - console.log(`Updated PR #${prNumber} to reference newly created issue #${issueID}.`); diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml new file mode 100644 index 000000000000..c05657993e86 --- /dev/null +++ b/.github/workflows/validate-pr.yml @@ -0,0 +1,16 @@ +name: Validate PR + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + validate-pr: + runs-on: ubuntu-24.04 + permissions: + pull-requests: write + steps: + - uses: getsentry/github-workflows/validate-pr@4243265ac9cc3ee5b89ad2b30c3797ac8483d63a + with: + app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} + private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From ae743615dea0481d9bccf9137d04270aec475a4b Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:25:38 +0100 Subject: [PATCH 24/39] fix(nuxt): Use virtual module for Nuxt pages data (SSR route parametrization) (#20020) Creates a virtual module with Vite when using Nuxt 4+ instead of creating a template. `useServerTemplate()` cannot be used here as it's not Nitro-only but the SSR-space (server) within Nuxt. Closes https://github.com/getsentry/sentry-javascript/issues/20010 --- packages/nuxt/src/module.ts | 53 ++++++++++++------- .../plugins/route-detector-legacy.server.ts | 49 +++++++++++++++++ .../runtime/plugins/route-detector.server.ts | 6 +-- 3 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 packages/nuxt/src/runtime/plugins/route-detector-legacy.server.ts diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 077bda66745b..ffe35a311e99 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -18,6 +18,7 @@ import { addStorageInstrumentation } from './vite/storageConfig'; import { addOTelCommonJSImportAlias, findDefaultSdkInitFile, getNitroMajorVersion } from './vite/utils'; export type ModuleOptions = SentryNuxtModuleOptions; +type NuxtPageSubset = { file?: string; path: string }; export default defineNuxtModule({ meta: { @@ -79,6 +80,8 @@ export default defineNuxtModule({ const serverConfigFile = findDefaultSdkInitFile('server', nuxt); const isNitroV3 = (await getNitroMajorVersion()) >= 3; + const nuxtMajor = parseInt((nuxt as unknown as { _version: string })._version?.split('.')[0] ?? '3', 10); + const isMinNuxtV4 = nuxtMajor >= 4; if (serverConfigFile) { if (isNitroV3) { @@ -91,10 +94,11 @@ export default defineNuxtModule({ addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); - addPlugin({ - src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'), - mode: 'server', - }); + if (isMinNuxtV4) { + addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'), mode: 'server' }); + } else { + addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector-legacy.server'), mode: 'server' }); + } // Preps the middleware instrumentation module. addMiddlewareImports(); @@ -108,26 +112,35 @@ export default defineNuxtModule({ addOTelCommonJSImportAlias(nuxt, isNitroV3); - const pagesDataTemplate = addTemplate({ - filename: 'sentry--nuxt-pages-data.mjs', - // Initial empty array (later filled in pages:extend hook) - // Template needs to be created in the root-level of the module to work - getContents: () => 'export default [];', - }); + let pagesData: NuxtPageSubset[] = []; nuxt.hooks.hook('pages:extend', pages => { - pagesDataTemplate.getContents = () => { - const pagesSubset = pages - .map(page => ({ file: page.file, path: page.path })) - .filter(page => { - // Check for dynamic parameter (e.g., :userId or [userId]) - return page.path.includes(':') || page?.file?.includes('['); - }); - - return `export default ${JSON.stringify(pagesSubset, null, 2)};`; - }; + pagesData = pages + .map(page => ({ file: page.file, path: page.path })) + .filter(page => { + // Check for dynamic parameter (e.g., :userId or [userId]) + return page.path.includes(':') || page?.file?.includes('['); + }); }); + if (isMinNuxtV4) { + const pagesDataVirtualModuleId = '#sentry/nuxt-pages-data.mjs'; + + // Vite virtual plugin (for the Vite SSR build, where addPlugin mode:'server' plugins are bundled) + addVitePlugin({ + name: 'sentry-nuxt-pages-data-virtual', + resolveId: id => (id === pagesDataVirtualModuleId ? `\0${pagesDataVirtualModuleId}` : null), + load: id => + id === `\0${pagesDataVirtualModuleId}` ? `export default ${JSON.stringify(pagesData, null, 2)};` : undefined, + }); + } else { + // Nuxt v3: register as a build template (accessible via #build/) + addTemplate({ + filename: 'sentry--nuxt-pages-data.mjs', + getContents: () => `export default ${JSON.stringify(pagesData, null, 2)};`, + }); + } + // Add the sentry config file to the include array nuxt.hook('prepare:types', options => { const tsConfig = options.tsConfig as { include?: string[] }; diff --git a/packages/nuxt/src/runtime/plugins/route-detector-legacy.server.ts b/packages/nuxt/src/runtime/plugins/route-detector-legacy.server.ts new file mode 100644 index 000000000000..d67102576158 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/route-detector-legacy.server.ts @@ -0,0 +1,49 @@ +import { debug, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { defineNuxtPlugin } from 'nuxt/app'; +import type { NuxtPageSubset } from '../utils/route-extraction'; +import { extractParametrizedRouteFromContext } from '../utils/route-extraction'; + +export default defineNuxtPlugin(nuxtApp => { + nuxtApp.hooks.hook('app:rendered', async renderContext => { + let buildTimePagesData: NuxtPageSubset[]; + try { + // This is a common Nuxt pattern to import build-time generated data (until Nuxt v3): https://nuxt.com/docs/4.x/api/kit/templates#creating-a-virtual-file-for-runtime-plugin + // @ts-expect-error This import is dynamically resolved at build time (`addTemplate` in module.ts) + const { default: importedPagesData } = await import('#build/sentry--nuxt-pages-data.mjs'); + buildTimePagesData = importedPagesData || []; + debug.log('Imported build-time pages data:', buildTimePagesData); + } catch (error) { + buildTimePagesData = []; + debug.warn('Failed to import build-time pages data:', error); + } + + const ssrContext = renderContext.ssrContext; + + const routeInfo = extractParametrizedRouteFromContext( + ssrContext?.modules, + ssrContext?.url || ssrContext?.event._path, + buildTimePagesData, + ); + + if (routeInfo === null) { + return; + } + + const activeSpan = getActiveSpan(); // In development mode, getActiveSpan() is always undefined + + if (activeSpan && routeInfo.parametrizedRoute) { + const rootSpan = getRootSpan(activeSpan); + + if (!rootSpan) { + return; + } + + debug.log('Matched parametrized server route:', routeInfo.parametrizedRoute); + + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': routeInfo.parametrizedRoute, + }); + } + }); +}); diff --git a/packages/nuxt/src/runtime/plugins/route-detector.server.ts b/packages/nuxt/src/runtime/plugins/route-detector.server.ts index 37c6bc17a4b5..1ed3a2f1d36b 100644 --- a/packages/nuxt/src/runtime/plugins/route-detector.server.ts +++ b/packages/nuxt/src/runtime/plugins/route-detector.server.ts @@ -7,9 +7,9 @@ export default defineNuxtPlugin(nuxtApp => { nuxtApp.hooks.hook('app:rendered', async renderContext => { let buildTimePagesData: NuxtPageSubset[]; try { - // This is a common Nuxt pattern to import build-time generated data: https://nuxt.com/docs/4.x/api/kit/templates#creating-a-virtual-file-for-runtime-plugin - // @ts-expect-error This import is dynamically resolved at build time (`addTemplate` in module.ts) - const { default: importedPagesData } = await import('#build/sentry--nuxt-pages-data.mjs'); + // Virtual module registered via addServerTemplate in module.ts (Nuxt v4+) + // @ts-expect-error - This is a virtual module + const { default: importedPagesData } = await import('#sentry/nuxt-pages-data.mjs'); buildTimePagesData = importedPagesData || []; debug.log('Imported build-time pages data:', buildTimePagesData); } catch (error) { From fcdb62e5eca174d86039342a71020dedbe2d67ad Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:36:50 +0100 Subject: [PATCH 25/39] feat(nuxt): Add middleware instrumentation compatibility for Nuxt 5 (#19968) Nitro v3 (used by Nuxt 5) ships with h3 v2, which restructures the `EventHandlerObject` type ([old](https://github.com/h3js/h3/blob/b72bb57060cf68e627575e0c350742f4fa8206fa/src/types/index.ts#L81-L92) / [new](https://github.com/h3js/h3/blob/7c2bc9b96ab9bc25f5ca02b0c15a81b8d079e159/src/types/handler.ts#L20-L28)). The previous `onRequest`/`onBeforeResponse` lifecycle hooks are replaced by a single middleware array, and `handler` is now optional. This PR updates the Nuxt SDK's middleware instrumentation to handle both shapes transparently: h3 v1 (`onRequest`, `onBeforeResponse`, required `handler`) for Nuxt 4 / Nitro v2, and h3 v2 (`middleware[]`, optional `handler`) for Nuxt 5 / Nitro v3. The Nuxt 5 test app middleware files are updated to match the new h3 v2 API, and unit/E2E test assertions are adjusted accordingly. Closes https://github.com/getsentry/sentry-javascript/issues/19954 --- .../nuxt-5/server/middleware/04.hooks.ts | 31 ++-- .../server/middleware/05.array-hooks.ts | 28 +--- .../nuxt-5/tests/middleware.test.ts | 146 ++++++------------ packages/nuxt/src/module.ts | 2 +- .../runtime/hooks/wrapMiddlewareHandler.ts | 86 +++++++---- packages/nuxt/src/vite/middlewareConfig.ts | 7 +- .../hooks/wrapMiddlewareHandler.test.ts | 93 +++++++++++ 7 files changed, 221 insertions(+), 172 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts index 726cfaba8c10..7ae2b9116713 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts @@ -2,16 +2,18 @@ import { defineHandler } from 'nitro'; import { getQuery } from 'nitro/h3'; export default defineHandler({ - onRequest: async event => { - // Set a header to indicate the onRequest hook ran - event.res?.headers.set('x-hooks-onrequest', 'executed'); + middleware: [ + async event => { + // Set a header to indicate the middleware ran + event.res?.headers.set('x-hooks-middleware', 'executed'); - // Check if we should throw an error in onRequest - const query = getQuery(event); - if (query.throwOnRequestError === 'true') { - throw new Error('OnRequest hook error'); - } - }, + // Check if we should throw an error in middleware + const query = getQuery(event); + if (query.throwOnRequestError === 'true') { + throw new Error('OnRequest hook error'); + } + }, + ], handler: async event => { // Set a header to indicate the main handler ran @@ -23,15 +25,4 @@ export default defineHandler({ throw new Error('Handler error'); } }, - - onBeforeResponse: async (event, response) => { - // Set a header to indicate the onBeforeResponse hook ran - event.res?.headers.set('x-hooks-onbeforeresponse', 'executed'); - - // Check if we should throw an error in onBeforeResponse - const query = getQuery(event); - if (query.throwOnBeforeResponseError === 'true') { - throw new Error('OnBeforeResponse hook error'); - } - }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts index f0bac6fb3113..cd5a447539c0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts @@ -2,10 +2,10 @@ import { defineHandler } from 'nitro'; import { getQuery } from 'nitro/h3'; export default defineHandler({ - // Array of onRequest handlers - onRequest: [ + // Array of middleware handlers (replaces onRequest in h3 v2) + middleware: [ async event => { - event.res?.headers.set('x-array-onrequest-0', 'executed'); + event.res?.headers.set('x-array-middleware-0', 'executed'); const query = getQuery(event); if (query.throwOnRequest0Error === 'true') { @@ -13,7 +13,7 @@ export default defineHandler({ } }, async event => { - event.res?.headers.set('x-array-onrequest-1', 'executed'); + event.res?.headers.set('x-array-middleware-1', 'executed'); const query = getQuery(event); if (query.throwOnRequest1Error === 'true') { @@ -25,24 +25,4 @@ export default defineHandler({ handler: async event => { event.res?.headers.set('x-array-handler', 'executed'); }, - - // Array of onBeforeResponse handlers - onBeforeResponse: [ - async (event, response) => { - event.res?.headers.set('x-array-onbeforeresponse-0', 'executed'); - - const query = getQuery(event); - if (query.throwOnBeforeResponse0Error === 'true') { - throw new Error('OnBeforeResponse[0] hook error'); - } - }, - async (event, response) => { - event.res?.headers.set('x-array-onbeforeresponse-1', 'executed'); - - const query = getQuery(event); - if (query.throwOnBeforeResponse1Error === 'true') { - throw new Error('OnBeforeResponse[1] hook error'); - } - }, - ], }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts index 3c314b80b59c..71ef433b6c07 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; // TODO: Skipped for Nuxt 5 as the SDK is not yet updated for that -test.describe.skip('Server Middleware Instrumentation', () => { +test.describe('Server Middleware Instrumentation', () => { test('should create separate spans for each server middleware', async ({ request }) => { const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; @@ -20,8 +20,8 @@ test.describe.skip('Server Middleware Instrumentation', () => { // Verify that we have spans for each middleware const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; - // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse - expect(middlewareSpans).toHaveLength(11); + // 3 simple + 2 hooks (middleware+handler) + 3 array hooks (2 middleware + 1 handler) + expect(middlewareSpans).toHaveLength(8); // Check for specific middleware spans const firstMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '01.first'); @@ -60,8 +60,8 @@ test.describe.skip('Server Middleware Instrumentation', () => { // Verify spans have different span IDs (each middleware gets its own span) const spanIds = middlewareSpans.map(span => span.span_id); const uniqueSpanIds = new Set(spanIds); - // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse) - expect(uniqueSpanIds.size).toBe(11); + // 3 simple + 2 hooks (middleware+handler) + 3 array hooks (2 middleware + 1 handler) + expect(uniqueSpanIds.size).toBe(8); // Verify spans share the same trace ID const traceIds = middlewareSpans.map(span => span.trace_id); @@ -128,7 +128,7 @@ test.describe.skip('Server Middleware Instrumentation', () => { ); }); - test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => { + test('should create spans for middleware and handler hooks', async ({ request }) => { const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; }); @@ -143,42 +143,35 @@ test.describe.skip('Server Middleware Instrumentation', () => { // Find spans for the hooks middleware const hooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); - // Should have spans for onRequest, handler, and onBeforeResponse - expect(hooksSpans).toHaveLength(3); + // Should have spans for middleware and handler (h3 v2 no longer has onBeforeResponse) + expect(hooksSpans).toHaveLength(2); // Find specific hook spans - const onRequestSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + const middlewareSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'middleware'); const handlerSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); - const onBeforeResponseSpan = hooksSpans.find( - span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', - ); - expect(onRequestSpan).toBeDefined(); + expect(middlewareSpan).toBeDefined(); expect(handlerSpan).toBeDefined(); - expect(onBeforeResponseSpan).toBeDefined(); // Verify span names include hook types - expect(onRequestSpan?.description).toBe('04.hooks.onRequest'); + expect(middlewareSpan?.description).toBe('04.hooks.middleware'); expect(handlerSpan?.description).toBe('04.hooks'); - expect(onBeforeResponseSpan?.description).toBe('04.hooks.onBeforeResponse'); // Verify all spans have correct middleware name (without hook suffix) - [onRequestSpan, handlerSpan, onBeforeResponseSpan].forEach(span => { + [middlewareSpan, handlerSpan].forEach(span => { expect(span?.data?.['nuxt.middleware.name']).toBe('04.hooks'); }); // Verify hook-specific attributes - expect(onRequestSpan?.data?.['nuxt.middleware.hook.name']).toBe('onRequest'); + expect(middlewareSpan?.data?.['nuxt.middleware.hook.name']).toBe('middleware'); expect(handlerSpan?.data?.['nuxt.middleware.hook.name']).toBe('handler'); - expect(onBeforeResponseSpan?.data?.['nuxt.middleware.hook.name']).toBe('onBeforeResponse'); - // Verify no index attributes for single hooks - expect(onRequestSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + // Verify middleware has index (middleware is always an array in h3 v2) + expect(middlewareSpan?.data?.['nuxt.middleware.hook.index']).toBe(0); expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); - expect(onBeforeResponseSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); }); - test('should create spans with index attributes for array hooks', async ({ request }) => { + test('should create spans with index attributes for array middleware', async ({ request }) => { const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; }); @@ -193,48 +186,35 @@ test.describe.skip('Server Middleware Instrumentation', () => { // Find spans for the array hooks middleware const arrayHooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '05.array-hooks'); - // Should have spans for 2 onRequest + 1 handler + 2 onBeforeResponse = 5 spans - expect(arrayHooksSpans).toHaveLength(5); - - // Find onRequest array spans - const onRequestSpans = arrayHooksSpans.filter(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); - expect(onRequestSpans).toHaveLength(2); + // Should have spans for 2 middleware + 1 handler = 3 spans (h3 v2 no longer has onBeforeResponse) + expect(arrayHooksSpans).toHaveLength(3); - // Find onBeforeResponse array spans - const onBeforeResponseSpans = arrayHooksSpans.filter( - span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + // Find middleware array spans + const middlewareArraySpans = arrayHooksSpans.filter( + span => span.data?.['nuxt.middleware.hook.name'] === 'middleware', ); - expect(onBeforeResponseSpans).toHaveLength(2); + expect(middlewareArraySpans).toHaveLength(2); // Find handler span const handlerSpan = arrayHooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); expect(handlerSpan).toBeDefined(); - // Verify index attributes for onRequest array - const onRequest0Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); - const onRequest1Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); - - expect(onRequest0Span).toBeDefined(); - expect(onRequest1Span).toBeDefined(); + // Verify index attributes for middleware array + const middleware0Span = middlewareArraySpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const middleware1Span = middlewareArraySpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); - // Verify index attributes for onBeforeResponse array - const onBeforeResponse0Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); - const onBeforeResponse1Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + expect(middleware0Span).toBeDefined(); + expect(middleware1Span).toBeDefined(); - expect(onBeforeResponse0Span).toBeDefined(); - expect(onBeforeResponse1Span).toBeDefined(); - - // Verify span names for array handlers - expect(onRequest0Span?.description).toBe('05.array-hooks.onRequest'); - expect(onRequest1Span?.description).toBe('05.array-hooks.onRequest'); - expect(onBeforeResponse0Span?.description).toBe('05.array-hooks.onBeforeResponse'); - expect(onBeforeResponse1Span?.description).toBe('05.array-hooks.onBeforeResponse'); + // Verify span names for array middleware handlers + expect(middleware0Span?.description).toBe('05.array-hooks.middleware'); + expect(middleware1Span?.description).toBe('05.array-hooks.middleware'); // Verify handler has no index expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); }); - test('should handle errors in onRequest hooks', async ({ request }) => { + test('should handle errors in middleware hooks', async ({ request }) => { const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; }); @@ -243,54 +223,26 @@ test.describe.skip('Server Middleware Instrumentation', () => { return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error'; }); - // Make request with query param to trigger error in onRequest + // Make request with query param to trigger error in middleware const response = await request.get('/api/middleware-test?throwOnRequestError=true'); expect(response.status()).toBe(500); const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); - // Find the onRequest span that should have error status - const onRequestSpan = serverTxnEvent.spans?.find( + // Find the middleware span that should have error status + const middlewareSpan = serverTxnEvent.spans?.find( span => span.op === 'middleware.nuxt' && span.data?.['nuxt.middleware.name'] === '04.hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onRequest', + span.data?.['nuxt.middleware.hook.name'] === 'middleware', ); - expect(onRequestSpan).toBeDefined(); - expect(onRequestSpan?.status).toBe('internal_error'); + expect(middlewareSpan).toBeDefined(); + expect(middlewareSpan?.status).toBe('internal_error'); expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error'); }); - test('should handle errors in onBeforeResponse hooks', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); - - const errorEventPromise = waitForError('nuxt-5', errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === 'OnBeforeResponse hook error'; - }); - - // Make request with query param to trigger error in onBeforeResponse - const response = await request.get('/api/middleware-test?throwOnBeforeResponseError=true'); - expect(response.status()).toBe(500); - - const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); - - // Find the onBeforeResponse span that should have error status - const onBeforeResponseSpan = serverTxnEvent.spans?.find( - span => - span.op === 'middleware.nuxt' && - span.data?.['nuxt.middleware.name'] === '04.hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', - ); - - expect(onBeforeResponseSpan).toBeDefined(); - expect(onBeforeResponseSpan?.status).toBe('internal_error'); - expect(errorEvent.exception?.values?.[0]?.value).toBe('OnBeforeResponse hook error'); - }); - - test('should handle errors in array hooks with proper index attribution', async ({ request }) => { + test('should handle errors in array middleware with proper index attribution', async ({ request }) => { const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; }); @@ -299,35 +251,35 @@ test.describe.skip('Server Middleware Instrumentation', () => { return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error'; }); - // Make request with query param to trigger error in second onRequest handler + // Make request with query param to trigger error in second middleware handler const response = await request.get('/api/middleware-test?throwOnRequest1Error=true'); expect(response.status()).toBe(500); const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); - // Find the second onRequest span that should have error status - const onRequest1Span = serverTxnEvent.spans?.find( + // Find the second middleware span that should have error status + const middleware1Span = serverTxnEvent.spans?.find( span => span.op === 'middleware.nuxt' && span.data?.['nuxt.middleware.name'] === '05.array-hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.name'] === 'middleware' && span.data?.['nuxt.middleware.hook.index'] === 1, ); - expect(onRequest1Span).toBeDefined(); - expect(onRequest1Span?.status).toBe('internal_error'); + expect(middleware1Span).toBeDefined(); + expect(middleware1Span?.status).toBe('internal_error'); expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error'); - // Verify the first onRequest handler still executed successfully - const onRequest0Span = serverTxnEvent.spans?.find( + // Verify the first middleware handler still executed successfully + const middleware0Span = serverTxnEvent.spans?.find( span => span.op === 'middleware.nuxt' && span.data?.['nuxt.middleware.name'] === '05.array-hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.name'] === 'middleware' && span.data?.['nuxt.middleware.hook.index'] === 0, ); - expect(onRequest0Span).toBeDefined(); - expect(onRequest0Span?.status).not.toBe('internal_error'); + expect(middleware0Span).toBeDefined(); + expect(middleware0Span?.status).not.toBe('internal_error'); }); }); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index ffe35a311e99..bccba280b9ce 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -166,7 +166,7 @@ export default defineNuxtModule({ return; } - if (serverConfigFile && !isNitroV3) { + if (serverConfigFile) { addMiddlewareInstrumentation(nitro); } diff --git a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts index b257d70b72d7..c0e79c902b93 100644 --- a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts +++ b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts @@ -21,6 +21,19 @@ import type { H3Event, } from 'h3'; +type RequestMiddleware = (event: H3Event) => void | Promise; +type HookName = 'onRequest' | 'onBeforeResponse' | 'middleware'; + +// Broader handler object type covering both h3 v1 and h3 v2 shapes. +type EventHandlerObjectH3 = EventHandlerObject & { + // h3 v1 (Nitro v2): onRequest, onBeforeResponse, handler (required) + onRequest?: RequestMiddleware | RequestMiddleware[]; + onBeforeResponse?: ResponseMiddleware | ResponseMiddleware[]; + + // h3 v2 (Nitro v3): middleware[], handler (optional), fetch, meta + middleware?: EventHandler[]; +}; + /** * Wraps a middleware handler with Sentry instrumentation. * @@ -35,36 +48,41 @@ export function wrapMiddlewareHandlerWithSentry - wrapEventHandler(h, fileName, 'onRequest', index), + // h3 v1 (Nitro v2): onRequest and response hooks + if (result.onRequest) { + result.onRequest = normalizeHandlers(result.onRequest, (h, index) => + wrapEventHandler(h as EventHandler, fileName, 'onRequest', index), ); } - if (handlerObj.onBeforeResponse) { - handlerObj.onBeforeResponse = normalizeHandlers(handlerObj.onBeforeResponse, (h, index) => + if (result.onBeforeResponse) { + result.onBeforeResponse = normalizeHandlers(result.onBeforeResponse, (h, index) => wrapResponseHandler(h, fileName, index), ); } - return handlerObj; + // h3 v2 (Nitro v3): middleware array replaces onRequest/onBeforeResponse + if (result.middleware?.length) { + result.middleware = result.middleware.map((h, index) => wrapEventHandler(h, fileName, 'middleware', index)); + } + + return result as THandler; } /** * Wraps a callable event handler with Sentry instrumentation. - * - * @param handler The event handler. - * @param handlerName The name of the event handler to be used for the span name and logging. */ function wrapEventHandler( handler: EventHandler, middlewareName: string, - hookName?: 'onRequest', + hookName?: HookName, index?: number, ): EventHandler { return async (event: H3Event) => { @@ -77,7 +95,7 @@ function wrapEventHandler( } /** - * Wraps a middleware response handler with Sentry instrumentation. + * Wraps a middleware response handler with Sentry instrumentation (h3 v1 only). */ function wrapResponseHandler(handler: ResponseMiddleware, middlewareName: string, index?: number): ResponseMiddleware { return async (event: H3Event, response: EventHandlerResponse) => { @@ -96,7 +114,7 @@ function withSpan( handler: () => TResult | Promise, attributes: SpanAttributes, middlewareName: string, - hookName?: 'handler' | 'onRequest' | 'onBeforeResponse', + hookName?: HookName | 'handler', ): Promise { const spanName = hookName && hookName !== 'handler' ? `${middlewareName}.${hookName}` : middlewareName; @@ -132,10 +150,7 @@ function withSpan( /** * Takes a list of handlers and wraps them with the normalizer function. */ -function normalizeHandlers( - handlers: T | T[], - normalizer: (h: T, index?: number) => T, -): T | T[] { +function normalizeHandlers(handlers: T | T[], normalizer: (h: T, index?: number) => T): T | T[] { return Array.isArray(handlers) ? handlers.map((handler, index) => normalizer(handler, index)) : normalizer(handlers); } @@ -145,7 +160,7 @@ function normalizeHandlers( function getSpanAttributes( event: H3Event, middlewareName: string, - hookName?: 'handler' | 'onRequest' | 'onBeforeResponse', + hookName?: HookName | 'handler', index?: number, ): SpanAttributes { const attributes: SpanAttributes = { @@ -161,18 +176,31 @@ function getSpanAttributes( attributes['nuxt.middleware.hook.index'] = index; } - // Add HTTP method - if (event.method) { - attributes['http.request.method'] = event.method; + // oxlint-disable-next-line typescript/no-explicit-any + const eventH3v2 = event as any; + // oxlint-disable-next-line @typescript-oxlint/no-unsafe-member-access + const method = event.method ?? eventH3v2?.req?.method; + // oxlint-disable-next-line @typescript-oxlint/no-unsafe-member-access + const path = event.path ?? eventH3v2?.url?.pathname; + + if (method) { + attributes['http.request.method'] = method; } - // Add route information - if (event.path) { - attributes['http.route'] = event.path; + if (path) { + attributes['http.route'] = path; + } + + // h3 v1 (Nuxt 4): headers are on event.node.req.headers + // h3 v2 (Nuxt 5): headers are on event.req.headers + let headers: Record = event.node?.req?.headers || {}; + + // oxlint-disable-next-line @typescript-oxlint/no-unsafe-member-access + if (!Object.keys(headers).length && eventH3v2?.req?.headers instanceof Headers) { + // oxlint-disable-next-line @typescript-oxlint/no-unsafe-member-access + headers = Object.fromEntries(eventH3v2?.req.headers.entries()); } - // Get headers from the Node.js request object - const headers = event.node?.req?.headers || {}; const headerAttributes = httpHeadersToSpanAttributes(headers, getClient()?.getOptions().sendDefaultPii ?? false); // Merge header attributes with existing attributes @@ -182,7 +210,7 @@ function getSpanAttributes( } /** - * Checks if the handler is an event handler, util for type narrowing. + * Checks if the handler is an event handler object, util for type narrowing. */ function isEventHandlerObject(handler: EventHandler | EventHandlerObject): handler is EventHandlerObject { return typeof handler !== 'function'; diff --git a/packages/nuxt/src/vite/middlewareConfig.ts b/packages/nuxt/src/vite/middlewareConfig.ts index d851345172d8..aa8f5a41ea23 100644 --- a/packages/nuxt/src/vite/middlewareConfig.ts +++ b/packages/nuxt/src/vite/middlewareConfig.ts @@ -90,8 +90,13 @@ function instrumentedEventHandler(handlerOrObject) { return eventHandler(wrapMiddlewareHandlerWithSentry(handlerOrObject, '${cleanFileName}')); } +function defineInstrumentedHandler(handlerOrObject) { + return defineHandler(wrapMiddlewareHandlerWithSentry(handlerOrObject, '${cleanFileName}')); +} + ${originalCode .replace(/defineEventHandler\(/g, 'defineInstrumentedEventHandler(') - .replace(/eventHandler\(/g, 'instrumentedEventHandler(')} + .replace(/eventHandler\(/g, 'instrumentedEventHandler(') + .replace(/defineHandler\(/g, 'defineInstrumentedHandler(')} `; } diff --git a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts index c1f73cd858fa..b340ab875ec0 100644 --- a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts +++ b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts @@ -468,6 +468,99 @@ describe('wrapMiddlewareHandlerWithSentry', () => { }); }); + describe('h3 v2 (Nitro v3) middleware array wrapping', () => { + it('should wrap middleware array handlers correctly', async () => { + const baseHandler: EventHandler = vi.fn().mockResolvedValue('handler-result'); + const middlewareHandler1 = vi.fn().mockResolvedValue(undefined); + const middlewareHandler2 = vi.fn().mockResolvedValue(undefined); + const handlerObject = { + handler: baseHandler, + middleware: [middlewareHandler1, middlewareHandler2], + }; + + const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject as any, 'v2-middleware'); + + expect(wrapped).toHaveProperty('middleware'); + expect(Array.isArray((wrapped as any).middleware)).toBe(true); + expect((wrapped as any).middleware).toHaveLength(2); + + await (wrapped as any).middleware[0](mockEvent); + await (wrapped as any).middleware[1](mockEvent); + + expect(middlewareHandler1).toHaveBeenCalledWith(mockEvent); + expect(middlewareHandler2).toHaveBeenCalledWith(mockEvent); + + expect(SentryCore.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'v2-middleware.middleware', + attributes: expect.objectContaining({ + [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nuxt', + 'nuxt.middleware.name': 'v2-middleware', + 'nuxt.middleware.hook.name': 'middleware', + 'nuxt.middleware.hook.index': 0, + }), + }), + expect.any(Function), + ); + expect(SentryCore.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'v2-middleware.middleware', + attributes: expect.objectContaining({ + 'nuxt.middleware.hook.name': 'middleware', + 'nuxt.middleware.hook.index': 1, + }), + }), + expect.any(Function), + ); + }); + + it('should wrap single-element middleware array with index', async () => { + const baseHandler: EventHandler = vi.fn().mockResolvedValue('handler-result'); + const middlewareHandler = vi.fn().mockResolvedValue(undefined); + const handlerObject = { + handler: baseHandler, + middleware: [middlewareHandler], + }; + + const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject as any, 'v2-single-middleware'); + + await (wrapped as any).middleware[0](mockEvent); + + expect(middlewareHandler).toHaveBeenCalledWith(mockEvent); + + const spanCall = (SentryCore.startSpan as any).mock.calls.find( + (call: any) => call[0]?.attributes?.['nuxt.middleware.hook.name'] === 'middleware', + ); + expect(spanCall[0].attributes['nuxt.middleware.hook.index']).toBe(0); + }); + + it('should handle h3 v2 object without middleware property', async () => { + const baseHandler: EventHandler = vi.fn().mockResolvedValue('v2-result'); + const handlerObject = { handler: baseHandler }; + + const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject as any, 'v2-handler-only'); + + const result = await (wrapped as any).handler(mockEvent); + expect(result).toBe('v2-result'); + expect(baseHandler).toHaveBeenCalledWith(mockEvent); + }); + + it('should propagate errors from middleware array handlers', async () => { + const baseHandler: EventHandler = vi.fn().mockResolvedValue('success'); + const error = new Error('Middleware error'); + const failingMiddleware = vi.fn().mockRejectedValue(error); + const handlerObject = { + handler: baseHandler, + middleware: [failingMiddleware], + }; + + const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject as any, 'failing-v2-middleware'); + + await expect((wrapped as any).middleware[0](mockEvent)).rejects.toThrow('Middleware error'); + expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.any(Object)); + }); + }); + describe('Sentry API integration', () => { it('should call Sentry APIs with correct parameters', async () => { const userHandler: EventHandler = vi.fn().mockResolvedValue('api-test-result'); From 94534e6733e5dcf6087b250a203f4b98734f30b1 Mon Sep 17 00:00:00 2001 From: Stephanie Anderson Date: Fri, 27 Mar 2026 22:42:08 +0100 Subject: [PATCH 26/39] chore: Update validate-pr action to latest version (#20027) Updates the pinned SHA for the validate-pr composite action from getsentry/github-workflows to pick up the bot allowlist fix (getsentry/github-workflows#155). Trusted bots (dependabot, renovate, github-actions, etc.) are now exempt from issue reference validation and draft enforcement. #skip-changelog Co-Authored-By: Claude Opus 4.6 (1M context) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/validate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index c05657993e86..3b82dd4026f6 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@4243265ac9cc3ee5b89ad2b30c3797ac8483d63a + - uses: getsentry/github-workflows/validate-pr@4ff40ada546d4a31b852a4279828b989a6193497 with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From 47473553f305fbc0d4ca6c9c86b67ace1d308a85 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:47:12 +0900 Subject: [PATCH 27/39] feat(solid): Add route parametrization for Solid Router (#20031) This PR adds route parametrization for the solidRouterBrowserTracingIntegration. It replaces raw URLs (e.g. /users/5) with parametrized routes (e.g. /users/:id) in transaction names. Closes: #16685 --- .../tests/performance.client.test.ts | 18 ++-- .../tests/performance.client.test.ts | 18 ++-- .../tests/performance.client.test.ts | 18 ++-- .../tests/performance.client.test.ts | 18 ++-- packages/solid/src/solidrouter.ts | 55 ++++++++++--- packages/solid/test/solidrouter.test.tsx | 82 +++++++++++-------- .../test/client/solidrouter.test.tsx | 82 +++++++++++-------- 7 files changed, 176 insertions(+), 115 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts index 63f97d519cf8..0cdc2465c065 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts @@ -18,14 +18,14 @@ test('sends a pageload transaction', async ({ page }) => { }, transaction: '/', transaction_info: { - source: 'url', + source: 'route', }, }); }); -test('sends a navigation transaction', async ({ page }) => { +test('sends a navigation transaction with parametrized route', async ({ page }) => { const transactionPromise = waitForTransaction('solidstart-dynamic-import', async transactionEvent => { - return transactionEvent?.transaction === '/users/5' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/`); @@ -39,9 +39,9 @@ test('sends a navigation transaction', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/5', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); }); @@ -51,7 +51,7 @@ test('updates the transaction when using the back button', async ({ page }) => { // The sentry solidRouterBrowserTracingIntegration tries to update such // transactions with the proper name once the `useLocation` hook triggers. const navigationTxnPromise = waitForTransaction('solidstart-dynamic-import', async transactionEvent => { - return transactionEvent?.transaction === '/users/6' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/back-navigation`); @@ -65,9 +65,9 @@ test('updates the transaction when using the back button', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/6', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); @@ -89,7 +89,7 @@ test('updates the transaction when using the back button', async ({ page }) => { }, transaction: '/back-navigation', transaction_info: { - source: 'url', + source: 'route', }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts index c689bca22539..f54318bf171c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts @@ -18,14 +18,14 @@ test('sends a pageload transaction', async ({ page }) => { }, transaction: '/', transaction_info: { - source: 'url', + source: 'route', }, }); }); -test('sends a navigation transaction', async ({ page }) => { +test('sends a navigation transaction with parametrized route', async ({ page }) => { const transactionPromise = waitForTransaction('solidstart-spa', async transactionEvent => { - return transactionEvent?.transaction === '/users/5' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/`); @@ -39,9 +39,9 @@ test('sends a navigation transaction', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/5', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); }); @@ -51,7 +51,7 @@ test('updates the transaction when using the back button', async ({ page }) => { // The sentry solidRouterBrowserTracingIntegration tries to update such // transactions with the proper name once the `useLocation` hook triggers. const navigationTxnPromise = waitForTransaction('solidstart-spa', async transactionEvent => { - return transactionEvent?.transaction === '/users/6' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/back-navigation`); @@ -65,9 +65,9 @@ test('updates the transaction when using the back button', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/6', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); @@ -89,7 +89,7 @@ test('updates the transaction when using the back button', async ({ page }) => { }, transaction: '/back-navigation', transaction_info: { - source: 'url', + source: 'route', }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts index bd5dece39b33..55eeb5a5c757 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts @@ -18,14 +18,14 @@ test('sends a pageload transaction', async ({ page }) => { }, transaction: '/', transaction_info: { - source: 'url', + source: 'route', }, }); }); -test('sends a navigation transaction', async ({ page }) => { +test('sends a navigation transaction with parametrized route', async ({ page }) => { const transactionPromise = waitForTransaction('solidstart-top-level-import', async transactionEvent => { - return transactionEvent?.transaction === '/users/5' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/`); @@ -39,9 +39,9 @@ test('sends a navigation transaction', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/5', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); }); @@ -51,7 +51,7 @@ test('updates the transaction when using the back button', async ({ page }) => { // The sentry solidRouterBrowserTracingIntegration tries to update such // transactions with the proper name once the `useLocation` hook triggers. const navigationTxnPromise = waitForTransaction('solidstart-top-level-import', async transactionEvent => { - return transactionEvent?.transaction === '/users/6' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/back-navigation`); @@ -65,9 +65,9 @@ test('updates the transaction when using the back button', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/6', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); @@ -89,7 +89,7 @@ test('updates the transaction when using the back button', async ({ page }) => { }, transaction: '/back-navigation', transaction_info: { - source: 'url', + source: 'route', }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts index 52d9cb219401..068fdc9b0cc2 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts @@ -18,14 +18,14 @@ test('sends a pageload transaction', async ({ page }) => { }, transaction: '/', transaction_info: { - source: 'url', + source: 'route', }, }); }); -test('sends a navigation transaction', async ({ page }) => { +test('sends a navigation transaction with parametrized route', async ({ page }) => { const transactionPromise = waitForTransaction('solidstart', async transactionEvent => { - return transactionEvent?.transaction === '/users/5' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/`); @@ -39,9 +39,9 @@ test('sends a navigation transaction', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/5', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); }); @@ -51,7 +51,7 @@ test('updates the transaction when using the back button', async ({ page }) => { // The sentry solidRouterBrowserTracingIntegration tries to update such // transactions with the proper name once the `useLocation` hook triggers. const navigationTxnPromise = waitForTransaction('solidstart', async transactionEvent => { - return transactionEvent?.transaction === '/users/6' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/back-navigation`); @@ -65,9 +65,9 @@ test('updates the transaction when using the back button', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/6', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); @@ -89,7 +89,7 @@ test('updates the transaction when using the back button', async ({ page }) => { }, transaction: '/back-navigation', transaction_info: { - source: 'url', + source: 'route', }, }); }); diff --git a/packages/solid/src/solidrouter.ts b/packages/solid/src/solidrouter.ts index 40af69caa2bc..0424183aea18 100644 --- a/packages/solid/src/solidrouter.ts +++ b/packages/solid/src/solidrouter.ts @@ -20,7 +20,7 @@ import type { RouteSectionProps, StaticRouter, } from '@solidjs/router'; -import { useBeforeLeave, useLocation } from '@solidjs/router'; +import { useBeforeLeave, useCurrentMatches, useLocation } from '@solidjs/router'; import type { Component, JSX, ParentProps } from 'solid-js'; import { createEffect, mergeProps, splitProps } from 'solid-js'; import { createComponent } from 'solid-js/web'; @@ -66,31 +66,60 @@ function SentryDefaultRoot(props: ParentProps): JSX.Element { */ function withSentryRouterRoot(Root: Component): Component { const SentryRouterRoot = (props: RouteSectionProps): JSX.Element => { - // TODO: This is a rudimentary first version of handling navigation spans - // It does not - // - use query params - // - parameterize the route + // Tracks the target of a pending navigation, so the effect can skip + // stale updates during redirects where the location signal + // hasn't caught up to the navigation span yet. + let pendingNavigationTarget: string | undefined; useBeforeLeave(({ to }: BeforeLeaveEventArgs) => { - // `to` could be `-1` if the browser back-button was used - handleNavigation(to.toString()); + const target = to.toString(); + pendingNavigationTarget = target; + handleNavigation(target); }); const location = useLocation(); + const matches = useCurrentMatches(); + createEffect(() => { const name = location.pathname; const rootSpan = getActiveRootSpan(); + if (!rootSpan) { + return; + } - if (rootSpan) { + // During redirects, the effect can fire before the router + // transition completes. In that case, location.pathname still points + // to the old route while the active span is already the navigation span. + // Skip the update to avoid overwriting the span with stale route data. + // `-1` is solid router's representation of a browser back-button + // navigation, where we don't know the target URL upfront. + if (pendingNavigationTarget && pendingNavigationTarget !== '-1' && name !== pendingNavigationTarget) { + return; + } + pendingNavigationTarget = undefined; + + const currentMatches = matches(); + const lastMatch = currentMatches[currentMatches.length - 1]; + + if (lastMatch) { + const parametrizedRoute = lastMatch.route.pattern || name; + rootSpan.updateName(parametrizedRoute); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + + const params = lastMatch.params; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + rootSpan.setAttribute(`url.path.parameter.${key}`, value); + rootSpan.setAttribute(`params.${key}`, value); + } + } + } else { + // No matched route - update back-button navigations and set source to url const { op, description } = spanToJSON(rootSpan); - - // We only need to update navigation spans that have been created by - // a browser back-button navigation (stored as `-1` by solid router) - // everything else was already instrumented correctly in `useBeforeLeave` if (op === 'navigation' && description === '-1') { rootSpan.updateName(name); - rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); } + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); } }); diff --git a/packages/solid/test/solidrouter.test.tsx b/packages/solid/test/solidrouter.test.tsx index 5a5ab77e9f2c..f33b7e72daf4 100644 --- a/packages/solid/test/solidrouter.test.tsx +++ b/packages/solid/test/solidrouter.test.tsx @@ -1,4 +1,5 @@ import { spanToJSON } from '@sentry/browser'; +import type { Span } from '@sentry/core'; import { createTransport, getCurrentScope, @@ -9,7 +10,7 @@ import { } from '@sentry/core'; import type { MemoryHistory } from '@solidjs/router'; import { createMemoryHistory, MemoryRouter, Navigate, Route } from '@solidjs/router'; -import { render } from '@solidjs/testing-library'; +import { render, waitFor } from '@solidjs/testing-library'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../src'; import { solidRouterBrowserTracingIntegration, withSentryRouterRouting } from '../src/solidrouter'; @@ -114,39 +115,54 @@ describe('solidRouterBrowserTracingIntegration', () => { }); it.each([ - ['', '/navigate-to-about', '/about'], - ['for nested navigation', '/navigate-to-about-us', '/about/us'], - ['for navigation with param', '/navigate-to-user', '/user/5'], - ['for nested navigation with params', '/navigate-to-user-post', '/user/5/post/12'], - ])('starts a navigation span %s', (_itDescription, navigationPath, path) => { - const spanStartMock = vi.fn(); - - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.on('spanStart', span => { - spanStartMock(spanToJSON(span)); - }); - client.addIntegration(solidRouterBrowserTracingIntegration()); - const SentryRouter = withSentryRouterRouting(MemoryRouter); - - const history = createMemoryHistory(); - history.set({ value: navigationPath }); - - renderRouter(SentryRouter, history); - - expect(spanStartMock).toHaveBeenCalledWith( - expect.objectContaining({ - op: 'navigation', - description: path, - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + ['', '/navigate-to-about', '/about', {}], + ['for nested navigation', '/navigate-to-about-us', '/about/us', {}], + ['for navigation with param', '/navigate-to-user', '/user/:id', { id: '5' }], + [ + 'for nested navigation with params', + '/navigate-to-user-post', + '/user/:id/post/:postId', + { id: '5', postId: '12' }, + ], + ])( + 'starts a parametrized navigation span %s', + async (_itDescription, navigationPath, parametrizedRoute, expectedParams) => { + const spans: Span[] = []; + + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.on('spanStart', span => { + spans.push(span); + }); + client.addIntegration(solidRouterBrowserTracingIntegration()); + const SentryRouter = withSentryRouterRouting(MemoryRouter); + + const history = createMemoryHistory(); + history.set({ value: navigationPath }); + + renderRouter(SentryRouter, history); + + // Wait for the router transition to complete (Navigate redirects are async) + await waitFor(() => { + const navSpan = spans.find(s => spanToJSON(s).op === 'navigation'); + expect(navSpan).toBeDefined(); + + const span = spanToJSON(navSpan!); + expect(span.description).toBe(parametrizedRoute); + expect(span.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.solidrouter', - }), - }), - ); - }); + }); + + for (const [key, value] of Object.entries(expectedParams as Record)) { + expect(span.data![`url.path.parameter.${key}`]).toBe(value); + expect(span.data![`params.${key}`]).toBe(value); + } + }); + }, + ); it('skips navigation span, with `instrumentNavigation: false`', () => { const spanStartMock = vi.fn(); @@ -172,7 +188,7 @@ describe('solidRouterBrowserTracingIntegration', () => { op: 'navigation', description: '/about', data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.solidrouter', }), diff --git a/packages/solidstart/test/client/solidrouter.test.tsx b/packages/solidstart/test/client/solidrouter.test.tsx index 143a7340456e..1b9623cabc13 100644 --- a/packages/solidstart/test/client/solidrouter.test.tsx +++ b/packages/solidstart/test/client/solidrouter.test.tsx @@ -1,4 +1,5 @@ import { spanToJSON } from '@sentry/browser'; +import type { Span } from '@sentry/core'; import { createTransport, getCurrentScope, @@ -9,7 +10,7 @@ import { } from '@sentry/core'; import type { MemoryHistory } from '@solidjs/router'; import { createMemoryHistory, MemoryRouter, Navigate, Route } from '@solidjs/router'; -import { render } from '@solidjs/testing-library'; +import { render, waitFor } from '@solidjs/testing-library'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../../src/client'; import { solidRouterBrowserTracingIntegration, withSentryRouterRouting } from '../../src/client/solidrouter'; @@ -114,39 +115,54 @@ describe('solidRouterBrowserTracingIntegration', () => { }); it.each([ - ['', '/navigate-to-about', '/about'], - ['for nested navigation', '/navigate-to-about-us', '/about/us'], - ['for navigation with param', '/navigate-to-user', '/user/5'], - ['for nested navigation with params', '/navigate-to-user-post', '/user/5/post/12'], - ])('starts a navigation span %s', (_itDescription, navigationPath, path) => { - const spanStartMock = vi.fn(); - - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.on('spanStart', span => { - spanStartMock(spanToJSON(span)); - }); - client.addIntegration(solidRouterBrowserTracingIntegration()); - const SentryRouter = withSentryRouterRouting(MemoryRouter); - - const history = createMemoryHistory(); - history.set({ value: navigationPath }); - - renderRouter(SentryRouter, history); - - expect(spanStartMock).toHaveBeenCalledWith( - expect.objectContaining({ - op: 'navigation', - description: path, - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + ['', '/navigate-to-about', '/about', {}], + ['for nested navigation', '/navigate-to-about-us', '/about/us', {}], + ['for navigation with param', '/navigate-to-user', '/user/:id', { id: '5' }], + [ + 'for nested navigation with params', + '/navigate-to-user-post', + '/user/:id/post/:postId', + { id: '5', postId: '12' }, + ], + ])( + 'starts a parametrized navigation span %s', + async (_itDescription, navigationPath, parametrizedRoute, expectedParams) => { + const spans: Span[] = []; + + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.on('spanStart', span => { + spans.push(span); + }); + client.addIntegration(solidRouterBrowserTracingIntegration()); + const SentryRouter = withSentryRouterRouting(MemoryRouter); + + const history = createMemoryHistory(); + history.set({ value: navigationPath }); + + renderRouter(SentryRouter, history); + + // Wait for the router transition to complete (Navigate redirects are async) + await waitFor(() => { + const navSpan = spans.find(s => spanToJSON(s).op === 'navigation'); + expect(navSpan).toBeDefined(); + + const span = spanToJSON(navSpan!); + expect(span.description).toBe(parametrizedRoute); + expect(span.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solidstart.solidrouter', - }), - }), - ); - }); + }); + + for (const [key, value] of Object.entries(expectedParams as Record)) { + expect(span.data![`url.path.parameter.${key}`]).toBe(value); + expect(span.data![`params.${key}`]).toBe(value); + } + }); + }, + ); it('skips navigation span, with `instrumentNavigation: false`', () => { const spanStartMock = vi.fn(); @@ -172,7 +188,7 @@ describe('solidRouterBrowserTracingIntegration', () => { op: 'navigation', description: '/about', data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solidstart.solidrouter', }), From c8e56ffd420e6ba1099baec8b302b2bba9219405 Mon Sep 17 00:00:00 2001 From: Stephanie Anderson Date: Mon, 30 Mar 2026 10:51:19 +0200 Subject: [PATCH 28/39] fix(ci): Update validate-pr action to remove draft enforcement (#20035) The validate-pr composite action's draft enforcement step was failing with: ``` API call failed: GraphQL: Resource not accessible by integration (convertPullRequestToDraft) ``` The SDK Maintainer Bot app lacks the permissions needed for the `convertPullRequestToDraft` GraphQL mutation. Rather than expanding the app's permissions, draft enforcement has been removed from the shared action in getsentry/github-workflows#159. This bumps the pinned SHA to pick up that fix. Co-Authored-By: Claude Opus 4.6 (1M context) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/validate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 3b82dd4026f6..b1fa22704bd8 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@4ff40ada546d4a31b852a4279828b989a6193497 + - uses: getsentry/github-workflows/validate-pr@33c378e8d3aa1515164b62c16c210784cee35638 with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From 2a8c1d65cdd9d59569678a492a80fcc0e6535010 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:00:18 +0900 Subject: [PATCH 29/39] feat(node-core): Add OTLP integration for node-core/light (#19729) Added `otlpIntegration` at `@sentry/node-core/light/otlp` for users who manage their own OpenTelemetry setup and want to send trace data to Sentry without adopting the full `@sentry/node` SDK. ```js import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; import * as Sentry from '@sentry/node-core/light'; import { otlpIntegration } from '@sentry/node-core/light/otlp'; const provider = new NodeTracerProvider(); provider.register(); Sentry.init({ dsn: '__DSN__', integrations: [ otlpIntegration({ // Export OTel spans to Sentry via OTLP (default: true) setupOtlpTracesExporter: true, // Send traces to a custom collector instead of the DSN-derived endpoint (default: undefined) collectorUrl: 'https://my-collector.example.com/v1/traces', }), ], }); ``` The integration links Sentry errors to OTel traces and exports spans to Sentry via OTLP.
Split up for easier reviewing: External propagation context support: https://github.com/getsentry/sentry-javascript/commit/1ec99378b5 OTLP integration: https://github.com/getsentry/sentry-javascript/commit/70d58adff4 E2E test app: https://github.com/getsentry/sentry-javascript/commit/19904655a2 CHANGELOG entry: https://github.com/getsentry/sentry-javascript/commit/b43c9de861 --------- Co-authored-by: Claude claude-opus-4-6 --- CHANGELOG.md | 27 ++++ .../node-core-light-otlp/.gitignore | 4 + .../node-core-light-otlp/.npmrc | 2 + .../node-core-light-otlp/package.json | 40 +++++ .../node-core-light-otlp/playwright.config.ts | 34 +++++ .../node-core-light-otlp/src/app.ts | 90 +++++++++++ .../start-event-proxy.mjs | 6 + .../node-core-light-otlp/start-otel-proxy.mjs | 6 + .../node-core-light-otlp/tests/errors.test.ts | 32 ++++ .../tests/otel-spans.test.ts | 16 ++ .../tests/request-isolation.test.ts | 60 ++++++++ .../node-core-light-otlp/tsconfig.json | 18 +++ packages/core/src/api.ts | 2 +- packages/core/src/currentScopes.ts | 30 ++++ packages/core/src/index.ts | 5 +- packages/core/src/utils/traceData.ts | 13 +- packages/core/test/lib/currentScopes.test.ts | 86 +++++++++++ .../core/test/lib/utils/traceData.test.ts | 44 ++++++ packages/node-core/package.json | 17 ++- packages/node-core/rollup.npm.config.mjs | 2 +- .../src/light/integrations/otlpIntegration.ts | 142 ++++++++++++++++++ .../integrations/otlpIntegration.test.ts | 73 +++++++++ yarn.lock | 81 +++++++++- 23 files changed, 818 insertions(+), 12 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-otel-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/otel-spans.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/request-isolation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/tsconfig.json create mode 100644 packages/core/test/lib/currentScopes.test.ts create mode 100644 packages/node-core/src/light/integrations/otlpIntegration.ts create mode 100644 packages/node-core/test/light/integrations/otlpIntegration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3960f3fa34..a9359fc67721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ ### Important Changes +- **feat(node-core): Add OTLP integration for node-core/light ([#19729](https://github.com/getsentry/sentry-javascript/pull/19729))** + + Added `otlpIntegration` at `@sentry/node-core/light/otlp` for users who manage + their own OpenTelemetry setup and want to send trace data to Sentry without + adopting the full `@sentry/node` SDK. + + ```js + import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; + import * as Sentry from '@sentry/node-core/light'; + import { otlpIntegration } from '@sentry/node-core/light/otlp'; + + const provider = new NodeTracerProvider(); + provider.register(); + + Sentry.init({ + dsn: '__DSN__', + integrations: [ + otlpIntegration({ + // Export OTel spans to Sentry via OTLP (default: true) + setupOtlpTracesExporter: true, + }), + ], + }); + ``` + + The integration links Sentry errors to OTel traces and exports spans to Sentry via OTLP. + - **feat(node, bun): Add runtime metrics integrations for Node.js and Bun ([#19923](https://github.com/getsentry/sentry-javascript/pull/19923), [#19979](https://github.com/getsentry/sentry-javascript/pull/19979))** New `nodeRuntimeMetricsIntegration` and `bunRuntimeMetricsIntegration` automatically collect runtime health metrics and send them to Sentry on a configurable interval (default: 30s). Collected metrics include memory (RSS, heap used/total), CPU utilization, event loop utilization, and process uptime. Node additionally collects event loop delay percentiles (p50, p99). Extra metrics like CPU time and external memory are available as opt-in. diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.gitignore new file mode 100644 index 000000000000..f5bd8548c7aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/package.json b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/package.json new file mode 100644 index 000000000000..fcf388cfaa89 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/package.json @@ -0,0 +1,40 @@ +{ + "name": "node-core-light-otlp-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", + "@opentelemetry/sdk-trace-base": "^2.5.1", + "@opentelemetry/sdk-trace-node": "^2.5.1", + "@sentry/node-core": "latest || *", + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "express": "^4.21.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "latest || *" + }, + "volta": { + "node": "22.18.0" + }, + "sentryTest": { + "variants": [ + { + "label": "node 22 (light mode + OTLP integration)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/playwright.config.ts new file mode 100644 index 000000000000..604e6d9e6861 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/playwright.config.ts @@ -0,0 +1,34 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig( + { + startCommand: 'pnpm start', + }, + { + webServer: [ + { + command: 'node ./start-event-proxy.mjs', + port: 3031, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'node ./start-otel-proxy.mjs', + port: 3032, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm start', + port: 3030, + stdout: 'pipe', + stderr: 'pipe', + env: { + PORT: '3030', + }, + }, + ], + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/src/app.ts new file mode 100644 index 000000000000..d8cb48eab19c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/src/app.ts @@ -0,0 +1,90 @@ +import { trace } from '@opentelemetry/api'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import * as Sentry from '@sentry/node-core/light'; +import { otlpIntegration } from '@sentry/node-core/light/otlp'; +import express from 'express'; + +const provider = new NodeTracerProvider({ + spanProcessors: [ + // The user's own exporter (sends to test proxy for verification) + new BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), + ], +}); + +provider.register(); + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + debug: true, + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', // Use event proxy for testing + integrations: [otlpIntegration()], +}); + +const app = express(); +const port = 3030; +const tracer = trace.getTracer('test-app'); + +app.get('/test-error', (_req, res) => { + Sentry.setTag('test', 'error'); + Sentry.captureException(new Error('Test error from light+otel')); + res.status(500).json({ error: 'Error captured' }); +}); + +app.get('/test-otel-span', (_req, res) => { + tracer.startActiveSpan('test-span', span => { + Sentry.captureException(new Error('Error inside OTel span')); + span.end(); + }); + + res.json({ ok: true }); +}); + +app.get('/test-isolation/:userId', async (req, res) => { + const userId = req.params.userId; + + // The light httpIntegration provides request isolation via diagnostics_channel. + // This should still work alongside the OTLP integration. + Sentry.setUser({ id: userId }); + Sentry.setTag('user_id', userId); + + // Simulate async work + await new Promise(resolve => setTimeout(resolve, Math.random() * 200 + 50)); + + const isolationScope = Sentry.getIsolationScope(); + const scopeData = isolationScope.getScopeData(); + + const isIsolated = scopeData.user?.id === userId && scopeData.tags?.user_id === userId; + + res.json({ + userId, + isIsolated, + scope: { + userId: scopeData.user?.id, + userIdTag: scopeData.tags?.user_id, + }, + }); +}); + +app.get('/test-isolation-error/:userId', (req, res) => { + const userId = req.params.userId; + Sentry.setTag('user_id', userId); + Sentry.setUser({ id: userId }); + + Sentry.captureException(new Error(`Error for user ${userId}`)); + res.json({ userId, captured: true }); +}); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok' }); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-event-proxy.mjs new file mode 100644 index 000000000000..3e170b6311bd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-light-otlp', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-otel-proxy.mjs new file mode 100644 index 000000000000..d3f1d89b1149 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-core-light-otlp-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts new file mode 100644 index 000000000000..9dd6b76a5e15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('should capture errors with correct tags', async ({ request }) => { + const errorEventPromise = waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Test error from light+otel'; + }); + + const response = await request.get('/test-error'); + expect(response.status()).toBe(500); + + const errorEvent = await errorEventPromise; + expect(errorEvent).toBeDefined(); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from light+otel'); + expect(errorEvent.tags?.test).toBe('error'); +}); + +test('should link error events to the active OTel trace context', async ({ request }) => { + const errorEventPromise = waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error inside OTel span'; + }); + + await request.get('/test-otel-span'); + + const errorEvent = await errorEventPromise; + expect(errorEvent).toBeDefined(); + + // The error event should have trace context from the OTel span + expect(errorEvent.contexts?.trace).toBeDefined(); + expect(errorEvent.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/); + expect(errorEvent.contexts?.trace?.span_id).toMatch(/[a-f0-9]{16}/); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/otel-spans.test.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/otel-spans.test.ts new file mode 100644 index 000000000000..b45c09e00b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/otel-spans.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForPlainRequest } from '@sentry-internal/test-utils'; + +test('User OTel exporter still receives spans', async ({ request }) => { + // The user's own OTel exporter sends spans to port 3032 (our test proxy). + // Verify that OTel span export still works alongside the Sentry OTLP integration. + const otelPromise = waitForPlainRequest('node-core-light-otlp-otel', data => { + const json = JSON.parse(data) as { resourceSpans: unknown[] }; + return json.resourceSpans.length > 0; + }); + + await request.get('/test-otel-span'); + + const otelData = await otelPromise; + expect(otelData).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/request-isolation.test.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/request-isolation.test.ts new file mode 100644 index 000000000000..3510e9f349bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/request-isolation.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('should isolate scope data across concurrent requests', async ({ request }) => { + const [response1, response2, response3] = await Promise.all([ + request.get('/test-isolation/user-1'), + request.get('/test-isolation/user-2'), + request.get('/test-isolation/user-3'), + ]); + + const data1 = await response1.json(); + const data2 = await response2.json(); + const data3 = await response3.json(); + + expect(data1.isIsolated).toBe(true); + expect(data1.userId).toBe('user-1'); + expect(data1.scope.userId).toBe('user-1'); + expect(data1.scope.userIdTag).toBe('user-1'); + + expect(data2.isIsolated).toBe(true); + expect(data2.userId).toBe('user-2'); + expect(data2.scope.userId).toBe('user-2'); + expect(data2.scope.userIdTag).toBe('user-2'); + + expect(data3.isIsolated).toBe(true); + expect(data3.userId).toBe('user-3'); + expect(data3.scope.userId).toBe('user-3'); + expect(data3.scope.userIdTag).toBe('user-3'); +}); + +test('should isolate errors across concurrent requests', async ({ request }) => { + const errorPromises = [ + waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error for user user-1'; + }), + waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error for user user-2'; + }), + waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error for user user-3'; + }), + ]; + + await Promise.all([ + request.get('/test-isolation-error/user-1'), + request.get('/test-isolation-error/user-2'), + request.get('/test-isolation-error/user-3'), + ]); + + const [error1, error2, error3] = await Promise.all(errorPromises); + + expect(error1?.user?.id).toBe('user-1'); + expect(error1?.tags?.user_id).toBe('user-1'); + + expect(error2?.user?.id).toBe('user-2'); + expect(error2?.tags?.user_id).toBe('user-2'); + + expect(error3?.user?.id).toBe('user-3'); + expect(error3?.tags?.user_id).toBe('user-3'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tsconfig.json new file mode 100644 index 000000000000..a2a82225afca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts index 924c6a8e28ad..2aea96fd825b 100644 --- a/packages/core/src/api.ts +++ b/packages/core/src/api.ts @@ -3,7 +3,7 @@ import type { DsnComponents, DsnLike } from './types-hoist/dsn'; import type { SdkInfo } from './types-hoist/sdkinfo'; import { dsnToString, makeDsn } from './utils/dsn'; -const SENTRY_API_VERSION = '7'; +export const SENTRY_API_VERSION = '7'; /** Returns the prefix to construct Sentry ingestion API endpoints. */ function getBaseApiEndpoint(dsn: DsnComponents): string { diff --git a/packages/core/src/currentScopes.ts b/packages/core/src/currentScopes.ts index fc40051e56d8..a88aed55c971 100644 --- a/packages/core/src/currentScopes.ts +++ b/packages/core/src/currentScopes.ts @@ -5,6 +5,31 @@ import { Scope } from './scope'; import type { TraceContext } from './types-hoist/context'; import { generateSpanId } from './utils/propagationContext'; +let _externalPropagationContextProvider: (() => { traceId: string; spanId: string } | undefined) | undefined; + +/** + * Register an external propagation context provider function. + * When registered, trace context will be read from the external source (e.g. OpenTelemetry) + * instead of from the Sentry scope's propagation context. + */ +export function registerExternalPropagationContext(fn: () => { traceId: string; spanId: string } | undefined): void { + _externalPropagationContextProvider = fn; +} + +/** + * Get the external propagation context, if a provider has been registered. + */ +export function getExternalPropagationContext(): { traceId: string; spanId: string } | undefined { + return _externalPropagationContextProvider?.(); +} + +/** + * Check if an external propagation context provider has been registered. + */ +export function hasExternalPropagationContext(): boolean { + return _externalPropagationContextProvider !== undefined; +} + /** * Get the currently active scope. */ @@ -125,6 +150,11 @@ export function getClient(): C | undefined { * Get a trace context for the given scope. */ export function getTraceContextFromScope(scope: Scope): TraceContext { + const externalContext = getExternalPropagationContext(); + if (externalContext) { + return { trace_id: externalContext.traceId, span_id: externalContext.spanId }; + } + const propagationContext = scope.getPropagationContext(); const { traceId, parentSpanId, propagationSpanId } = propagationContext; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 09a4f36ebdb2..d155da8adf72 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,6 +41,9 @@ export { withIsolationScope, getClient, getTraceContextFromScope, + registerExternalPropagationContext, + getExternalPropagationContext, + hasExternalPropagationContext, } from './currentScopes'; export { getDefaultCurrentScope, getDefaultIsolationScope } from './defaultScopes'; export { setAsyncContextStrategy } from './asyncContext'; @@ -49,7 +52,7 @@ export { makeSession, closeSession, updateSession } from './session'; export { Scope } from './scope'; export type { CaptureContext, ScopeContext, ScopeData } from './scope'; export { notifyEventProcessors } from './eventProcessors'; -export { getEnvelopeEndpointWithUrlEncodedAuth, getReportDialogEndpoint } from './api'; +export { getEnvelopeEndpointWithUrlEncodedAuth, getReportDialogEndpoint, SENTRY_API_VERSION } from './api'; export { Client } from './client'; export { ServerRuntimeClient } from './server-runtime-client'; export { initAndBind, setCurrentClient } from './sdk'; diff --git a/packages/core/src/utils/traceData.ts b/packages/core/src/utils/traceData.ts index 9958e2761960..c19b2560b605 100644 --- a/packages/core/src/utils/traceData.ts +++ b/packages/core/src/utils/traceData.ts @@ -1,7 +1,7 @@ import { getAsyncContextStrategy } from '../asyncContext'; import { getMainCarrier } from '../carrier'; import type { Client } from '../client'; -import { getClient, getCurrentScope } from '../currentScopes'; +import { getClient, getCurrentScope, hasExternalPropagationContext } from '../currentScopes'; import { isEnabled } from '../exports'; import type { Scope } from '../scope'; import { getDynamicSamplingContextFromScope, getDynamicSamplingContextFromSpan } from '../tracing'; @@ -20,6 +20,10 @@ import { generateSentryTraceHeader, generateTraceparentHeader, TRACEPARENT_REGEX * This function also applies some validation to the generated sentry-trace and baggage values to ensure that * only valid strings are returned. * + * When an external propagation context is registered (e.g. via the OTLP integration) and there is no active + * Sentry span, this function returns an empty object to defer outgoing request propagation to the external + * propagator (e.g. an OpenTelemetry propagator). + * * If (@param options.propagateTraceparent) is `true`, the function will also generate a `traceparent` value, * following the W3C traceparent header format. * @@ -42,6 +46,13 @@ export function getTraceData( const scope = options.scope || getCurrentScope(); const span = options.span || getActiveSpan(); + + // When no active span and external propagation context is registered (e.g. OTLP integration), + // return empty to let the OTel propagator handle outgoing request propagation. + if (!span && hasExternalPropagationContext()) { + return {}; + } + const sentryTrace = span ? spanToTraceHeader(span) : scopeToTraceHeader(scope); const dsc = span ? getDynamicSamplingContextFromSpan(span) : getDynamicSamplingContextFromScope(client, scope); const baggage = dynamicSamplingContextToSentryBaggageHeader(dsc); diff --git a/packages/core/test/lib/currentScopes.test.ts b/packages/core/test/lib/currentScopes.test.ts new file mode 100644 index 000000000000..2320235ac4b0 --- /dev/null +++ b/packages/core/test/lib/currentScopes.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + getExternalPropagationContext, + getTraceContextFromScope, + hasExternalPropagationContext, + registerExternalPropagationContext, +} from '../../src/currentScopes'; +import { Scope } from '../../src/scope'; + +describe('External Propagation Context', () => { + afterEach(() => { + // Reset by registering a provider that returns undefined + registerExternalPropagationContext(() => undefined); + }); + + describe('registerExternalPropagationContext', () => { + it('registers a provider function', () => { + registerExternalPropagationContext(() => ({ + traceId: 'abc123', + spanId: 'def456', + })); + + expect(hasExternalPropagationContext()).toBe(true); + }); + }); + + describe('getExternalPropagationContext', () => { + it('returns undefined when provider returns undefined', () => { + registerExternalPropagationContext(() => undefined); + expect(getExternalPropagationContext()).toBeUndefined(); + }); + + it('returns trace context from provider', () => { + registerExternalPropagationContext(() => ({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + })); + + const result = getExternalPropagationContext(); + expect(result).toEqual({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + }); + }); + }); + + describe('hasExternalPropagationContext', () => { + it('returns true after registration', () => { + registerExternalPropagationContext(() => undefined); + expect(hasExternalPropagationContext()).toBe(true); + }); + }); + + describe('getTraceContextFromScope with external propagation context', () => { + it('uses external propagation context when available', () => { + registerExternalPropagationContext(() => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + spanId: 'bbbbbbbbbbbbbb01', + })); + + const scope = new Scope(); + scope.setPropagationContext({ + traceId: 'cccccccccccccccccccccccccccccc01', + sampleRand: 0.5, + }); + + const traceContext = getTraceContextFromScope(scope); + expect(traceContext.trace_id).toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'); + expect(traceContext.span_id).toBe('bbbbbbbbbbbbbb01'); + expect(traceContext.parent_span_id).toBeUndefined(); + }); + + it('falls back to scope propagation context when provider returns undefined', () => { + registerExternalPropagationContext(() => undefined); + + const scope = new Scope(); + scope.setPropagationContext({ + traceId: 'cccccccccccccccccccccccccccccc01', + sampleRand: 0.5, + }); + + const traceContext = getTraceContextFromScope(scope); + expect(traceContext.trace_id).toBe('cccccccccccccccccccccccccccccc01'); + }); + }); +}); diff --git a/packages/core/test/lib/utils/traceData.test.ts b/packages/core/test/lib/utils/traceData.test.ts index 379103a8a48c..6baf7a9d7a40 100644 --- a/packages/core/test/lib/utils/traceData.test.ts +++ b/packages/core/test/lib/utils/traceData.test.ts @@ -6,6 +6,7 @@ import { getIsolationScope, getMainCarrier, getTraceData, + registerExternalPropagationContext, Scope, SentrySpan, setAsyncContextStrategy, @@ -347,4 +348,47 @@ describe('getTraceData', () => { expect(traceData.traceparent).toBeDefined(); expect(traceData.traceparent).toMatch(/00-12345678901234567890123456789099-[0-9a-f]{16}-00/); }); + + it('returns empty object when no span and external propagation context is registered', () => { + setupClient(); + + registerExternalPropagationContext(() => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + spanId: 'bbbbbbbbbbbbbb01', + })); + + const traceData = getTraceData(); + expect(traceData).toEqual({}); + + // Clean up + registerExternalPropagationContext(() => undefined); + }); + + it('still returns trace data from span even when external propagation context is registered', () => { + setupClient(); + + registerExternalPropagationContext(() => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + spanId: 'bbbbbbbbbbbbbb01', + })); + + const span = new SentrySpan({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + sampled: true, + }); + + withActiveSpan(span, () => { + const data = getTraceData(); + + expect(data).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-environment=production,sentry-public_key=123,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', + }); + }); + + // Clean up + registerExternalPropagationContext(() => undefined); + }); }); diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 8a3c8dfaa927..091ec88e0365 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -54,6 +54,16 @@ "require": { "default": "./build/cjs/init.js" } + }, + "./light/otlp": { + "import": { + "types": "./build/types/light/integrations/otlpIntegration.d.ts", + "default": "./build/esm/light/integrations/otlpIntegration.js" + }, + "require": { + "types": "./build/types/light/integrations/otlpIntegration.d.ts", + "default": "./build/cjs/light/integrations/otlpIntegration.js" + } } }, "typesVersions": { @@ -73,7 +83,8 @@ "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" + "@opentelemetry/semantic-conventions": "^1.39.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -96,6 +107,9 @@ }, "@opentelemetry/semantic-conventions": { "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true } }, "dependencies": { @@ -107,6 +121,7 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", "@opentelemetry/instrumentation": "^0.213.0", "@opentelemetry/resources": "^2.6.0", "@opentelemetry/sdk-trace-base": "^2.6.0", diff --git a/packages/node-core/rollup.npm.config.mjs b/packages/node-core/rollup.npm.config.mjs index 9bae67fd2dd8..9fa0a1fb19b9 100644 --- a/packages/node-core/rollup.npm.config.mjs +++ b/packages/node-core/rollup.npm.config.mjs @@ -19,7 +19,7 @@ export default [ localVariablesWorkerConfig, ...makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/init.ts', 'src/light/index.ts'], + entrypoints: ['src/index.ts', 'src/init.ts', 'src/light/index.ts', 'src/light/integrations/otlpIntegration.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/packages/node-core/src/light/integrations/otlpIntegration.ts b/packages/node-core/src/light/integrations/otlpIntegration.ts new file mode 100644 index 000000000000..3f4507813525 --- /dev/null +++ b/packages/node-core/src/light/integrations/otlpIntegration.ts @@ -0,0 +1,142 @@ +import { trace } from '@opentelemetry/api'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import type { SpanExporter } from '@opentelemetry/sdk-trace-base'; +import { BasicTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import type { Client, IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, registerExternalPropagationContext, SENTRY_API_VERSION } from '@sentry/core'; + +interface OtlpIntegrationOptions { + /** + * Whether to set up the OTLP traces exporter that sends spans to Sentry. + * Default: true + */ + setupOtlpTracesExporter?: boolean; + + /** + * URL of your own OpenTelemetry collector. + * When set, the exporter will send traces to this URL instead of the Sentry OTLP endpoint derived from the DSN. + * Default: undefined (uses DSN-derived endpoint) + */ + collectorUrl?: string; +} + +const INTEGRATION_NAME = 'OtlpIntegration'; + +const _otlpIntegration = ((userOptions: OtlpIntegrationOptions = {}) => { + const options = { + setupOtlpTracesExporter: userOptions.setupOtlpTracesExporter ?? true, + collectorUrl: userOptions.collectorUrl, + }; + + let _spanProcessor: BatchSpanProcessor | undefined; + let _tracerProvider: BasicTracerProvider | undefined; + + return { + name: INTEGRATION_NAME, + + setup(_client: Client): void { + // Always register external propagation context so that Sentry error/log events + // are linked to the active OTel trace context. + registerExternalPropagationContext(() => { + const activeSpan = trace.getActiveSpan(); + if (!activeSpan) { + return undefined; + } + const spanContext = activeSpan.spanContext(); + return { traceId: spanContext.traceId, spanId: spanContext.spanId }; + }); + + debug.log(`[${INTEGRATION_NAME}] External propagation context registered.`); + }, + + afterAllSetup(client: Client): void { + if (options.setupOtlpTracesExporter) { + setupTracesExporter(client); + } + }, + }; + + function setupTracesExporter(client: Client): void { + let endpoint: string; + let headers: Record | undefined; + + if (options.collectorUrl) { + endpoint = options.collectorUrl; + debug.log(`[${INTEGRATION_NAME}] Sending traces to collector at ${endpoint}`); + } else { + const dsn = client.getDsn(); + if (!dsn) { + debug.warn(`[${INTEGRATION_NAME}] No DSN found. OTLP exporter not set up.`); + return; + } + + const { protocol, host, port, path, projectId, publicKey } = dsn; + + const basePath = path ? `/${path}` : ''; + const portStr = port ? `:${port}` : ''; + endpoint = `${protocol}://${host}${portStr}${basePath}/api/${projectId}/integration/otlp/v1/traces/`; + + const sdkInfo = client.getSdkMetadata()?.sdk; + const sentryClient = sdkInfo ? `, sentry_client=${sdkInfo.name}/${sdkInfo.version}` : ''; + headers = { + 'X-Sentry-Auth': `Sentry sentry_version=${SENTRY_API_VERSION}, sentry_key=${publicKey}${sentryClient}`, + }; + } + + let exporter: SpanExporter; + try { + exporter = new OTLPTraceExporter({ + url: endpoint, + headers, + }); + } catch (e) { + debug.warn(`[${INTEGRATION_NAME}] Failed to create OTLPTraceExporter:`, e); + return; + } + + _spanProcessor = new BatchSpanProcessor(exporter); + + // Add span processor to existing global tracer provider. + // trace.getTracerProvider() returns a ProxyTracerProvider; unwrap it to get the real provider. + const globalProvider = trace.getTracerProvider(); + const delegate = + 'getDelegate' in globalProvider + ? (globalProvider as unknown as { getDelegate(): unknown }).getDelegate() + : globalProvider; + + // In OTel v2, addSpanProcessor was removed. We push into the internal _spanProcessors + // array on the MultiSpanProcessor, which is how OTel's own forceFlush() accesses it. + const activeProcessor = (delegate as Record)?._activeSpanProcessor as + | { _spanProcessors?: unknown[] } + | undefined; + if (activeProcessor?._spanProcessors) { + activeProcessor._spanProcessors.push(_spanProcessor); + debug.log(`[${INTEGRATION_NAME}] Added span processor to existing TracerProvider.`); + } else { + // No user-configured provider; create a minimal one and set it as global + _tracerProvider = new BasicTracerProvider({ + spanProcessors: [_spanProcessor], + }); + trace.setGlobalTracerProvider(_tracerProvider); + debug.log(`[${INTEGRATION_NAME}] Created new TracerProvider with OTLP span processor.`); + } + + client.on('flush', () => { + void _spanProcessor?.forceFlush(); + }); + + client.on('close', () => { + void _spanProcessor?.shutdown(); + void _tracerProvider?.shutdown(); + }); + } +}) satisfies IntegrationFn; + +/** + * OTLP integration for the Sentry light SDK. + * + * Bridges an existing OpenTelemetry setup with Sentry by: + * 1. Linking Sentry error/log events to the active OTel trace context + * 2. Exporting OTel spans to Sentry via OTLP (or to a custom collector) + */ +export const otlpIntegration = defineIntegration(_otlpIntegration); diff --git a/packages/node-core/test/light/integrations/otlpIntegration.test.ts b/packages/node-core/test/light/integrations/otlpIntegration.test.ts new file mode 100644 index 000000000000..8d40bfad18cf --- /dev/null +++ b/packages/node-core/test/light/integrations/otlpIntegration.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { otlpIntegration } from '../../../src/light/integrations/otlpIntegration'; +import { cleanupLightSdk, mockLightSdkInit } from '../../helpers/mockLightSdkInit'; + +describe('Light Mode | otlpIntegration', () => { + afterEach(() => { + cleanupLightSdk(); + }); + + it('has correct integration name', () => { + const integration = otlpIntegration(); + expect(integration.name).toBe('OtlpIntegration'); + }); + + it('accepts empty options', () => { + const integration = otlpIntegration(); + expect(integration.name).toBe('OtlpIntegration'); + }); + + it('accepts all options', () => { + const integration = otlpIntegration({ + setupOtlpTracesExporter: false, + collectorUrl: 'https://my-collector.example.com/v1/traces', + }); + expect(integration.name).toBe('OtlpIntegration'); + }); + + describe('endpoint construction', () => { + it('constructs correct endpoint from DSN', () => { + const client = mockLightSdkInit({ + integrations: [otlpIntegration()], + }); + + const dsn = client?.getDsn(); + expect(dsn).toBeDefined(); + expect(dsn?.host).toBe('domain'); + expect(dsn?.projectId).toBe('123'); + }); + + it('handles DSN with port and path', () => { + const client = mockLightSdkInit({ + dsn: 'https://key@sentry.example.com:9000/mypath/456', + integrations: [otlpIntegration()], + }); + + const dsn = client?.getDsn(); + expect(dsn?.host).toBe('sentry.example.com'); + expect(dsn?.port).toBe('9000'); + expect(dsn?.path).toBe('mypath'); + expect(dsn?.projectId).toBe('456'); + }); + }); + + describe('auth header', () => { + it('constructs correct X-Sentry-Auth header format with sentry_client', () => { + const client = mockLightSdkInit({ + integrations: [otlpIntegration()], + }); + + const dsn = client?.getDsn(); + expect(dsn?.publicKey).toBe('username'); + + const sdkInfo = client?.getSdkMetadata()?.sdk; + expect(sdkInfo?.name).toBe('sentry.javascript.node-light'); + expect(sdkInfo?.version).toBeDefined(); + + const expectedAuth = `Sentry sentry_version=7, sentry_key=${dsn?.publicKey}, sentry_client=${sdkInfo?.name}/${sdkInfo?.version}`; + expect(expectedAuth).toMatch( + /^Sentry sentry_version=7, sentry_key=username, sentry_client=sentry\.javascript\.node-light\/.+$/, + ); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 0963b2267409..9fcb36f70517 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6225,6 +6225,17 @@ dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" +"@opentelemetry/exporter-trace-otlp-http@^0.213.0": + version "0.213.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.213.0.tgz#7bba861a71787361b83a03746ed4bf5c18048775" + integrity sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA== + dependencies: + "@opentelemetry/core" "2.6.0" + "@opentelemetry/otlp-exporter-base" "0.213.0" + "@opentelemetry/otlp-transformer" "0.213.0" + "@opentelemetry/resources" "2.6.0" + "@opentelemetry/sdk-trace-base" "2.6.0" + "@opentelemetry/instrumentation-amqplib@0.60.0": version "0.60.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.60.0.tgz#a2b2abe3cf433bea166c18a703c8ddf6accf83da" @@ -6460,6 +6471,27 @@ import-in-the-middle "^2.0.6" require-in-the-middle "^8.0.0" +"@opentelemetry/otlp-exporter-base@0.213.0": + version "0.213.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.213.0.tgz#e9a7c1dfaecc2573b9c5fbcd7ccc0086513c1350" + integrity sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg== + dependencies: + "@opentelemetry/core" "2.6.0" + "@opentelemetry/otlp-transformer" "0.213.0" + +"@opentelemetry/otlp-transformer@0.213.0": + version "0.213.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz#e830244d21817805b8967963ffc4651b8f5c96ee" + integrity sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw== + dependencies: + "@opentelemetry/api-logs" "0.213.0" + "@opentelemetry/core" "2.6.0" + "@opentelemetry/resources" "2.6.0" + "@opentelemetry/sdk-logs" "0.213.0" + "@opentelemetry/sdk-metrics" "2.6.0" + "@opentelemetry/sdk-trace-base" "2.6.0" + protobufjs "^7.0.0" + "@opentelemetry/redis-common@^0.38.2": version "0.38.2" resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz#cefa4f3e79db1cd54f19e233b7dfb56621143955" @@ -6473,7 +6505,25 @@ "@opentelemetry/core" "2.6.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-trace-base@^2.6.0": +"@opentelemetry/sdk-logs@0.213.0": + version "0.213.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz#babf51dfd3e2bc882a41a0de2a13a2077d6df764" + integrity sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g== + dependencies: + "@opentelemetry/api-logs" "0.213.0" + "@opentelemetry/core" "2.6.0" + "@opentelemetry/resources" "2.6.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-metrics@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz#c9f63eb68a5c7600a4ffc84bdce3ef59c9b1af47" + integrity sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw== + dependencies: + "@opentelemetry/core" "2.6.0" + "@opentelemetry/resources" "2.6.0" + +"@opentelemetry/sdk-trace-base@2.6.0", "@opentelemetry/sdk-trace-base@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz#d7e752a0906f2bcae3c1261e224aef3e3b3746f9" integrity sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ== @@ -9749,10 +9799,10 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=18": - version "25.4.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.4.0.tgz#f25d8467984d6667cc4c1be1e2f79593834aaedb" - integrity sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw== +"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=13.7.0", "@types/node@>=18": + version "25.3.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.3.3.tgz#605862544ee7ffd7a936bcbf0135a14012f1e549" + integrity sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ== dependencies: undici-types "~7.18.0" @@ -21187,7 +21237,7 @@ long@^4.0.0: resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== -long@^5.3.2: +long@^5.0.0, long@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== @@ -25600,6 +25650,24 @@ property-information@^7.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== +protobufjs@^7.0.0: + version "7.5.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" + integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + proxy-addr@^2.0.7, proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -28513,7 +28581,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From fff880e60138f60ec6b300d571cef4ea6564d652 Mon Sep 17 00:00:00 2001 From: Stephanie Anderson Date: Mon, 30 Mar 2026 12:19:05 +0200 Subject: [PATCH 30/39] fix(ci): Update validate-pr action to remove draft enforcement (#20037) The validate-pr action's draft enforcement step was failing with: `API call failed: GraphQL: Resource not accessible by integration (convertPullRequestToDraft)` Draft enforcement has been removed from the shared action in https://github.com/getsentry/github-workflows/pull/159. This bumps the pinned SHA. Co-Authored-By: Claude Opus 4.6 (1M context) [noreply@anthropic.com](mailto:noreply@anthropic.com) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/validate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index b1fa22704bd8..44da67faa43e 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@33c378e8d3aa1515164b62c16c210784cee35638 + - uses: getsentry/github-workflows/validate-pr@0b52fc6a867b744dcbdf5d25c18bc8d1c95710e1 with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From 119c06f6ffe91b275fe6f66f1cdf3d538bb5ddcd Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 30 Mar 2026 12:30:50 +0200 Subject: [PATCH 31/39] fix(core): Guard nullish response in supabase PostgREST handler (#20033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/getsentry/sentry-javascript/issues/20032 ### Context: In the `supabaseIntegration`'s PostgREST instrumentation, the `.then()` success handler accesses `res.error` without checking if `res` is nullish first. This causes crashes in environments like React Native where the response can be `undefined`. A related error recently trended on the React Native SDK (see Linear comment) ### Summary: - Added a null guard on `res` before accessing `res.error` in `instrumentPostgRESTFilterBuilder`, changing `if (res.error)` to `if (res && res.error)` — matching the existing pattern used in `instrumentAuthOperation` - The existing `setHttpStatus` block already had a proper guard (`if (res && typeof res === 'object' && 'status' in res)`), so only the error-handling path was affected - Span `.end()` and breadcrumb creation continue to work correctly regardless of whether `res` is nullish - Added a new test file for the supabase integration covering the nullish response scenario and existing utility functions Before submitting a pull request, please take a look at our [Contributing](https://github.com/getsentry/sentry-javascript/blob/master/CONTRIBUTING.md) guidelines and verify: - [x] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (`yarn lint`) & (`yarn test`). - [x] Link an issue if there is one related to your pull request. If no issue is linked, one will be auto-generated and linked. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- packages/core/src/integrations/supabase.ts | 2 +- .../test/lib/integrations/supabase.test.ts | 179 ++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/lib/integrations/supabase.test.ts diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 1b6f24cc3136..dac7530b46f0 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -403,7 +403,7 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte span.end(); } - if (res.error) { + if (res?.error) { const err = new Error(res.error.message) as SupabaseError; if (res.error.code) { err.code = res.error.code; diff --git a/packages/core/test/lib/integrations/supabase.test.ts b/packages/core/test/lib/integrations/supabase.test.ts new file mode 100644 index 000000000000..519dda4f06a0 --- /dev/null +++ b/packages/core/test/lib/integrations/supabase.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as breadcrumbModule from '../../../src/breadcrumbs'; +import * as exportsModule from '../../../src/exports'; +import { + extractOperation, + instrumentSupabaseClient, + translateFiltersIntoMethods, +} from '../../../src/integrations/supabase'; +import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase'; + +// Mock tracing to avoid needing full SDK setup +vi.mock('../../../src/tracing', () => ({ + startSpan: (_opts: any, cb: (span: any) => any) => { + const mockSpan = { + setStatus: vi.fn(), + end: vi.fn(), + }; + return cb(mockSpan); + }, + setHttpStatus: vi.fn(), + SPAN_STATUS_OK: 1, + SPAN_STATUS_ERROR: 2, +})); + +describe('Supabase Integration', () => { + describe('extractOperation', () => { + it('returns select for GET', () => { + expect(extractOperation('GET')).toBe('select'); + }); + + it('returns insert for POST without resolution header', () => { + expect(extractOperation('POST')).toBe('insert'); + }); + + it('returns upsert for POST with resolution header', () => { + expect(extractOperation('POST', { Prefer: 'resolution=merge-duplicates' })).toBe('upsert'); + }); + + it('returns update for PATCH', () => { + expect(extractOperation('PATCH')).toBe('update'); + }); + + it('returns delete for DELETE', () => { + expect(extractOperation('DELETE')).toBe('delete'); + }); + }); + + describe('translateFiltersIntoMethods', () => { + it('returns select(*) for wildcard', () => { + expect(translateFiltersIntoMethods('select', '*')).toBe('select(*)'); + }); + + it('returns select with columns', () => { + expect(translateFiltersIntoMethods('select', 'id,name')).toBe('select(id,name)'); + }); + + it('translates eq filter', () => { + expect(translateFiltersIntoMethods('id', 'eq.123')).toBe('eq(id, 123)'); + }); + }); + + describe('instrumentPostgRESTFilterBuilder - nullish response handling', () => { + let captureExceptionSpy: ReturnType; + let addBreadcrumbSpy: ReturnType; + + beforeEach(() => { + captureExceptionSpy = vi.spyOn(exportsModule, 'captureException').mockImplementation(() => ''); + addBreadcrumbSpy = vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function createMockSupabaseClient(resolveWith: unknown): unknown { + // Create a PostgRESTFilterBuilder-like class + class MockPostgRESTFilterBuilder { + method = 'GET'; + headers: Record = { 'X-Client-Info': 'supabase-js/2.0.0' }; + url = new URL('https://example.supabase.co/rest/v1/todos'); + schema = 'public'; + body = undefined; + + then(onfulfilled?: (value: any) => any, onrejected?: (reason: any) => any): Promise { + return Promise.resolve(resolveWith).then(onfulfilled, onrejected); + } + } + + class MockPostgRESTQueryBuilder { + select() { + return new MockPostgRESTFilterBuilder(); + } + insert() { + return new MockPostgRESTFilterBuilder(); + } + upsert() { + return new MockPostgRESTFilterBuilder(); + } + update() { + return new MockPostgRESTFilterBuilder(); + } + delete() { + return new MockPostgRESTFilterBuilder(); + } + } + + // Create a mock SupabaseClient constructor + class MockSupabaseClient { + auth = { + admin: {} as any, + } as SupabaseClientInstance['auth']; + + from(_table: string): PostgRESTQueryBuilder { + return new MockPostgRESTQueryBuilder() as unknown as PostgRESTQueryBuilder; + } + } + + return new MockSupabaseClient(); + } + + it('handles undefined response without throwing', async () => { + const client = createMockSupabaseClient(undefined); + instrumentSupabaseClient(client); + + const builder = (client as any).from('todos'); + const result = builder.select('*'); + + // This should not throw even though the response is undefined + const res = await result; + expect(res).toBeUndefined(); + }); + + it('handles null response without throwing', async () => { + const client = createMockSupabaseClient(null); + instrumentSupabaseClient(client); + + const builder = (client as any).from('todos'); + const result = builder.select('*'); + + const res = await result; + expect(res).toBeNull(); + }); + + it('still adds breadcrumb when response is undefined', async () => { + const client = createMockSupabaseClient(undefined); + instrumentSupabaseClient(client); + + const builder = (client as any).from('todos'); + await builder.select('*'); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'supabase', + category: 'db.select', + }), + ); + }); + + it('does not capture exception when response is undefined', async () => { + const client = createMockSupabaseClient(undefined); + instrumentSupabaseClient(client); + + const builder = (client as any).from('todos'); + await builder.select('*'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('still captures error when response has error', async () => { + const client = createMockSupabaseClient({ status: 400, error: { message: 'Bad request', code: '400' } }); + instrumentSupabaseClient(client); + + const builder = (client as any).from('todos'); + await builder.select('*'); + + expect(captureExceptionSpy).toHaveBeenCalled(); + }); + }); +}); From 738b3e7b39ddbb29c14818528f20ee1a62a565b3 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 30 Mar 2026 13:41:11 +0200 Subject: [PATCH 32/39] refactor(browser): Reduce browser package bundle size (#19856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A collection of small, safe optimizations across the browser package. Combined saves **~60 bytes gzipped**. ## Changes UPDATE (@Lms24): Removed some initial changes, leaving them here for posterity | File | Change | Impact | |------|--------|--------| | ~`helpers.ts` + `stacktrace.ts`~ | ~Rename internal `sentryWrapped` → `sW` in wrap(). Update frame stripping regex to match both names.~ | ~10B gzip~ | | `breadcrumbs.ts` | Remove unused `breadcrumbData` variable from fetch handler | dead code | | `browserapierrors.ts` | Encode `DEFAULT_EVENT_TARGET` as `string.split(",")` instead of array literal | 51B raw | | `globalhandlers.ts` | Remove redundant intermediate variable aliases in `_enhanceEventWithInitialFrame` | cleaner code | | `detectBrowserExtension.ts` | Replace `array.some(startsWith)` with single regex test | ~3B gzip | | `eventbuilder.ts` | Simplify `getErrorPropertyFromObject` to `Object.values().find()` | ~9B gzip | | `lazyLoadIntegration.ts` | Derive CDN bundle filenames from ~integration names~ list of integration names instead of storing duplicate key-value pairs | ~30B gzip | ### lazyLoadIntegration detail The `LazyLoadableIntegrations` object stored 21 key-value pairs where values were mostly derivable from keys (strip `"Integration"`, lowercase). Replaced with: - An array of integration names (encoded as `string.split(",")`) - A derivation function - A 3-entry exceptions map for hyphenated names (`replay-canvas`, `feedback-modal`, `feedback-screenshot`) All changes are behavior-preserving. No public API modifications. Part of #19833. Co-Authored-By: Claude claude@anthropic.com --------- Co-authored-by: Lukas Stracke --- packages/browser/src/eventbuilder.ts | 11 +--- .../browser/src/integrations/breadcrumbs.ts | 12 +--- .../src/integrations/browserapierrors.ts | 38 ++--------- .../src/integrations/globalhandlers.ts | 13 ++-- .../src/utils/detectBrowserExtension.ts | 4 +- .../browser/src/utils/lazyLoadIntegration.ts | 63 ++++++++++++------- 6 files changed, 52 insertions(+), 89 deletions(-) diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index 798a068b5adf..b430007b552d 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -401,14 +401,5 @@ function getObjectClassName(obj: unknown): string | undefined | void { /** If a plain object has a property that is an `Error`, return this error. */ function getErrorPropertyFromObject(obj: Record): Error | undefined { - for (const prop in obj) { - if (Object.prototype.hasOwnProperty.call(obj, prop)) { - const value = obj[prop]; - if (value instanceof Error) { - return value; - } - } - } - - return undefined; + return Object.values(obj).find((v): v is Error => v instanceof Error); } diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 79022dc6e31e..de99621bf52f 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -287,13 +287,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe return; } - const breadcrumbData: FetchBreadcrumbData = { - method: handlerData.fetchData.method, - url: handlerData.fetchData.url, - }; - if (handlerData.error) { - const data: FetchBreadcrumbData = handlerData.fetchData; const hint: FetchBreadcrumbHint = { data: handlerData.error, input: handlerData.args, @@ -303,7 +297,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe const breadcrumb = { category: 'fetch', - data, + data: handlerData.fetchData, level: 'error', type: 'http', } satisfies Breadcrumb; @@ -318,10 +312,6 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe status_code: response?.status, }; - breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; - breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; - breadcrumbData.status_code = response?.status; - const hint: FetchBreadcrumbHint = { input: handlerData.args, response, diff --git a/packages/browser/src/integrations/browserapierrors.ts b/packages/browser/src/integrations/browserapierrors.ts index 7e94c2bc7167..cd32435fa5b0 100644 --- a/packages/browser/src/integrations/browserapierrors.ts +++ b/packages/browser/src/integrations/browserapierrors.ts @@ -2,39 +2,11 @@ import type { IntegrationFn, WrappedFunction } from '@sentry/core'; import { defineIntegration, fill, getFunctionName, getOriginalFunction } from '@sentry/core'; import { WINDOW, wrap } from '../helpers'; -const DEFAULT_EVENT_TARGET = [ - 'EventTarget', - 'Window', - 'Node', - 'ApplicationCache', - 'AudioTrackList', - 'BroadcastChannel', - 'ChannelMergerNode', - 'CryptoOperation', - 'EventSource', - 'FileReader', - 'HTMLUnknownElement', - 'IDBDatabase', - 'IDBRequest', - 'IDBTransaction', - 'KeyOperation', - 'MediaController', - 'MessagePort', - 'ModalWindow', - 'Notification', - 'SVGElementInstance', - 'Screen', - 'SharedWorker', - 'TextTrack', - 'TextTrackCue', - 'TextTrackList', - 'WebSocket', - 'WebSocketWorker', - 'Worker', - 'XMLHttpRequest', - 'XMLHttpRequestEventTarget', - 'XMLHttpRequestUpload', -]; +// Using a comma-separated string and split for smaller bundle size vs an array literal +const DEFAULT_EVENT_TARGET = + 'EventTarget,Window,Node,ApplicationCache,AudioTrackList,BroadcastChannel,ChannelMergerNode,CryptoOperation,EventSource,FileReader,HTMLUnknownElement,IDBDatabase,IDBRequest,IDBTransaction,KeyOperation,MediaController,MessagePort,ModalWindow,Notification,SVGElementInstance,Screen,SharedWorker,TextTrack,TextTrackCue,TextTrackList,WebSocket,WebSocketWorker,Worker,XMLHttpRequest,XMLHttpRequestEventTarget,XMLHttpRequestUpload'.split( + ',', + ); const INTEGRATION_NAME = 'BrowserApiErrors'; diff --git a/packages/browser/src/integrations/globalhandlers.ts b/packages/browser/src/integrations/globalhandlers.ts index c8cd806d0062..70b3516b63b1 100644 --- a/packages/browser/src/integrations/globalhandlers.ts +++ b/packages/browser/src/integrations/globalhandlers.ts @@ -159,8 +159,8 @@ export function _eventFromRejectionWithPrimitive(reason: Primitive): Event { function _enhanceEventWithInitialFrame( event: Event, url: string | undefined, - line: number | undefined, - column: number | undefined, + lineno: number | undefined, + colno: number | undefined, ): Event { // event.exception const e = (event.exception = event.exception || {}); @@ -173,18 +173,13 @@ function _enhanceEventWithInitialFrame( // event.exception.values[0].stacktrace.frames const ev0sf = (ev0s.frames = ev0s.frames || []); - const colno = column; - const lineno = line; - const filename = getFilenameFromUrl(url) ?? getLocationHref(); - - // event.exception.values[0].stacktrace.frames if (ev0sf.length === 0) { ev0sf.push({ colno, - filename, + lineno, + filename: getFilenameFromUrl(url) ?? getLocationHref(), function: UNKNOWN_FUNCTION, in_app: true, - lineno, }); } diff --git a/packages/browser/src/utils/detectBrowserExtension.ts b/packages/browser/src/utils/detectBrowserExtension.ts index 52e667ccecf2..95ad7cebcf06 100644 --- a/packages/browser/src/utils/detectBrowserExtension.ts +++ b/packages/browser/src/utils/detectBrowserExtension.ts @@ -55,11 +55,11 @@ function _isEmbeddedBrowserExtension(): boolean { } const href = getLocationHref(); - const extensionProtocols = ['chrome-extension', 'moz-extension', 'ms-browser-extension', 'safari-web-extension']; // Running the SDK in a dedicated extension page and calling Sentry.init is fine; no risk of data leakage const isDedicatedExtensionPage = - WINDOW === WINDOW.top && extensionProtocols.some(protocol => href.startsWith(`${protocol}://`)); + WINDOW === WINDOW.top && + /^(?:chrome-extension|moz-extension|ms-browser-extension|safari-web-extension):\/\//.test(href); return !isDedicatedExtensionPage; } diff --git a/packages/browser/src/utils/lazyLoadIntegration.ts b/packages/browser/src/utils/lazyLoadIntegration.ts index 8a6688fe5953..ec0b11f099c0 100644 --- a/packages/browser/src/utils/lazyLoadIntegration.ts +++ b/packages/browser/src/utils/lazyLoadIntegration.ts @@ -3,33 +3,48 @@ import { getClient, SDK_VERSION } from '@sentry/core'; import type { BrowserClient } from '../client'; import { WINDOW } from '../helpers'; -// This is a map of integration function method to bundle file name. -const LazyLoadableIntegrations = { - replayIntegration: 'replay', +// Single source of truth: as const array provides both the runtime list and the type. +// Bundle file names are derived: strip 'Integration' suffix, lowercase. +// Exceptions (hyphenated bundle names) are listed in HYPHENATED_BUNDLES. +const LAZY_LOADABLE_NAMES = [ + 'replayIntegration', + 'replayCanvasIntegration', + 'feedbackIntegration', + 'feedbackModalIntegration', + 'feedbackScreenshotIntegration', + 'captureConsoleIntegration', + 'contextLinesIntegration', + 'linkedErrorsIntegration', + 'dedupeIntegration', + 'extraErrorDataIntegration', + 'graphqlClientIntegration', + 'httpClientIntegration', + 'reportingObserverIntegration', + 'rewriteFramesIntegration', + 'browserProfilingIntegration', + 'moduleMetadataIntegration', + 'instrumentAnthropicAiClient', + 'instrumentOpenAiClient', + 'instrumentGoogleGenAIClient', + 'instrumentLangGraph', + 'createLangChainCallbackHandler', +] as const; + +type ElementOf = T[number]; +type LazyLoadableIntegrationName = ElementOf; + +const HYPHENATED_BUNDLES: Partial> = { replayCanvasIntegration: 'replay-canvas', - feedbackIntegration: 'feedback', feedbackModalIntegration: 'feedback-modal', feedbackScreenshotIntegration: 'feedback-screenshot', - captureConsoleIntegration: 'captureconsole', - contextLinesIntegration: 'contextlines', - linkedErrorsIntegration: 'linkederrors', - dedupeIntegration: 'dedupe', - extraErrorDataIntegration: 'extraerrordata', - graphqlClientIntegration: 'graphqlclient', - httpClientIntegration: 'httpclient', - reportingObserverIntegration: 'reportingobserver', - rewriteFramesIntegration: 'rewriteframes', - browserProfilingIntegration: 'browserprofiling', - moduleMetadataIntegration: 'modulemetadata', - instrumentAnthropicAiClient: 'instrumentanthropicaiclient', - instrumentOpenAiClient: 'instrumentopenaiclient', - instrumentGoogleGenAIClient: 'instrumentgooglegenaiclient', - instrumentLangGraph: 'instrumentlanggraph', - createLangChainCallbackHandler: 'createlangchaincallbackhandler', -} as const; +}; + +function getBundleName(name: string): string { + return HYPHENATED_BUNDLES[name as LazyLoadableIntegrationName] || name.replace('Integration', '').toLowerCase(); +} const WindowWithMaybeIntegration = WINDOW as { - Sentry?: Partial>; + Sentry?: Partial>; }; /** @@ -37,10 +52,10 @@ const WindowWithMaybeIntegration = WINDOW as { * Rejects if the integration cannot be loaded. */ export async function lazyLoadIntegration( - name: keyof typeof LazyLoadableIntegrations, + name: LazyLoadableIntegrationName, scriptNonce?: string, ): Promise { - const bundle = LazyLoadableIntegrations[name]; + const bundle = LAZY_LOADABLE_NAMES.includes(name) ? getBundleName(name) : undefined; // `window.Sentry` is only set when using a CDN bundle, but this method can also be used via the NPM package const sentryOnWindow = (WindowWithMaybeIntegration.Sentry = WindowWithMaybeIntegration.Sentry || {}); From 08cab24260c02b1dd3dc982b96eb26303eb0ccc1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 30 Mar 2026 13:49:40 +0200 Subject: [PATCH 33/39] fix(node): Deduplicate `sentry-trace` and `baggage` headers on outgoing requests (#19960) This patch fixes a bunch of closely related issues with our node fetch and http integrations for outgoing request header propagation. ### Summary: - We now dedupe sentry-trace and baggage headers more aggressively, resolving multiple scenarios where duplicated sentry headers were attached to outgoing requests - We now always prefer the first sentry tracing headers pair set onto a request. This allows users to set custom sentry headers (for whatever reason) and ensures our instrumentation doesn't overwrite itself. - We no longer mix individual `sentry-` baggage entries when merging two headers where both contain `sentry-` entries. We only take one of the two and delete the other. See PR for further details! closes https://github.com/getsentry/sentry-javascript/issues/19158 --- .../fetch-trace-header-merging/test.ts | 3 +- .../test.ts | 9 +- .../suites/tracing/double-baggage/expects.ts | 37 ++ .../double-baggage/no-spans/instrument.mjs | 9 + .../double-baggage/no-spans/scenario.mjs | 45 ++ .../tracing/double-baggage/no-spans/test.ts | 47 ++ .../spans-no-parent/instrument.mjs | 9 + .../spans-no-parent/scenario.mjs | 45 ++ .../double-baggage/spans-no-parent/test.ts | 46 ++ .../spans-parent/instrument.mjs | 9 + .../double-baggage/spans-parent/scenario.mjs | 50 ++ .../double-baggage/spans-parent/test.ts | 92 ++++ packages/node-core/src/utils/baggage.ts | 40 +- .../src/utils/outgoingFetchRequest.ts | 135 ++++- .../src/utils/outgoingHttpRequest.ts | 11 +- packages/node-core/test/utils/baggage.test.ts | 42 +- .../test/utils/outgoingFetchRequest.test.ts | 478 ++++++++++++++++++ packages/opentelemetry/src/propagator.ts | 20 +- .../opentelemetry/test/propagator.test.ts | 90 +++- 19 files changed, 1168 insertions(+), 49 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/double-baggage/expects.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/test.ts create mode 100644 packages/node-core/test/utils/outgoingFetchRequest.test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts index 7c0f6db3483b..dd57b0f6ad84 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts @@ -39,9 +39,10 @@ async function assertRequests({ // No merged sentry trace headers expect(headers['sentry-trace']).not.toContain(','); + expect(headers['sentry-trace']).toBe('12312012123120121231201212312012-1121201211212012-1'); // No multiple baggage entries - expect(headers['baggage'].match(/sentry-release/g) ?? []).toHaveLength(1); + expect(headers['baggage']).toBe('sentry-release=4.2.0'); }); } diff --git a/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts index dbdd69ffb45b..eebafa06bfd1 100644 --- a/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts +++ b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts @@ -1,6 +1,7 @@ import { afterAll, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; import type { TestAPIResponse } from '../server'; +import { extractTraceparentData } from '@sentry/core'; afterAll(() => { cleanupChildProcesses(); @@ -33,7 +34,6 @@ test('should ignore sentry-values in `baggage` header of a third party vendor an 'sentry-environment=myEnv', 'sentry-release=2.1.0', expect.stringMatching(/sentry-sample_rand=\d+/), - 'sentry-sample_rate=0.54', 'third=party', ]); }); @@ -46,6 +46,11 @@ test('should ignore sentry-values in `baggage` header of a third party vendor an expect(response).toBeDefined(); const baggage = response?.test_data.baggage?.split(',').sort(); + const sentryTraceHeader = response?.test_data['sentry-trace']; + + const sentryTrace = extractTraceparentData(sentryTraceHeader); + + expect(sentryTrace?.traceId).toMatch(/^[0-9a-f]{32}$/); expect(response).toMatchObject({ test_data: { @@ -63,7 +68,7 @@ test('should ignore sentry-values in `baggage` header of a third party vendor an expect.stringMatching(/sentry-sample_rand=\d+/), 'sentry-sample_rate=1', 'sentry-sampled=true', - expect.stringMatching(/sentry-trace_id=[\da-f]{32}/), + `sentry-trace_id=${sentryTrace?.traceId}`, 'sentry-transaction=GET%20%2Ftest%2Fexpress', 'third=party', ]); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/expects.ts b/dev-packages/node-integration-tests/suites/tracing/double-baggage/expects.ts new file mode 100644 index 000000000000..e092f29c1e65 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/expects.ts @@ -0,0 +1,37 @@ +import { expect } from 'vitest'; +import { extractTraceparentData, parseBaggageHeader, TRACEPARENT_REGEXP } from '@sentry/core'; + +export function expectNoDuplicateSentryBaggageKeys(baggage: string | string[] | undefined): void { + expect(baggage).toBeDefined(); + const baggageStr = Array.isArray(baggage) ? baggage.join(',') : (baggage as string); + const sentryEntries = baggageStr.split(',').filter(entry => entry.trim().startsWith('sentry-')); + const sentryKeyNames = sentryEntries.map(entry => entry.trim().split('=')[0]); + const uniqueKeyNames = [...new Set(sentryKeyNames)]; + expect(sentryKeyNames).toEqual(uniqueKeyNames); +} + +export function expectConsistentTraceId(headers: Record): void { + const sentryTrace = headers['sentry-trace']; + expect(sentryTrace).toMatch(TRACEPARENT_REGEXP); + + const sentryTraceData = extractTraceparentData(sentryTrace as string)!; + expect(sentryTraceData.traceId).toMatch(/^[a-f\d]{32}$/); + + const baggage = parseBaggageHeader(headers['baggage']); + + const baggageTraceId = baggage!['sentry-trace_id']; + expect(baggageTraceId).toBeDefined(); + expect(baggageTraceId).toMatch(/^[a-f\d]{32}$/); + + expect(sentryTraceData.traceId).toEqual(baggageTraceId); +} + +export function expectUserSetTraceId(headers: Record): void { + const xSentryTrace = extractTraceparentData(headers['x-tracedata-sentry-trace'] as string); + const sentryTrace = extractTraceparentData(headers['sentry-trace'] as string); + expect(xSentryTrace?.traceId).toBe(sentryTrace?.traceId); + + const xBaggage = parseBaggageHeader(headers['x-tracedata-baggage']); + const baggage = parseBaggageHeader(headers['baggage']); + expect(xBaggage).toEqual(baggage); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/instrument.mjs new file mode 100644 index 000000000000..7acd36926f22 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/instrument.mjs @@ -0,0 +1,9 @@ +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', + // explicitly not setting tracesSampleRate, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/scenario.mjs new file mode 100644 index 000000000000..046e980c5fe2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/scenario.mjs @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/node'; +import http from 'http'; + +async function run() { + const traceData = Sentry.getTraceData(); + // fetch with manual getTraceData() headers - the core reproduction case from #19158 + await fetch(`${process.env.SERVER_URL}/api/fetch-custom-headers`, { + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }).then(res => res.text()); + + // fetch without manual headers (baseline - auto-instrumentation only) + await fetch(`${process.env.SERVER_URL}/api/fetch`).then(res => res.text()); + + // http.request with manual getTraceData() headers + await new Promise((resolve, reject) => { + const url = new URL(`${process.env.SERVER_URL}/api/http-custom-headers`); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'GET', + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }, + res => { + res.on('data', () => {}); + res.on('end', () => resolve()); + }, + ); + req.on('error', reject); + req.end(); + }); + + Sentry.captureException(new Error('done')); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/test.ts new file mode 100644 index 000000000000..c517bca0d25b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/test.ts @@ -0,0 +1,47 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { expectConsistentTraceId, expectNoDuplicateSentryBaggageKeys, expectUserSetTraceId } from '../expects'; + +describe('double baggage prevention', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('fetch with manual getTraceData() does not duplicate sentry baggage entries', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/fetch-custom-headers', headers => { + // fetch with manual getTraceData() headers + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}$/)); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + }) + .get('/api/fetch', headers => { + // fetch without manual headers (baseline) + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}$/)); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + }) + .get('/api/http-custom-headers', headers => { + // http.request with manual getTraceData() headers + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}$/)); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .ignore('transaction') + .expect({ + event: { + exception: { + values: [{ type: 'Error', value: 'done' }], + }, + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/instrument.mjs @@ -0,0 +1,9 @@ +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, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/scenario.mjs new file mode 100644 index 000000000000..dd5841685463 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/scenario.mjs @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/node'; +import http from 'http'; + +async function run() { + const traceData = Sentry.getTraceData(); + // fetch with manual getTraceData() headers - the core reproduction case from #19158 + await fetch(`${process.env.SERVER_URL}/api/fetch-custom-headers`, { + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }).then(res => res.text()); + + // fetch without manual headers (baseline - auto-instrumentation only) + await fetch(`${process.env.SERVER_URL}/api/fetch`, {}).then(res => res.text()); + + // http.request with manual getTraceData() headers + await new Promise((resolve, reject) => { + const url = new URL(`${process.env.SERVER_URL}/api/http-custom-headers`); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'GET', + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }, + res => { + res.on('data', () => {}); + res.on('end', () => resolve()); + }, + ); + req.on('error', reject); + req.end(); + }); + + Sentry.captureException(new Error('done')); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/test.ts b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/test.ts new file mode 100644 index 000000000000..6b5d20cdb5f7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/test.ts @@ -0,0 +1,46 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { expectConsistentTraceId, expectNoDuplicateSentryBaggageKeys, expectUserSetTraceId } from '../expects'; + +describe('double baggage prevention', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('fetch with manual getTraceData() does not duplicate sentry baggage entries', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/fetch-custom-headers', headers => { + // fetch with manual getTraceData() headers + expect(headers['sentry-trace']).not.toContain(','); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + }) + .get('/api/fetch', headers => { + // fetch without manual headers (baseline) + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}(-[01])?$/)); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + }) + .get('/api/http-custom-headers', headers => { + // http.request with manual getTraceData() headers + expect(headers['sentry-trace']).not.toContain(','); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [{ type: 'Error', value: 'done' }], + }, + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/instrument.mjs @@ -0,0 +1,9 @@ +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, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/scenario.mjs new file mode 100644 index 000000000000..a577bad62333 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/scenario.mjs @@ -0,0 +1,50 @@ +import * as Sentry from '@sentry/node'; +import http from 'http'; + +async function run() { + const traceData = Sentry.getTraceData(); + // fetch with manual getTraceData() headers - the core reproduction case from #19158 + await fetch(`${process.env.SERVER_URL}/api/fetch-custom-headers`, { + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }).then(res => res.text()); + + // fetch without manual headers (baseline - auto-instrumentation only) + await fetch(`${process.env.SERVER_URL}/api/fetch`, { + headers: { + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }).then(res => res.text()); + + // http.request with manual getTraceData() headers + await new Promise((resolve, reject) => { + const url = new URL(`${process.env.SERVER_URL}/api/http-custom-headers`); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'GET', + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }, + res => { + res.on('data', () => {}); + res.on('end', () => resolve()); + }, + ); + req.on('error', reject); + req.end(); + }); + + Sentry.captureException(new Error('done')); +} + +Sentry.startSpan({ name: 'parent_span' }, () => run()); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/test.ts b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/test.ts new file mode 100644 index 000000000000..22de5cb285b3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/test.ts @@ -0,0 +1,92 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { extractTraceparentData } from '@sentry/core'; +import { expectConsistentTraceId, expectNoDuplicateSentryBaggageKeys, expectUserSetTraceId } from '../expects'; + +describe('double baggage prevention - http.client spans with parent span', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + let transactionTraceId = '000'; + let fetchSpanId = '000'; + let httpCustomHeadersSpanId = '000'; + let fetchCustomHeadersSpanId = '000'; + + test('fetch with manual getTraceData() does not duplicate sentry baggage entries', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/fetch-custom-headers', headers => { + // fetch with manual getTraceData() headers — core reproduction case + const sentryTrace = extractTraceparentData(headers['sentry-trace'] as string); + transactionTraceId = sentryTrace!.traceId!; + fetchCustomHeadersSpanId = sentryTrace!.parentSpanId!; + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expect(headers['sentry-trace']).not.toContain(','); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + }) + .get('/api/fetch', headers => { + // fetch without manual headers (baseline) + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}(-[01])?$/)); + expectConsistentTraceId(headers); + const sentryTrace = extractTraceparentData(headers['sentry-trace'] as string); + fetchSpanId = sentryTrace!.parentSpanId!; + }) + .get('/api/http-custom-headers', headers => { + // http.request with manual getTraceData() headers + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expect(headers['sentry-trace']).not.toContain(','); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + const sentryTrace = extractTraceparentData(headers['sentry-trace'] as string); + httpCustomHeadersSpanId = sentryTrace!.parentSpanId!; + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .ignore('event') + .expect({ + transaction: txn => { + expect(transactionTraceId).toMatch(/^[a-f0-9]{32}$/); + + expect(txn).toMatchObject({ + transaction: 'parent_span', + spans: [ + { + op: 'http.client', + description: expect.stringMatching(/^GET .*\/api\/fetch-custom-headers$/), + data: {}, + // span id is expected to be different since users call getTraceData() before the + // http.client span is created + span_id: expect.not.stringContaining(fetchCustomHeadersSpanId), + start_timestamp: expect.any(Number), + trace_id: transactionTraceId, + }, + { + op: 'http.client', + description: expect.stringMatching(/^GET .*\/api\/fetch$/), + data: {}, + span_id: fetchSpanId, + start_timestamp: expect.any(Number), + trace_id: transactionTraceId, + }, + { + op: 'http.client', + description: expect.stringMatching(/^GET .*\/api\/http-custom-headers$/), + data: {}, + // span id is expected to be different since users call getTraceData() before the + // http.client span is created + span_id: expect.not.stringContaining(httpCustomHeadersSpanId), + start_timestamp: expect.any(Number), + trace_id: transactionTraceId, + }, + ], + }); + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/packages/node-core/src/utils/baggage.ts b/packages/node-core/src/utils/baggage.ts index d236851559db..496c834d5c23 100644 --- a/packages/node-core/src/utils/baggage.ts +++ b/packages/node-core/src/utils/baggage.ts @@ -1,4 +1,4 @@ -import { objectToBaggageHeader, parseBaggageHeader } from '@sentry/core'; +import { objectToBaggageHeader, parseBaggageHeader, SENTRY_BAGGAGE_KEY_PREFIX } from '@sentry/core'; /** * Merge two baggage headers into one. @@ -24,15 +24,39 @@ export function mergeBaggageHeaders { - // Sentry-specific keys always take precedence from new baggage - // Non-Sentry keys only added if not already present - if (key.startsWith('sentry-') || !mergedBaggageEntries[key]) { + // Single pass over new entries to partition sentry vs non-sentry + const newSentryEntries: Record = {}; + const newNonSentryEntries: Record = {}; + for (const [key, value] of Object.entries(newBaggageEntries)) { + if (key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { + newSentryEntries[key] = value; + } else { + newNonSentryEntries[key] = value; + } + } + + const hasNewSentryEntries = Object.keys(newSentryEntries).length > 0; + + // If new baggage contains at least one sentry- value, we remove all old sentry- values + // otherwise, we keep old sentry- values. If we don't remove old sentry- values, we end + // up with an inconsistent dynamic sampling context propagation. + const mergedBaggageEntries: Record = {}; + if (existingBaggageEntries) { + for (const [key, value] of Object.entries(existingBaggageEntries)) { + if (hasNewSentryEntries && key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { + continue; + } mergedBaggageEntries[key] = value; } - }); + } + + // Sentry entries from new baggage always overwrite; non-sentry only if not already present + Object.assign(mergedBaggageEntries, newSentryEntries); + for (const [key, value] of Object.entries(newNonSentryEntries)) { + if (!mergedBaggageEntries[key]) { + mergedBaggageEntries[key] = value; + } + } return objectToBaggageHeader(mergedBaggageEntries); } diff --git a/packages/node-core/src/utils/outgoingFetchRequest.ts b/packages/node-core/src/utils/outgoingFetchRequest.ts index ce04a26db1e0..cad20496e478 100644 --- a/packages/node-core/src/utils/outgoingFetchRequest.ts +++ b/packages/node-core/src/utils/outgoingFetchRequest.ts @@ -10,13 +10,10 @@ import { } from '@sentry/core'; import type { UndiciRequest, UndiciResponse } from '../integrations/node-fetch/types'; import { mergeBaggageHeaders } from './baggage'; - +import { debug } from '@sentry/core'; const SENTRY_TRACE_HEADER = 'sentry-trace'; const SENTRY_BAGGAGE_HEADER = 'baggage'; - -// For baggage, we make sure to merge this into a possibly existing header -const BAGGAGE_HEADER_REGEX = /baggage: (.*)\r\n/; - +const W3C_TRACEPARENT_HEADER = 'traceparent'; /** * Add trace propagation headers to an outgoing fetch/undici request. * @@ -45,55 +42,137 @@ export function addTracePropagationHeadersToFetchRequest( const { 'sentry-trace': sentryTrace, baggage, traceparent } = addedHeaders; + const requestHeaders = Array.isArray(request.headers) ? request.headers : stringToArrayHeaders(request.headers); + + // OTel's UndiciInstrumentation calls propagation.inject() which unconditionally + // appends headers to the request. When the user also sets headers via getTraceData(), + // this results in duplicate sentry-trace and baggage (and optionally traceparent) entries. + // We clean these up before applying our own logic. + _deduplicateArrayHeader(requestHeaders, SENTRY_TRACE_HEADER); + _deduplicateArrayHeader(requestHeaders, SENTRY_BAGGAGE_HEADER); + if (propagateTraceparent) { + _deduplicateArrayHeader(requestHeaders, W3C_TRACEPARENT_HEADER); + } + // We do not want to overwrite existing headers here // If the core UndiciInstrumentation is registered, it will already have set the headers // We do not want to add any then - if (Array.isArray(request.headers)) { - const requestHeaders = request.headers; + const hasExistingSentryTraceHeader = _findExistingHeaderIndex(requestHeaders, SENTRY_TRACE_HEADER) !== -1; - // We do not want to overwrite existing header here, if it was already set - if (sentryTrace && !requestHeaders.includes(SENTRY_TRACE_HEADER)) { + // We do not want to set any headers if we already have an existing sentry-trace header. + // sentry-trace is still the source of truth, otherwise we risk mixing up baggage and sentry-trace values. + if (!hasExistingSentryTraceHeader) { + if (sentryTrace) { requestHeaders.push(SENTRY_TRACE_HEADER, sentryTrace); } - if (traceparent && !requestHeaders.includes('traceparent')) { + if (traceparent && _findExistingHeaderIndex(requestHeaders, 'traceparent') === -1) { requestHeaders.push('traceparent', traceparent); } // For baggage, we make sure to merge this into a possibly existing header - const existingBaggagePos = requestHeaders.findIndex(header => header === SENTRY_BAGGAGE_HEADER); - if (baggage && existingBaggagePos === -1) { + const existingBaggageIndex = _findExistingHeaderIndex(requestHeaders, SENTRY_BAGGAGE_HEADER); + if (baggage && existingBaggageIndex === -1) { requestHeaders.push(SENTRY_BAGGAGE_HEADER, baggage); } else if (baggage) { - const existingBaggage = requestHeaders[existingBaggagePos + 1]; - const merged = mergeBaggageHeaders(existingBaggage, baggage); + // headers in format [key_0, value_0, key_1, value_1, ...], hence the +1 here + const existingBaggageValue = requestHeaders[existingBaggageIndex + 1]; + const merged = mergeBaggageHeaders(existingBaggageValue, baggage); if (merged) { - requestHeaders[existingBaggagePos + 1] = merged; + requestHeaders[existingBaggageIndex + 1] = merged; + } + } + } + + if (!Array.isArray(request.headers)) { + // For original string request headers, we need to write them back to the request + request.headers = arrayToStringHeaders(requestHeaders); + } +} + +function stringToArrayHeaders(requestHeaders: string): string[] { + const headersArray = requestHeaders.split('\r\n'); + const headers: string[] = []; + for (const header of headersArray) { + try { + const colonIndex = header.indexOf(':'); + if (colonIndex === -1) { + continue; + } + const key = header.slice(0, colonIndex).trim(); + const value = header.slice(colonIndex + 1).trim(); + if (key) { + headers.push(key, value); } + } catch { + debug.warn(`Failed to convert string request header to array header: ${header}`); + } + } + return headers; +} + +function arrayToStringHeaders(headers: string[]): string { + const headerPairs: string[] = []; + + for (let i = 0; i < headers.length; i += 2) { + const key = headers[i]; + const value = headers[i + 1]; + if (!key || value == null) { + // skip falsy keys but only null/undefined values + continue; } - } else { - const requestHeaders = request.headers; - // We do not want to overwrite existing header here, if it was already set - if (sentryTrace && !requestHeaders.includes(`${SENTRY_TRACE_HEADER}:`)) { - request.headers += `${SENTRY_TRACE_HEADER}: ${sentryTrace}\r\n`; + headerPairs.push(`${key}: ${value}`); + } + + if (!headerPairs.length) { + return ''; + } + + return headerPairs.join('\r\n').concat('\r\n'); +} + +/** + * For a given header name, if there are multiple entries in the [key, value, key, value, ...] array, + * keep the first entry and remove the rest. + * For baggage, values are merged to preserve all entries but to dedupe sentry- values, and always + * keep the first occurrence of them + */ +function _deduplicateArrayHeader(headers: string[], headerName: string): void { + let firstIndex = -1; + for (let i = 0; i < headers.length; i += 2) { + if (headers[i] !== headerName) { + continue; } - if (traceparent && !requestHeaders.includes('traceparent:')) { - request.headers += `traceparent: ${traceparent}\r\n`; + if (firstIndex === -1) { + firstIndex = i; + continue; } - const existingBaggage = request.headers.match(BAGGAGE_HEADER_REGEX)?.[1]; - if (baggage && !existingBaggage) { - request.headers += `${SENTRY_BAGGAGE_HEADER}: ${baggage}\r\n`; - } else if (baggage) { - const merged = mergeBaggageHeaders(existingBaggage, baggage); + const firstHeaderValue = headers[firstIndex + 1]; + if (headerName === SENTRY_BAGGAGE_HEADER && firstHeaderValue) { + // mergeBaggageHeaders always takes sentry- values from the new baggage (2nd param) and merges + // it with the existing one (1st param). Here, we want to keep the first header's existing + // sentry- values in favor of the new ones. Hence we swap the parameters. + const merged = mergeBaggageHeaders(headers[i + 1], firstHeaderValue); if (merged) { - request.headers = request.headers.replace(BAGGAGE_HEADER_REGEX, `baggage: ${merged}\r\n`); + headers[firstIndex + 1] = merged; } } + headers.splice(i, 2); + i -= 2; } } +/** + * Find the index of an existing header in an array of headers. + * Only take even indices, because headers are in format [key_0, value_0, key_1, value_1, ...] + * otherwise we could match a header _value_ with @param name + */ +function _findExistingHeaderIndex(headers: string[], name: string): number { + return headers.findIndex((header, i) => i % 2 === 0 && header === name); +} + /** Add a breadcrumb for an outgoing fetch/undici request. */ export function addFetchRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void { const data = getBreadcrumbData(request); diff --git a/packages/node-core/src/utils/outgoingHttpRequest.ts b/packages/node-core/src/utils/outgoingHttpRequest.ts index 7eafa941286a..34624900b472 100644 --- a/packages/node-core/src/utils/outgoingHttpRequest.ts +++ b/packages/node-core/src/utils/outgoingHttpRequest.ts @@ -63,7 +63,13 @@ export function addTracePropagationHeadersToOutgoingRequest( const { 'sentry-trace': sentryTrace, baggage, traceparent } = headersToAdd; - if (sentryTrace && !request.getHeader('sentry-trace')) { + const hasExistingSentryTraceHeader = !!request.getHeader('sentry-trace'); + + if (hasExistingSentryTraceHeader) { + return; + } + + if (sentryTrace) { try { request.setHeader('sentry-trace', sentryTrace); DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added sentry-trace header to outgoing request'); @@ -92,7 +98,8 @@ export function addTracePropagationHeadersToOutgoingRequest( } if (baggage) { - const newBaggage = mergeBaggageHeaders(request.getHeader('baggage'), baggage); + const existingBaggage = request.getHeader('baggage'); + const newBaggage = mergeBaggageHeaders(existingBaggage, baggage); if (newBaggage) { try { request.setHeader('baggage', newBaggage); diff --git a/packages/node-core/test/utils/baggage.test.ts b/packages/node-core/test/utils/baggage.test.ts index aae5c48d6068..0d7ff5f757d5 100644 --- a/packages/node-core/test/utils/baggage.test.ts +++ b/packages/node-core/test/utils/baggage.test.ts @@ -69,7 +69,6 @@ describe('mergeBaggageHeaders', () => { expect(entries).toContain('third=party'); expect(entries).toContain('sentry-environment=myEnv'); expect(entries).toContain('sentry-release=2.1.0'); - expect(entries).toContain('sentry-sample_rate=0.54'); expect(entries).not.toContain('sentry-environment=staging'); expect(entries).not.toContain('sentry-release=9.9.9'); }); @@ -87,7 +86,7 @@ describe('mergeBaggageHeaders', () => { it('handles array-type existing baggage', () => { const result = mergeBaggageHeaders(['foo=bar', 'other=vendor'], 'sentry-release=1.0.0'); - const entries = result?.split(','); + const entries = (result as string)?.split(','); expect(entries).toContain('foo=bar'); expect(entries).toContain('other=vendor'); expect(entries).toContain('sentry-release=1.0.0'); @@ -115,7 +114,7 @@ describe('mergeBaggageHeaders', () => { expect(entries).not.toContain('sentry-environment=old'); }); - it('matches OTEL propagation.inject() behavior for Sentry keys', () => { + it('overwrites existing Sentry entries with new SDK values', () => { const result = mergeBaggageHeaders( 'sentry-trace_id=abc123,sentry-sampled=false,non-sentry=keep', 'sentry-trace_id=xyz789,sentry-sampled=true', @@ -128,4 +127,41 @@ describe('mergeBaggageHeaders', () => { expect(entries).not.toContain('sentry-trace_id=abc123'); expect(entries).not.toContain('sentry-sampled=false'); }); + + it('merges non-conflicting baggage entries', () => { + const existing = 'custom-key=value'; + const newBaggage = 'sentry-environment=production'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('custom-key=value,sentry-environment=production'); + }); + + it('overwrites existing Sentry entries when keys conflict', () => { + const existing = 'sentry-environment=staging'; + const newBaggage = 'sentry-environment=production'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('sentry-environment=production'); + }); + + it('handles multiple entries with Sentry conflicts', () => { + const existing = 'custom-key=value1,sentry-environment=staging'; + const newBaggage = 'sentry-environment=production,sentry-trace_id=123'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toContain('custom-key=value1'); + expect(result).toContain('sentry-environment=production'); + expect(result).toContain('sentry-trace_id=123'); + expect(result).not.toContain('sentry-environment=staging'); + }); + + it('removes all sentry- values from old baggage and only adds new ones (if at least one new sentry- value is present)', () => { + const existing = 'sentry-trace_id=old,sentry-sampled=false,non-sentry=keep'; + const newBaggage = 'sentry-trace_id=new,sentry-environment=new'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('non-sentry=keep,sentry-trace_id=new,sentry-environment=new'); + }); + + it('preserves existing sentry entries when new baggage has no sentry entries', () => { + const result = mergeBaggageHeaders('sentry-release=1.0.0,foo=bar', 'baz=qux'); + + expect(result).toBe('sentry-release=1.0.0,foo=bar,baz=qux'); + }); }); diff --git a/packages/node-core/test/utils/outgoingFetchRequest.test.ts b/packages/node-core/test/utils/outgoingFetchRequest.test.ts new file mode 100644 index 000000000000..f023c2cab268 --- /dev/null +++ b/packages/node-core/test/utils/outgoingFetchRequest.test.ts @@ -0,0 +1,478 @@ +import type { MockedFunction } from 'vitest'; +import { describe, beforeEach, vi, expect, it } from 'vitest'; +import type { UndiciRequest } from '../../src/integrations/node-fetch/types'; +import { addTracePropagationHeadersToFetchRequest } from '../../src/utils/outgoingFetchRequest'; +import { LRUMap } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; + +const mockedGetTraceData: MockedFunction<() => ReturnType> = vi.hoisted(() => + vi.fn(() => ({ + 'sentry-trace': 'trace_id_1-span_id_1-1', + baggage: 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + })), +); + +const mockedClientGetOptions: MockedFunction<() => Partial> = vi.hoisted(() => + vi.fn(() => ({ + tracePropagationTargets: ['https://example.com'], + propagateTraceparent: true, + })), +); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + getClient: vi.fn(() => ({ + getOptions: mockedClientGetOptions, + })), + shouldPropagateTraceForUrl: () => true, + getTraceData: mockedGetTraceData, + }; +}); + +describe('addTracePropagationHeadersToFetchRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("doesn't add headers if shouldPropagateTraceForUrl returns false", () => { + vi.spyOn(SentryCore, 'shouldPropagateTraceForUrl').mockReturnValueOnce(false); + + const request = { + headers: [] as string[], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([]); + }); + + describe('when headers are an array', () => { + it('adds sentry-trace and baggage headers to request', () => { + const request = { + headers: [] as string[], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'trace_id_1-span_id_1-1', + 'baggage', + 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + ]); + }); + + it('adds sentry-trace, baggage and traceparent headers to request', () => { + mockedGetTraceData.mockReturnValueOnce({ + 'sentry-trace': 'trace_id_1-span_id_1-1', + baggage: 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + traceparent: '00-trace_id_1-span_id_1-01', + }); + + const request = { + headers: [] as string[], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'trace_id_1-span_id_1-1', + 'traceparent', + '00-trace_id_1-span_id_1-01', + 'baggage', + 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + ]); + }); + + it('preserves non-sentry entries in existing baggage header', () => { + const request = { + headers: ['baggage', 'other=entry,not=sentry'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'baggage', + 'other=entry,not=sentry,sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + 'sentry-trace', + 'trace_id_1-span_id_1-1', + ]); + }); + + it('preserves pre-existing traceparent header', () => { + mockedGetTraceData.mockReturnValueOnce({ + 'sentry-trace': 'trace_id_1-span_id_1-1', + baggage: 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + traceparent: '00-trace_id_1-span_id_1-01', + }); + + const request = { + headers: ['traceparent', '00-some-other-trace_id-span_id_x-01'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'traceparent', + '00-some-other-trace_id-span_id_x-01', + 'sentry-trace', + 'trace_id_1-span_id_1-1', + 'baggage', + 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + ]); + }); + + describe('when sentry-trace is already set', () => { + it("preserves original sentry-trace header doesn't add baggage", () => { + const request = { + headers: ['sentry-trace', 'trace_id_2-span_id_2-1'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual(['sentry-trace', 'trace_id_2-span_id_2-1']); + }); + + it('preserves original baggage header', () => { + const request = { + headers: [ + 'sentry-trace', + 'trace_id_2-span_id_2-1', + 'baggage', + 'sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging', + ], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'trace_id_2-span_id_2-1', + 'baggage', + 'sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging', + ]); + }); + + it("doesn't add traceparent header even if propagateTraceparent is true", () => { + mockedGetTraceData.mockReturnValueOnce({ + 'sentry-trace': 'trace_id_2-span_id_2-1', + baggage: 'sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging', + traceparent: '00-trace_id_2-span_id_2-01', + }); + + const request = { + headers: ['sentry-trace', 'trace_id_2-span_id_2-1'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual(['sentry-trace', 'trace_id_2-span_id_2-1']); + }); + }); + + describe('pre-existing header deduplication', () => { + it('deduplicates sentry-trace and baggage headers', () => { + const request = { + headers: [ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + 'sentry-trace', + 'undici-trace_id-abc-1', + 'baggage', + 'sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici', + ], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + ]); + }); + + it('deduplicates traceparent headers if propagateTraceparent is true', () => { + mockedClientGetOptions.mockReturnValueOnce({ + tracePropagationTargets: ['https://example.com'], + propagateTraceparent: true, + }); + + const request = { + headers: [ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + 'traceparent', + '00-user-trace_id-xyz-1-01', + 'sentry-trace', + 'undici-trace_id-abc-1', + 'baggage', + 'sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici', + 'traceparent', + '00-undici-trace_id-abc-1-01', + ], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + 'traceparent', + '00-user-trace_id-xyz-1-01', + ]); + }); + + // admittedly an unrealistic edge case but doesn't hurt to test it + it("doesn't crash with incomplete headers array", () => { + const request = { + headers: [ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + 'sentry-trace', + 'undici-trace_id-abc-1', + 'baggage', + 'sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici', + 'baggage', // only the key, no value + ], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + ]); + }); + + it('dedupes multiple baggage headers with sentry- values keeps non-sentry values around', () => { + const request = { + headers: [ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'user-added=value,another=one', + 'baggage', + 'yet-another=value,another=two', + 'sentry-trace', + 'undici-trace_id-abc-1', + 'baggage', + 'sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici,', + ], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici,yet-another=value,another=two,user-added=value', + ]); + }); + + it('dedupes multiple baggage headers keeps non-sentry values around', () => { + const request = { + headers: ['baggage', 'user-added=value,another=one', 'baggage', 'yet-another=value,another=two'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'baggage', + 'yet-another=value,another=two,user-added=value,sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + 'sentry-trace', + 'trace_id_1-span_id_1-1', + ]); + }); + }); + + it('doesn\'t mistake a header value with "sentry-trace" for a sentry-trace header', () => { + const request = { + headers: ['x-allow-header', 'sentry-trace'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'x-allow-header', + 'sentry-trace', + 'sentry-trace', + 'trace_id_1-span_id_1-1', + 'baggage', + 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + ]); + }); + + it('doesn\'t mistake a header value with "baggage" for a sentry-trace header', () => { + const request = { + headers: ['x-allow-header', 'baggage'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'x-allow-header', + 'baggage', + 'sentry-trace', + 'trace_id_1-span_id_1-1', + 'baggage', + 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + ]); + }); + }); + + describe('when headers are a string', () => { + it('adds sentry-trace and baggage headers to request', () => { + const request = { + headers: '', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe( + 'sentry-trace: trace_id_1-span_id_1-1\r\n' + + 'baggage: sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging\r\n', + ); + }); + + describe('when sentry-trace is already set', () => { + it("preserves original sentry-trace header doesn't add baggage", () => { + const request = { + headers: 'sentry-trace: trace_id_2-span_id_2-1\r\n', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe('sentry-trace: trace_id_2-span_id_2-1\r\n'); + }); + + it('preserves the original baggage header', () => { + const request = { + headers: + 'sentry-trace: trace_id_2-span_id_2-1\r\n' + + 'baggage: sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging\r\n', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe( + 'sentry-trace: trace_id_2-span_id_2-1\r\n' + + 'baggage: sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging\r\n', + ); + }); + }); + + describe('pre-existing header deduplication', () => { + it('deduplicates sentry-trace and baggage headers', () => { + const request = { + headers: + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n' + + 'sentry-trace: undici-trace_id-abc-1\r\n' + + 'baggage: sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici\r\n', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe( + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n', + ); + }); + + it("doesn't crash with incomplete headers string", () => { + const request = { + headers: + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n' + + 'sentry-trace: undici-trace_id-abc-1\r\n' + + 'baggage: sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici\r\n' + + 'baggage: \r\n', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe( + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n', + ); + }); + }); + + it("doesn't dedupe nearly-sentry-tracing headers", () => { + const request = { + headers: + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n' + + 'x-sentry-trace: custom-trace_id-abc-1\r\n' + + 'x-baggage: sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici\r\n', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe( + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n' + + 'x-sentry-trace: custom-trace_id-abc-1\r\n' + + 'x-baggage: sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici\r\n', + ); + }); + }); +}); diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index 2b5873658706..b8582b37d964 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -63,6 +63,8 @@ export class SentryPropagator extends W3CBaggagePropagator { } const existingBaggageHeader = getExistingBaggage(carrier); + const existingSentryTraceHeader = getExistingSentryTrace(carrier); + let baggage = propagation.getBaggage(context) || propagation.createBaggage({}); const { dynamicSamplingContext, traceId, spanId, sampled } = getInjectionData(context); @@ -72,12 +74,18 @@ export class SentryPropagator extends W3CBaggagePropagator { if (baggageEntries) { Object.entries(baggageEntries).forEach(([key, value]) => { + if (!existingSentryTraceHeader && key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { + // Edge case: A baggage header with sentry- keys was added previously but no + // sentry-trace header. In this case we remove the old sentry-keys and add new + // ones below. + return; + } baggage = baggage.setEntry(key, { value }); }); } } - if (dynamicSamplingContext) { + if (!existingSentryTraceHeader && dynamicSamplingContext) { baggage = Object.entries(dynamicSamplingContext).reduce((b, [dscKey, dscValue]) => { if (dscValue) { return b.setEntry(`${SENTRY_BAGGAGE_KEY_PREFIX}${dscKey}`, { value: dscValue }); @@ -87,7 +95,7 @@ export class SentryPropagator extends W3CBaggagePropagator { } // We also want to avoid setting the default OTEL trace ID, if we get that for whatever reason - if (traceId && traceId !== INVALID_TRACEID) { + if (!existingSentryTraceHeader && traceId && traceId !== INVALID_TRACEID) { setter.set(carrier, SENTRY_TRACE_HEADER, generateSentryTraceHeader(traceId, spanId, sampled)); if (propagateTraceparent) { @@ -248,6 +256,14 @@ function getExistingBaggage(carrier: unknown): string | undefined { } } +function getExistingSentryTrace(carrier: unknown): string | string[] | undefined { + try { + return (carrier as Record)[SENTRY_TRACE_HEADER]; + } catch { + return undefined; + } +} + /** * It is pretty tricky to get access to the outgoing request URL of a request in the propagator. * As we only have access to the context of the span to be sent and the carrier (=headers), diff --git a/packages/opentelemetry/test/propagator.test.ts b/packages/opentelemetry/test/propagator.test.ts index ef8223b5974a..7610f040a72d 100644 --- a/packages/opentelemetry/test/propagator.test.ts +++ b/packages/opentelemetry/test/propagator.test.ts @@ -500,14 +500,17 @@ describe('SentryPropagator', () => { ); }); - it('should overwrite existing sentry baggage header', () => { + it('overwrites existing sentry baggage values and add sentry-trace header if sentry-trace is not set yet', () => { + // This is an edeg case where someone set a baggage header with existing sentry- values but no sentry-trace header. + // There's no evidence this occurs in real-life but if it does, we can assume that this must be some kind of error + // Hence, we overwrite the existing sentry- values with our new ones but keep all other non-sentry values. const spanContext = { traceId: 'd4cda95b652f4a1592b449d5929fda1b', spanId: '6e0c63257de34c92', traceFlags: TraceFlags.SAMPLED, }; - const carrier = { + const carrier: Record = { baggage: 'foo=bar,other=yes,sentry-release=9.9.9,sentry-other=yes', }; const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); @@ -520,11 +523,11 @@ describe('SentryPropagator', () => { 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', 'sentry-public_key=abc', 'sentry-environment=production', - 'sentry-other=yes', 'sentry-release=1.0.0', 'sentry-sampled=true', ].sort(), ); + expect(carrier[SENTRY_TRACE_HEADER]).toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'); }); it('should create baggage without propagation context', () => { @@ -537,6 +540,7 @@ describe('SentryPropagator', () => { expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe( `foo=bar,sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=${traceId}`, ); + expect(carrier[SENTRY_TRACE_HEADER]).toBeDefined(); // whenever we set baggage, we must also set sentry-trace }); it('should NOT set baggage and sentry-trace header if instrumentation is suppressed', () => { @@ -551,6 +555,86 @@ describe('SentryPropagator', () => { expect(carrier[SENTRY_TRACE_HEADER]).toBe(undefined); expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(undefined); }); + + it("doesn't set baggage header if sentry-trace header is already set", () => { + const carrier: Record = { + [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', + }; + propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); + + expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(undefined); + expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); + }); + + describe('traceparent header', () => { + it("doesn't change baggage header if sentry-trace header is already set", () => { + const carrier: Record = { + [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', + [SENTRY_BAGGAGE_HEADER]: 'foo=bar,other=yes,sentry-release=9.9.9', + }; + propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); + + expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe('foo=bar,other=yes,sentry-release=9.9.9'); + expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); + }); + + it("doesn't set traceparent header if sentry-trace header is already set", () => { + mockSdkInit({ propagateTraceparent: true }); + const carrier: Record = { + [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', + }; + propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); + + expect(carrier['traceparent']).toBe(undefined); + expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); + }); + + it('sets traceparent header if propagateTraceparent is true', () => { + mockSdkInit({ + environment: 'production', + release: '1.0.0', + tracesSampleRate: 1, + dsn: 'https://abc@domain/123', + propagateTraceparent: true, + }); + + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'foo=bar', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-public_key=abc', + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-sampled=true', + ].sort(), + ); + expect(carrier['traceparent']).toBe('00-d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-01'); + }); + + it("doesn't set traceparent header if propagateTraceparent is false", () => { + mockSdkInit({ + environment: 'production', + release: '1.0.0', + tracesSampleRate: 1, + dsn: 'https://abc@domain/123', + propagateTraceparent: false, + }); + const carrier: Record = {}; + propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); + + expect(carrier['traceparent']).toBe(undefined); + expect(carrier[SENTRY_TRACE_HEADER]).toBeDefined(); + }); + }); }); describe('extract', () => { From 9bfc68252faa63d0bb202ec5f5fc4b5142aa7fbc Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 30 Mar 2026 09:13:59 -0400 Subject: [PATCH 34/39] ref(browser-tests): Add waitForMetricRequest helper (#20002) ## Summary - Adds a shared `waitForMetricRequest` helper to browser integration test utils, following the same `page.waitForRequest` pattern as `waitForErrorRequest`, `waitForTransactionRequest`, etc. - Refactors element timing tests to use `waitForMetricRequest` instead of a custom `createMetricCollector` with polling-based `waitForIdentifiers` - The new helper accumulates `SerializedMetric[]` across envelope requests and resolves when the callback returns `true` for the full collected set Closes #20005 (added automatically) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../tracing/metrics/element-timing/test.ts | 133 +++++------------- .../utils/helpers.ts | 52 +++++++ 2 files changed, 89 insertions(+), 96 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts index bbff70505c0a..6f418c79a024 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts @@ -1,84 +1,15 @@ -import type { Page, Request, Route } from '@playwright/test'; +import type { Page, Route } from '@playwright/test'; import { expect } from '@playwright/test'; -import type { Envelope } from '@sentry/core'; +import type { SerializedMetric } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; -import { - properFullEnvelopeRequestParser, - shouldSkipMetricsTest, - shouldSkipTracingTest, -} from '../../../../utils/helpers'; - -type MetricItem = Record & { - name: string; - type: string; - value: number; - unit?: string; - attributes: Record; -}; - -function extractMetricsFromRequest(req: Request): MetricItem[] { - try { - const envelope = properFullEnvelopeRequestParser(req); - const items = envelope[1]; - const metrics: MetricItem[] = []; - for (const item of items) { - const [header] = item; - if (header.type === 'trace_metric') { - const payload = item[1] as { items?: MetricItem[] }; - if (payload.items) { - metrics.push(...payload.items); - } - } - } - return metrics; - } catch { - return []; - } -} - -/** - * Collects element timing metrics from envelope requests on the page. - * Returns a function to get all collected metrics so far and a function - * that waits until all expected identifiers have been seen in render_time metrics. - */ -function createMetricCollector(page: Page) { - const collectedRequests: Request[] = []; - - page.on('request', req => { - if (!req.url().includes('/api/1337/envelope/')) return; - const metrics = extractMetricsFromRequest(req); - if (metrics.some(m => m.name.startsWith('ui.element.'))) { - collectedRequests.push(req); - } - }); - - function getAll(): MetricItem[] { - return collectedRequests.flatMap(req => extractMetricsFromRequest(req)); - } +import { shouldSkipMetricsTest, shouldSkipTracingTest, waitForMetrics } from '../../../../utils/helpers'; - async function waitForIdentifiers(identifiers: string[], timeout = 30_000): Promise { - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const all = getAll().filter(m => m.name === 'ui.element.render_time'); - const seen = new Set(all.map(m => m.attributes['ui.element.identifier']?.value)); - if (identifiers.every(id => seen.has(id))) { - return; - } - await page.waitForTimeout(500); - } - // Final check with assertion for clear error message - const all = getAll().filter(m => m.name === 'ui.element.render_time'); - const seen = all.map(m => m.attributes['ui.element.identifier']?.value); - for (const id of identifiers) { - expect(seen).toContain(id); - } - } - - function reset(): void { - collectedRequests.length = 0; - } +function getIdentifier(m: SerializedMetric): unknown { + return m.attributes?.['ui.element.identifier']?.value; +} - return { getAll, waitForIdentifiers, reset }; +function getPaintType(m: SerializedMetric): unknown { + return m.attributes?.['ui.element.paint_type']?.value; } sentryTest( @@ -91,19 +22,23 @@ sentryTest( serveAssets(page); const url = await getLocalTestUrl({ testDir: __dirname }); - const collector = createMetricCollector(page); - await page.goto(url); + const expectedIdentifiers = ['image-fast', 'text1', 'button1', 'image-slow', 'lazy-image', 'lazy-text']; - // Wait until all expected element identifiers have been flushed as metrics - await collector.waitForIdentifiers(['image-fast', 'text1', 'button1', 'image-slow', 'lazy-image', 'lazy-text']); + // Wait for all expected element identifiers to arrive as metrics + const [allMetrics] = await Promise.all([ + waitForMetrics(page, metrics => { + const seen = new Set(metrics.filter(m => m.name === 'ui.element.render_time').map(getIdentifier)); + return expectedIdentifiers.every(id => seen.has(id)); + }), + page.goto(url), + ]); - const allMetrics = collector.getAll().filter(m => m.name.startsWith('ui.element.')); const renderTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.render_time'); const loadTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.load_time'); - const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); - const loadIdentifiers = loadTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); + const renderIdentifiers = renderTimeMetrics.map(getIdentifier); + const loadIdentifiers = loadTimeMetrics.map(getIdentifier); // All text and image elements should have render_time expect(renderIdentifiers).toContain('image-fast'); @@ -124,18 +59,18 @@ sentryTest( expect(loadIdentifiers).not.toContain('lazy-text'); // Validate metric structure for image-fast - const imageFastRender = renderTimeMetrics.find(m => m.attributes['ui.element.identifier']?.value === 'image-fast'); + const imageFastRender = renderTimeMetrics.find(m => getIdentifier(m) === 'image-fast'); expect(imageFastRender).toMatchObject({ name: 'ui.element.render_time', type: 'distribution', unit: 'millisecond', value: expect.any(Number), }); - expect(imageFastRender!.attributes['ui.element.paint_type']?.value).toBe('image-paint'); + expect(getPaintType(imageFastRender!)).toBe('image-paint'); // Validate text-paint metric - const text1Render = renderTimeMetrics.find(m => m.attributes['ui.element.identifier']?.value === 'text1'); - expect(text1Render!.attributes['ui.element.paint_type']?.value).toBe('text-paint'); + const text1Render = renderTimeMetrics.find(m => getIdentifier(m) === 'text1'); + expect(getPaintType(text1Render!)).toBe('text-paint'); }, ); @@ -147,25 +82,31 @@ sentryTest('emits element timing metrics after navigation', async ({ getLocalTes serveAssets(page); const url = await getLocalTestUrl({ testDir: __dirname }); - const collector = createMetricCollector(page); + + // Start listening before navigation to avoid missing metrics + const pageloadMetricsPromise = waitForMetrics(page, metrics => + metrics.some(m => m.name === 'ui.element.render_time' && getIdentifier(m) === 'image-fast'), + ); await page.goto(url); // Wait for pageload element timing metrics to arrive before navigating - await collector.waitForIdentifiers(['image-fast', 'text1']); + await pageloadMetricsPromise; - // Reset so we only capture post-navigation metrics - collector.reset(); + // Start listening before click to avoid missing metrics + const navigationMetricsPromise = waitForMetrics(page, metrics => { + const seen = new Set(metrics.filter(m => m.name === 'ui.element.render_time').map(getIdentifier)); + return seen.has('navigation-image') && seen.has('navigation-text'); + }); // Trigger navigation await page.locator('#button1').click(); // Wait for navigation element timing metrics - await collector.waitForIdentifiers(['navigation-image', 'navigation-text']); + const navigationMetrics = await navigationMetricsPromise; - const allMetrics = collector.getAll(); - const renderTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.render_time'); - const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); + const renderTimeMetrics = navigationMetrics.filter(m => m.name === 'ui.element.render_time'); + const renderIdentifiers = renderTimeMetrics.map(getIdentifier); expect(renderIdentifiers).toContain('navigation-image'); expect(renderIdentifiers).toContain('navigation-text'); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 50150c6bee20..879e672b6c87 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -8,6 +8,8 @@ import type { Event as SentryEvent, EventEnvelope, EventEnvelopeHeaders, + SerializedMetric, + SerializedMetricContainer, SerializedSession, TransactionEvent, } from '@sentry/core'; @@ -283,6 +285,56 @@ export function waitForClientReportRequest(page: Page, callback?: (report: Clien }); } +/** + * Wait for metric requests. Accumulates metrics across all matching requests + * and resolves when the callback returns true for the full set of collected metrics. + * If no callback is provided, resolves on the first request containing metrics. + */ +export function waitForMetrics( + page: Page, + callback?: (metrics: SerializedMetric[]) => boolean, +): Promise { + const collected: SerializedMetric[] = []; + + return page + .waitForRequest(req => { + const postData = req.postData(); + if (!postData) { + return false; + } + + try { + const envelope = properFullEnvelopeRequestParser(req); + const items = envelope[1]; + const metrics: SerializedMetric[] = []; + for (const item of items) { + const [header] = item; + if (header.type === 'trace_metric') { + const payload = item[1] as SerializedMetricContainer; + if (payload.items) { + metrics.push(...payload.items); + } + } + } + + if (metrics.length === 0) { + return false; + } + + collected.push(...metrics); + + if (callback) { + return callback(collected); + } + + return true; + } catch { + return false; + } + }) + .then(() => collected); +} + export async function waitForSession( page: Page, callback?: (session: SerializedSession) => boolean, From 28f94f3ad844093147eb6886978553e2c56712a7 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 19 Mar 2026 10:46:50 -0700 Subject: [PATCH 35/39] fix(react-router): Disable debug ID injection in Vite plugin to prevent double injection (##19890) Set sourcemaps.disable to true (boolean) instead of 'disable-upload' (string) in makeCustomSentryVitePlugins. The Rollup plugin checks disable !== true, so the string value was not disabling debug ID injection. This caused double injection with two different UUIDs per file when sentryOnBuildEnd also ran sentry-cli sourcemaps inject, breaking source map resolution. Fixes GH-19874 Co-Authored-By: Claude --- packages/react-router/src/vite/makeCustomSentryVitePlugins.ts | 2 +- .../test/vite/makeCustomSentryVitePlugins.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts index 1863a7b66f9f..fcec109c6baa 100644 --- a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts +++ b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts @@ -42,7 +42,7 @@ export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuil }, // will be handled in buildEnd hook sourcemaps: { - disable: 'disable-upload', + disable: true, ...unstable_sentryVitePluginOptions?.sourcemaps, }, ...unstable_sentryVitePluginOptions, diff --git a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts index 98dc6d8ba662..b98a6ebfb80d 100644 --- a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts +++ b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts @@ -54,13 +54,13 @@ describe('makeCustomSentryVitePlugins', () => { expect(plugins?.[0]?.name).toBe('sentry-vite-plugin'); }); - it('should disable sourcemap upload with "disable-upload" by default', async () => { + it('should disable sourcemap upload by default', async () => { await makeCustomSentryVitePlugins({}); expect(sentryVitePlugin).toHaveBeenCalledWith( expect.objectContaining({ sourcemaps: expect.objectContaining({ - disable: 'disable-upload', + disable: true, }), }), ); From 381549244a6a94e30ade9b2545fbdd03312eaf5a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 30 Mar 2026 16:44:03 +0200 Subject: [PATCH 36/39] fix(profiling): Disable profiling in worker threads (#20040) The native CPU profiler's sampling thread can race with V8's GC in worker threads, causing heap corruption and ~40-60% crash rate under allocation pressure. This PR adds a JS-side guard while a long-term native addon should be added separately. - Adds isMainThread guard in ContinuousProfiler.initialize() to skip profiler startup in worker threads - Adds isMainThread guard in maybeProfileSpan() to prevent legacy span profiling in worker threads - Updates worker thread tests to verify profiling is a no-op across all profiling modes closes https://github.com/getsentry/sentry-javascript/issues/20029 repro https://github.com/chargome/repro.JS-2019 --- packages/profiling-node/src/integration.ts | 9 ++ .../profiling-node/src/spanProfileUtils.ts | 8 ++ .../test/integration.worker.test.ts | 92 ++++++++++++------- 3 files changed, 74 insertions(+), 35 deletions(-) diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 9b4fd1601420..4cb51ac540b5 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -14,6 +14,7 @@ import { } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { CpuProfilerBindings, ProfileFormat, type RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; +import { isMainThread } from 'worker_threads'; import { DEBUG_BUILD } from './debug-build'; import { NODE_MAJOR } from './nodeVersion'; import { MAX_PROFILE_DURATION_MS, maybeProfileSpan, stopSpanProfile } from './spanProfileUtils'; @@ -62,6 +63,14 @@ class ContinuousProfiler { * @param client */ public initialize(client: NodeClient): void { + if (!isMainThread) { + DEBUG_BUILD && + debug.warn( + '[Profiling] nodeProfilingIntegration() does not support worker threads — profiling will be disabled for this thread.', + ); + return; + } + this._client = client; const options = client.getOptions(); diff --git a/packages/profiling-node/src/spanProfileUtils.ts b/packages/profiling-node/src/spanProfileUtils.ts index bf1be3a0bf44..436e741ff7a2 100644 --- a/packages/profiling-node/src/spanProfileUtils.ts +++ b/packages/profiling-node/src/spanProfileUtils.ts @@ -3,6 +3,7 @@ import type { CustomSamplingContext, Span } from '@sentry/core'; import { debug, spanIsSampled, spanToJSON, uuid4 } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; import { CpuProfilerBindings, type RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; +import { isMainThread } from 'worker_threads'; import { DEBUG_BUILD } from './debug-build'; import { isValidSampleRate } from './utils'; @@ -17,6 +18,13 @@ export function maybeProfileSpan( span: Span, customSamplingContext?: CustomSamplingContext, ): string | undefined { + // Profiling is not supported in worker threads as the native CPU profiler's + // sampling thread can race with V8's GC across isolates, causing heap corruption. + if (!isMainThread) { + DEBUG_BUILD && debug.log('[Profiling] Skipping span profiling in worker thread.'); + return; + } + // profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate. We dont perform // the actual multiplication to get the final rate, but we discard the profile if the span was sampled, // so anything after this block from here is based on the span sampling. diff --git a/packages/profiling-node/test/integration.worker.test.ts b/packages/profiling-node/test/integration.worker.test.ts index 1d34c03b33cb..fea3c1eb4c4f 100644 --- a/packages/profiling-node/test/integration.worker.test.ts +++ b/packages/profiling-node/test/integration.worker.test.ts @@ -1,6 +1,7 @@ import type { ProfilingIntegration, Transport } from '@sentry/core'; import * as Sentry from '@sentry/node'; -import { expect, it, vi } from 'vitest'; +import { CpuProfilerBindings } from '@sentry-internal/node-cpu-profiler'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { _nodeProfilingIntegration } from '../src/integration'; // Mock the modules before the import, so that the value is initialized before the module is loaded @@ -12,7 +13,7 @@ vi.mock('worker_threads', () => { }); vi.setConfig({ testTimeout: 10_000 }); -function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] { +function makeClient(options: Partial = {}): [Sentry.NodeClient, Transport] { const integration = _nodeProfilingIntegration(); const client = new Sentry.NodeClient({ stackParser: Sentry.defaultStackParser, @@ -28,48 +29,69 @@ function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] { return undefined; }, }), + ...options, }); return [client, client.getTransport() as Transport]; } -it('worker threads context', () => { - const [client, transport] = makeContinuousProfilingClient(); - Sentry.setCurrentClient(client); - client.init(); +describe('worker threads', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not start continuous profiling in worker threads', () => { + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeClient(); + Sentry.setCurrentClient(client); + client.init(); + + const integration = client.getIntegrationByName>('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } - const transportSpy = vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + // Calling start should be a no-op in a worker thread + integration._profiler.start(); - const nonProfiledTransaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); - nonProfiledTransaction.end(); + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + transaction.end(); - expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[1]).not.toMatchObject({ - contexts: { - profile: {}, - }, + // The native profiler should never have been called + expect(startProfilingSpy).not.toHaveBeenCalled(); + + integration._profiler.stop(); }); - const integration = client.getIntegrationByName>('ProfilingIntegration'); - if (!integration) { - throw new Error('Profiling integration not found'); - } - - integration._profiler.start(); - const profiledTransaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); - profiledTransaction.end(); - integration._profiler.stop(); - - expect(transportSpy.mock.calls?.[1]?.[0]?.[1]?.[0]?.[1]).toMatchObject({ - contexts: { - trace: { - data: expect.objectContaining({ - ['thread.id']: '9999', - ['thread.name']: 'worker', - }), - }, - profile: { - profiler_id: expect.any(String), - }, - }, + it('does not start span profiling in worker threads', () => { + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeClient({ profilesSampleRate: 1 }); + Sentry.setCurrentClient(client); + client.init(); + + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + transaction.end(); + + // The native profiler should never have been called even with profilesSampleRate set + expect(startProfilingSpy).not.toHaveBeenCalled(); + }); + + it('does not start trace lifecycle profiling in worker threads', () => { + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeClient({ + profileSessionSampleRate: 1.0, + profileLifecycle: 'trace', + }); + Sentry.setCurrentClient(client); + client.init(); + + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + transaction.end(); + + // The native profiler should never have been called + expect(startProfilingSpy).not.toHaveBeenCalled(); }); }); From 8f08fcb5404b152e90f258f207b71d150f20b01a Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:39:13 +0900 Subject: [PATCH 37/39] fix(browser-tests): Pin axios to 1.13.5 to avoid compromised 1.14.1 (#20047) axios 1.14.1 contains a supply chain attack via the plain-crypto-js dependency. This PR pins to 1.13.5 to prevent accidental upgrades. See: https://x.com/feross/status/2038807290422370479 Co-authored-by: Claude claude-opus-4-6 --- dev-packages/browser-integration-tests/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 13fa11441031..ff4db72c90e7 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -62,7 +62,7 @@ "@sentry-internal/rrweb": "2.34.0", "@sentry/browser": "10.46.0", "@supabase/supabase-js": "2.49.3", - "axios": "^1.12.2", + "axios": "1.13.5", "babel-loader": "^10.1.1", "fflate": "0.8.2", "html-webpack-plugin": "^5.5.0", diff --git a/yarn.lock b/yarn.lock index 9fcb36f70517..d5208f635fcd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11868,7 +11868,7 @@ aws-ssl-profiles@^1.1.2: resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641" integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== -axios@^1.12.0, axios@^1.12.2: +axios@1.13.5, axios@^1.12.0: version "1.13.5" resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43" integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q== From 2c0ce6f3a74a3fcfc3eb158a4a5547b124c1aab5 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:41:49 +0900 Subject: [PATCH 38/39] feat(deps): Bump OpenTelemetry dependencies (#20046) This PR bumps all OpenTelemetry instrumentation packages and core dependencies: - @opentelemetry/api: ^1.9.0 -> ^1.9.1 - @opentelemetry/core: ^2.6.0 -> ^2.6.1 - @opentelemetry/context-async-hooks: ^2.6.0 -> ^2.6.1 - @opentelemetry/resources: ^2.6.0 -> ^2.6.1 - @opentelemetry/sdk-trace-base: ^2.6.0 -> ^2.6.1 - @opentelemetry/exporter-trace-otlp-http: ^0.213.0 -> ^0.214.0 - @opentelemetry/instrumentation: ^0.213.0 -> ^0.214.0 - @opentelemetry/instrumentation-http: 0.213.0 -> 0.214.0 - @opentelemetry/instrumentation-amqplib: 0.60.0 -> 0.61.0 - @opentelemetry/instrumentation-aws-sdk: 0.68.0 -> 0.69.0 - @opentelemetry/instrumentation-connect: 0.56.0 -> 0.57.0 - @opentelemetry/instrumentation-dataloader: 0.30.0 -> 0.31.0 - @opentelemetry/instrumentation-express: 0.62.0 -> 0.62.0 - @opentelemetry/instrumentation-fs: 0.32.0 -> 0.33.0 - @opentelemetry/instrumentation-generic-pool: 0.56.0 -> 0.57.0 - @opentelemetry/instrumentation-graphql: 0.61.0 -> 0.62.0 - @opentelemetry/instrumentation-hapi: 0.59.0 -> 0.60.0 - @opentelemetry/instrumentation-ioredis: 0.61.0 -> 0.62.0 - @opentelemetry/instrumentation-kafkajs: 0.22.0 -> 0.23.0 - @opentelemetry/instrumentation-knex: 0.57.0 -> 0.58.0 - @opentelemetry/instrumentation-koa: 0.61.0 -> 0.62.0 - @opentelemetry/instrumentation-lru-memoizer: 0.57.0 -> 0.58.0 - @opentelemetry/instrumentation-mongodb: 0.66.0 -> 0.67.0 - @opentelemetry/instrumentation-mongoose: 0.59.0 -> 0.60.0 - @opentelemetry/instrumentation-mysql: 0.59.0 -> 0.60.0 - @opentelemetry/instrumentation-mysql2: 0.59.0 -> 0.60.0 - @opentelemetry/instrumentation-nestjs-core: 0.59.0 -> 0.60.0 - @opentelemetry/instrumentation-pg: 0.65.0 -> 0.66.0 - @opentelemetry/instrumentation-redis: 0.61.0 -> 0.62.0 - @opentelemetry/instrumentation-tedious: 0.32.0 -> 0.33.0 - @opentelemetry/instrumentation-undici: 0.23.0 -> 0.24.0 - @prisma/instrumentation: 7.4.2 -> 7.6.0 - @fastify/otel: 0.17.1 -> 0.18.0 Closes: #20036 --- .../package.json | 4 +- .../package.json | 8 +- .../node-core-express-otel-v2/package.json | 4 +- .../node-otel-without-tracing/package.json | 10 +- .../node-core-integration-tests/package.json | 14 +- packages/aws-serverless/package.json | 6 +- packages/cloudflare/package.json | 2 +- packages/deno/package.json | 2 +- packages/hono/package.json | 2 +- packages/nestjs/package.json | 8 +- packages/nextjs/package.json | 2 +- packages/node-core/package.json | 14 +- packages/node/package.json | 60 +-- packages/opentelemetry/package.json | 8 +- packages/react-router/package.json | 6 +- packages/remix/package.json | 4 +- packages/tanstackstart-react/package.json | 2 +- packages/vercel-edge/package.json | 8 +- yarn.lock | 403 +++++++++--------- 19 files changed, 286 insertions(+), 281 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json index c78cc950074b..974d0711acc8 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json @@ -14,8 +14,8 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-http": "^0.213.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-http": "^0.214.0", "@opentelemetry/resources": "^2.6.0", "@opentelemetry/sdk-trace-node": "^2.6.0", "@opentelemetry/semantic-conventions": "^1.40.0", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json index 749c25696505..00e1ab056be6 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json @@ -14,13 +14,13 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-http": "^0.213.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-http": "^0.214.0", "@opentelemetry/resources": "^2.6.0", "@opentelemetry/sdk-trace-node": "^2.6.0", "@opentelemetry/semantic-conventions": "^1.40.0", - "@opentelemetry/sdk-node": "^0.213.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", + "@opentelemetry/sdk-node": "^0.214.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", "@types/express": "4.17.17", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json index 4db7c3440bed..77b6006ee947 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json @@ -16,8 +16,8 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-http": "^0.213.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-http": "^0.214.0", "@opentelemetry/resources": "^2.6.0", "@opentelemetry/sdk-trace-node": "^2.6.0", "@opentelemetry/semantic-conventions": "^1.40.0", diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json index 59da61d6d7da..8e5563fdb4ec 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json @@ -12,11 +12,11 @@ }, "dependencies": { "@opentelemetry/api": "1.9.0", - "@opentelemetry/sdk-trace-node": "2.6.0", - "@opentelemetry/exporter-trace-otlp-http": "0.213.0", - "@opentelemetry/instrumentation-undici": "0.23.0", - "@opentelemetry/instrumentation-http": "0.213.0", - "@opentelemetry/instrumentation": "0.213.0", + "@opentelemetry/sdk-trace-node": "2.6.1", + "@opentelemetry/exporter-trace-otlp-http": "0.214.0", + "@opentelemetry/instrumentation-undici": "0.24.0", + "@opentelemetry/instrumentation-http": "0.214.0", + "@opentelemetry/instrumentation": "0.214.0", "@sentry/node": "latest || *", "@types/express": "4.17.17", "@types/node": "^18.19.1", diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index 711de7f20519..6ef14031034f 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -26,13 +26,13 @@ "@nestjs/common": "^11", "@nestjs/core": "^11", "@nestjs/platform-express": "^11", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-http": "0.213.0", - "@opentelemetry/resources": "^2.6.0", - "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.6.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-http": "0.214.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/core": "10.46.0", "@sentry/node-core": "10.46.0", diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 4bb7fd56f5b6..c0afaa7f7c36 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -65,9 +65,9 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-aws-sdk": "0.68.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-aws-sdk": "0.69.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/core": "10.46.0", "@sentry/node": "10.46.0", diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index cc4e720d8a45..6e6b4c36cdfd 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -49,7 +49,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "@sentry/core": "10.46.0" }, "peerDependencies": { diff --git a/packages/deno/package.json b/packages/deno/package.json index def1e04d26e4..98b039511dfa 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -24,7 +24,7 @@ "/build" ], "dependencies": { - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "@sentry/core": "10.46.0" }, "scripts": { diff --git a/packages/hono/package.json b/packages/hono/package.json index 3b30a6583577..59fda0a959a0 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -65,7 +65,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "@sentry/cloudflare": "10.46.0", "@sentry/core": "10.46.0", "@sentry/node": "10.46.0" diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 6da89cb2ed8a..50105e65486d 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -44,10 +44,10 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-nestjs-core": "0.59.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-nestjs-core": "0.60.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/core": "10.46.0", "@sentry/node": "10.46.0" diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index f79bee2c9dc5..cae2bf083f20 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -76,7 +76,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@rollup/plugin-commonjs": "28.0.1", "@sentry-internal/browser-utils": "10.46.0", diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 091ec88e0365..f8e24ab7b5d2 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -118,13 +118,13 @@ "import-in-the-middle": "^3.0.0" }, "devDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/resources": "^2.6.0", - "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.6.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@types/node": "^18.19.1" }, diff --git a/packages/node/package.json b/packages/node/package.json index 83a4214a6829..273f2adca754 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -65,37 +65,37 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-amqplib": "0.60.0", - "@opentelemetry/instrumentation-connect": "0.56.0", - "@opentelemetry/instrumentation-dataloader": "0.30.0", - "@opentelemetry/instrumentation-express": "0.61.0", - "@opentelemetry/instrumentation-fs": "0.32.0", - "@opentelemetry/instrumentation-generic-pool": "0.56.0", - "@opentelemetry/instrumentation-graphql": "0.61.0", - "@opentelemetry/instrumentation-hapi": "0.59.0", - "@opentelemetry/instrumentation-http": "0.213.0", - "@opentelemetry/instrumentation-ioredis": "0.61.0", - "@opentelemetry/instrumentation-kafkajs": "0.22.0", - "@opentelemetry/instrumentation-knex": "0.57.0", - "@opentelemetry/instrumentation-koa": "0.61.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.57.0", - "@opentelemetry/instrumentation-mongodb": "0.66.0", - "@opentelemetry/instrumentation-mongoose": "0.59.0", - "@opentelemetry/instrumentation-mysql": "0.59.0", - "@opentelemetry/instrumentation-mysql2": "0.59.0", - "@opentelemetry/instrumentation-pg": "0.65.0", - "@opentelemetry/instrumentation-redis": "0.61.0", - "@opentelemetry/instrumentation-tedious": "0.32.0", - "@opentelemetry/instrumentation-undici": "0.23.0", - "@opentelemetry/resources": "^2.6.0", - "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.6.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-amqplib": "0.61.0", + "@opentelemetry/instrumentation-connect": "0.57.0", + "@opentelemetry/instrumentation-dataloader": "0.31.0", + "@opentelemetry/instrumentation-express": "0.62.0", + "@opentelemetry/instrumentation-fs": "0.33.0", + "@opentelemetry/instrumentation-generic-pool": "0.57.0", + "@opentelemetry/instrumentation-graphql": "0.62.0", + "@opentelemetry/instrumentation-hapi": "0.60.0", + "@opentelemetry/instrumentation-http": "0.214.0", + "@opentelemetry/instrumentation-ioredis": "0.62.0", + "@opentelemetry/instrumentation-kafkajs": "0.23.0", + "@opentelemetry/instrumentation-knex": "0.58.0", + "@opentelemetry/instrumentation-koa": "0.62.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", + "@opentelemetry/instrumentation-mongodb": "0.67.0", + "@opentelemetry/instrumentation-mongoose": "0.60.0", + "@opentelemetry/instrumentation-mysql": "0.60.0", + "@opentelemetry/instrumentation-mysql2": "0.60.0", + "@opentelemetry/instrumentation-pg": "0.66.0", + "@opentelemetry/instrumentation-redis": "0.62.0", + "@opentelemetry/instrumentation-tedious": "0.33.0", + "@opentelemetry/instrumentation-undici": "0.24.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", - "@prisma/instrumentation": "7.4.2", - "@fastify/otel": "0.17.1", + "@prisma/instrumentation": "7.6.0", + "@fastify/otel": "0.18.0", "@sentry/core": "10.46.0", "@sentry/node-core": "10.46.0", "@sentry/opentelemetry": "10.46.0", diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index d512d57afa8f..f4192101577d 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -49,10 +49,10 @@ "@opentelemetry/semantic-conventions": "^1.39.0" }, "devDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.6.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0" }, "scripts": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index ddb4d65e28f7..86646107c846 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -45,9 +45,9 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/browser": "10.46.0", "@sentry/cli": "^2.58.5", diff --git a/packages/remix/package.json b/packages/remix/package.json index 6e4a0d2da5a8..88ad022db3a8 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -64,8 +64,8 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@remix-run/router": "^1.23.2", "@sentry/cli": "^2.58.5", diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 12863fd29e10..a0895dc3b178 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -63,7 +63,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry-internal/browser-utils": "10.46.0", "@sentry/core": "10.46.0", diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index 265edf1ad023..45fda6487a51 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -39,14 +39,14 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/resources": "^2.6.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/resources": "^2.6.1", "@sentry/core": "10.46.0" }, "devDependencies": { "@edge-runtime/types": "4.0.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/opentelemetry": "10.46.0" }, diff --git a/yarn.lock b/yarn.lock index d5208f635fcd..9991a4549bca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4417,10 +4417,10 @@ resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.0.0.tgz#f22824caff3ae506b18207bad4126dbc6ccdb6b8" integrity sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ== -"@fastify/otel@0.17.1": - version "0.17.1" - resolved "https://registry.yarnpkg.com/@fastify/otel/-/otel-0.17.1.tgz#a7f13edc40dbc2e0c2a59d54e388f11e4d2235ce" - integrity sha512-K4wyxfUZx2ux5o+b6BtTqouYFVILohLZmSbA2tKUueJstNcBnoGPVhllCaOvbQ3ZrXdUxUC/fyrSWSCqHhdOPg== +"@fastify/otel@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@fastify/otel/-/otel-0.18.0.tgz#d21814af7c97579856698e03aae0581beb3e734b" + integrity sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA== dependencies: "@opentelemetry/core" "^2.0.0" "@opentelemetry/instrumentation" "^0.212.0" @@ -6201,255 +6201,260 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api-logs@0.213.0": - version "0.213.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.213.0.tgz#c7abc7d3c4586cfbfd737c0a2fcfb2323a9def75" - integrity sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw== +"@opentelemetry/api-logs@0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz#74a54ad7b166c6fa30a0df811954c0f5a435deee" + integrity sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA== dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0": +"@opentelemetry/api@1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== -"@opentelemetry/context-async-hooks@^2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz#6c824e900630b378233c1a78ca7f0dc5a3b460b2" - integrity sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q== +"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05" + integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q== -"@opentelemetry/core@2.6.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.6.0.tgz#719c829ed98bd7af808a2d2c83374df1fd1f3c66" - integrity sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg== +"@opentelemetry/context-async-hooks@^2.6.1": + version "2.6.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.1.tgz#06e60d5b3fba992a832af7f034758574e951bba3" + integrity sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ== + +"@opentelemetry/core@2.6.1", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.6.1": + version "2.6.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.6.1.tgz#a59d22a9ae3be80bb41b280bbbe1fe9fbdb6c2a5" + integrity sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g== dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/exporter-trace-otlp-http@^0.213.0": - version "0.213.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.213.0.tgz#7bba861a71787361b83a03746ed4bf5c18048775" - integrity sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA== +"@opentelemetry/exporter-trace-otlp-http@^0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.214.0.tgz#2a140d0bafa8690f29ed7f76bf27e3daa607da92" + integrity sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw== dependencies: - "@opentelemetry/core" "2.6.0" - "@opentelemetry/otlp-exporter-base" "0.213.0" - "@opentelemetry/otlp-transformer" "0.213.0" - "@opentelemetry/resources" "2.6.0" - "@opentelemetry/sdk-trace-base" "2.6.0" + "@opentelemetry/core" "2.6.1" + "@opentelemetry/otlp-exporter-base" "0.214.0" + "@opentelemetry/otlp-transformer" "0.214.0" + "@opentelemetry/resources" "2.6.1" + "@opentelemetry/sdk-trace-base" "2.6.1" -"@opentelemetry/instrumentation-amqplib@0.60.0": - version "0.60.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.60.0.tgz#a2b2abe3cf433bea166c18a703c8ddf6accf83da" - integrity sha512-q/B2IvoVXRm1M00MvhnzpMN6rKYOszPXVsALi6u0ss4AYHe+TidZEtLW9N1ZhrobI1dSriHnBqqtAOZVAv07sg== +"@opentelemetry/instrumentation-amqplib@0.61.0": + version "0.61.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.61.0.tgz#e9d52f56dfc4cb8a26837f31c1832af18859f1f2" + integrity sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" -"@opentelemetry/instrumentation-aws-sdk@0.68.0": - version "0.68.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.68.0.tgz#436353e94d32c7cdb5b6bb4ed28bdd16bd4f39a4" - integrity sha512-nHXSRX3iYSE9MaiPE+jIovuNA8dTmleeg0vdLHkk5nvWCYFf/I9kMdqA3KcfKCPonVc5+NtSTft6OVtuGtawIA== +"@opentelemetry/instrumentation-aws-sdk@0.69.0": + version "0.69.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.69.0.tgz#461de2337b1931c195f0d284760206a657bdee06" + integrity sha512-JfSp3anFL5Lx/ysQSa4MnKxvSsXSnYpgQ831Y+yNs5wJZcJC4tB+YpnKH+bU5oFdKEF59FpI6Gn5Wg2vjVpR2A== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.34.0" -"@opentelemetry/instrumentation-connect@0.56.0": - version "0.56.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.56.0.tgz#8d846d2f7cf1f6b2723e5b0ff5595e8d31cb7446" - integrity sha512-PKp+sSZ7AfzMvGgO3VCyo1inwNu+q7A1k9X88WK4PQ+S6Hp7eFk8pie+sWHDTaARovmqq5V2osav3lQej2B0nw== +"@opentelemetry/instrumentation-connect@0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.57.0.tgz#66b58af135ef6d52ad546cb440b808a149118296" + integrity sha512-FMEBChnI4FLN5TE9DHwfH7QpNir1JzXno1uz/TAucVdLCyrG0jTrKIcNHt/i30A0M2AunNBCkcd8Ei26dIPKdg== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.27.0" "@types/connect" "3.4.38" -"@opentelemetry/instrumentation-dataloader@0.30.0": - version "0.30.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.30.0.tgz#7fbea57b27165324092639abf090ca3697eb7a80" - integrity sha512-MXHP2Q38cd2OhzEBKAIXUi9uBlPEYzF6BNJbyjUXBQ6kLaf93kRC41vNMIz0Nl5mnuwK7fDvKT+/lpx7BXRwdg== +"@opentelemetry/instrumentation-dataloader@0.31.0": + version "0.31.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.31.0.tgz#43bfbe09f99e84eb0d8b6e9f914c2e51a45e6d95" + integrity sha512-f654tZFQXS5YeLDNb9KySrwtg7SnqZN119FauD7acBoTzuLduaiGTNz88ixcVSOOMGZ+EjJu/RFtx5klObC95g== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" -"@opentelemetry/instrumentation-express@0.61.0": - version "0.61.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.61.0.tgz#49b4d144ab6e9d6e035941a51f5e573e84e3647f" - integrity sha512-Xdmqo9RZuZlL29Flg8QdwrrX7eW1CZ7wFQPKHyXljNymgKhN1MCsYuqQ/7uxavhSKwAl7WxkTzKhnqpUApLMvQ== +"@opentelemetry/instrumentation-express@0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.62.0.tgz#c03e353caf04b7074004ce899faf759dec210b8d" + integrity sha512-Tvx+vgAZKEQxU3Rx+xWLiR0mLxHwmk69/8ya04+VsV9WYh8w6Lhx5hm5yAMvo1wy0KqWgFKBLwSeo3sHCwdOww== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-fs@0.32.0": - version "0.32.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.32.0.tgz#2010d86da8ab3d543f8e44c8fff81b94f904d91d" - integrity sha512-koR6apx0g0wX6RRiPpjA4AFQUQUbXrK16kq4/SZjVp7u5cffJhNkY4TnITxcGA4acGSPYAfx3NHRIv4Khn1axQ== +"@opentelemetry/instrumentation-fs@0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.33.0.tgz#75f2ccf653b772801b398cc2ad0974e8785f2e3d" + integrity sha512-sCZWXGalQ01wr3tAhSR9ucqFJ0phidpAle6/17HVjD6gN8FLmZMK/8sKxdXYHy3PbnlV1P4zeiSVFNKpbFMNLA== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" -"@opentelemetry/instrumentation-generic-pool@0.56.0": - version "0.56.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.56.0.tgz#01560f52d5bac6fb6312a1f0bc74bf0939119894" - integrity sha512-fg+Jffs6fqrf0uQS0hom7qBFKsbtpBiBl8+Vkc63Gx8xh6pVh+FhagmiO6oM0m3vyb683t1lP7yGYq22SiDnqg== +"@opentelemetry/instrumentation-generic-pool@0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.57.0.tgz#4220a2fc1974b40a989171a9b5f3d1eeab92683f" + integrity sha512-orhmlaK+ZIW9hKU+nHTbXrCSXZcH83AescTqmpamHRobRmYSQwRbD0a1odc0yAzuzOtxYiHiXAnpnIpaSSY7Ow== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" -"@opentelemetry/instrumentation-graphql@0.61.0": - version "0.61.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.61.0.tgz#d1f896095a891c9576967645e7fcba935da82a94" - integrity sha512-pUiVASv6nh2XrerTvlbVHh7vKFzscpgwiQ/xvnZuAIzQ5lRjWVdRPUuXbvZJ/Yq79QsE81TZdJ7z9YsXiss1ew== +"@opentelemetry/instrumentation-graphql@0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.62.0.tgz#dc2fc92c6be331c4f95b62a40983c8aedb8f9bf9" + integrity sha512-3YNuLVPUxafXkH1jBAbGsKNsP3XVzcFDhCDCE3OqBwCwShlqQbLMRMFh1T/d5jaVZiGVmSsfof+ICKD2iOV8xg== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" -"@opentelemetry/instrumentation-hapi@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.59.0.tgz#412ea19e97ead684c5737e1f1aaa19ff940512d3" - integrity sha512-33wa4mEr+9+ztwdgLor1SeBu4Opz4IsmpcLETXAd3VmBrOjez8uQtrsOhPCa5Vhbm5gzDlMYTgFRLQzf8/YHFA== +"@opentelemetry/instrumentation-hapi@0.60.0": + version "0.60.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.60.0.tgz#ad1ba65a32347351c310ac0f194fe66b8e9d9e7d" + integrity sha512-aNljZKYrEa7obLAxd1bCEDxF7kzCLGXTuTJZ8lMR9rIVEjmuKBXN1gfqpm/OB//Zc2zP4iIve1jBp7sr3mQV6w== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-http@0.213.0": - version "0.213.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.213.0.tgz#b379d6bcbae43a7d6d54070f3794527021f176c9" - integrity sha512-B978Xsm5XEPGhm1P07grDoaOFLHapJPkOG9h016cJsyWWxmiLnPu2M/4Nrm7UCkHSiLnkXgC+zVGUAIahy8EEA== +"@opentelemetry/instrumentation-http@0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.214.0.tgz#d4a31a638b798e191f4f556c257a4d3c97d65ba0" + integrity sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg== dependencies: - "@opentelemetry/core" "2.6.0" - "@opentelemetry/instrumentation" "0.213.0" + "@opentelemetry/core" "2.6.1" + "@opentelemetry/instrumentation" "0.214.0" "@opentelemetry/semantic-conventions" "^1.29.0" forwarded-parse "2.1.2" -"@opentelemetry/instrumentation-ioredis@0.61.0": - version "0.61.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.61.0.tgz#e862540cbf188d0ca368d3a75020d165cb8beefb" - integrity sha512-hsHDadUtAFbws1YSDc1XW0svGFKiUbqv2td1Cby+UAiwvojm1NyBo/taifH0t8CuFZ0x/2SDm0iuTwrM5pnVOg== +"@opentelemetry/instrumentation-ioredis@0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.62.0.tgz#4fd1775577132de5d92165caee6bbc0ae16a8c8a" + integrity sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/redis-common" "^0.38.2" "@opentelemetry/semantic-conventions" "^1.33.0" -"@opentelemetry/instrumentation-kafkajs@0.22.0": - version "0.22.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.22.0.tgz#a3cf7aca003f96211e514a348b7568799efdfba1" - integrity sha512-wJU4IBQMUikdJAcTChLFqK5lo+flo7pahqd8DSLv7uMxsdOdAHj6RzKYAm8pPfUS6ItKYutYyuicwKaFwQKsoA== +"@opentelemetry/instrumentation-kafkajs@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.23.0.tgz#6b7d449d88d674ddc295a0d0cf2156f0f7d5889f" + integrity sha512-4K+nVo+zI+aDz0Z85SObwbdixIbzS9moIuKJaYsdlzcHYnKOPtB7ya8r8Ezivy/GVIBHiKJVq4tv+BEkgOMLaQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.30.0" -"@opentelemetry/instrumentation-knex@0.57.0": - version "0.57.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.57.0.tgz#d46622a3f82f3df2ba29c64498d6ef828a40457e" - integrity sha512-vMCSh8kolEm5rRsc+FZeTZymWmIJwc40hjIKnXH4O0Dv/gAkJJIRXCsPX5cPbe0c0j/34+PsENd0HqKruwhVYw== +"@opentelemetry/instrumentation-knex@0.58.0": + version "0.58.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.58.0.tgz#48878fe40bc48834d6b4c4148433c84524a2558a" + integrity sha512-Hc/o8fSsaWxZ8r1Yw4rNDLwTpUopTf4X32y4W6UhlHmW8Wizz8wfhgOKIelSeqFVTKBBPIDUOsQWuIMxBmu8Bw== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.1" -"@opentelemetry/instrumentation-koa@0.61.0": - version "0.61.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.61.0.tgz#c12f57b023834afb1c142c11746d560bcc288b5b" - integrity sha512-lvrfWe9ShK/D2X4brmx8ZqqeWPfRl8xekU0FCn7C1dHm5k6+rTOOi36+4fnaHAP8lig9Ux6XQ1D4RNIpPCt1WQ== +"@opentelemetry/instrumentation-koa@0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.62.0.tgz#65fdf96c1b1ffb382167cd3b7a244631afd0cc1f" + integrity sha512-uVip0VuGUQXZ+vFxkKxAUNq8qNl+VFlyHDh/U6IQ8COOEDfbEchdaHnpFrMYF3psZRUuoSIgb7xOeXj00RdwDA== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.36.0" -"@opentelemetry/instrumentation-lru-memoizer@0.57.0": - version "0.57.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.57.0.tgz#4da92ecd1bc5d5e9c7de28ea14ed57c9f29cfefd" - integrity sha512-cEqpUocSKJfwDtLYTTJehRLWzkZ2eoePCxfVIgGkGkb83fMB71O+y4MvRHJPbeV2bdoWdOVrl8uO0+EynWhTEA== +"@opentelemetry/instrumentation-lru-memoizer@0.58.0": + version "0.58.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.58.0.tgz#7c730a0cb963e8ac5f3d11023518050e5f124a6a" + integrity sha512-6grM3TdMyHzlGY1cUA+mwoPueB1F3dYKgKtZIH6jOFXqfHAByyLTc+6PFjGM9tKh52CFBJaDwodNlL/Td39z7Q== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" -"@opentelemetry/instrumentation-mongodb@0.66.0": - version "0.66.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.66.0.tgz#990bf4571382d3b02a9584927411c92c375d2fd4" - integrity sha512-d7m9QnAY+4TCWI4q1QRkfrc6fo/92VwssaB1DzQfXNRvu51b78P+HJlWP7Qg6N6nkwdb9faMZNBCZJfftmszkw== +"@opentelemetry/instrumentation-mongodb@0.67.0": + version "0.67.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.67.0.tgz#ac45611586e363e2d96c735d50f97556dd33c37e" + integrity sha512-1WJp5N1lYfHq2IhECOTewFs5Tf2NfUOwQRqs/rZdXKTezArMlucxgzAaqcgp3A3YREXopXTpXHsxZTGHjNhMdQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" -"@opentelemetry/instrumentation-mongoose@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.59.0.tgz#8446ece86df59f09c630e7df6d794c8cd08f58d8" - integrity sha512-6/jWU+c1NgznkVLDU/2y0bXV2nJo3o9FWZ9mZ9nN6T/JBNRoMnVXZl2FdBmgH+a5MwaWLs5kmRJTP5oUVGIkPw== +"@opentelemetry/instrumentation-mongoose@0.60.0": + version "0.60.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.60.0.tgz#9481a90d3f75d66244d7f63709529cb7f2823103" + integrity sha512-8BahAZpKsOoc+lrZGb7Ofn4g3z8qtp5IxDfvAVpKXsEheQN7ONMH5djT5ihy6yf8yyeQJGS0gXFfpEAEeEHqQg== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" -"@opentelemetry/instrumentation-mysql2@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.59.0.tgz#938cd4a294b7e4a6e8c3855b8cfe267c8d2e5493" - integrity sha512-n9/xrVCRBfG9egVbffnlU1uhr+HX0vF4GgtAB/Bvm48wpFgRidqD8msBMiym1kRYzmpWvJqTxNT47u1MkgBEdw== +"@opentelemetry/instrumentation-mysql2@0.60.0": + version "0.60.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.60.0.tgz#10eddc3f933a80f11e334ae31c67e9d1156373ca" + integrity sha512-m/5d3bxQALllCzezYDk/6vajh0tj5OijMMvOZGr+qN1NMXm1dzMNwyJ0gNZW7Fo3YFRyj/jJMxIw+W7d525dlw== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" "@opentelemetry/sql-common" "^0.41.2" -"@opentelemetry/instrumentation-mysql@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.59.0.tgz#bf43cafbac5928236ea53704a52c718349c22e38" - integrity sha512-r+V/Fh0sm7Ga8/zk/TI5H5FQRAjwr0RrpfPf8kNIehlsKf12XnvIaZi8ViZkpX0gyPEpLXqzqWD6QHlgObgzZw== +"@opentelemetry/instrumentation-mysql@0.60.0": + version "0.60.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.60.0.tgz#e8e13b60f8d8fe8d0f4941f200ae3e4a4e5e4a3c" + integrity sha512-08pO8GFPEIz2zquKDGteBZDNmwketdgH8hTe9rVYgW9kCJXq1Psj3wPQGx+VaX4ZJKCfPeoLMYup9+cxHvZyVQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" "@types/mysql" "2.15.27" -"@opentelemetry/instrumentation-nestjs-core@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.59.0.tgz#858e7514e0842ceec1356cb0ba55cb3c60dbace6" - integrity sha512-tt2cFTENV8XB3D3xjhOz0q4hLc1eqkMZS5UyT9nnHF5FfYH94S2vAGdssvsMv+pFtA6/PmhPUZd4onUN1O7STg== +"@opentelemetry/instrumentation-nestjs-core@0.60.0": + version "0.60.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.60.0.tgz#60a34a8a3af7e3ab4cd7e46c783c99ff2430f2fb" + integrity sha512-BZqFAoD+frnwjpb0/T4kEEQMhl2YykZch4n2MMLKAVTzTehTBBV2hZxvFF629ipS+WOGBKjCjz1dycU9QNIckQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.30.0" -"@opentelemetry/instrumentation-pg@0.65.0": - version "0.65.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.65.0.tgz#f1f76f8c57c5c6fec68c77ce6ee104fee5de13e1" - integrity sha512-W0zpHEIEuyZ8zvb3njaX9AAbHgPYOsSWVOoWmv1sjVRSF6ZpBqtlxBWbU+6hhq1TFWBeWJOXZ8nZS/PUFpLJYQ== +"@opentelemetry/instrumentation-pg@0.66.0": + version "0.66.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.66.0.tgz#78d16b50dc4c5d851015823611a46243d63a88fb" + integrity sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.34.0" "@opentelemetry/sql-common" "^0.41.2" "@types/pg" "8.15.6" "@types/pg-pool" "2.0.7" -"@opentelemetry/instrumentation-redis@0.61.0": - version "0.61.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.61.0.tgz#b43b9c3b5d0b124f2e60b055e4529a3a4b55dbc4" - integrity sha512-JnPexA034/0UJRsvH96B0erQoNOqKJZjE2ZRSw9hiTSC23LzE0nJE/u6D+xqOhgUhRnhhcPHq4MdYtmUdYTF+Q== +"@opentelemetry/instrumentation-redis@0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.62.0.tgz#ecde90337fa49fec8d243bcbb8d470ce1a9ee7a1" + integrity sha512-y3pPpot7WzR/8JtHcYlTYsyY8g+pbFhAqbwAuG5bLPnR6v6pt1rQc0DpH0OlGP/9CZbWBP+Zhwp9yFoygf/ZXQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/redis-common" "^0.38.2" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-tedious@0.32.0": - version "0.32.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.32.0.tgz#8204a14adb71adcbf7d72705244d606bb69e428a" - integrity sha512-BQS6gG8RJ1foEqfEZ+wxoqlwfCAzb1ZVG0ad8Gfe4x8T658HJCLGLd4E4NaoQd8EvPfLqOXgzGaE/2U4ytDSWA== +"@opentelemetry/instrumentation-tedious@0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.33.0.tgz#00f6698f8afae1b350bf0c463a59eeae3c8d25d7" + integrity sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" "@types/tedious" "^4.0.14" -"@opentelemetry/instrumentation-undici@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.23.0.tgz#e328bf6e53847ba7baa2a345d02221cc62917cec" - integrity sha512-LL0VySzKVR2cJSFVZaTYpZl1XTpBGnfzoQPe2W7McS2267ldsaEIqtQY6VXs2KCXN0poFjze5110PIpxHDaDGg== +"@opentelemetry/instrumentation-undici@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.24.0.tgz#6ad41245012742899294edf65aa79fd190369094" + integrity sha512-oKzZ3uvqP17sV0EsoQcJgjEfIp0kiZRbYu/eD8p13Cbahumf8lb/xpYeNr/hfAJ4owzEtIDcGIjprfLcYbIKBQ== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.24.0" -"@opentelemetry/instrumentation@0.213.0", "@opentelemetry/instrumentation@^0.213.0": - version "0.213.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.213.0.tgz#55362569efd0cba00aab9921a78dd20dfddf70b6" - integrity sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w== +"@opentelemetry/instrumentation@0.214.0", "@opentelemetry/instrumentation@^0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz#2649e8a29a8c4748bc583d35281c80632f046e25" + integrity sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w== dependencies: - "@opentelemetry/api-logs" "0.213.0" + "@opentelemetry/api-logs" "0.214.0" import-in-the-middle "^3.0.0" require-in-the-middle "^8.0.0" @@ -6471,25 +6476,25 @@ import-in-the-middle "^2.0.6" require-in-the-middle "^8.0.0" -"@opentelemetry/otlp-exporter-base@0.213.0": - version "0.213.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.213.0.tgz#e9a7c1dfaecc2573b9c5fbcd7ccc0086513c1350" - integrity sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg== - dependencies: - "@opentelemetry/core" "2.6.0" - "@opentelemetry/otlp-transformer" "0.213.0" - -"@opentelemetry/otlp-transformer@0.213.0": - version "0.213.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz#e830244d21817805b8967963ffc4651b8f5c96ee" - integrity sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw== - dependencies: - "@opentelemetry/api-logs" "0.213.0" - "@opentelemetry/core" "2.6.0" - "@opentelemetry/resources" "2.6.0" - "@opentelemetry/sdk-logs" "0.213.0" - "@opentelemetry/sdk-metrics" "2.6.0" - "@opentelemetry/sdk-trace-base" "2.6.0" +"@opentelemetry/otlp-exporter-base@0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.214.0.tgz#97d63666d56e92391e6a9840959ff68c5c5a90f6" + integrity sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg== + dependencies: + "@opentelemetry/core" "2.6.1" + "@opentelemetry/otlp-transformer" "0.214.0" + +"@opentelemetry/otlp-transformer@0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz#c3dca1101364cb819090356f51979f503e6c5330" + integrity sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w== + dependencies: + "@opentelemetry/api-logs" "0.214.0" + "@opentelemetry/core" "2.6.1" + "@opentelemetry/resources" "2.6.1" + "@opentelemetry/sdk-logs" "0.214.0" + "@opentelemetry/sdk-metrics" "2.6.1" + "@opentelemetry/sdk-trace-base" "2.6.1" protobufjs "^7.0.0" "@opentelemetry/redis-common@^0.38.2": @@ -6497,39 +6502,39 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz#cefa4f3e79db1cd54f19e233b7dfb56621143955" integrity sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA== -"@opentelemetry/resources@2.6.0", "@opentelemetry/resources@^2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.6.0.tgz#1a945dbb8986043d8b593c358d5d8e3de6becf5a" - integrity sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ== +"@opentelemetry/resources@2.6.1", "@opentelemetry/resources@^2.6.1": + version "2.6.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.6.1.tgz#e1b02772c5f65c0e074d59e4743188f7575e97c7" + integrity sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA== dependencies: - "@opentelemetry/core" "2.6.0" + "@opentelemetry/core" "2.6.1" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-logs@0.213.0": - version "0.213.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz#babf51dfd3e2bc882a41a0de2a13a2077d6df764" - integrity sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g== +"@opentelemetry/sdk-logs@0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz#3d887ef93d8d65f1230a68900209b8a9e8e03c76" + integrity sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA== dependencies: - "@opentelemetry/api-logs" "0.213.0" - "@opentelemetry/core" "2.6.0" - "@opentelemetry/resources" "2.6.0" + "@opentelemetry/api-logs" "0.214.0" + "@opentelemetry/core" "2.6.1" + "@opentelemetry/resources" "2.6.1" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-metrics@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz#c9f63eb68a5c7600a4ffc84bdce3ef59c9b1af47" - integrity sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw== +"@opentelemetry/sdk-metrics@2.6.1": + version "2.6.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz#9cdc9e636ec31399f228f23d9663beda5e63ee56" + integrity sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ== dependencies: - "@opentelemetry/core" "2.6.0" - "@opentelemetry/resources" "2.6.0" + "@opentelemetry/core" "2.6.1" + "@opentelemetry/resources" "2.6.1" -"@opentelemetry/sdk-trace-base@2.6.0", "@opentelemetry/sdk-trace-base@^2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz#d7e752a0906f2bcae3c1261e224aef3e3b3746f9" - integrity sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ== +"@opentelemetry/sdk-trace-base@2.6.1", "@opentelemetry/sdk-trace-base@^2.6.1": + version "2.6.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz#ed353062be4c28a0649247ad369654020c29bfce" + integrity sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw== dependencies: - "@opentelemetry/core" "2.6.0" - "@opentelemetry/resources" "2.6.0" + "@opentelemetry/core" "2.6.1" + "@opentelemetry/resources" "2.6.1" "@opentelemetry/semantic-conventions" "^1.29.0" "@opentelemetry/semantic-conventions@^1.24.0", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0", "@opentelemetry/semantic-conventions@^1.36.0", "@opentelemetry/semantic-conventions@^1.40.0": @@ -7068,10 +7073,10 @@ dependencies: "@prisma/debug" "6.15.0" -"@prisma/instrumentation@7.4.2": - version "7.4.2" - resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-7.4.2.tgz#b05e814d0647343febd26a8ccb039d27ccc69eca" - integrity sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg== +"@prisma/instrumentation@7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-7.6.0.tgz#22a4ea3e9d8cdc57cbaa0e26ccf10cb8db854549" + integrity sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ== dependencies: "@opentelemetry/instrumentation" "^0.207.0" From 3d4e38d1829a874d0c4887c0b84bbad992fc0e05 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 31 Mar 2026 10:58:22 +0200 Subject: [PATCH 39/39] meta(changelog): Update changelog for 10.47.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9359fc67721..25792ba2ce11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott + +## 10.47.0 + ### Important Changes - **feat(node-core): Add OTLP integration for node-core/light ([#19729](https://github.com/getsentry/sentry-javascript/pull/19729))** @@ -53,11 +57,68 @@ }); ``` -- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -- feat(core): Support embedding APIs in google-genai ([#19797](https://github.com/getsentry/sentry-javascript/pull/19797)) +- **feat(core): Support embedding APIs in google-genai ([#19797](https://github.com/getsentry/sentry-javascript/pull/19797))** Adds instrumentation for the Google GenAI [`embedContent`](https://ai.google.dev/gemini-api/docs/embeddings) API, creating `gen_ai.embeddings` spans. +- **feat(browser): Add `elementTimingIntegration` for tracking element render and load times ([#19869](https://github.com/getsentry/sentry-javascript/pull/19869))** + + The new `elementTimingIntegration` captures Element Timing API data as Sentry metrics. It emits `element_timing.render_time` and `element_timing.load_time` distribution metrics for elements annotated with the `elementtiming` HTML attribute. + + ```ts + import * as Sentry from '@sentry/browser'; + + Sentry.init({ + dsn: '__DSN__', + integrations: [Sentry.browserTracingIntegration(), Sentry.elementTimingIntegration()], + }); + ``` + + ```html + + ``` + +### Other Changes + +- feat(nuxt): Add middleware instrumentation compatibility for Nuxt 5 ([#19968](https://github.com/getsentry/sentry-javascript/pull/19968)) +- feat(nuxt): Support parametrized SSR routes in Nuxt 5 ([#19977](https://github.com/getsentry/sentry-javascript/pull/19977)) +- feat(solid): Add route parametrization for Solid Router ([#20031](https://github.com/getsentry/sentry-javascript/pull/20031)) +- fix(core): Guard nullish response in supabase PostgREST handler ([#20033](https://github.com/getsentry/sentry-javascript/pull/20033)) +- fix(node): Deduplicate `sentry-trace` and `baggage` headers on outgoing requests ([#19960](https://github.com/getsentry/sentry-javascript/pull/19960)) +- fix(node): Ensure startNewTrace propagates traceId in OTel environments ([#19963](https://github.com/getsentry/sentry-javascript/pull/19963)) +- fix(nuxt): Use virtual module for Nuxt pages data (SSR route parametrization) ([#20020](https://github.com/getsentry/sentry-javascript/pull/20020)) +- fix(opentelemetry): Convert seconds timestamps in span.end() to milliseconds ([#19958](https://github.com/getsentry/sentry-javascript/pull/19958)) +- fix(profiling): Disable profiling in worker threads ([#20040](https://github.com/getsentry/sentry-javascript/pull/20040)) +- fix(react-router): Disable debug ID injection in Vite plugin to prevent double injection ([#19890](https://github.com/getsentry/sentry-javascript/pull/19890)) +- refactor(browser): Reduce browser package bundle size ([#19856](https://github.com/getsentry/sentry-javascript/pull/19856)) +- feat(deps): Bump OpenTelemetry dependencies ([#20046](https://github.com/getsentry/sentry-javascript/pull/20046)) + +
+ Internal Changes + +- chore: Add shared validate-pr composite action ([#20025](https://github.com/getsentry/sentry-javascript/pull/20025)) +- chore: Update validate-pr action to latest version ([#20027](https://github.com/getsentry/sentry-javascript/pull/20027)) +- chore(deps): Bump @apollo/server from 5.4.0 to 5.5.0 ([#20007](https://github.com/getsentry/sentry-javascript/pull/20007)) +- chore(deps): Bump amqplib from 0.10.7 to 0.10.9 ([#20000](https://github.com/getsentry/sentry-javascript/pull/20000)) +- chore(deps): Bump srvx from 0.11.12 to 0.11.13 ([#20001](https://github.com/getsentry/sentry-javascript/pull/20001)) +- chore(deps-dev): Bump node-forge from 1.3.2 to 1.4.0 ([#20012](https://github.com/getsentry/sentry-javascript/pull/20012)) +- chore(deps-dev): Bump yaml from 2.8.2 to 2.8.3 ([#19985](https://github.com/getsentry/sentry-javascript/pull/19985)) +- ci(deps): Bump actions/upload-artifact from 6 to 7 ([#19569](https://github.com/getsentry/sentry-javascript/pull/19569)) +- docs(release): Update publishing-a-release.md ([#19982](https://github.com/getsentry/sentry-javascript/pull/19982)) +- feat(deps): Bump babel-loader from 10.0.0 to 10.1.1 ([#19997](https://github.com/getsentry/sentry-javascript/pull/19997)) +- feat(deps): Bump handlebars from 4.7.7 to 4.7.9 ([#20008](https://github.com/getsentry/sentry-javascript/pull/20008)) +- fix(browser-tests): Pin axios to 1.13.5 to avoid compromised 1.14.1 ([#20047](https://github.com/getsentry/sentry-javascript/pull/20047)) +- fix(ci): Update validate-pr action to remove draft enforcement ([#20035](https://github.com/getsentry/sentry-javascript/pull/20035)) +- fix(ci): Update validate-pr action to remove draft enforcement ([#20037](https://github.com/getsentry/sentry-javascript/pull/20037)) +- fix(e2e): Pin @opentelemetry/api to 1.9.0 in ts3.8 test app ([#19992](https://github.com/getsentry/sentry-javascript/pull/19992)) +- ref(browser-tests): Add waitForMetricRequest helper ([#20002](https://github.com/getsentry/sentry-javascript/pull/20002)) +- ref(core): Consolidate getOperationName into one shared utility ([#19971](https://github.com/getsentry/sentry-javascript/pull/19971)) +- ref(core): Introduce instrumented method registry for AI integrations ([#19981](https://github.com/getsentry/sentry-javascript/pull/19981)) +- test(deno): Expand Deno E2E test coverage ([#19957](https://github.com/getsentry/sentry-javascript/pull/19957)) +- test(e2e): Add e2e tests for `nodeRuntimeMetricsIntegration` ([#19989](https://github.com/getsentry/sentry-javascript/pull/19989)) + +
+ ## 10.46.0 ### Important Changes