From ef5596b9f1c3e3596f9cbc6e3fe6691db3424045 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:02:21 +0200 Subject: [PATCH 001/126] test(replay): Fix flaky EventBufferCompressionWorker size limit test (#25519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `size limit` tests in `EventBufferCompressionWorker.test.ts` build events sized against the real `REPLAY_MAX_EVENT_BUFFER_SIZE` (~20MB). That pushes 13–20MB of JSON through `jsdom-worker` and fflate on every test, in the same process. The tests don't hang. They're just CPU-bound, taking 6–11s on a busy CI runner, so they sometimes go over the 10s timeout. "resets size limit on clear" failed twice on `develop` this way. Under CPU load locally I could reproduce the same `Test timed out in 10000ms`. The size check is a plain counter in `EventBufferCompressionWorker`, so the actual byte count doesn't matter. This mocks `REPLAY_MAX_EVENT_BUFFER_SIZE` to 100kB for this file, the same approach `test/integration/eventBuffer.test.ts` already uses. The custom 10s timeout is gone because it's no longer needed. The tests now run in ~15ms each, and they still fail if `clear()` stops resetting the size counter. via **Francesco Gringl-Novy**. -- [View Junior Session](https://junior-prod.sentry.dev/conversations/agent%3A22b3952a4f7fd1693cd7e6065ac6f2c3) [[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A22b3952a4f7fd1693cd7e6065ac6f2c3/?project=4510944073809921) Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Francesco Gringl-Novy --- .../unit/eventBuffer/EventBufferCompressionWorker.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/replay-internal/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts b/packages/replay-internal/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts index 1cfcd1cc83b1..826542408d46 100644 --- a/packages/replay-internal/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts +++ b/packages/replay-internal/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts @@ -12,6 +12,11 @@ import { BASE_TIMESTAMP } from '../..'; import { decompress } from '../../utils/compression'; import { getTestEventIncremental } from '../../utils/getTestEvent'; +vi.mock('../../../src/constants', async importOriginal => ({ + ...(await importOriginal()), + REPLAY_MAX_EVENT_BUFFER_SIZE: 100_000, +})); + const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP }); describe('Unit | eventBuffer | EventBufferCompressionWorker', () => { @@ -152,7 +157,7 @@ describe('Unit | eventBuffer | EventBufferCompressionWorker', () => { await expect(() => buffer.addEvent({ data: { o: 3 }, timestamp: BASE_TIMESTAMP, type: 3 })).rejects.toBeDefined(); }); - describe('size limit', { timeout: 10_000 }, () => { + describe('size limit', () => { it('rejects if size exceeds limit', async function () { const buffer = createEventBuffer({ useCompression: true, From 80427197be3423a21b8a0a10f41385974cb88281 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:51:30 +0200 Subject: [PATCH 002/126] test(browser): Fix flaky long task, pageload cancellation and CLS timing assertions (#25518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These browser integration tests showed up in last week's flaky test report. Each one failed on `develop` because of a timing assertion that was too strict for loaded CI runners. - **Long task / long animation frame:** the test script blocks for 105ms, but the 0.15s upper bound failed with real durations of 0.15–0.37s. A long animation frame covers the whole frame, and a long task can be stretched by CPU contention. The upper bound is now 1s, which still catches unit or offset bugs. Separately, the long-task tests used the *first* `ui.long_task` span. Sometimes that was an unrelated pageload task (~50–65ms), so the test failed the 0.1s lower bound. They now use the longest span, which is the blocking script. - **`reportPageLoaded` navigation:** the pageload span started at time origin, but init's 1s `setTimeout` can run late (1.59s seen in CI). The bound is now 2.5s, the point where `reportPageLoaded()` would have ended the span. The finish reason is still asserted to be `cancelled`. - **CLS 0.35:** the ±0.01 window was much tighter than the other cases (0.3378 seen in CI). It's now ±0.03. via **Francesco Gringl-Novy**. -- [View Junior Session](https://junior-prod.sentry.dev/conversations/slack%3AC0C22CSDV6X%3A1792407289.765319) [[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AC0C22CSDV6X%3A1792407289.765319/?project=4510944073809921) Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Francesco Gringl-Novy --- .../long-animation-frame-enabled-static/test.ts | 4 ++-- .../long-animation-frame-enabled/test.ts | 4 ++-- .../test.ts | 4 ++-- .../long-tasks-and-animation-frame-enabled/test.ts | 4 ++-- .../long-tasks-enabled-static/test.ts | 13 +++++++------ .../long-tasks-enabled/test.ts | 13 +++++++------ .../long-tasks-no-animation-frame/test.ts | 13 +++++++------ .../reportPageLoaded-static/navigation/test.ts | 5 +++-- .../reportPageLoaded/navigation/test.ts | 5 +++-- .../suites/tracing/metrics/web-vitals-cls/test.ts | 2 +- 10 files changed, 36 insertions(+), 31 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-static/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-static/test.ts index 0a3f97f6a786..4bab75a82e99 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-static/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-static/test.ts @@ -59,7 +59,7 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); @@ -111,6 +111,6 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/test.ts index 8a1cc8026aaa..181c5457838b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/test.ts @@ -64,7 +64,7 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); @@ -114,5 +114,5 @@ sentryTest('captures long animation frame span for event listener.', async ({ br const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-static/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-static/test.ts index fb252c80fa30..5881bf8b3ba0 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-static/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-static/test.ts @@ -61,7 +61,7 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); @@ -113,6 +113,6 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/test.ts index 192956b477d9..6a2638a61c45 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/test.ts @@ -66,7 +66,7 @@ sentryTest( const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }, ); @@ -116,5 +116,5 @@ sentryTest('captures long animation frame span for event listener.', async ({ br const duration = end - start; expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-static/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-static/test.ts index e34b0d779f7e..5ca64416fd71 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-static/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-static/test.ts @@ -19,18 +19,19 @@ sentryTest('should capture long task.', async ({ browserName, getLocalTestUrl, p expect(uiSpans?.length).toBeGreaterThan(0); - const [firstUISpan] = uiSpans || []; - expect(firstUISpan).toEqual( + const getDuration = (span: { start_timestamp?: number; timestamp?: number }): number => + (span.timestamp ?? 0) - (span.start_timestamp ?? 0); + // Other long tasks (e.g. SDK evaluation) can happen during pageload, so look at the longest one, which is the blocking script + const [longTaskSpan] = [...(uiSpans || [])].sort((a, b) => getDuration(b) - getDuration(a)); + expect(longTaskSpan).toEqual( expect.objectContaining({ op: 'ui.long_task', description: 'Main UI thread blocked', parent_span_id: eventData.contexts?.trace?.span_id, }), ); - const start = firstUISpan.start_timestamp ?? 0; - const end = firstUISpan.timestamp ?? 0; - const duration = end - start; + const duration = getDuration(longTaskSpan); expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/test.ts index add1a8130425..9dc2b8d249cf 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/test.ts @@ -24,8 +24,11 @@ sentryTest('captures long task.', async ({ browserName, getLocalTestUrl, page }) const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui')); expect(uiSpans.length).toBeGreaterThan(0); - const [firstUISpan] = uiSpans; - expect(firstUISpan).toEqual( + const getDuration = (span: { start_timestamp?: number; end_timestamp?: number }): number => + (span.end_timestamp ?? 0) - (span.start_timestamp ?? 0); + // Other long tasks (e.g. SDK evaluation) can happen during pageload, so look at the longest one, which is the blocking script + const [longTaskSpan] = [...uiSpans].sort((a, b) => getDuration(b) - getDuration(a)); + expect(longTaskSpan).toEqual( expect.objectContaining({ name: 'Main UI thread blocked', parent_span_id: pageloadSpan.span_id, @@ -35,10 +38,8 @@ sentryTest('captures long task.', async ({ browserName, getLocalTestUrl, page }) }), ); - const start = firstUISpan.start_timestamp ?? 0; - const end = firstUISpan.end_timestamp ?? 0; - const duration = end - start; + const duration = getDuration(longTaskSpan); expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/test.ts index 707a336d3df1..9ca47dc01e4b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/test.ts @@ -26,18 +26,19 @@ sentryTest('should capture long task.', async ({ browserName, getLocalTestUrl, p expect(uiSpans.length).toBeGreaterThan(0); - const [firstUISpan] = uiSpans; - expect(firstUISpan).toEqual( + const getDuration = (span: { start_timestamp?: number; end_timestamp?: number }): number => + (span.end_timestamp ?? 0) - (span.start_timestamp ?? 0); + // Other long tasks (e.g. SDK evaluation) can happen during pageload, so look at the longest one, which is the blocking script + const [longTaskSpan] = [...uiSpans].sort((a, b) => getDuration(b) - getDuration(a)); + expect(longTaskSpan).toEqual( expect.objectContaining({ attributes: expect.objectContaining({ [SENTRY_OP]: { type: 'string', value: 'ui.long_task' } }), name: 'Main UI thread blocked', parent_span_id: pageloadSpan.span_id, }), ); - const start = firstUISpan.start_timestamp ?? 0; - const end = firstUISpan.end_timestamp ?? 0; - const duration = end - start; + const duration = getDuration(longTaskSpan); expect(duration).toBeGreaterThanOrEqual(0.1); - expect(duration).toBeLessThanOrEqual(0.15); + expect(duration).toBeLessThan(1); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-static/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-static/navigation/test.ts index 376c35d25a7f..1a991d751c83 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-static/navigation/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-static/navigation/test.ts @@ -35,7 +35,8 @@ sentryTest( [SENTRY_IDLE_SPAN_FINISH_REASON]: 'cancelled', }); - // ending span after 1s but adding a margin of 0.5s to account for timing weirdness in CI to avoid flakes - expect(spanDurationSeconds).toBeLessThan(1.5); + // The navigation starts 1s after init, but init itself can run well after the time origin on slow CI machines. + // Calling `reportPageLoaded()` would only end the span after 2.5s. + expect(spanDurationSeconds).toBeLessThan(2.5); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts index b67276f0bcd0..308a33bfa6fb 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts @@ -33,7 +33,8 @@ sentryTest( [SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'cancelled' }, }); - // ending span after 1s but adding a margin of 0.5s to account for timing weirdness in CI to avoid flakes - expect(spanDurationSeconds).toBeLessThan(1.5); + // The navigation starts 1s after init, but init itself can run well after the time origin on slow CI machines. + // Calling `reportPageLoaded()` would only end the span after 2.5s. + expect(spanDurationSeconds).toBeLessThan(2.5); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts index 22e494e73447..a42e7a6542e4 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts @@ -71,7 +71,7 @@ sentryTest('captures CLS as a streamed span with source attributes', async ({ ge [ { cls: 0.05, min: 0.03, max: 0.07, source: 'body > div#content > p#partial' }, { cls: 0.21, min: 0.18, max: 0.23, source: 'body > div#content > p' }, - { cls: 0.35, min: 0.34, max: 0.36, source: 'body > div#content > p' }, + { cls: 0.35, min: 0.32, max: 0.38, source: 'body > div#content > p' }, ].forEach(({ cls, min, max, source }) => { sentryTest(`captures CLS ${cls} with its source`, async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); From c88b69f9950d5dca1450c995f14c2efe5d4bd828 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:36:06 +0200 Subject: [PATCH 003/126] feat(server-utils): Record Workers AI Clairic calls as gen_ai.extract spans (#25624) ## What Workers AI calls to Cloudflare Clairic (`@cf/cloudflare/clairic`) now create `extract ` spans with op `gen_ai.extract`, instead of `chat` spans without messages. - Records the request and response bodies as input and output messages, with images replaced by `[Blob substitute]` - Records the response model and token usage - Adds unit tests and a Cloudflare integration test ## Why Clairic is a typed field extraction model, and the new extraction span spec (getsentry/sentry-conventions#728) covers it. The op is a local constant until the conventions release. Ref #25615 --------- Co-authored-by: Claude Opus 5.5 --- .../suites/tracing/workers-ai/index.ts | 12 ++++ .../suites/tracing/workers-ai/mocks.ts | 12 ++++ .../suites/tracing/workers-ai/test.ts | 59 ++++++++++++++++++- .../src/ai/core/gen-ai-attributes.ts | 6 ++ .../server-utils/src/ai/workers-ai/utils.ts | 57 +++++++++++++++++- .../ai/lib/utils/workers-ai-utils.test.ts | 57 ++++++++++++++++++ 6 files changed, 200 insertions(+), 3 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts index 5f793c2c2e54..b4769bda5b9f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts @@ -51,6 +51,18 @@ export default Sentry.withSentry( return new Response(JSON.stringify(result)); } + if (url.pathname === '/extract-clairic') { + const result = await ai.run('@cf/cloudflare/clairic', { + model: 'clairic', + state: 'Invoice INV-2291 from Northwind Supply. Total due: $1,080.00.', + fields: { + invoice_number: { type: 'text' }, + total: { type: 'number', decimals: 2, unit: 'USD', instructions: 'Amount due' }, + }, + }); + return new Response(JSON.stringify(result)); + } + if (url.pathname === '/stream') { const stream = (await ai.run('@cf/meta/llama-3.1-8b-instruct', { messages: [{ role: 'user', content: 'What is the capital of France?' }], diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts index 0d7e599cca52..0b69b24a63ed 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts @@ -43,6 +43,18 @@ export class MockAi { }; } + if (model === '@cf/cloudflare/clairic') { + return { + model: 'clairic', + answers: { + invoice_number: { type: 'text', span: [8, 16], confidence: 1, value: 'INV-2291' }, + total: { type: 'number', confidence: 0.99, value: 1080 }, + }, + truncated: false, + usage: { state_tokens: 30, schema_tokens: 20, input_tokens: 50, output_tokens: 4, passes: 1 }, + }; + } + if (inputs?.stream === true) { return createSseStream([ '{"response":"The capital "}', diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts index aca3281b19c2..53ac07e0c875 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts @@ -24,7 +24,10 @@ import { import { GEN_AI_DECIDE } from '@sentry/conventions/op'; import { SDK_VERSION } from '@sentry/core'; import { expect, it } from 'vitest'; -import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; +import { + GEN_AI_EXTRACT_OP, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, +} from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -230,6 +233,60 @@ it('traces a Clef evaluation like a TypeSafe Jev evaluation', async ({ signal }) await runner.completed(); }); +it('traces a Clairic extraction', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); + + expect(genAiSpans[0]).toEqual( + expect.objectContaining({ + name: 'extract @cf/cloudflare/clairic', + status: 'ok', + is_segment: false, + attributes: { + [SENTRY_ORIGIN]: { value: 'auto.ai.cloudflare.workers_ai', type: 'string' }, + [SENTRY_OP]: { value: GEN_AI_EXTRACT_OP, type: 'string' }, + [GEN_AI_PROVIDER_NAME]: { value: 'cloudflare.workers_ai', type: 'string' }, + [GEN_AI_OPERATION_NAME]: { value: 'extract', type: 'string' }, + [GEN_AI_REQUEST_MODEL]: { value: '@cf/cloudflare/clairic', type: 'string' }, + [GEN_AI_RESPONSE_MODEL]: { value: 'clairic', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS]: { value: 50, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 4, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 54, type: 'integer' }, + // collect only output messages + [GEN_AI_OUTPUT_MESSAGES]: { + type: 'string', + value: JSON.stringify([ + { + answers: { + invoice_number: { type: 'text', span: [8, 16], confidence: 1, value: 'INV-2291' }, + total: { type: 'number', confidence: 0.99, value: 1080 }, + }, + truncated: false, + }, + ]), + }, + [SENTRY_IS_LOCALHOST]: { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + }, + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/extract-clairic'); + await runner.completed(); +}); + // The Workers AI integration deliberately does not call `captureException` itself. // When a `run` call fails, the error must bubble up out of the fetch handler and be // reported by the top-level Cloudflare instrumentation instead — so it shows up in diff --git a/packages/server-utils/src/ai/core/gen-ai-attributes.ts b/packages/server-utils/src/ai/core/gen-ai-attributes.ts index 6b577af8eb4d..a5b99078a6a2 100644 --- a/packages/server-utils/src/ai/core/gen-ai-attributes.ts +++ b/packages/server-utils/src/ai/core/gen-ai-attributes.ts @@ -34,3 +34,9 @@ export const GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE = 'gen_ai.response.stop_reaso * The tool call ID */ export const GEN_AI_TOOL_CALL_ID_ATTRIBUTE = 'gen_ai.tool.call.id'; + +/** + * The span op for extraction of typed fields from a state (e.g. Cloudflare Clairic) + * TODO: Import from `@sentry/conventions/op` once getsentry/sentry-conventions#728 is released + */ +export const GEN_AI_EXTRACT_OP = 'gen_ai.extract'; diff --git a/packages/server-utils/src/ai/workers-ai/utils.ts b/packages/server-utils/src/ai/workers-ai/utils.ts index 29db4d2cd136..d01a40ef3f53 100644 --- a/packages/server-utils/src/ai/workers-ai/utils.ts +++ b/packages/server-utils/src/ai/workers-ai/utils.ts @@ -3,6 +3,7 @@ import { GEN_AI_EMBEDDINGS_INPUT, GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_FREQUENCY_PENALTY, GEN_AI_REQUEST_MAX_TOKENS, @@ -11,6 +12,7 @@ import { GEN_AI_REQUEST_TEMPERATURE, GEN_AI_REQUEST_TOP_K, GEN_AI_REQUEST_TOP_P, + GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_TEXT, GEN_AI_RESPONSE_TOOL_CALLS, GEN_AI_SYSTEM_INSTRUCTIONS, @@ -19,7 +21,7 @@ import { import { GEN_AI_CHAT, GEN_AI_EMBEDDINGS, GEN_AI_DECIDE } from '@sentry/conventions/op'; import { isObjectLike, stringify } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; -import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; +import { GEN_AI_EXTRACT_OP, GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; import { extractSystemInstructions, getEvaluationInputMessages, @@ -32,12 +34,13 @@ export { setOutputMessagesAttribute }; import { WORKERS_AI_ORIGIN, WORKERS_AI_PROVIDER_NAME } from './constants'; import type { WorkersAiInput, WorkersAiOutput } from './types'; -export type WorkersAiOperationName = 'chat' | 'embeddings' | 'decide'; +export type WorkersAiOperationName = 'chat' | 'embeddings' | 'decide' | 'extract'; export const WORKERS_AI_OPERATION_SPAN_OPS: Record = { chat: GEN_AI_CHAT, embeddings: GEN_AI_EMBEDDINGS, decide: GEN_AI_DECIDE, + extract: GEN_AI_EXTRACT_OP, }; /** @@ -45,6 +48,13 @@ export const WORKERS_AI_OPERATION_SPAN_OPS: Record model.startsWith(prefix))) { return 'decide'; } + if (typeof model === 'string' && EXTRACT_MODEL_PREFIXES.some(prefix => model.startsWith(prefix))) { + return 'extract'; + } if (inputs && typeof inputs === 'object') { if ('messages' in inputs || 'prompt' in inputs) { return 'chat'; @@ -123,6 +136,16 @@ export function addRequestAttributes(span: Span, inputs: unknown, operationName: return; } + if (operationName === 'extract') { + // Record the request body as passed, but never the image data. + const { model: _model, ...request } = inputs as Record; + if (Array.isArray(request.images)) { + request.images = request.images.map(() => BLOB_SUBSTITUTE); + } + span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify([request])); + return; + } + // Store embeddings input on a separate attribute if (operationName === 'embeddings') { const text = params.text; @@ -166,6 +189,11 @@ export function addResponseAttributes( return; } + if (operationName === 'extract') { + addExtractResponseAttributes(span, result, recordOutputs); + return; + } + if ( !result || typeof result !== 'object' || @@ -200,3 +228,28 @@ export function addResponseAttributes( setOutputMessagesAttribute(span, { responseText, toolCalls }); } } + +function addExtractResponseAttributes(span: Span, body: unknown, recordOutputs: boolean): void { + if (!isObjectLike(body)) { + return; + } + + const { model, usage, ...response } = body; + + if (typeof model === 'string') { + span.setAttribute(GEN_AI_RESPONSE_MODEL, model); + } + + if (isObjectLike(usage)) { + span.setAttributes( + getTokenUsageAttributes( + typeof usage.input_tokens === 'number' ? usage.input_tokens : undefined, + typeof usage.output_tokens === 'number' ? usage.output_tokens : undefined, + ), + ); + } + + if (recordOutputs) { + span.setAttribute(GEN_AI_OUTPUT_MESSAGES, stringify([response])); + } +} diff --git a/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts b/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts index 85d690e01421..fab753399663 100644 --- a/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts +++ b/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts @@ -69,6 +69,10 @@ describe('workers-ai utils', () => { expect(getOperationName('@cf/cloudflare/clef', { state: 'Help!', questions: {} })).toBe('decide'); }); + it('returns "extract" for Cloudflare Clairic models', () => { + expect(getOperationName('@cf/cloudflare/clairic', { state: 'Invoice INV-1', fields: {} })).toBe('extract'); + }); + it('does not return "decide" for other TypeSafe models', () => { expect(getOperationName('typesafe/other', { prompt: 'Hello' })).toBe('chat'); }); @@ -141,6 +145,34 @@ describe('workers-ai utils', () => { }); describe('addRequestAttributes', () => { + it('records extract requests without the model and image data', () => { + const { span, attributes } = createMockSpan(); + const fields = { total: { type: 'number', unit: 'USD' } }; + + addRequestAttributes( + span, + { + model: 'clairic', + state: 'Total: $10', + fields, + images: ['data:image/png;base64,AAAA', 'data:image/png;base64,BBBB'], + reference_date: '2026-10-21', + }, + 'extract', + ); + + expect(attributes).toEqual({ + [GEN_AI_INPUT_MESSAGES]: JSON.stringify([ + { + state: 'Total: $10', + fields, + images: ['[Blob substitute]', '[Blob substitute]'], + reference_date: '2026-10-21', + }, + ]), + }); + }); + it('records messages and extracts system instructions', () => { const { span, attributes } = createMockSpan(); @@ -226,6 +258,31 @@ describe('workers-ai utils', () => { }); }); + it('records extract responses without the model and usage', () => { + const { span, attributes } = createMockSpan(); + const answers = { total: { type: 'number', value: 10, confidence: 0.99 } }; + + addResponseAttributes( + span, + { + model: 'clairic', + answers, + truncated: false, + usage: { state_tokens: 5, input_tokens: 40, output_tokens: 3, passes: 1 }, + }, + true, + 'extract', + ); + + expect(attributes).toEqual({ + [GEN_AI_RESPONSE_MODEL]: 'clairic', + [GEN_AI_USAGE_INPUT_TOKENS]: 40, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 3, + [GEN_AI_USAGE_TOTAL_TOKENS]: 43, + [GEN_AI_OUTPUT_MESSAGES]: JSON.stringify([{ answers, truncated: false }]), + }); + }); + it('sets token usage and computes the total from input and output tokens', () => { const { span, attributes } = createMockSpan(); From d476963f9b33d83209536623ab9ad5abd5eb8fde Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:36:07 +0200 Subject: [PATCH 004/126] test(cloudflare): Add Workers AI E2E test for Clairic (#25625) ## What Adds a real Worker E2E test that calls Clairic and checks the `gen_ai.extract` span in Sentry. ## Why The E2E test calls the real model, so it confirms the response shape that the binding returns. Closes: #25615 --------- Co-authored-by: Claude Opus 5.5 --- .../package.json | 1 + .../src/index.ts | 11 ++++ .../tests/send-to-sentry.test.ts | 66 ++++++++++++++----- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json index be9b44cc4422..8650666b7870 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json @@ -19,6 +19,7 @@ "@cloudflare/workers-types": "^5.20260727.1", "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@sentry/conventions": "0.27.0", "@types/node": "^26.1.2", "sentry": "~0.44.1", "typescript": "~6.0.3", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts index 990859d99f7e..641a211dd92a 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts @@ -46,6 +46,17 @@ export default { }); return Response.json({ traceId: spanContext?.traceId }); } + case '/test-workers-ai-clairic': { + await env.AI.run('@cf/cloudflare/clairic', { + model: 'clairic', + state: 'Invoice INV-2291 from Northwind Supply. Total due: $1,080.00.', + fields: { + invoice_number: { type: 'text' }, + total: { type: 'number', decimals: 2, unit: 'USD', instructions: 'Amount due' }, + }, + }); + return Response.json({ traceId: spanContext?.traceId }); + } case '/test-span': return Response.json({ spanId: spanContext?.spanId, traceId: spanContext?.traceId }); case '/test-workflow-sleep': { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts index fd79fa5b7743..ea4e136d5536 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts @@ -1,5 +1,13 @@ import { randomBytes } from 'node:crypto'; import { expect, test } from '@playwright/test'; +import { + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_REQUEST_MODEL, + GEN_AI_RESPONSE_MODEL, +} from '@sentry/conventions/attributes'; +import { GEN_AI_CHAT, GEN_AI_DECIDE, HTTP_SERVER } from '@sentry/conventions/op'; import { EVENT_POLLING_OPTIONS, fetchSpanAttributes, @@ -37,7 +45,7 @@ test('Sends an unhandled exception and its request span to Sentry', async () => console.log(`Polling for unhandled error: sentry trace view ${traceTarget(traceId)}`); await expect.poll(() => findErrorInTrace(traceId), EVENT_POLLING_OPTIONS).toBeDefined(); - await expect.poll(() => findSpanInTrace(traceId, 'http.server'), EVENT_POLLING_OPTIONS).toBeDefined(); + await expect.poll(() => findSpanInTrace(traceId, HTTP_SERVER), EVENT_POLLING_OPTIONS).toBeDefined(); }); test('Sends a request span to Sentry', async () => { @@ -48,7 +56,7 @@ test('Sends a request span to Sentry', async () => { console.log(`Polling for request spanId ${spanId}: sentry trace view ${traceTarget(traceId)}`); await expect - .poll(() => findSpanInTrace(traceId, 'http.server'), EVENT_POLLING_OPTIONS) + .poll(() => findSpanInTrace(traceId, HTTP_SERVER), EVENT_POLLING_OPTIONS) .toMatchObject({ event_id: spanId }); }); @@ -82,15 +90,15 @@ test('Sends a Workers AI gen_ai span to Sentry', async () => { let spanId: string | undefined; await expect - .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.chat')?.event_id), EVENT_POLLING_OPTIONS) + .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_CHAT)?.event_id), EVENT_POLLING_OPTIONS) .toBeDefined(); // Sentry stores `gen_ai.response.text` as `gen_ai.output.messages`. await expect .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) .toMatchObject({ - 'gen_ai.input.messages': expect.stringContaining('Say hi'), - 'gen_ai.output.messages': expect.stringContaining('"role":"assistant"'), + [GEN_AI_INPUT_MESSAGES]: expect.stringContaining('Say hi'), + [GEN_AI_OUTPUT_MESSAGES]: expect.stringContaining('"role":"assistant"'), }); }); @@ -101,17 +109,17 @@ test('Sends a Workers AI gen_ai.decide span for a TypeSafe Jev call to Sentry', let spanId: string | undefined; await expect - .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.decide')?.event_id), EVENT_POLLING_OPTIONS) + .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS) .toBeDefined(); await expect .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) .toMatchObject({ - 'gen_ai.operation.name': 'decide', - 'gen_ai.request.model': 'typesafe/jev', - 'gen_ai.response.model': expect.stringMatching(/^jev-/), - 'gen_ai.input.messages': expect.stringContaining('My payouts have been failing'), - 'gen_ai.output.messages': expect.stringContaining('"is_urgent"'), + [GEN_AI_OPERATION_NAME]: 'decide', + [GEN_AI_REQUEST_MODEL]: 'typesafe/jev', + [GEN_AI_RESPONSE_MODEL]: expect.stringMatching(/^jev-/), + [GEN_AI_INPUT_MESSAGES]: expect.stringContaining('My payouts have been failing'), + [GEN_AI_OUTPUT_MESSAGES]: expect.stringContaining('"is_urgent"'), }); }); @@ -122,16 +130,40 @@ test('Sends a Workers AI gen_ai.decide span for a Clef call to Sentry', async () let spanId: string | undefined; await expect - .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.decide')?.event_id), EVENT_POLLING_OPTIONS) + .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + await expect + .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + [GEN_AI_OPERATION_NAME]: 'decide', + [GEN_AI_REQUEST_MODEL]: '@cf/cloudflare/clef', + [GEN_AI_RESPONSE_MODEL]: expect.stringContaining('clef'), + [GEN_AI_INPUT_MESSAGES]: expect.stringContaining('Checkout has been failing'), + [GEN_AI_OUTPUT_MESSAGES]: expect.stringContaining('"urgent"'), + }); +}); + +test('Sends a Workers AI gen_ai.extract span for a Clairic call to Sentry', async () => { + const { traceId }: { traceId: string } = JSON.parse( + await fetchFromWorker(`${workerUrl}/test-workers-ai-clairic`, 200), + ); + + console.log(`Polling for gen_ai.extract span: sentry trace view ${traceTarget(traceId)}`); + + let spanId: string | undefined; + // TODO: Use `GEN_AI_EXTRACT` from `@sentry/conventions/op` once getsentry/sentry-conventions#728 is released + await expect + .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.extract')?.event_id), EVENT_POLLING_OPTIONS) .toBeDefined(); await expect .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) .toMatchObject({ - 'gen_ai.operation.name': 'decide', - 'gen_ai.request.model': '@cf/cloudflare/clef', - 'gen_ai.response.model': expect.stringContaining('clef'), - 'gen_ai.input.messages': expect.stringContaining('Checkout has been failing'), - 'gen_ai.output.messages': expect.stringContaining('"urgent"'), + [GEN_AI_OPERATION_NAME]: 'extract', + [GEN_AI_REQUEST_MODEL]: '@cf/cloudflare/clairic', + [GEN_AI_RESPONSE_MODEL]: expect.stringContaining('clairic'), + [GEN_AI_INPUT_MESSAGES]: expect.stringContaining('Northwind Supply'), + [GEN_AI_OUTPUT_MESSAGES]: expect.stringContaining('INV-2291'), }); }); From f15e256364e4d51b90a2cc8502c705b0f440f0e1 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:54:21 +0200 Subject: [PATCH 005/126] ci: Always report optional E2E results and update the report on re-runs (#25651) ## What The optional E2E report comment is now posted after every run, not only on failures. The optional run reports itself, so a re-run of it updates the comment. - Comment on passing runs too - Re-running the optional run (all or failed jobs) updates the comment ## Why A passing run left no trace on the PR, and re-running the failed optional tests did not update the comment. Co-authored-by: Claude Opus 5.5 --- .github/workflows/build.yml | 3 +- .github/workflows/optional-e2e.yml | 33 ++++++++++++++++ .../optional-e2e-gh-action/action.yml | 18 ++++++--- dev-packages/optional-e2e-gh-action/index.mjs | 39 ++++++++++++------- 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 343eccc16d35..23377c9c06cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1153,7 +1153,7 @@ jobs: # - We skip optional tests on release branches # - The tests run in optional-e2e.yml, so their failures do not mark the PR as failed. This job only waits for - # them and reports failures in a PR comment. + # them. optional-e2e.yml reports the results in a PR comment. job_optional_e2e_tests: name: E2E Tests (optional) # We only run E2E tests for non-fork PRs because the E2E tests require secrets to work and they can't be accessed from forks @@ -1171,7 +1171,6 @@ jobs: permissions: contents: read actions: write - pull-requests: write steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 diff --git a/.github/workflows/optional-e2e.yml b/.github/workflows/optional-e2e.yml index 8374ea398ec0..ffe170e70231 100644 --- a/.github/workflows/optional-e2e.yml +++ b/.github/workflows/optional-e2e.yml @@ -115,3 +115,36 @@ jobs: working-directory: ${{ runner.temp }}/test-application timeout-minutes: 10 run: ${{ matrix.assert-command || 'pnpm test:assert' }} + + # A separate job in this workflow, so that a re-run of this run (all jobs or only the failed ones) also updates the + # comment. GitHub re-runs the jobs that depend on a re-run job. + job_report: + name: Report in PR comment + needs: [job_optional_e2e_tests] + # Not for a cancelled run: a new push cancels the old run, and the new run reports. + if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && inputs.pr_number != '' }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: read + pull-requests: write + steps: + - name: Check out commit ${{ env.HEAD_COMMIT }} + uses: actions/checkout@v7 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version-file: 'package.json' + - name: Install pnpm + uses: ./.github/actions/install-pnpm + - name: Install action dependencies + run: pnpm install --frozen-lockfile --filter @sentry-internal/optional-e2e-gh-action + - name: Report optional E2E results + uses: ./dev-packages/optional-e2e-gh-action + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + mode: report + pr_number: ${{ inputs.pr_number }} diff --git a/dev-packages/optional-e2e-gh-action/action.yml b/dev-packages/optional-e2e-gh-action/action.yml index 684ee62d491f..54edd85f97df 100644 --- a/dev-packages/optional-e2e-gh-action/action.yml +++ b/dev-packages/optional-e2e-gh-action/action.yml @@ -1,15 +1,23 @@ name: 'optional-e2e-gh-action' -description: 'Run the optional E2E tests in their own workflow, wait for them and report failures in a PR comment' +description: + 'Run the optional E2E tests in their own workflow and wait for them, or report their results in a PR comment' inputs: github_token: required: true description: 'a github access token' + mode: + required: false + default: 'dispatch' + description: '`dispatch` to run the tests and wait for them, `report` to report the current run in a PR comment' commit: - required: true - description: 'The commit that the tarballs were built from' + required: false + description: 'The commit that the tarballs were built from (dispatch mode)' matrix: - required: true - description: 'The optional E2E test matrix as JSON' + required: false + description: 'The optional E2E test matrix as JSON (dispatch mode)' + pr_number: + required: false + description: 'The PR to report in (report mode)' runs: using: 'node24' main: 'index.mjs' diff --git a/dev-packages/optional-e2e-gh-action/index.mjs b/dev-packages/optional-e2e-gh-action/index.mjs index 54136e26542f..59194dbc4bd2 100644 --- a/dev-packages/optional-e2e-gh-action/index.mjs +++ b/dev-packages/optional-e2e-gh-action/index.mjs @@ -12,6 +12,12 @@ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); async function run() { const octokit = getOctokit(core.getInput('github_token')); const { repo } = context; + + if (core.getInput('mode') === 'report') { + await report(octokit, repo); + return; + } + const pr = context.payload.pull_request; const correlationId = `${context.runId}-${process.env.GITHUB_RUN_ATTEMPT}`; @@ -41,14 +47,20 @@ async function run() { if (workflowRun.conclusion !== 'success') { core.warning(`Optional E2E tests ${workflowRun.conclusion}: ${workflowRun.html_url}`); } +} - if (pr) { - // A failed report must not fail this job, because that marks the PR as failed. - try { - await reportInComment(octokit, repo, pr.number, workflowRun); - } catch (error) { - core.warning(`Could not report the optional E2E results in a PR comment: ${error.message}`); - } +// Runs as the last job of the optional E2E run itself, so a re-run of that run also updates the comment. +async function report(octokit, repo) { + const prNumber = Number(core.getInput('pr_number')); + if (!prNumber) { + return; + } + + try { + const { data: workflowRun } = await octokit.rest.actions.getWorkflowRun({ ...repo, run_id: context.runId }); + await reportInComment(octokit, repo, prNumber, workflowRun); + } catch (error) { + core.warning(`Could not report the optional E2E results in a PR comment: ${error.message}`); } } @@ -80,8 +92,7 @@ async function waitForRun(octokit, repo, workflowId, correlationId) { return workflowRun; } -// Keeps one comment per PR up to date, so new pushes do not add more comments. The comment is only -// created when a check fails. +// Keeps one comment per PR up to date, so new pushes and re-runs do not add more comments. async function reportInComment(octokit, repo, prNumber, workflowRun) { const jobs = await octokit.paginate(octokit.rest.actions.listJobsForWorkflowRun, { ...repo, @@ -89,9 +100,13 @@ async function reportInComment(octokit, repo, prNumber, workflowRun) { filter: 'latest', per_page: 100, }); - const ran = jobs.filter(({ conclusion }) => conclusion !== 'skipped'); + // The report job itself is still in progress. + const ran = jobs.filter(({ status, conclusion }) => status === 'completed' && conclusion !== 'skipped'); const failing = ran.filter(({ conclusion }) => conclusion !== 'success'); const successful = ran.filter(({ conclusion }) => conclusion === 'success'); + if (!ran.length) { + return; + } const comments = await octokit.paginate(octokit.rest.issues.listComments, { ...repo, @@ -102,10 +117,6 @@ async function reportInComment(octokit, repo, prNumber, workflowRun) { ({ user, body }) => user?.login === 'github-actions[bot]' && body?.startsWith(COMMENT_MARKER), ); - if (!failing.length && !previousComment) { - return; - } - const counts = [ failing.length && `${failing.length} failing`, successful.length && `${successful.length} successful`, From 09fb95c2086510bc4a49e88b1c52eb3595cda920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 22 Oct 2026 18:36:35 +0200 Subject: [PATCH 006/126] fix(node): Read module versions from the isolated layouts of pnpm and Bun (#25526) This fixes `modulesIntegration` for CJS apps that pnpm or Bun install with the isolated layout. The integration reads the version of each module in `require.cache` from its `package.json`, but only for packages directly in a `node_modules` folder of the app. pnpm and Bun keep them in `node_modules/<.pnpm or .bun>//node_modules/`, so `event.modules` had only the version ranges from the app's `package.json`. This also fixes scoped packages (`@scope/name`), which the integration did not read in any layout. The store keeps every version of a package. When more than one version is loaded, `event.modules` has the one that `require('')` in the main module loads. ## Known issues - When only dependencies of the app load a package, and they load two versions of it, the version that was loaded last wins. The new `suites/modules` test writes packages into these layouts, because the monorepo installs with the hoisted layout. --------- Co-authored-by: Claude Opus 5.5 --- .../suites/modules/scenario-isolated.js | 18 ++++++++ .../suites/modules/test.ts | 43 ++++++++++++++++++- packages/node/src/integrations/modules.ts | 42 ++++++++++++++++-- 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/modules/scenario-isolated.js diff --git a/dev-packages/node-integration-tests/suites/modules/scenario-isolated.js b/dev-packages/node-integration-tests/suites/modules/scenario-isolated.js new file mode 100644 index 000000000000..29f9e62decca --- /dev/null +++ b/dev-packages/node-integration-tests/suites/modules/scenario-isolated.js @@ -0,0 +1,18 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +// `test.ts` writes these packages in the folders where the isolated layouts of pnpm and Bun keep them +require('pnpm-isolated-package'); +require('@pnpm-isolated/scoped-package'); +require('bun-isolated-package'); +require('@bun-isolated/scoped-package'); +// Another version of `pnpm-isolated-package`, loaded last as a dependency of the app would load it +require('./node_modules/.pnpm/pnpm-isolated-package@1.0.0/node_modules/pnpm-isolated-package'); + +Sentry.captureException(new Error('error_1')); diff --git a/dev-packages/node-integration-tests/suites/modules/test.ts b/dev-packages/node-integration-tests/suites/modules/test.ts index f47c4c1f8b09..57c221f9f7f0 100644 --- a/dev-packages/node-integration-tests/suites/modules/test.ts +++ b/dev-packages/node-integration-tests/suites/modules/test.ts @@ -1,5 +1,6 @@ import { SDK_VERSION } from '@sentry/core'; -import { join } from 'path'; +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../utils/runner'; @@ -27,6 +28,46 @@ describe('modulesIntegration', () => { await runner.completed(); }); + test('CJS with the isolated layouts of pnpm and Bun', async () => { + const nodeModulesDir = join(__dirname, 'node_modules'); + for (const [storeDir, name, version, linked] of [ + ['.pnpm', 'pnpm-isolated-package', '1.2.3', true], + // Only a dependency of the app uses this version, so it is not linked into `node_modules` + ['.pnpm', 'pnpm-isolated-package', '1.0.0', false], + ['.pnpm', '@pnpm-isolated/scoped-package', '2.3.4', true], + ['.bun', 'bun-isolated-package', '4.5.6', true], + ['.bun', '@bun-isolated/scoped-package', '5.6.7', true], + ] as const) { + const packageDir = join(nodeModulesDir, storeDir, `${name.replace('/', '+')}@${version}`, 'node_modules', name); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name, version })); + writeFileSync(join(packageDir, 'index.js'), 'module.exports = {};'); + + if (linked) { + mkdirSync(dirname(join(nodeModulesDir, name)), { recursive: true }); + symlinkSync(packageDir, join(nodeModulesDir, name), 'junction'); + } + } + + try { + await createRunner(__dirname, 'scenario-isolated.js') + .expect({ + event: { + modules: { + 'pnpm-isolated-package': '1.2.3', + '@pnpm-isolated/scoped-package': '2.3.4', + 'bun-isolated-package': '4.5.6', + '@bun-isolated/scoped-package': '5.6.7', + }, + }, + }) + .start() + .completed(); + } finally { + rmSync(nodeModulesDir, { recursive: true, force: true }); + } + }); + test('ESM', async () => { const runner = createRunner(__dirname, 'server.mjs') .withInstrument(join(__dirname, 'instrument.mjs')) diff --git a/packages/node/src/integrations/modules.ts b/packages/node/src/integrations/modules.ts index 508bae1beace..54842d0cd5a3 100644 --- a/packages/node/src/integrations/modules.ts +++ b/packages/node/src/integrations/modules.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; import { GLOBAL_OBJ, type IntegrationFn } from '@sentry/core'; type ModuleInfo = Record; @@ -73,6 +73,34 @@ function collectModules(): ModuleInfo { }; } +/** + * Whether `dir` is a node_modules folder of the main module, a folder + * `/<.pnpm or .bun>//node_modules`, where the isolated layouts of pnpm and Bun keep + * each package, or a scope folder (`@scope`) in one of these folders. + */ +function isMainModulesDir(dir: string, mainPaths: string[]): boolean { + if (basename(dir).startsWith('@')) { + return isMainModulesDir(dirname(dir), mainPaths); + } + + if (mainPaths.includes(dir)) { + return true; + } + + const storeDir = dirname(dirname(dir)); + return ( + basename(dir) === 'node_modules' && + ['.pnpm', '.bun'].includes(basename(storeDir)) && + mainPaths.includes(dirname(storeDir)) + ); +} + +/** Whether `require(name)` in the main module loads the package in `packageDir`. */ +function isResolvedFromMain(name: string, packageDir: string, mainPaths: string[]): boolean { + const resolvedDir = mainPaths.map(dir => join(dir, name)).find(dir => existsSync(dir)); + return !!resolvedDir && realpathSync(resolvedDir) === realpathSync(packageDir); +} + /** Extract information about package.json modules from require.cache */ function collectRequireModules(): ModuleInfo { const mainPaths = require.main?.paths || []; @@ -81,6 +109,7 @@ function collectRequireModules(): ModuleInfo { // We start with the modules from package.json (if possible) // These may be overwritten by more specific versions from the require.cache const infos: ModuleInfo = {}; + const packageDirs: Record = {}; const seen = new Set(); paths.forEach(path => { @@ -94,7 +123,7 @@ function collectRequireModules(): ModuleInfo { if (!dir || orig === dir || seen.has(orig)) { return undefined; } - if (mainPaths.indexOf(dir) < 0) { + if (!isMainModulesDir(dir, mainPaths)) { return updir(); } @@ -110,7 +139,12 @@ function collectRequireModules(): ModuleInfo { name: string; version: string; }; - infos[info.name] = info.version; + // The isolated layouts of pnpm and Bun keep every version of a package. Keep the one that the main module loads. + const previousDir = packageDirs[info.name]; + if (!previousDir || !isResolvedFromMain(info.name, previousDir, mainPaths)) { + infos[info.name] = info.version; + packageDirs[info.name] = orig; + } } catch { // no-empty } From 40eff8039a68bb8aa77da87f6b744c6ef65012df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 22 Oct 2026 18:36:37 +0200 Subject: [PATCH 007/126] chore: Use the isolated pnpm node_modules layout (#25368) This moves the monorepo from the hoisted `node_modules` layout to the isolated pnpm default. Each package now declares the packages that it imports, instead of using what the root folder had. A full build gives the same files as with the hoisted layout. - Packages declare the dependencies that they imported from the hoisted root folder, with the version that the root folder had. The lockfile gets no new versions. - The root `package.json` no longer lists every workspace package as `workspace:*`. Each package declares the workspace packages that it imports, for example `@sentry-internal/rollup-utils` in the rollup configs. The root keeps three of them: `@sentry-internal/node-integration-tests` (its suites import it by name, also from tmp folders with their own `package.json`), `@sentry/eslint-plugin-sdk` (oxlint plugin) and `@sentry/sveltekit` (size-limit bundles its self-import). - `@sentry/replay` declares `@sentry/conventions` as a dev dependency, so its build still inlines the constant from there. - rollup-utils reads the `sideEffects` field of external packages from the nearest `node_modules` of the built package, not only from the root folder, and warns when it does not find an installed package there. - The integration test runner adds the bins of node-integration-tests to the `PATH` of setup commands (for example `prisma generate`), because the Bun and Deno packages run the shared suites with their own `PATH`. ## packageExtensions decisions `@apm-js-collab/code-transformer-bundler-plugins` imports the types of Vite, Rollup, webpack and esbuild without declaring them. `pnpm-workspace.yaml` declares them as optional peers, so its types use the Vite of the importing package. The real fix belongs upstream: apm-js-collab/code-transformer-bundler-plugins#49. ## Peer decisions `autoInstallPeers` stays `false`. The pnpm default (`true`) adds 674 package versions for peers that no package needs, for example `@glimmer/component` and the GraphQL tools of Gatsby. --------- Co-authored-by: Claude Opus 5.5 --- .../browser-integration-tests/package.json | 5 + .../bun-integration-tests/package.json | 3 + .../package.json | 1 + .../cloudflare-integration-tests/package.json | 1 + .../deno-integration-tests/package.json | 1 + .../node-integration-tests/package.json | 7 + .../utils/runner/createEsmAndCjsTests.ts | 9 +- .../utils/runner/createRunner.ts | 2 +- dev-packages/rollup-utils/npmHelpers.mjs | 41 +- dev-packages/test-utils/package.json | 2 + docs/new-sdk-release-checklist.md | 3 +- package.json | 68 +-- packages/astro/package.json | 1 + packages/aws-serverless/package.json | 1 + packages/browser-utils/package.json | 3 + packages/browser/package.json | 1 + packages/bun/package.json | 1 + packages/bundler-plugins/package.json | 3 + .../test/webpack/child-compiler.test.ts | 2 +- packages/cloudflare/package.json | 3 + packages/core/package.json | 1 + packages/deno/package.json | 3 + packages/effect/package.json | 1 + packages/elysia/package.json | 1 + packages/ember/package.json | 3 +- packages/esbuild-plugin/package.json | 3 + packages/eslint-plugin-sdk/package.json | 4 + packages/feedback/package.json | 1 + packages/gatsby/package.json | 1 + packages/google-cloud-serverless/package.json | 1 + packages/hono/package.json | 6 + packages/integration-shims/package.json | 3 + packages/nestjs/package.json | 1 + packages/nextjs/package.json | 2 + packages/nitro/package.json | 2 + packages/node-native/package.json | 1 + packages/node/package.json | 1 + packages/nuxt/package.json | 9 +- packages/opentelemetry/package.json | 3 +- packages/profiling-node/package.json | 1 + packages/react-router/package.json | 4 + packages/react/package.json | 1 + packages/remix/package.json | 4 + packages/replay-canvas/package.json | 1 + packages/replay-internal/package.json | 2 + packages/rollup-plugin/package.json | 3 + .../server-runtime-injection/package.json | 1 + packages/server-utils/package.json | 4 +- .../test/orchestrion/externalRequire.test.ts | 4 +- packages/solid/package.json | 1 + packages/solidstart/package.json | 4 + packages/svelte/package.json | 1 + packages/sveltekit/package.json | 3 + packages/tanstackstart-react/package.json | 2 + packages/vercel-edge/package.json | 1 + packages/vite-plugin/package.json | 3 + packages/vue/package.json | 1 + packages/wasm/package.json | 3 + packages/webpack-plugin/package.json | 3 + pnpm-lock.yaml | 543 +++++++++++------- pnpm-workspace.yaml | 21 +- scripts/verify-packages-versions.js | 15 - 62 files changed, 536 insertions(+), 295 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 9f64a6253811..1185f988c11e 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -72,6 +72,11 @@ "webpack": "^5.95.0" }, "devDependencies": { + "@sentry/browser-utils": "11.8.0", + "@sentry/core": "11.8.0", + "@sentry/replay-canvas": "11.8.0", + "@sentry/rrweb-types": "^2.44.1", + "@sentry/wasm": "11.8.0", "@types/node": "^20.19.0", "eslint-plugin-regexp": "^3.1.0", "glob": "^13.0.6" diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index 474736db5c7c..a6c4fae7993e 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -23,6 +23,9 @@ "devDependencies": { "@sentry-internal/node-integration-tests": "11.8.0", "@sentry-internal/test-utils": "11.8.0", + "@sentry/core": "11.8.0", + "@types/mysql": "^2.15.27", + "@types/pg": "^8.20.0", "bun-types": "^1.2.9", "vitest": "^4.1.11" }, diff --git a/dev-packages/bundler-plugin-integration-tests/package.json b/dev-packages/bundler-plugin-integration-tests/package.json index 599a2738b37a..7486705905d0 100644 --- a/dev-packages/bundler-plugin-integration-tests/package.json +++ b/dev-packages/bundler-plugin-integration-tests/package.json @@ -16,6 +16,7 @@ }, "devDependencies": { "premove": "^4.0.0", + "sentry": "^0.45.0", "typescript": "~6.0.3", "vitest": "^4.1.11" }, diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 486042a9d6c1..cbfdb93e96a4 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -34,6 +34,7 @@ "@cloudflare/vite-plugin": "1.34.0", "@cloudflare/workers-types": "^4.20260426.0", "@cloudflare/workers-types-v5": "npm:@cloudflare/workers-types@5.20260710.1", + "@opentelemetry/api": "^1.9.1", "@sentry-internal/test-utils": "11.8.0", "@sentry/conventions": "0.27.0", "eslint-plugin-regexp": "^3.1.0", diff --git a/dev-packages/deno-integration-tests/package.json b/dev-packages/deno-integration-tests/package.json index 4a59368859e3..e8e8d3bddaa6 100644 --- a/dev-packages/deno-integration-tests/package.json +++ b/dev-packages/deno-integration-tests/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@sentry-internal/node-integration-tests": "11.8.0", + "@sentry/conventions": "^0.27.0", "vitest": "^4.1.11" }, "volta": { diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index fdef92a3ba24..5118d28f1d46 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -56,7 +56,9 @@ "@sentry/core": "11.8.0", "@sentry/hono": "11.8.0", "@sentry/node": "11.8.0", + "@sentry/server-runtime-injection": "11.8.0", "@sentry/server-utils": "11.8.0", + "@smithy/node-http-handler": "4.12.1", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", @@ -112,17 +114,22 @@ "yargs": "^16.2.0" }, "devDependencies": { + "@hapi/boom": "^10.0.1", "@neondatabase/serverless": "1.2.0", "@opentelemetry/instrumentation-http": "0.220.0", + "@sentry-internal/rollup-utils": "11.8.0", "@sentry/conventions": "0.27.0", "@sentry-internal/test-utils": "11.8.0", + "@sentry/node-native": "11.8.0", "@types/amqplib": "^0.10.5", + "@types/express": "^4.17.21", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", "esbuild": "0.28.1", "eslint-plugin-regexp": "^3.1.0", "globby": "11", "react": "^18.3.1", + "winston-transport": "^4.9.0", "zod": "^3.24.1" }, "config": { diff --git a/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts b/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts index 60b63507ff77..7c6265f05c38 100644 --- a/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts +++ b/dev-packages/node-integration-tests/utils/runner/createEsmAndCjsTests.ts @@ -1,10 +1,10 @@ import { exec } from 'child_process'; import { existsSync } from 'fs'; import { cp, mkdir, readFile, rm, writeFile } from 'fs/promises'; -import { basename, join } from 'path'; +import { basename, delimiter, join } from 'path'; import { promisify } from 'util'; import { afterAll, beforeAll, test, type TestAPI } from 'vitest'; -import { CLEANUP_STEPS, createRunner } from './createRunner'; +import { CLEANUP_STEPS, createRunner, PACKAGE_ROOT } from './createRunner'; const execPromise = promisify(exec); @@ -267,7 +267,10 @@ function prepareTmpDir( } if (options?.afterSetupCommand) { - const env = { ...process.env, PATH: `${join(tmpDirPath, 'node_modules', '.bin')}:${process.env.PATH}` }; + // The Bun and Deno packages run these suites with their own `PATH`, which does not have the bins of + // this package (e.g. `prisma`). + const binPaths = [join(tmpDirPath, 'node_modules', '.bin'), join(PACKAGE_ROOT, 'node_modules', '.bin')]; + const env = { ...process.env, PATH: [...binPaths, process.env.PATH].join(delimiter) }; await execPromise(options.afterSetupCommand, { cwd: tmpDirPath, env }); } } diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts index d9fd1d5f149b..b3d519cd3751 100644 --- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts +++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts @@ -121,7 +121,7 @@ const COMPILE_CACHE_ENV: Record = // The Bun and Deno packages run these suites from their own folder. Scenarios read the working // directory (e.g. `modulesIntegration` reads its `package.json`), so it is always this package. -const PACKAGE_ROOT = join(__dirname, '..', '..'); +export const PACKAGE_ROOT = join(__dirname, '..', '..'); /** Node flags that preload a module before the entry point. */ const PRELOAD_FLAGS = ['--import', '--require', '-r']; diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index a113feb1aa75..64031564f127 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -5,7 +5,7 @@ */ import * as fs from 'fs'; -import { builtinModules } from 'module'; +import { builtinModules, isBuiltin } from 'module'; import * as path from 'path'; import { fileURLToPath } from 'url'; @@ -44,18 +44,49 @@ function isSideEffectFreePackage(packageName) { let sideEffectFree = false; try { - const json = JSON.parse( - fs.readFileSync(path.resolve(repoRoot, 'node_modules', packageName, 'package.json'), { encoding: 'utf8' }), - ); + const json = JSON.parse(fs.readFileSync(findPackageJson(packageName), { encoding: 'utf8' })); sideEffectFree = json.sideEffects === false; } catch { - // Not resolvable from the repo root - assume side effects. + // Not resolvable from the built package - assume side effects. + if (isInstalledPackage(packageName)) { + // oxlint-disable-next-line no-console + console.warn( + `[rollup-utils] Cannot read the package.json of "${packageName}" from ${process.cwd()}, so its imports are kept. Declare it in the package.json of the built package.`, + ); + } } sideEffectsCache.set(packageName, sideEffectFree); return sideEffectFree; } +/** + * The `package.json` of a dependency of the built package (the working directory): the nearest + * `node_modules/` from there up to the repo root, like Node resolves it. + */ +function findPackageJson(packageName) { + for (let dir = process.cwd(); ; dir = path.dirname(dir)) { + const candidate = path.join(dir, 'node_modules', packageName, 'package.json'); + if (fs.existsSync(candidate) || dir === repoRoot || dir === path.dirname(dir)) { + return candidate; + } + } +} + +/** + * Whether `packageName` must have a `package.json` in a `node_modules` folder of the built package. This is false for + * builtins, for virtual modules and placeholders (e.g. `$app`, `cloudflare:workers`), which are no valid npm package + * names, for the built package itself, and for optional peers, which are not always installed. + */ +function isInstalledPackage(packageName) { + return ( + !isBuiltin(packageName) && + /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(packageName) && + packageName !== packageDotJSON.name && + !packageDotJSON.peerDependenciesMeta?.[packageName]?.optional + ); +} + function getPackageName(id) { const segments = id.split('/'); return id.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index 041937219804..18d15c905a70 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -66,7 +66,9 @@ }, "devDependencies": { "@playwright/test": "1.64.1", + "@sentry-internal/rollup-utils": "11.8.0", "@sentry/core": "11.8.0", + "@types/express": "^4.17.21", "@types/ws": "^8.18.1", "eslint-plugin-regexp": "^3.1.0" }, diff --git a/docs/new-sdk-release-checklist.md b/docs/new-sdk-release-checklist.md index f113924cda0a..b6c58e3f62d5 100644 --- a/docs/new-sdk-release-checklist.md +++ b/docs/new-sdk-release-checklist.md @@ -12,8 +12,7 @@ differ slightly for other SDKs depending on how they are structured and how they - [ ] The package has a working unit testing environment - [ ] The package builds correctly (inspect `/build` directory) - [ ] The package is in the `packages` list of `pnpm-workspace.yaml` and in the `workspaces` list of the root - `package.json` (in the same order), and it is in the root `devDependencies` with the version `workspace:*`. - `scripts/verify-packages-versions.js` checks this. + `package.json` (in the same order). `scripts/verify-packages-versions.js` checks this. - [ ] Make sure that the `README.md` content is up to date and contains at least: - [ ] The correct name + a brief description of the SDK diff --git a/package.json b/package.json index ef21f823efff..793d94d14968 100644 --- a/package.json +++ b/package.json @@ -123,9 +123,9 @@ "dev-packages/bundler-tests" ], "//_devDependencies_comment": [ - "Every workspace package is listed with `workspace:*`, so that pnpm links it into the root node_modules.", - "Yarn workspaces did this for all packages, and some configs import workspace packages that they do not declare,", - "for example `@sentry-internal/rollup-utils` in the rollup configs." + "`@sentry-internal/node-integration-tests`: its suites import it by name, also from tmp folders with their own package.json.", + "`@sentry/eslint-plugin-sdk`: the root oxlint config loads it as a JS plugin.", + "`@sentry/sveltekit`: size-limit bundles its build output, which imports `@sentry/sveltekit/browser-tracing-variant`." ], "devDependencies": { "@rollup/plugin-commonjs": "^25.0.7", @@ -133,69 +133,9 @@ "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-terser": "^0.4.4", - "@sentry-internal/browser-integration-tests": "workspace:*", - "@sentry-internal/bun-integration-tests": "workspace:*", - "@sentry-internal/bundler-plugin-integration-tests": "workspace:*", - "@sentry-internal/bundler-tests": "workspace:*", - "@sentry-internal/clear-cache-gh-action": "workspace:*", - "@sentry-internal/cloudflare-integration-tests": "workspace:*", - "@sentry-internal/deno-integration-tests": "workspace:*", - "@sentry-internal/e2e-tests": "workspace:*", - "@sentry-internal/external-contributor-gh-action": "workspace:*", - "@sentry-internal/integration-shims": "workspace:*", "@sentry-internal/node-integration-tests": "workspace:*", - "@sentry-internal/optional-e2e-gh-action": "workspace:*", - "@sentry-internal/replay-worker": "workspace:*", - "@sentry-internal/rollup-utils": "workspace:*", - "@sentry-internal/size-limit-gh-action": "workspace:*", - "@sentry-internal/test-utils": "workspace:*", - "@sentry/angular": "workspace:*", - "@sentry/astro": "workspace:*", - "@sentry/aws-serverless": "workspace:*", - "@sentry/browser": "workspace:*", - "@sentry/browser-utils": "workspace:*", - "@sentry/bun": "workspace:*", - "@sentry/bundler-plugins": "workspace:*", - "@sentry/cloudflare": "workspace:*", - "@sentry/core": "workspace:*", - "@sentry/deno": "workspace:*", - "@sentry/effect": "workspace:*", - "@sentry/elysia": "workspace:*", - "@sentry/ember": "workspace:*", - "@sentry/esbuild-plugin": "workspace:*", - "@sentry/eslint-config-sdk": "workspace:*", "@sentry/eslint-plugin-sdk": "workspace:*", - "@sentry/feedback": "workspace:*", - "@sentry/gatsby": "workspace:*", - "@sentry/google-cloud-serverless": "workspace:*", - "@sentry/hono": "workspace:*", - "@sentry/nestjs": "workspace:*", - "@sentry/nextjs": "workspace:*", - "@sentry/nitro": "workspace:*", - "@sentry/node": "workspace:*", - "@sentry/node-native": "workspace:*", - "@sentry/nuxt": "workspace:*", - "@sentry/opentelemetry": "workspace:*", - "@sentry/profiling-node": "workspace:*", - "@sentry/react": "workspace:*", - "@sentry/react-router": "workspace:*", - "@sentry/remix": "workspace:*", - "@sentry/replay": "workspace:*", - "@sentry/replay-canvas": "workspace:*", - "@sentry/rollup-plugin": "workspace:*", - "@sentry/server-runtime-injection": "workspace:*", - "@sentry/server-utils": "workspace:*", - "@sentry/solid": "workspace:*", - "@sentry/solidstart": "workspace:*", - "@sentry/svelte": "workspace:*", "@sentry/sveltekit": "workspace:*", - "@sentry/tanstackstart-react": "workspace:*", - "@sentry/typescript": "workspace:*", - "@sentry/vercel-edge": "workspace:*", - "@sentry/vite-plugin": "workspace:*", - "@sentry/vue": "workspace:*", - "@sentry/wasm": "workspace:*", - "@sentry/webpack-plugin": "workspace:*", "@size-limit/esbuild": "~12.1.0", "@size-limit/file": "~12.1.0", "@size-limit/webpack": "~12.1.0", @@ -218,9 +158,11 @@ "rollup-plugin-esbuild": "^6.2.1", "rollup-plugin-license": "^3.7.1", "size-limit": "~12.1.0", + "tslib": "^2.4.1", "tsx": "^4.23.0", "typescript": "~7.0.2", "vitest": "^4.1.11", + "webpack": "^5.104.1", "yalc": "^1.0.0-pre.53" }, "version": "0.0.0", diff --git a/packages/astro/package.json b/packages/astro/package.json index 3378c90eecc3..e4b3504a0281 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -60,6 +60,7 @@ "@sentry/bundler-plugins": "11.8.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "astro": "^4.16.19", "vite": "^8.3.1" }, diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 7779dc1cc698..4ea85a300a3a 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -64,6 +64,7 @@ "@types/aws-lambda": "^8.10.161" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "@types/node": "^20.19.0", "@vercel/nft": "^1.11.0" }, diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index 0d63187ddb91..98de486ea380 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -37,6 +37,9 @@ "@sentry/conventions": "^0.27.0", "web-vitals": "^6.2.3" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0" + }, "scripts": { "build": "run-p build:transpile build:types", "build:dev": "pnpm run build", diff --git a/packages/browser/package.json b/packages/browser/package.json index 8923b9b763fb..5bc097965ab3 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -47,6 +47,7 @@ }, "devDependencies": { "@sentry-internal/integration-shims": "11.8.0", + "@sentry-internal/rollup-utils": "11.8.0", "fake-indexeddb": "^6.2.5" }, "scripts": { diff --git a/packages/bun/package.json b/packages/bun/package.json index e1431a381b77..ee8236541ed9 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -49,6 +49,7 @@ "@sentry/server-utils": "11.8.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "bun-types": "^1.2.9" }, "scripts": { diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index 9cacb1a31eda..bd56dfc15eaf 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -132,7 +132,10 @@ }, "devDependencies": { "@babel/core": "^7.18.5", + "@babel/parser": "^7.29.8", "@babel/preset-react": "^7.23.3", + "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/test-utils": "11.8.0", "@types/babel__core": "^7.20.5", "@types/node": "^20.19.0", "premove": "^4.0.0", diff --git a/packages/bundler-plugins/test/webpack/child-compiler.test.ts b/packages/bundler-plugins/test/webpack/child-compiler.test.ts index 04edf0b82b9f..e4dc7f5f571c 100644 --- a/packages/bundler-plugins/test/webpack/child-compiler.test.ts +++ b/packages/bundler-plugins/test/webpack/child-compiler.test.ts @@ -53,7 +53,7 @@ describe('child compiler injection', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('injects release information into child-compiler bundles', async () => { + it('injects release information into child-compiler bundles', { timeout: 15_000 }, async () => { const outDir = path.join(tmpDir, 'dist'); await build({ diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 96d516fec10e..23534c205b03 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -77,7 +77,10 @@ }, "devDependencies": { "@cloudflare/workers-types": "5.20260710.1", + "@sentry-internal/rollup-utils": "11.8.0", "@types/node": "^20.19.0", + "acorn": "^8.14.0", + "vite": "^8.3.1", "wrangler": "4.61.0" }, "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index f320a3bf6dce..8199fda2b144 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,6 +91,7 @@ }, "sideEffects": false, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "zod": "^3.24.1" }, "dependencies": { diff --git a/packages/deno/package.json b/packages/deno/package.json index 30e4e0df1dba..871eb5cffe9a 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -33,6 +33,9 @@ "@sentry/server-runtime-injection": "11.8.0", "@sentry/server-utils": "11.8.0" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0" + }, "scripts": { "deno-types": "node ./scripts/download-deno-types.mjs", "build": "run-s build:transpile build:types", diff --git a/packages/effect/package.json b/packages/effect/package.json index 6997a2cdd1cc..e66f914fcf33 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -59,6 +59,7 @@ }, "devDependencies": { "@effect/vitest": "^4.0.0", + "@sentry-internal/rollup-utils": "11.8.0", "effect": "^4.0.0" }, "scripts": { diff --git a/packages/elysia/package.json b/packages/elysia/package.json index df0246417cbe..a178d5faaa4e 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -40,6 +40,7 @@ "elysia": "^1.4.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "bun-types": "^1.2.9", "elysia": "^1.4.29" }, diff --git a/packages/ember/package.json b/packages/ember/package.json index 04f9d8acd6b6..cca89e8345aa 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -62,7 +62,8 @@ "@glint/ember-tsc": "^1.5.0", "@rollup/plugin-babel": "^7.0.0", "ember-source": "^6.12.0", - "typescript": "~5.8.0" + "typescript": "~5.8.0", + "vite": "^8.3.1" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" diff --git a/packages/esbuild-plugin/package.json b/packages/esbuild-plugin/package.json index 32d433894f2b..131cf0fae96d 100644 --- a/packages/esbuild-plugin/package.json +++ b/packages/esbuild-plugin/package.json @@ -50,6 +50,9 @@ "dependencies": { "@sentry/bundler-plugins": "11.8.0" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0" + }, "volta": { "extends": "../../package.json" } diff --git a/packages/eslint-plugin-sdk/package.json b/packages/eslint-plugin-sdk/package.json index 8ba25aac41ee..34ea51ff22d5 100644 --- a/packages/eslint-plugin-sdk/package.json +++ b/packages/eslint-plugin-sdk/package.json @@ -21,6 +21,10 @@ "publishConfig": { "access": "public" }, + "devDependencies": { + "@types/eslint": "^8.2.1", + "eslint": "^8.57.0" + }, "scripts": { "clean": "pnpm exec rimraf sentry-eslint-plugin-sdk-*.tgz", "lint:fix": "oxlint . --fix --type-aware", diff --git a/packages/feedback/package.json b/packages/feedback/package.json index 2fed4e3e96b2..6e5f831b8a47 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -36,6 +36,7 @@ "@sentry/core": "11.8.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "preact": "^10.29.8" }, "scripts": { diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 96454570f0ed..24116594896d 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -47,6 +47,7 @@ "react": "17.x || 18.x || 19.x" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "@testing-library/react": "^15.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index 9372b4874209..0a3aef657e75 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -45,6 +45,7 @@ "devDependencies": { "@google-cloud/bigquery": "^5.3.0", "@google-cloud/common": "^3.4.1", + "@sentry-internal/rollup-utils": "11.8.0", "@types/express": "^5.0.6", "@types/node": "^20.19.0", "nock": "^14.0.17" diff --git a/packages/hono/package.json b/packages/hono/package.json index 31159af08968..bfe3c2f150dd 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -104,7 +104,13 @@ }, "devDependencies": { "@cloudflare/workers-types": "4.20250922.0", + "@sentry-internal/rollup-utils": "11.8.0", + "@sentry/bun": "11.8.0", + "@sentry/cloudflare": "11.8.0", + "@sentry/deno": "11.8.0", + "@sentry/node": "11.8.0", "@types/node": "^20.19.0", + "hono": "^4.13.7", "wrangler": "4.62.0" }, "scripts": { diff --git a/packages/integration-shims/package.json b/packages/integration-shims/package.json index 0112e57ee242..c9f7ddcd1f99 100644 --- a/packages/integration-shims/package.json +++ b/packages/integration-shims/package.json @@ -50,6 +50,9 @@ "dependencies": { "@sentry/core": "11.8.0" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0" + }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" }, diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index a1904cd78987..801ec046e0b8 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -52,6 +52,7 @@ "devDependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", + "@sentry-internal/rollup-utils": "11.8.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" }, diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 726b67e539f0..a28187f94076 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -99,6 +99,8 @@ "stacktrace-parser": "^0.1.11" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", + "cjs-module-lexer": "^2.2.0", "next": "15.5.27", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/packages/nitro/package.json b/packages/nitro/package.json index 5ec0a62cecab..bd0d6f007142 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -43,8 +43,10 @@ "@sentry/server-utils": "11.8.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "h3": "^2.0.1-rc.20", "nitro": "^3.0.260415-beta", + "srvx": "^0.11.22", "unstorage": "^2.0.0-alpha.7" }, "scripts": { diff --git a/packages/node-native/package.json b/packages/node-native/package.json index d74d8798aaa9..d7b6889674e9 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -60,6 +60,7 @@ "@sentry/node": "11.8.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "@types/node": "^20.19.0" }, "volta": { diff --git a/packages/node/package.json b/packages/node/package.json index ad648b05569b..66820c04a670 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -86,6 +86,7 @@ "@sentry/bundler-plugins": "11.8.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "@types/node": "^20.19.0" }, "scripts": { diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 5b8de469cb9f..840a64a77d1c 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -65,12 +65,19 @@ "@sentry/vue": "11.8.0" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20260519.1", "@nuxt/nitro-server": "^3.21.11", + "@nuxt/schema": "^3.21.11", + "@sentry-internal/rollup-utils": "11.8.0", + "db0": "^0.3.4", "h3": "^1.15.10", "nitro": "^3.0.260311-beta", + "nitropack": "^2.13.4", "nuxi": "^3.25.1", "nuxt": "3.21.11", - "vite": "^8.3.1" + "unstorage": "^1.17.5", + "vite": "^8.3.1", + "vue": "^3.5.41" }, "scripts": { "build": "run-s build:types build:transpile", diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index c3089d1f9ab1..b9c0e6e4e2cc 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -34,7 +34,8 @@ "@opentelemetry/api": "^1.9.0" }, "devDependencies": { - "@opentelemetry/api": "^1.10.0" + "@opentelemetry/api": "^1.10.0", + "@sentry-internal/rollup-utils": "11.8.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/profiling-node/package.json b/packages/profiling-node/package.json index 2d15039a2079..aede93e4f503 100644 --- a/packages/profiling-node/package.json +++ b/packages/profiling-node/package.json @@ -55,6 +55,7 @@ "@sentry/node": "11.8.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "@types/node": "^20.19.0" }, "volta": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index bcc70482f8d5..f1d0304eea03 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -70,6 +70,10 @@ "devDependencies": { "@react-router/dev": "^7.17.0", "@react-router/node": "^7.15.0", + "@sentry-internal/rollup-utils": "11.8.0", + "@testing-library/react": "^15.0.5", + "@types/node": "^20.19.0", + "@types/react": "17.0.3", "react": "^18.3.1", "react-router": "^7.18.0", "vite": "^8.3.1" diff --git a/packages/react/package.json b/packages/react/package.json index 17e3a19caf2f..65fd22a6673c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -71,6 +71,7 @@ } }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "@testing-library/react": "^15.0.5", "@testing-library/react-hooks": "^7.0.2", "@types/history-4": "npm:@types/history@4.7.8", diff --git a/packages/remix/package.json b/packages/remix/package.json index 1952617db498..7418326e78df 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -92,9 +92,13 @@ "@remix-run/react": "^2.17.5", "@remix-run/route-pattern": "^0.24.0", "@remix-run/server-runtime": "^2.17.4", + "@sentry-internal/rollup-utils": "11.8.0", "@types/express": "^4.17.14", + "@types/react": "17.0.3", + "@types/semver": "^7.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", + "semver": "^7.8.5", "vite": "^8.3.1" }, "peerDependencies": { diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index ba0dc39ec23f..09e8e7753563 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -58,6 +58,7 @@ }, "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "@sentry/rrweb": "2.44.1" }, "dependencies": { diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index 064c5e526c83..b666d3a29c89 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -73,6 +73,8 @@ "devDependencies": { "@babel/core": "^7.29.6", "@sentry-internal/replay-worker": "11.8.0", + "@sentry-internal/rollup-utils": "11.8.0", + "@sentry/conventions": "^0.27.0", "@sentry/rrweb": "2.44.1", "@sentry/rrweb-snapshot": "2.44.1", "fflate": "0.8.3", diff --git a/packages/rollup-plugin/package.json b/packages/rollup-plugin/package.json index 74d8f39c26a9..283545012928 100644 --- a/packages/rollup-plugin/package.json +++ b/packages/rollup-plugin/package.json @@ -51,6 +51,9 @@ "dependencies": { "@sentry/bundler-plugins": "11.8.0" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0" + }, "peerDependencies": { "rollup": ">=3.2.0" }, diff --git a/packages/server-runtime-injection/package.json b/packages/server-runtime-injection/package.json index a14ef23c5b85..6ed2721683d3 100644 --- a/packages/server-runtime-injection/package.json +++ b/packages/server-runtime-injection/package.json @@ -51,6 +51,7 @@ "devDependencies": { "@apm-js-collab/code-transformer": "^0.18.1", "@apm-js-collab/tracing-hooks": "^0.13.3", + "@sentry-internal/rollup-utils": "11.8.0", "@types/node": "^20.19.0", "@vercel/nft": "^1.11.0", "meriyah": "^6.1.4", diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 6ce9dc818ba7..1ab0c2b80b41 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -103,9 +103,11 @@ "devDependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4", "@neondatabase/serverless": "1.2.0", + "@sentry-internal/rollup-utils": "11.8.0", "@types/node": "^20.19.0", "meriyah": "^6.1.4", - "vite": "^8.3.1" + "vite": "^8.3.1", + "webpack": "^5.104.1" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/server-utils/test/orchestrion/externalRequire.test.ts b/packages/server-utils/test/orchestrion/externalRequire.test.ts index 73951a3b971e..18619295982e 100644 --- a/packages/server-utils/test/orchestrion/externalRequire.test.ts +++ b/packages/server-utils/test/orchestrion/externalRequire.test.ts @@ -168,6 +168,8 @@ describe('createExternalRequireResolver', () => { }); it('keeps a package external that is not installed', async () => { - await expect(resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile)).resolves.toBeNull(); + await expect( + resolveRequire(createExternalRequireResolver(['pg']), 'not-installed-package', pgFile), + ).resolves.toBeNull(); }); }); diff --git a/packages/solid/package.json b/packages/solid/package.json index a6f1a7561dc5..600c74fae0d5 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -72,6 +72,7 @@ } }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "@solidjs/router": "^1.0.0", "@solidjs/testing-library": "0.8.5", "@tanstack/solid-router": "^1.169.2", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index ae1d2146c890..75d6f528fa79 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -90,11 +90,15 @@ "@sentry/conventions": "0.27.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", + "@sentry/browser": "11.8.0", "@solidjs/router": "^1.0.0", "@solidjs/start": "^1.3.2", "@solidjs/testing-library": "0.8.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.5.2", + "@types/node": "^20.19.0", + "nitropack": "^2.13.4", "solid-js": "^1.9.11", "vinxi": "^0.5.11", "vite": "^8.3.1", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index edf1f1671b5c..24bdc1111c23 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -41,6 +41,7 @@ "svelte": "3.x || 4.x || 5.x" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "@sveltejs/vite-plugin-svelte": "1.4.0", "@testing-library/svelte": "^3.2.1", "svelte": "3.59.2", diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index dff463adbfca..4cd737e752bd 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -85,8 +85,11 @@ "sorcery": "1.0.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", + "@sentry/browser": "11.8.0", "@sveltejs/kit": "^2.70.2", "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@types/node": "^20.19.0", "svelte": "^4.2.8", "vite": "^8.3.1" }, diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 8fb673986c4b..67facf555cc5 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -62,6 +62,8 @@ "@sentry/bundler-plugins": "11.8.0" }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", + "@types/node": "^20.19.0", "vite": "^8.3.1" }, "scripts": { diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index eece40a07a5b..01bac2e99907 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@edge-runtime/types": "4.0.0", + "@sentry-internal/rollup-utils": "11.8.0", "@sentry/opentelemetry": "11.8.0" }, "scripts": { diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index eb848edbfb19..ace4ac4508a5 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -50,6 +50,9 @@ "dependencies": { "@sentry/bundler-plugins": "11.8.0" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0" + }, "volta": { "extends": "../../package.json" } diff --git a/packages/vue/package.json b/packages/vue/package.json index c18da3230f13..da81ef86597e 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -62,6 +62,7 @@ } }, "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0", "@tanstack/vue-router": "^1.64.0", "vue": "~3.5.44" }, diff --git a/packages/wasm/package.json b/packages/wasm/package.json index 394b88025568..7f502ea58770 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -35,6 +35,9 @@ "@sentry/browser": "11.8.0", "@sentry/core": "11.8.0" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0" + }, "scripts": { "build": "run-p build:transpile build:bundle build:types", "build:bundle": "rollup --config rollup.bundle.config.mjs", diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json index 376c24635b5d..9a538e9734df 100644 --- a/packages/webpack-plugin/package.json +++ b/packages/webpack-plugin/package.json @@ -55,6 +55,9 @@ "dependencies": { "@sentry/bundler-plugins": "11.8.0" }, + "devDependencies": { + "@sentry-internal/rollup-utils": "11.8.0" + }, "peerDependencies": { "webpack": ">=5.1.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea449d6f0cdc..ff5278597ebd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,8 @@ overrides: '@angular-devkit/build-angular>minimatch': 5.1.9 nitropack>rollup-plugin-visualizer: ^6.0.3 +packageExtensionsChecksum: sha256-fhlie0ez1IGoAMNlEjx0m7qxdvOSi+Hp7amur8v5jaY= + importers: .: @@ -187,195 +189,15 @@ importers: '@rollup/plugin-terser': specifier: ^0.4.4 version: 0.4.4(rollup@4.62.3) - '@sentry-internal/browser-integration-tests': - specifier: workspace:* - version: link:dev-packages/browser-integration-tests - '@sentry-internal/bun-integration-tests': - specifier: workspace:* - version: link:dev-packages/bun-integration-tests - '@sentry-internal/bundler-plugin-integration-tests': - specifier: workspace:* - version: link:dev-packages/bundler-plugin-integration-tests - '@sentry-internal/bundler-tests': - specifier: workspace:* - version: link:dev-packages/bundler-tests - '@sentry-internal/clear-cache-gh-action': - specifier: workspace:* - version: link:dev-packages/clear-cache-gh-action - '@sentry-internal/cloudflare-integration-tests': - specifier: workspace:* - version: link:dev-packages/cloudflare-integration-tests - '@sentry-internal/deno-integration-tests': - specifier: workspace:* - version: link:dev-packages/deno-integration-tests - '@sentry-internal/e2e-tests': - specifier: workspace:* - version: link:dev-packages/e2e-tests - '@sentry-internal/external-contributor-gh-action': - specifier: workspace:* - version: link:dev-packages/external-contributor-gh-action - '@sentry-internal/integration-shims': - specifier: workspace:* - version: link:packages/integration-shims '@sentry-internal/node-integration-tests': specifier: workspace:* version: link:dev-packages/node-integration-tests - '@sentry-internal/optional-e2e-gh-action': - specifier: workspace:* - version: link:dev-packages/optional-e2e-gh-action - '@sentry-internal/replay-worker': - specifier: workspace:* - version: link:packages/replay-worker - '@sentry-internal/rollup-utils': - specifier: workspace:* - version: link:dev-packages/rollup-utils - '@sentry-internal/size-limit-gh-action': - specifier: workspace:* - version: link:dev-packages/size-limit-gh-action - '@sentry-internal/test-utils': - specifier: workspace:* - version: link:dev-packages/test-utils - '@sentry/angular': - specifier: workspace:* - version: link:packages/angular - '@sentry/astro': - specifier: workspace:* - version: link:packages/astro - '@sentry/aws-serverless': - specifier: workspace:* - version: link:packages/aws-serverless - '@sentry/browser': - specifier: workspace:* - version: link:packages/browser - '@sentry/browser-utils': - specifier: workspace:* - version: link:packages/browser-utils - '@sentry/bun': - specifier: workspace:* - version: link:packages/bun - '@sentry/bundler-plugins': - specifier: workspace:* - version: link:packages/bundler-plugins - '@sentry/cloudflare': - specifier: workspace:* - version: link:packages/cloudflare - '@sentry/core': - specifier: workspace:* - version: link:packages/core - '@sentry/deno': - specifier: workspace:* - version: link:packages/deno - '@sentry/effect': - specifier: workspace:* - version: link:packages/effect - '@sentry/elysia': - specifier: workspace:* - version: link:packages/elysia - '@sentry/ember': - specifier: workspace:* - version: link:packages/ember - '@sentry/esbuild-plugin': - specifier: workspace:* - version: link:packages/esbuild-plugin - '@sentry/eslint-config-sdk': - specifier: workspace:* - version: link:packages/eslint-config-sdk '@sentry/eslint-plugin-sdk': specifier: workspace:* version: link:packages/eslint-plugin-sdk - '@sentry/feedback': - specifier: workspace:* - version: link:packages/feedback - '@sentry/gatsby': - specifier: workspace:* - version: link:packages/gatsby - '@sentry/google-cloud-serverless': - specifier: workspace:* - version: link:packages/google-cloud-serverless - '@sentry/hono': - specifier: workspace:* - version: link:packages/hono - '@sentry/nestjs': - specifier: workspace:* - version: link:packages/nestjs - '@sentry/nextjs': - specifier: workspace:* - version: link:packages/nextjs - '@sentry/nitro': - specifier: workspace:* - version: link:packages/nitro - '@sentry/node': - specifier: workspace:* - version: link:packages/node - '@sentry/node-native': - specifier: workspace:* - version: link:packages/node-native - '@sentry/nuxt': - specifier: workspace:* - version: link:packages/nuxt - '@sentry/opentelemetry': - specifier: workspace:* - version: link:packages/opentelemetry - '@sentry/profiling-node': - specifier: workspace:* - version: link:packages/profiling-node - '@sentry/react': - specifier: workspace:* - version: link:packages/react - '@sentry/react-router': - specifier: workspace:* - version: link:packages/react-router - '@sentry/remix': - specifier: workspace:* - version: link:packages/remix - '@sentry/replay': - specifier: workspace:* - version: link:packages/replay-internal - '@sentry/replay-canvas': - specifier: workspace:* - version: link:packages/replay-canvas - '@sentry/rollup-plugin': - specifier: workspace:* - version: link:packages/rollup-plugin - '@sentry/server-runtime-injection': - specifier: workspace:* - version: link:packages/server-runtime-injection - '@sentry/server-utils': - specifier: workspace:* - version: link:packages/server-utils - '@sentry/solid': - specifier: workspace:* - version: link:packages/solid - '@sentry/solidstart': - specifier: workspace:* - version: link:packages/solidstart - '@sentry/svelte': - specifier: workspace:* - version: link:packages/svelte '@sentry/sveltekit': specifier: workspace:* version: link:packages/sveltekit - '@sentry/tanstackstart-react': - specifier: workspace:* - version: link:packages/tanstackstart-react - '@sentry/typescript': - specifier: workspace:* - version: link:packages/typescript - '@sentry/vercel-edge': - specifier: workspace:* - version: link:packages/vercel-edge - '@sentry/vite-plugin': - specifier: workspace:* - version: link:packages/vite-plugin - '@sentry/vue': - specifier: workspace:* - version: link:packages/vue - '@sentry/wasm': - specifier: workspace:* - version: link:packages/wasm - '@sentry/webpack-plugin': - specifier: workspace:* - version: link:packages/webpack-plugin '@size-limit/esbuild': specifier: ~12.1.0 version: 12.1.0(size-limit@12.1.0(jiti@2.7.0)) @@ -442,6 +264,9 @@ importers: size-limit: specifier: ~12.1.0 version: 12.1.0(jiti@2.7.0) + tslib: + specifier: ^2.4.1 + version: 2.8.1 tsx: specifier: ^4.23.0 version: 4.23.0 @@ -451,6 +276,9 @@ importers: vitest: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@21.1.2(supports-color@10.0.0))(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) + webpack: + specifier: ^5.104.1 + version: 5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3) yalc: specifier: ^1.0.0-pre.53 version: 1.0.0-pre.53 @@ -500,6 +328,21 @@ importers: specifier: ^5.95.0 version: 5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3) devDependencies: + '@sentry/browser-utils': + specifier: 11.8.0 + version: link:../../packages/browser-utils + '@sentry/core': + specifier: 11.8.0 + version: link:../../packages/core + '@sentry/replay-canvas': + specifier: 11.8.0 + version: link:../../packages/replay-canvas + '@sentry/rrweb-types': + specifier: ^2.44.1 + version: 2.44.1 + '@sentry/wasm': + specifier: 11.8.0 + version: link:../../packages/wasm '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -534,6 +377,15 @@ importers: '@sentry-internal/test-utils': specifier: 11.8.0 version: link:../test-utils + '@sentry/core': + specifier: 11.8.0 + version: link:../../packages/core + '@types/mysql': + specifier: ^2.15.27 + version: 2.15.27 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 bun-types: specifier: ^1.2.9 version: 1.2.9 @@ -550,6 +402,9 @@ importers: premove: specifier: ^4.0.0 version: 4.0.0 + sentry: + specifier: ^0.45.0 + version: 0.45.0 typescript: specifier: ~6.0.3 version: 6.0.3 @@ -648,6 +503,9 @@ importers: '@cloudflare/workers-types-v5': specifier: npm:@cloudflare/workers-types@5.20260710.1 version: '@cloudflare/workers-types@5.20260710.1' + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.10.0 '@sentry-internal/test-utils': specifier: 11.8.0 version: link:../test-utils @@ -688,6 +546,9 @@ importers: '@sentry-internal/node-integration-tests': specifier: 11.8.0 version: link:../node-integration-tests + '@sentry/conventions': + specifier: ^0.27.0 + version: 0.27.0 vitest: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@21.1.2(supports-color@10.0.0))(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) @@ -806,9 +667,15 @@ importers: '@sentry/node': specifier: 11.8.0 version: link:../../packages/node + '@sentry/server-runtime-injection': + specifier: 11.8.0 + version: link:../../packages/server-runtime-injection '@sentry/server-utils': specifier: 11.8.0 version: link:../../packages/server-utils + '@smithy/node-http-handler': + specifier: 4.12.1 + version: 4.12.1 '@types/mongodb': specifier: ^3.6.20 version: 3.6.20 @@ -969,21 +836,33 @@ importers: specifier: ^16.2.0 version: 16.2.0 devDependencies: + '@hapi/boom': + specifier: ^10.0.1 + version: 10.0.1 '@neondatabase/serverless': specifier: 1.2.0 version: 1.2.0 '@opentelemetry/instrumentation-http': specifier: 0.220.0 version: 0.220.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../rollup-utils '@sentry-internal/test-utils': specifier: 11.8.0 version: link:../test-utils '@sentry/conventions': specifier: 0.27.0 version: 0.27.0 + '@sentry/node-native': + specifier: 11.8.0 + version: link:../../packages/node-native '@types/amqplib': specifier: ^0.10.5 version: 0.10.5 + '@types/express': + specifier: ^4.17.21 + version: 4.17.21 '@types/node-cron': specifier: ^3.0.11 version: 3.0.11 @@ -1002,6 +881,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + winston-transport: + specifier: ^4.9.0 + version: 4.9.0 zod: specifier: ^3.24.1 version: 3.25.76 @@ -1070,9 +952,15 @@ importers: '@playwright/test': specifier: 1.64.1 version: 1.64.1 + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../rollup-utils '@sentry/core': specifier: 11.8.0 version: link:../../packages/core + '@types/express': + specifier: ^4.17.21 + version: 4.17.21 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -1162,6 +1050,9 @@ importers: specifier: 11.8.0 version: link:../server-utils devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils astro: specifier: ^4.16.19 version: 4.16.19(@types/node@20.19.43)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(rollup@4.62.3)(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(typescript@7.0.2) @@ -1190,6 +1081,9 @@ importers: specifier: ^8.10.161 version: 8.10.161 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -1221,6 +1115,9 @@ importers: '@sentry-internal/integration-shims': specifier: 11.8.0 version: link:../integration-shims + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils fake-indexeddb: specifier: ^6.2.5 version: 6.2.5 @@ -1236,6 +1133,10 @@ importers: web-vitals: specifier: ^6.2.3 version: 6.2.3 + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils packages/bun: dependencies: @@ -1252,6 +1153,9 @@ importers: specifier: 11.8.0 version: link:../server-utils devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils bun-types: specifier: ^1.2.9 version: 1.2.9 @@ -1283,9 +1187,18 @@ importers: '@babel/core': specifier: ^7.18.5 version: 7.29.7(supports-color@8.1.1) + '@babel/parser': + specifier: ^7.29.8 + version: 7.29.9 '@babel/preset-react': specifier: ^7.23.3 version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils + '@sentry-internal/test-utils': + specifier: 11.8.0 + version: link:../../dev-packages/test-utils '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -1329,9 +1242,18 @@ importers: '@cloudflare/workers-types': specifier: 5.20260710.1 version: 5.20260710.1 + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 + acorn: + specifier: ^8.14.0 + version: 8.18.0 + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) wrangler: specifier: 4.61.0 version: 4.61.0(@cloudflare/workers-types@5.20260710.1) @@ -1342,6 +1264,9 @@ importers: specifier: ^0.27.0 version: 0.27.0 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils zod: specifier: ^3.24.1 version: 3.25.76 @@ -1363,6 +1288,10 @@ importers: '@sentry/server-utils': specifier: 11.8.0 version: link:../server-utils + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils packages/effect: dependencies: @@ -1382,6 +1311,9 @@ importers: '@effect/vitest': specifier: ^4.0.0 version: 4.0.0(effect@4.0.0)(vitest@4.1.11) + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils effect: specifier: ^4.0.0 version: 4.0.0 @@ -1398,6 +1330,9 @@ importers: specifier: 11.8.0 version: link:../core devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils bun-types: specifier: ^1.2.9 version: 1.2.9 @@ -1465,12 +1400,19 @@ importers: typescript: specifier: ~5.8.0 version: 5.8.3 + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) packages/esbuild-plugin: dependencies: '@sentry/bundler-plugins': specifier: 11.8.0 version: link:../bundler-plugins + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils packages/eslint-config-sdk: dependencies: @@ -1506,7 +1448,14 @@ importers: specifier: 8.57.0 version: 8.57.0(supports-color@10.0.0) - packages/eslint-plugin-sdk: {} + packages/eslint-plugin-sdk: + devDependencies: + '@types/eslint': + specifier: ^8.2.1 + version: 8.2.1 + eslint: + specifier: ^8.57.0 + version: 8.57.0(supports-color@10.0.0) packages/feedback: dependencies: @@ -1514,6 +1463,9 @@ importers: specifier: 11.8.0 version: link:../core devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils preact: specifier: ^10.29.8 version: 10.29.8 @@ -1530,6 +1482,9 @@ importers: specifier: 11.8.0 version: link:../react devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@testing-library/react': specifier: ^15.0.5 version: 15.0.5(@types/react@17.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1567,6 +1522,9 @@ importers: '@google-cloud/common': specifier: ^3.4.1 version: 3.6.0(encoding@0.1.13)(supports-color@10.0.0) + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@types/express': specifier: ^5.0.6 version: 5.0.6 @@ -1595,9 +1553,27 @@ importers: '@cloudflare/workers-types': specifier: 4.20250922.0 version: 4.20250922.0 + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils + '@sentry/bun': + specifier: 11.8.0 + version: link:../bun + '@sentry/cloudflare': + specifier: 11.8.0 + version: link:../cloudflare + '@sentry/deno': + specifier: 11.8.0 + version: link:../deno + '@sentry/node': + specifier: 11.8.0 + version: link:../node '@types/node': specifier: ^20.19.0 version: 20.19.43 + hono: + specifier: ^4.13.7 + version: 4.13.7 wrangler: specifier: 4.62.0 version: 4.62.0(@cloudflare/workers-types@4.20250922.0) @@ -1607,6 +1583,10 @@ importers: '@sentry/core': specifier: 11.8.0 version: link:../core + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils packages/nestjs: dependencies: @@ -1629,6 +1609,9 @@ importers: '@nestjs/core': specifier: ^10.0.0 version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.2))(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -1684,6 +1667,12 @@ importers: specifier: ^0.1.11 version: 0.1.11 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils + cjs-module-lexer: + specifier: ^2.2.0 + version: 2.2.0 next: specifier: 15.5.27 version: 15.5.27(@babel/core@7.29.7(supports-color@10.0.0))(@opentelemetry/api@1.10.0)(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) @@ -1715,12 +1704,18 @@ importers: specifier: 11.8.0 version: link:../server-utils devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils h3: specifier: ^2.0.1-rc.20 version: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) nitro: specifier: ^3.0.260415-beta version: 3.0.260415-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) + srvx: + specifier: ^0.11.22 + version: 0.11.22 unstorage: specifier: ^2.0.0-alpha.7 version: 2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@1.5.1) @@ -1749,6 +1744,9 @@ importers: specifier: 11.8.0 version: link:../server-utils devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -1765,6 +1763,9 @@ importers: specifier: ^0.5.1 version: 0.5.1 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -1802,24 +1803,45 @@ importers: specifier: 11.8.0 version: link:../vue devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260519.1 + version: 4.20260519.1 '@nuxt/nitro-server': specifier: ^3.21.11 version: 3.21.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(encoding@0.1.13)(esbuild@0.28.1)(ioredis@5.11.0(supports-color@10.0.0))(magicast@0.5.4)(mysql2@3.23.3(@types/node@20.19.43))(nuxt@3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c))(oxc-parser@0.152.0)(rolldown@1.2.11)(rollup@4.62.3)(srvx@0.11.22)(supports-color@10.0.0)(typescript@7.0.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) + '@nuxt/schema': + specifier: ^3.21.11 + version: 3.21.11 + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils + db0: + specifier: ^0.3.4 + version: 0.3.4(mysql2@3.23.3(@types/node@20.19.43)) h3: specifier: ^1.15.10 version: 1.15.11 nitro: specifier: ^3.0.260311-beta version: 3.0.260311-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) + nitropack: + specifier: ^2.13.4 + version: 2.13.4(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(encoding@0.1.13)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(srvx@0.11.22)(supports-color@10.0.0)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) nuxi: specifier: ^3.25.1 version: 3.25.1 nuxt: specifier: 3.21.11 version: 3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c) + unstorage: + specifier: ^1.17.5 + version: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0)) vite: specifier: ^8.3.1 version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) + vue: + specifier: ^3.5.41 + version: 3.5.44(typescript@7.0.2) packages/opentelemetry: dependencies: @@ -1833,6 +1855,9 @@ importers: '@opentelemetry/api': specifier: ^1.10.0 version: 1.10.0 + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils packages/profiling-node: dependencies: @@ -1846,6 +1871,9 @@ importers: specifier: ^2.4.4 version: 2.4.4 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -1862,6 +1890,9 @@ importers: specifier: 11.8.0 version: link:../core devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@testing-library/react': specifier: ^15.0.5 version: 15.0.5(@types/react@17.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1959,6 +1990,18 @@ importers: '@react-router/node': specifier: ^7.15.0 version: 7.18.1(react-router@7.18.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@6.0.3) + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils + '@testing-library/react': + specifier: ^15.0.5 + version: 15.0.5(@types/react@17.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 + '@types/react': + specifier: 17.0.3 + version: 17.0.3 react: specifier: ^18.3.1 version: 18.3.1 @@ -2017,15 +2060,27 @@ importers: '@remix-run/server-runtime': specifier: ^2.17.4 version: 2.17.5(typescript@5.8.3) + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@types/express': specifier: ^4.17.14 version: 4.17.21 + '@types/react': + specifier: 17.0.3 + version: 17.0.3 + '@types/semver': + specifier: ^7.5.3 + version: 7.5.3 react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + semver: + specifier: ^7.8.5 + version: 7.8.5 vite: specifier: ^8.3.1 version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) @@ -2039,6 +2094,9 @@ importers: specifier: 11.8.0 version: link:../replay-internal devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@sentry/rrweb': specifier: 2.44.1 version: 2.44.1 @@ -2058,6 +2116,12 @@ importers: '@sentry-internal/replay-worker': specifier: 11.8.0 version: link:../replay-worker + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils + '@sentry/conventions': + specifier: ^0.27.0 + version: 0.27.0 '@sentry/rrweb': specifier: 2.44.1 version: 2.44.1 @@ -2085,6 +2149,10 @@ importers: '@sentry/bundler-plugins': specifier: 11.8.0 version: link:../bundler-plugins + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils packages/server-runtime-injection: dependencies: @@ -2101,6 +2169,9 @@ importers: '@apm-js-collab/tracing-hooks': specifier: ^0.13.3 version: 0.13.3(supports-color@7.2.0) + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -2128,10 +2199,13 @@ importers: devDependencies: '@apm-js-collab/code-transformer-bundler-plugins': specifier: ^0.7.4 - version: 0.7.4 + version: 0.7.4(esbuild@0.28.1)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) '@neondatabase/serverless': specifier: 1.2.0 version: 1.2.0 + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 version: 20.19.43 @@ -2141,6 +2215,9 @@ importers: vite: specifier: ^8.3.1 version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) + webpack: + specifier: ^5.104.1 + version: 5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3) packages/solid: dependencies: @@ -2154,6 +2231,9 @@ importers: specifier: 11.8.0 version: link:../core devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@solidjs/router': specifier: ^1.0.0 version: 1.0.0(solid-js@1.9.11) @@ -2209,6 +2289,12 @@ importers: specifier: 11.8.0 version: link:../solid devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils + '@sentry/browser': + specifier: 11.8.0 + version: link:../browser '@solidjs/router': specifier: ^1.0.0 version: 1.0.0(solid-js@1.9.11) @@ -2224,6 +2310,12 @@ importers: '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 + nitropack: + specifier: ^2.13.4 + version: 2.13.4(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(encoding@0.1.13)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(srvx@0.11.22)(supports-color@10.0.0)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) solid-js: specifier: ^1.9.11 version: 1.9.11 @@ -2252,6 +2344,9 @@ importers: specifier: ~0.30.0 version: 0.30.21 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@sveltejs/vite-plugin-svelte': specifier: 1.4.0 version: 1.4.0(supports-color@10.0.0)(svelte@3.59.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) @@ -2307,12 +2402,21 @@ importers: specifier: 1.0.0 version: 1.0.0 devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils + '@sentry/browser': + specifier: 11.8.0 + version: link:../browser '@sveltejs/kit': specifier: ^2.70.2 version: 2.70.2(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@3.1.2(supports-color@10.0.0)(svelte@4.2.8)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)))(svelte@4.2.8)(typescript@6.0.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) '@sveltejs/vite-plugin-svelte': specifier: ^3.0.0 version: 3.1.2(supports-color@10.0.0)(svelte@4.2.8)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 svelte: specifier: ^4.2.8 version: 4.2.8 @@ -2350,6 +2454,12 @@ importers: specifier: 11.8.0 version: link:../server-utils devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 vite: specifier: ^8.3.1 version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) @@ -2371,6 +2481,9 @@ importers: '@edge-runtime/types': specifier: 4.0.0 version: 4.0.0 + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@sentry/opentelemetry': specifier: 11.8.0 version: link:../opentelemetry @@ -2380,6 +2493,10 @@ importers: '@sentry/bundler-plugins': specifier: 11.8.0 version: link:../bundler-plugins + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils packages/vue: dependencies: @@ -2393,6 +2510,9 @@ importers: specifier: 11.8.0 version: link:../core devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils '@tanstack/vue-router': specifier: ^1.64.0 version: 1.141.4(vue@3.5.44(typescript@7.0.2)) @@ -2408,12 +2528,20 @@ importers: '@sentry/core': specifier: 11.8.0 version: link:../core + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils packages/webpack-plugin: dependencies: '@sentry/bundler-plugins': specifier: 11.8.0 version: link:../bundler-plugins + devDependencies: + '@sentry-internal/rollup-utils': + specifier: 11.8.0 + version: link:../../dev-packages/rollup-utils packages: @@ -2645,6 +2773,20 @@ packages: '@apm-js-collab/code-transformer-bundler-plugins@0.7.4': resolution: {integrity: sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg==} engines: {node: '>=18.0.0'} + peerDependencies: + esbuild: '*' + rollup: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true '@apm-js-collab/code-transformer@0.18.1': resolution: {integrity: sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==} @@ -19742,12 +19884,17 @@ snapshots: optionalDependencies: zod: 3.25.76 - '@apm-js-collab/code-transformer-bundler-plugins@0.7.4': + '@apm-js-collab/code-transformer-bundler-plugins@0.7.4(esbuild@0.28.1)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))': dependencies: '@apm-js-collab/code-transformer': 0.18.1 es-module-lexer: 2.1.0 magic-string: 0.30.21 module-details-from-path: 1.0.4 + optionalDependencies: + esbuild: 0.28.1 + rollup: 4.62.3 + vite: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) + webpack: 5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3) '@apm-js-collab/code-transformer@0.18.1': dependencies: @@ -24217,7 +24364,7 @@ snapshots: '@npmcli/fs@2.1.2': dependencies: '@gar/promisify': 1.1.3 - semver: 7.5.3 + semver: 7.8.5 '@npmcli/git@3.0.2': dependencies: @@ -24228,7 +24375,7 @@ snapshots: proc-log: 2.0.1 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.5.3 + semver: 7.8.5 which: 2.0.2 transitivePeerDependencies: - bluebird @@ -26883,7 +27030,7 @@ snapshots: '@types/body-parser@1.19.2': dependencies: '@types/connect': 3.4.38 - '@types/node': 14.18.63 + '@types/node': 20.19.43 '@types/bonjour@3.5.13': dependencies: @@ -26906,7 +27053,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 14.18.63 + '@types/node': 20.19.43 '@types/cookie@0.6.0': {} @@ -26938,7 +27085,7 @@ snapshots: '@types/express-serve-static-core@4.17.43': dependencies: - '@types/node': 14.18.63 + '@types/node': 20.19.43 '@types/qs': 6.9.6 '@types/range-parser': 1.2.3 '@types/send': 0.17.4 @@ -26955,7 +27102,7 @@ snapshots: '@types/body-parser': 1.19.2 '@types/express-serve-static-core': 4.17.43 '@types/qs': 6.9.6 - '@types/serve-static': 1.15.5 + '@types/serve-static': 2.2.0 '@types/express@5.0.6': dependencies: @@ -27111,7 +27258,7 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.2 - '@types/node': 14.18.63 + '@types/node': 20.19.43 '@types/serve-index@1.9.4': dependencies: @@ -28951,7 +29098,7 @@ snapshots: builtins@5.0.1: dependencies: - semver: 7.5.3 + semver: 7.8.5 bun-types@1.2.9: dependencies: @@ -29560,7 +29707,7 @@ snapshots: postcss-modules-scope: 3.0.0(postcss@8.4.31) postcss-modules-values: 4.0.0(postcss@8.4.31) postcss-value-parser: 4.2.0 - semver: 7.5.3 + semver: 7.8.5 webpack: 5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3) css-prefers-color-scheme@6.0.3(postcss@8.4.31): @@ -34866,7 +35013,7 @@ snapshots: nopt: 6.0.0 npmlog: 6.0.2 rimraf: 3.0.2 - semver: 7.5.3 + semver: 7.8.5 tar: 6.2.1 which: 2.0.2 transitivePeerDependencies: @@ -34918,7 +35065,7 @@ snapshots: dependencies: hosted-git-info: 5.1.0 is-core-module: 2.16.1 - semver: 7.5.3 + semver: 7.8.5 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -34935,7 +35082,7 @@ snapshots: npm-install-checks@5.0.0: dependencies: - semver: 7.5.3 + semver: 7.8.5 npm-normalize-package-bin@1.0.1: {} @@ -34947,7 +35094,7 @@ snapshots: dependencies: hosted-git-info: 5.1.0 proc-log: 2.0.1 - semver: 7.5.3 + semver: 7.8.5 validate-npm-package-name: 4.0.0 npm-packlist@2.2.2: @@ -34969,7 +35116,7 @@ snapshots: npm-install-checks: 5.0.0 npm-normalize-package-bin: 1.0.1 npm-package-arg: 9.1.0 - semver: 7.5.3 + semver: 7.8.5 npm-registry-fetch@13.3.1(supports-color@10.0.0): dependencies: @@ -36281,7 +36428,7 @@ snapshots: cosmiconfig: 7.1.0 klona: 2.0.6 postcss: 8.4.31 - semver: 7.5.3 + semver: 7.8.5 webpack: 5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3) postcss-logical@5.0.4(postcss@8.4.31): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7416bffa6632..19563e5cb4a2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -69,8 +69,6 @@ strictPeerDependencies: false # The packages depend on each other by exact version, not with `workspace:`. # This links them from the workspace instead of installing them from npm. linkWorkspacePackages: true -# A flat node_modules folder like Yarn 1, because some packages and configs import dependencies that they do not declare. -nodeLinker: hoisted overrides: nx>minimatch: 10.2.5 @@ -78,6 +76,25 @@ overrides: '@angular-devkit/build-angular>minimatch': 5.1.9 nitropack>rollup-plugin-visualizer: ^6.0.3 +# Dependencies that import packages without declaring them. pnpm links these peers to the versions of the importer. +packageExtensions: + # Its type declarations import the bundler types: https://github.com/apm-js-collab/code-transformer-bundler-plugins/issues/49 + '@apm-js-collab/code-transformer-bundler-plugins': + peerDependencies: + esbuild: '*' + rollup: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + # Dependencies whose install scripts run. pnpm 11+ fails the install on build scripts that are not listed. # `false` only for scripts that just print a message. allowBuilds: diff --git a/scripts/verify-packages-versions.js b/scripts/verify-packages-versions.js index c45e2206ad9a..7268b26ba961 100644 --- a/scripts/verify-packages-versions.js +++ b/scripts/verify-packages-versions.js @@ -59,19 +59,4 @@ must contain the same packages in the same order. process.exit(1); } -// Configs import workspace packages that they do not declare, so every workspace package must be linked into the -// root node_modules folder, as Yarn did. -const missingRootDevDependencies = pkg.workspaces - .map(workspace => require(path.join(__dirname, '..', workspace, 'package.json')).name) - .filter(name => pkg.devDependencies[name] !== 'workspace:*'); - -if (missingRootDevDependencies.length > 0) { - console.error(` -[MISSING ROOT DEV DEPENDENCIES]: Every workspace package must be in the \`devDependencies\` of the root package.json -with the version \`workspace:*\`. Missing: -${missingRootDevDependencies.join('\n')} -`); - process.exit(1); -} - process.exit(0); From 21f2cf118df6f30530026d833deac59cbd607493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 22 Oct 2026 20:42:59 +0200 Subject: [PATCH 008/126] chore: Pin pnpm 10 in Volta and remove .npmrc (#25641) This changes the Volta pnpm pin from 9.15.9 to 10.34.6 and removes the root `.npmrc`. Volta cannot start pnpm 11 or newer ([volta-cli/volta#2114](https://github.com/volta-cli/volta/issues/2114)), and pnpm 10 switches to the `packageManager` version by default, so `manage-package-manager-versions=true` is not needed. `package.json` and `CONTRIBUTING.md` now explain the pin and that it needs `VOLTA_FEATURE_PNPM=1` in the shell profile. ## Why - `pnpm test --watch` failed locally with `Unknown option: 'watch'`, because pnpm 9 reads the options of its `test` command before it switches. - npm warned `Unknown project config "manage-package-manager-versions"` at every call in the repo. - Without `VOLTA_FEATURE_PNPM=1`, Volta ignores the pin, and the docs did not name the variable. Co-authored-by: Claude Opus 5.5 --- .../fix-security-vulnerability/scripts/classify-alerts.mjs | 2 +- .npmrc | 3 --- CONTRIBUTING.md | 5 +++-- package.json | 6 +++++- 4 files changed, 9 insertions(+), 7 deletions(-) delete mode 100644 .npmrc diff --git a/.agents/skills/fix-security-vulnerability/scripts/classify-alerts.mjs b/.agents/skills/fix-security-vulnerability/scripts/classify-alerts.mjs index 9d516625709e..03723204cb62 100644 --- a/.agents/skills/fix-security-vulnerability/scripts/classify-alerts.mjs +++ b/.agents/skills/fix-security-vulnerability/scripts/classify-alerts.mjs @@ -69,7 +69,7 @@ function fetchOpenAlerts() { } // Resolve a dependency's installed directory, preferring the consumer's nested node_modules and -// falling back to the hoisted root node_modules (`node-linker=hoisted` in .npmrc hoists most deps to the root). +// falling back to the hoisted root node_modules (`nodeLinker: hoisted` in pnpm-workspace.yaml hoists most deps to the root). function resolveDepDir(name, baseDir) { const candidates = [join(baseDir, 'node_modules', name), join(REPO_ROOT, 'node_modules', name)]; return candidates.find(dir => existsSync(join(dir, 'package.json'))); diff --git a/.npmrc b/.npmrc deleted file mode 100644 index d2209a0ecc1b..000000000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -# Volta runs pnpm 9.15.9, because it cannot run newer versions. This setting makes pnpm 9 switch to the -# `packageManager` version of package.json. The pnpm settings are in pnpm-workspace.yaml. -manage-package-manager-versions=true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c65ca5d97765..951352e7be99 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,8 +14,9 @@ Developer Documentation. ## Setting up an Environment -We use [Volta](https://volta.sh/) to ensure we use consistent versions of node and pnpm. Make sure to enable -[pnpm support in Volta](https://docs.volta.sh/advanced/pnpm). +We use [Volta](https://volta.sh/) to ensure we use consistent versions of node and pnpm. Volta manages pnpm only when +[pnpm support](https://docs.volta.sh/advanced/pnpm) is enabled, so add `export VOLTA_FEATURE_PNPM=1` to your shell +profile (for example `~/.zshrc`). `sentry-javascript` is a monorepo containing several packages, managed with pnpm workspaces and Nx. To get started, install all dependencies, and then perform an initial build, so TypeScript can read all of the linked type definitions. diff --git a/package.json b/package.json index 793d94d14968..7ad8d15938b9 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,13 @@ "yalc:publish": "nx run-many -t yalc:publish" }, "packageManager": "pnpm@12.11.2", + "//_volta_comment": [ + "Volta cannot start pnpm 11 or newer (volta-cli/volta#2114). It starts this pnpm 10 version, which switches to the `packageManager` version.", + "Volta reads the pnpm version only when `VOLTA_FEATURE_PNPM=1` is set, see CONTRIBUTING.md." + ], "volta": { "node": "24.22.1", - "pnpm": "9.15.9" + "pnpm": "10.34.6" }, "//_workspaces_comment": [ "pnpm reads the workspace packages from pnpm-workspace.yaml. Deno and the repo scripts read this list.", From 3aafe9e6dc0fd176336bb03e91324c2e0b6aafc9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:21:52 +0000 Subject: [PATCH 009/126] ci(e2e): rightsize namespace runner jobs --- .../actions/install-dependencies/action.yml | 1 + .github/actions/install-playwright/action.yml | 7 ++ .github/workflows/build.yml | 109 ++++++------------ .../workflows/enforce-license-compliance.yml | 6 + dev-packages/e2e-tests/README.md | 4 + dev-packages/e2e-tests/lib/getTestMatrix.mjs | 8 +- .../aws-serverless-layer/package.json | 1 + .../aws-serverless-layer/tests/layer.test.ts | 5 +- .../browser-bfcache/package.json | 3 + .../nextjs-15-basepath/package.json | 3 + .../nextjs-15-intl/package.json | 3 + .../nextjs-15-t3/package.json | 3 + .../test-applications/nextjs-15/package.json | 1 + .../nextjs-16-cacheComponents/package.json | 1 + .../nextjs-16-static/package.json | 3 + .../package.json | 1 + .../nextjs-16-streaming/package.json | 1 + .../nextjs-16-tunnel/package.json | 1 + .../test-applications/nextjs-16/package.json | 1 + .../nextjs-app-dir/package.json | 1 + .../nextjs-pages-dir/package.json | 1 + .../test-applications/nuxt-3-min/package.json | 3 + .../nuxt-4-static/package.json | 1 + .../test-applications/nuxt-4/package.json | 1 + .../react-router-6/package.json | 3 + .../react-router-7-lazy-routes/package.json | 3 + .../react-router-8-framework/package.json | 1 + .../scripts/use-ts-5_0.js | 16 --- .../src/coreHandlers/handleClick.ts | 9 +- .../unit/coreHandlers/handleClick.test.ts | 32 +++++ 30 files changed, 133 insertions(+), 100 deletions(-) delete mode 100644 dev-packages/node-integration-tests/scripts/use-ts-5_0.js diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index 27971a484f63..2a4b331fd3f6 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -11,6 +11,7 @@ runs: echo 'paths<> "$GITHUB_OUTPUT" shell: bash diff --git a/.github/actions/install-playwright/action.yml b/.github/actions/install-playwright/action.yml index bd84a13e9107..378e77666093 100644 --- a/.github/actions/install-playwright/action.yml +++ b/.github/actions/install-playwright/action.yml @@ -34,6 +34,13 @@ runs: playwright apt + # `--with-deps` names packages that the runner image already has (glib, mesa, nss, ...), and `apt-get install` would + # upgrade them to the latest Ubuntu update. That is half of the download on a cold cache, and the image versions work. + - name: Keep installed apt packages + if: steps.playwright-version.outputs.version != '' + run: echo 'APT::Get::Upgrade "false";' | sudo tee /etc/apt/apt.conf.d/99-no-upgrade > /dev/null + shell: bash + # Downloads only the browsers and apt packages that are not on the cache volume yet. `npx` runs outside the repo, # so it can't pick up a `playwright` that a parallel `pnpm install` is still linking. - name: Install Playwright diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 23377c9c06cc..e202edc68667 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -343,6 +343,14 @@ jobs: run: pnpm run lint:es-compatibility - name: Lint types run: pnpm run lint:types + - name: Check for circular dependencies + run: pnpm run circularDepCheck + - name: Check for dts files that reference stuff in the temporary build folder + run: | + if grep -r --include "*.d.ts" --exclude-dir ".nx" 'import("@sentry(-internal)?/[^/]*/build' .; then + echo "Found illegal TypeScript import statement." + exit 1 + fi - name: Check Playwright versions run: | version=$(node -p "require('./dev-packages/test-utils/package.json').devDependencies['@playwright/test']") @@ -354,7 +362,8 @@ jobs: job_check_lockfile: name: Check lockfile needs: [job_get_metadata] - timeout-minutes: 10 + # `pnpm dedupe --check` fetches metadata for every package from npm, so this job can take 8+ min on PRs. + timeout-minutes: 20 runs-on: namespace-profile-sentry-javascript if: | needs.job_get_metadata.outputs.changed_deps == 'true' || @@ -406,27 +415,6 @@ jobs: - name: Check file formatting run: pnpm run format:check - job_circular_dep_check: - name: Circular Dependency Check - needs: [job_get_metadata, job_build] - timeout-minutes: 10 - runs-on: - namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' - && '-pr' || '' }} - steps: - - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v7 - with: - ref: ${{ env.HEAD_COMMIT }} - - name: Set up Node - uses: actions/setup-node@v7 - with: - node-version-file: 'package.json' - - name: Restore caches - uses: ./.github/actions/restore-cache - - name: Run madge - run: pnpm run circularDepCheck - job_artifacts: name: Upload Artifacts needs: [job_get_metadata, job_build, job_build_layer, job_build_bundles, job_build_tarballs] @@ -772,35 +760,8 @@ jobs: overwrite: true retention-days: 7 - job_check_for_faulty_dts: - name: Check for faulty .d.ts files - needs: [job_get_metadata, job_build] - runs-on: - namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' - && '-pr' || '' }} - timeout-minutes: 5 - steps: - - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v7 - with: - ref: ${{ env.HEAD_COMMIT }} - - name: Set up Node - uses: actions/setup-node@v7 - with: - node-version-file: 'package.json' - - name: Restore caches - uses: ./.github/actions/restore-cache - - name: Check for dts files that reference stuff in the temporary build folder - run: | - if grep -r --include "*.d.ts" --exclude-dir ".nx" 'import("@sentry(-internal)?/[^/]*/build' .; then - echo "Found illegal TypeScript import statement." - exit 1 - fi - job_node_integration_tests: - name: - Node (${{ matrix.node }})${{ (matrix.typescript && format(' (TS {0})', matrix.typescript)) || '' }} Integration - Tests (${{ matrix.shard }}/3) + name: Node (${{ matrix.node }}) Integration Tests (${{ matrix.shard }}/3) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_node_integration == 'true' || github.event_name != 'pull_request' runs-on: @@ -812,19 +773,6 @@ jobs: matrix: node: [20.19, 22, 24, 26] shard: [1, 2, 3] - typescript: - - false - include: - # Only check typescript for latest version (to streamline CI) - - node: 24 - typescript: '5.0' - shard: 1 - - node: 24 - typescript: '5.0' - shard: 2 - - node: 24 - typescript: '5.0' - shard: 3 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -850,11 +798,6 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache - - name: Overwrite typescript version - if: matrix.typescript == '5.0' - run: node ./scripts/use-ts-5_0.js - working-directory: dev-packages/node-integration-tests - - name: Run integration tests working-directory: dev-packages/node-integration-tests run: pnpm run test --shard=${{ matrix.shard }}/3 @@ -1083,9 +1026,10 @@ jobs: if: always() && needs.job_build_tarballs.result == 'success' && needs.job_build.outputs.e2e-matrix !='{"include":[]}' needs: [job_get_metadata, job_build, job_build_layer, job_build_tarballs] + # Most apps finish in under a minute on 4 vCPUs, so they run on 2. Apps that take longer set `sentryTest.runner`. runs-on: - namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript-e2e${{ github.event_name == - 'pull_request' && '-pr' || '' }} + ${{ matrix.runner || 'namespace-profile-sentry-javascript-small' }};overrides.cache-tag=sentry-javascript-e2e${{ + github.event_name == 'pull_request' && '-pr' || '' }} timeout-minutes: 15 env: # We just use a dummy DSN here, only send to the tunnel anyhow @@ -1115,9 +1059,10 @@ jobs: runtime: ${{ matrix.runtime }} deno-version: ${{ matrix.deno-version }} - # Installs Playwright while the app builds. Keep it right before the build: the runner sets up a background - # composite action's steps from the job's shared `github` context, which fails with "Collection was modified" if - # a foreground step starts at that moment. Starting next to `download-artifact` also made it fail to read the event file. + # Installs Playwright while the app installs and builds. Keep it right before the install: the runner sets up a + # background composite action's steps from the job's shared `github` context, which fails with "Collection was + # modified" if a foreground step starts at that moment. Starting next to `download-artifact` also made it fail to + # read the event file. - name: Install Playwright id: install-playwright background: true @@ -1126,6 +1071,23 @@ jobs: browsers: chromium cwd: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }} + # Every build command runs `pnpm install`, so installing first makes that a no-op. Some downloads from the npm + # registry crawl at tens of kB/s for the whole 7 minutes of the build step, and pnpm does not give up on a slow + # download. Another attempt reuses what is already in the store and opens new connections. + # The hoisted variant installs with another node linker, so installing first would only redo the install. + - name: Install E2E app dependencies + if: ${{ !contains(matrix.build-command, 'hoisted') }} + working-directory: ${{ runner.temp }}/test-application + timeout-minutes: 7 + run: | + for attempt in 1 2; do + timeout 150 pnpm install && exit 0 + echo "::warning::pnpm install attempt $attempt failed or did not finish in 150 s, retrying" + done + pnpm install + env: + SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }} + - name: Build E2E app working-directory: ${{ runner.temp }}/test-application timeout-minutes: 7 @@ -1216,7 +1178,6 @@ jobs: job_lint, job_check_lockfile, job_check_format, - job_circular_dep_check, job_size_check, ] # Always run this, even if a dependent job failed diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index e748668b92db..5d44ccb3497e 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -17,6 +17,12 @@ on: - v9 - v8 +# 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: enforce-license-compliance: runs-on: namespace-profile-sentry-javascript diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 971ba3057c10..d71ba9b1b8a3 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -280,6 +280,10 @@ For example, if something is changed in the browser package, only E2E test apps You can add additional information about the test (e.g. canary versions, optional in CI) by adding `sentryTest` in the `package.json` of a test application. +The required E2E jobs run on 2 vCPUs (`namespace-profile-sentry-javascript-small`). If an app's job takes longer than +about 90 seconds, set `"sentryTest": { "runner": "namespace-profile-sentry-javascript" }` to run it and its variants on +4 vCPUs. + If you add Sentry dependencies to your test application, set the dependency versions to `latest || *` in order for it to work with both regular and prerelease versions: diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.mjs b/dev-packages/e2e-tests/lib/getTestMatrix.mjs index bae7c3262787..cec2cb530828 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.mjs +++ b/dev-packages/e2e-tests/lib/getTestMatrix.mjs @@ -72,6 +72,8 @@ function addIncludesForTestApp(testApp, includes, { optionalMode }) { const shouldSkip = packageJson.sentryTest?.skip || false; const isOptional = packageJson.sentryTest?.optional || false; const variants = (optionalMode ? packageJson.sentryTest?.optionalVariants : packageJson.sentryTest?.variants) || []; + const runner = packageJson.sentryTest?.runner; + const baseInclude = { 'test-application': testApp, ...(runner ? { runner } : {}) }; if (shouldSkip) { return; @@ -79,9 +81,7 @@ function addIncludesForTestApp(testApp, includes, { optionalMode }) { // Add the basic test-application itself, if it is in the current mode if (optionalMode === isOptional) { - includes.push({ - 'test-application': testApp, - }); + includes.push(baseInclude); } variants.forEach(variant => { @@ -94,7 +94,7 @@ function addIncludesForTestApp(testApp, includes, { optionalMode }) { // Don't leak the `skip` flag into the matrix include. const { skip: _skip, ...variantConfig } = variant; includes.push({ - 'test-application': testApp, + ...baseInclude, ...variantConfig, }); }); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/package.json b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/package.json index 66acf6b2afeb..f24cd95e12a0 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/package.json +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/package.json @@ -24,6 +24,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "NODE_VERSION=22 pnpm test:build", diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts index 538b533967e0..8e47ac4255a4 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts @@ -337,7 +337,8 @@ test.describe('Lambda layer', () => { const transactionEvent = await transactionEventPromise; expect(transactionEvent.contexts?.trace?.status).toBe('ok'); - expect(transactionEvent.timestamp! - transactionEvent.start_timestamp!).toBeGreaterThanOrEqual(0.2); + // Node timers can fire up to a millisecond early, so the 200 ms callback delay can measure as 199.x ms + expect(transactionEvent.timestamp! - transactionEvent.start_timestamp!).toBeGreaterThanOrEqual(0.19); }); test('sets error status when a callback-style handler calls back with an error after it returned', async ({ @@ -360,7 +361,7 @@ test.describe('Lambda layer', () => { const [errorEvent, transactionEvent] = await Promise.all([errorEventPromise, transactionEventPromise]); expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); - expect(transactionEvent.timestamp! - transactionEvent.start_timestamp!).toBeGreaterThanOrEqual(0.2); + expect(transactionEvent.timestamp! - transactionEvent.start_timestamp!).toBeGreaterThanOrEqual(0.19); expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); }); diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json index 1db45bd6f42e..e1c004757888 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json @@ -23,5 +23,8 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json index 318d05a6185f..379b4038a5e9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json @@ -26,5 +26,8 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json index 998a4976ad3a..15294b246cbc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -27,5 +27,8 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json index db01367495e8..2bbf3cdb8dcb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json @@ -46,5 +46,8 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 30da1c0e8747..7f18b33c2879 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -34,6 +34,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "optionalVariants": [ { "build-command": "pnpm test:build-latest", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json index 855491173d4f..e90155de6c2c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json @@ -46,6 +46,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-webpack", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json index 50f57e8ca472..b2b626143a71 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/package.json @@ -42,5 +42,8 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json index 3e6d5d60e233..231afe799973 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json @@ -46,6 +46,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-webpack", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json index 39da00835495..af35c23fcc8d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json @@ -44,6 +44,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-webpack", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index adbc49ec455c..53c5adcc5761 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -54,6 +54,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-webpack", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 25ec54f931d0..34b8d7f62dc9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -67,6 +67,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-webpack", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json index 43cea1078cf2..1fd3800e0d2e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json @@ -32,6 +32,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-15", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json index fb3aeaf3399f..44591f3a2ec0 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json @@ -33,6 +33,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-15", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json index b5299208a860..06fe7b58519a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json @@ -27,5 +27,8 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json index ee355572b388..798eef35e989 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json @@ -33,6 +33,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "E2E_TEST_OTEL_SETUP=true pnpm test:build", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 92fa2a41d87b..bf2941ed1992 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -35,6 +35,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "E2E_TEST_OTEL_SETUP=true pnpm test:build", diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/package.json b/dev-packages/e2e-tests/test-applications/react-router-6/package.json index 2ee798930dc4..dd022fa72381 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-6/package.json @@ -50,5 +50,8 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/package.json index 561d8a362f29..605ff3df45a9 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/package.json @@ -48,5 +48,8 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "runner": "namespace-profile-sentry-javascript" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index de5503c7626a..bc4dc0092188 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -69,6 +69,7 @@ "extends": "../../package.json" }, "sentryTest": { + "runner": "namespace-profile-sentry-javascript", "variants": [ { "build-command": "pnpm test:build-latest", diff --git a/dev-packages/node-integration-tests/scripts/use-ts-5_0.js b/dev-packages/node-integration-tests/scripts/use-ts-5_0.js deleted file mode 100644 index 281bb4e9dcb7..000000000000 --- a/dev-packages/node-integration-tests/scripts/use-ts-5_0.js +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable no-console */ -const { execSync } = require('child_process'); -const { join } = require('path'); - -const cwd = join(__dirname, '../../..'); - -const tsVersion = '5.0.4'; - -console.log(`Installing typescript@${tsVersion}, and @types/node@18...`); - -execSync(`pnpm add --save-dev --workspace-root typescript@${tsVersion} @types/node@^18`, { - stdio: 'inherit', - cwd, -}); - -console.log('TypeScript version updated successfully.'); diff --git a/packages/replay-internal/src/coreHandlers/handleClick.ts b/packages/replay-internal/src/coreHandlers/handleClick.ts index f4576c3a7ca7..c61278494646 100644 --- a/packages/replay-internal/src/coreHandlers/handleClick.ts +++ b/packages/replay-internal/src/coreHandlers/handleClick.ts @@ -183,10 +183,10 @@ export class ClickDetector implements ReplayClickDetector { const now = nowInSeconds(); this._clicks.forEach(click => { - if (!click.mutationAfter && this._lastMutation) { + if (click.mutationAfter === undefined && this._lastMutation) { click.mutationAfter = click.timestamp <= this._lastMutation ? this._lastMutation - click.timestamp : undefined; } - if (!click.scrollAfter && this._lastScroll) { + if (click.scrollAfter === undefined && this._lastScroll) { click.scrollAfter = click.timestamp <= this._lastScroll ? this._lastScroll - click.timestamp : undefined; } @@ -215,8 +215,9 @@ export class ClickDetector implements ReplayClickDetector { /** Generate matching breadcrumb(s) for the click. */ private _generateBreadcrumbs(click: Click): void { const replay = this._replay; - const hadScroll = click.scrollAfter && click.scrollAfter <= this._scrollTimeout; - const hadMutation = click.mutationAfter && click.mutationAfter <= this._threshold; + // A mutation or scroll in the same millisecond as the click gives `0`, which still counts + const hadScroll = click.scrollAfter !== undefined && click.scrollAfter <= this._scrollTimeout; + const hadMutation = click.mutationAfter !== undefined && click.mutationAfter <= this._threshold; const isSlowClick = !hadScroll && !hadMutation; const { clickCount, clickBreadcrumb } = click; diff --git a/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts index 66beddf6ab19..7a51c5ca3e5b 100644 --- a/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts @@ -321,6 +321,22 @@ describe('Unit | coreHandlers | handleClick', () => { expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); }); + test('it does not consider clicks with a mutation in the same millisecond as slow click', async () => { + const breadcrumb: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const node = document.createElement('button'); + detector.handleClick(breadcrumb, node); + detector.registerMutation(BASE_TIMESTAMP); + + vi.advanceTimersByTime(3_500); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + }); + test('it considers clicks with mutation after threshold as slow click', async () => { const breadcrumb: Breadcrumb = { timestamp: BASE_TIMESTAMP / 1000, @@ -429,6 +445,22 @@ describe('Unit | coreHandlers | handleClick', () => { ); }); + test('it does not consider clicks with a scroll in the same millisecond as slow click', async () => { + const breadcrumb: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const node = document.createElement('button'); + detector.handleClick(breadcrumb, node); + detector.registerScroll(BASE_TIMESTAMP); + + vi.advanceTimersByTime(3_500); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + }); + test('it does not consider clicks with scroll before threshold as slow click', async () => { const breadcrumb: Breadcrumb = { timestamp: BASE_TIMESTAMP / 1000, From 5521cfc9a14cb6061c5c8d3add7fb83fc617dbb6 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 23 Oct 2026 12:00:53 +0530 Subject: [PATCH 010/126] feat(cloudflare): instrument Workers KV bindings (#25488) Fixes #24037 Implements the Workers KV portion of #24037. - add `isKVNamespace` detection using `get`, `put`, `list`, and `getWithMetadata`, excluding JSRPC proxies - add KV instrumentation for `get`, `getWithMetadata`, `put`, `delete`, and `list` - emit cache spans with KV cache/database attributes - add unit tests and a Cloudflare integration suite Vectorize and Analytics Engine remain separate follow-ups. --------- Co-authored-by: JPeer264 --- .../suites/kv/index.ts | 55 ++++ .../suites/kv/instrument.server.ts | 6 + .../suites/kv/test.ts | 290 ++++++++++++++++++ .../suites/kv/vite.config.mts | 7 + .../suites/kv/wrangler.jsonc | 13 + .../instrumentations/worker/instrumentEnv.ts | 10 + .../instrumentations/worker/instrumentKV.ts | 108 +++++++ packages/cloudflare/src/utils/isBinding.ts | 17 +- .../instrumentations/instrumentEnv.test.ts | 55 ++++ .../worker/instrumentKV.test.ts | 189 ++++++++++++ .../cloudflare/test/utils/isBinding.test.ts | 39 ++- 11 files changed, 787 insertions(+), 2 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/kv/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/kv/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/kv/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/kv/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc create mode 100644 packages/cloudflare/src/instrumentations/worker/instrumentKV.ts create mode 100644 packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/index.ts b/dev-packages/cloudflare-integration-tests/suites/kv/index.ts new file mode 100644 index 000000000000..64ef3ae0e0b6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/index.ts @@ -0,0 +1,55 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; + +interface Env { + MY_KV: KVNamespace; +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/kv/get') { + await env.MY_KV.put('get-key', 'get-value'); + const value = await env.MY_KV.get('get-key'); + return new Response(value); + } + + if (url.pathname === '/kv/get-miss') { + const value = await env.MY_KV.get('missing-key'); + return new Response(value ?? 'miss'); + } + + if (url.pathname === '/kv/bulk-get') { + await env.MY_KV.put('bulk-a', 'a'); + await env.MY_KV.put('bulk-b', 'b'); + const values = await env.MY_KV.get(['bulk-a', 'bulk-b']); + return Response.json(Object.fromEntries(values)); + } + + if (url.pathname === '/kv/get-with-metadata') { + await env.MY_KV.put('metadata-key', 'metadata-value', { metadata: { source: 'test' } }); + const value = await env.MY_KV.getWithMetadata('metadata-key'); + return Response.json(value); + } + + if (url.pathname === '/kv/put') { + await env.MY_KV.put('put-key', 'put-value'); + return new Response('OK'); + } + + if (url.pathname === '/kv/list') { + await env.MY_KV.put('list-a', 'a'); + await env.MY_KV.put('list-b', 'b'); + await env.MY_KV.list({ prefix: 'list-' }); + return new Response('OK'); + } + + if (url.pathname === '/kv/delete') { + await env.MY_KV.put('delete-key', 'gone'); + await env.MY_KV.delete('delete-key'); + return new Response('OK'); + } + + return new Response('not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/kv/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts new file mode 100644 index 000000000000..9b51d4fe0509 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts @@ -0,0 +1,290 @@ +import { + CACHE_HIT, + CACHE_KEY, + CACHE_OPERATION, + DB_COLLECTION_NAME, + DB_OPERATION_NAME, + DB_SYSTEM_NAME, + SENTRY_ENVIRONMENT, + SENTRY_IS_LOCALHOST, + SENTRY_OP, + SENTRY_ORIGIN, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, + URL_PATH, +} from '@sentry/conventions/attributes'; +import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; +import { CACHE_OPERATION_NAMES, SDK_VERSION } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; +import { getSpansFromEnvelope } from '../../spanUtils'; + +it('emits cache.get for a KV read with key and hit attributes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const getSpans = spans.filter(span => span.name === CACHE_GET); + + expect(getSpans).toHaveLength(1); + expect(getSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [CACHE_KEY]: { type: 'array', value: ['get-key'] }, + [CACHE_HIT]: { type: 'boolean', value: true }, + [DB_OPERATION_NAME]: { type: 'string', value: 'get' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/get'); + await runner.completed(); +}); + +it('emits cache.get with cache.hit false for a KV read of a missing key', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const getSpans = spans.filter(span => span.name === CACHE_GET); + + expect(getSpans).toHaveLength(1); + expect(getSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [CACHE_KEY]: { type: 'array', value: ['missing-key'] }, + [CACHE_HIT]: { type: 'boolean', value: false }, + [DB_OPERATION_NAME]: { type: 'string', value: 'get' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/get-miss'); + await runner.completed(); +}); + +it('emits cache.get for a bulk KV read with all keys in cache.key', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const getSpans = spans.filter(span => span.name === CACHE_GET); + + expect(getSpans).toHaveLength(1); + expect(getSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [CACHE_KEY]: { type: 'array', value: ['bulk-a', 'bulk-b'] }, + [CACHE_HIT]: { type: 'boolean', value: true }, + [DB_OPERATION_NAME]: { type: 'string', value: 'get' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/bulk-get'); + await runner.completed(); +}); + +it('emits cache.get for getWithMetadata', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const getSpans = spans.filter(span => span.name === CACHE_GET); + + expect(getSpans).toHaveLength(1); + expect(getSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [CACHE_KEY]: { type: 'array', value: ['metadata-key'] }, + [CACHE_HIT]: { type: 'boolean', value: true }, + [DB_OPERATION_NAME]: { type: 'string', value: 'getWithMetadata' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/get-with-metadata'); + await runner.completed(); +}); + +it('emits cache.put for KV writes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const putSpans = spans.filter(span => span.name === CACHE_PUT); + + expect(putSpans).toHaveLength(1); + expect(putSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_PUT }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_PUT] }, + [CACHE_KEY]: { type: 'array', value: ['put-key'] }, + [DB_OPERATION_NAME]: { type: 'string', value: 'put' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/put'); + await runner.completed(); +}); + +it('emits cache.remove for KV deletes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const removeSpans = spans.filter(span => span.name === CACHE_REMOVE); + + expect(removeSpans).toHaveLength(1); + expect(removeSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_REMOVE }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_REMOVE] }, + [CACHE_KEY]: { type: 'array', value: ['delete-key'] }, + [DB_OPERATION_NAME]: { type: 'string', value: 'delete' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/delete'); + await runner.completed(); +}); + +it('emits cache.get without cache.key or cache.hit for KV list operations', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const getSpans = spans.filter(span => span.name === CACHE_GET); + + expect(getSpans).toHaveLength(1); + expect(getSpans[0]?.attributes).toEqual({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [DB_OPERATION_NAME]: { type: 'string', value: 'list' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan?.span_id }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + }); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/list'); + await runner.completed(); +}); + +it("emits the KV spans of each request under that request's own segment span", async ({ signal }) => { + const segmentTraceIds: string[] = []; + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + expect(segmentSpan?.attributes[URL_PATH]).toEqual({ type: 'string', value: '/kv/get' }); + expect(spans.filter(span => !span.is_segment)).toEqual([ + expect.objectContaining({ + name: CACHE_PUT, + trace_id: segmentSpan?.trace_id, + parent_span_id: segmentSpan?.span_id, + }), + expect.objectContaining({ + name: CACHE_GET, + trace_id: segmentSpan?.trace_id, + parent_span_id: segmentSpan?.span_id, + }), + ]); + segmentTraceIds.push(segmentSpan!.trace_id); + }) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + expect(segmentSpan?.attributes[URL_PATH]).toEqual({ type: 'string', value: '/kv/put' }); + expect(spans.filter(span => !span.is_segment)).toEqual([ + expect.objectContaining({ + name: CACHE_PUT, + trace_id: segmentSpan?.trace_id, + parent_span_id: segmentSpan?.span_id, + }), + ]); + segmentTraceIds.push(segmentSpan!.trace_id); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/get'); + await runner.makeRequest('get', '/kv/put'); + await runner.completed(); + + expect(segmentTraceIds).toHaveLength(2); + expect(segmentTraceIds[0]).not.toBe(segmentTraceIds[1]); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/kv/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc new file mode 100644 index 000000000000..b40a23adc182 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-kv", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "kv_namespaces": [ + { + "binding": "MY_KV", + "id": "00000000000000000000000000000000", + }, + ], +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 33b02b9bd74f..18cf562d95be 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -6,6 +6,7 @@ import { isD1Database, isDurableObjectNamespace, isJSRPC, + isKVNamespace, isQueue, isR2Bucket, } from '../../utils/isBinding'; @@ -14,6 +15,7 @@ import { appendRpcMeta } from '../../utils/rpcMeta'; import { createRpcPropagationResolver } from '../../utils/rpcPropagation'; import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace'; import { instrumentFetcher } from './instrumentFetcher'; +import { instrumentKV } from './instrumentKV'; import { instrumentQueueProducer } from './instrumentQueueProducer'; import { instrumentR2Bucket } from './instrumentR2'; @@ -31,6 +33,7 @@ const instrumentedBindings = new WeakMap(); * - DurableObjectNamespace (via `idFromName` duck-typing) * - Service bindings / JSRPC proxies * - Queue producers (via `send` + `sendBatch` duck-typing) + * - Workers KV namespaces (via `get` + `put` + `list` + `getWithMetadata` duck-typing) * - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing) * - Workers AI (via `run` + `gateway` + `toMarkdown` duck-typing) * @@ -71,6 +74,13 @@ export function instrumentEnv>(env: Env, opt return instrumented; } + if (isKVNamespace(item)) { + const bindingName = typeof prop === 'string' ? prop : String(prop); + const instrumented = instrumentKV(item, bindingName); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + if (isR2Bucket(item)) { const bindingName = typeof prop === 'string' ? prop : String(prop); const instrumented = instrumentR2Bucket(item, bindingName); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts b/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts new file mode 100644 index 000000000000..685b9fc46726 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts @@ -0,0 +1,108 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; +import { + CACHE_HIT, + CACHE_KEY, + CACHE_OPERATION, + DB_COLLECTION_NAME, + DB_OPERATION_NAME, + DB_SYSTEM_NAME, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; +import { CACHE_OPERATION_NAMES, isObjectLike, startSpan } from '@sentry/core'; + +const ORIGIN = 'auto.cache.cloudflare.kv'; + +type KVOperation = 'get' | 'getWithMetadata' | 'put' | 'delete' | 'list'; + +const OPERATION_SPAN_OPS = { + get: CACHE_GET, + getWithMetadata: CACHE_GET, + put: CACHE_PUT, + delete: CACHE_REMOVE, + list: CACHE_GET, +} as const satisfies Record; + +function getCacheKeys(operation: KVOperation, args: unknown[]): string[] | undefined { + if (operation === 'list') { + return undefined; + } + + const key = args[0]; + + if (Array.isArray(key)) { + return key.map(value => String(value)); + } + + return typeof key === 'string' ? [key] : key == null ? undefined : [String(key)]; +} + +function createSpanOptions(bindingName: string, operation: KVOperation, args: unknown[]) { + const cacheOperation = OPERATION_SPAN_OPS[operation]; + const cacheOperationName = CACHE_OPERATION_NAMES[cacheOperation]; + const keys = getCacheKeys(operation, args); + return { + name: cacheOperation, + attributes: { + [SENTRY_OP]: cacheOperation, + [CACHE_OPERATION]: cacheOperationName, + [SENTRY_ORIGIN]: ORIGIN, + ...(keys && keys.length > 0 && { [CACHE_KEY]: keys }), + [DB_OPERATION_NAME]: operation, + [DB_COLLECTION_NAME]: bindingName, + [DB_SYSTEM_NAME]: 'cloudflare-kv', + }, + }; +} + +function resolveCacheHit(operation: KVOperation, result: unknown): boolean { + if (operation === 'list') { + return false; + } + + if (result instanceof Map) { + return Array.from(result.values()).some(value => resolveCacheHit(operation, value)); + } + + if (operation === 'getWithMetadata' && isObjectLike(result) && 'value' in result) { + return result.value != null; + } + + return result != null; +} + +/** + * Wraps a Cloudflare Workers KV binding to create cache spans on namespace operations. + * + * Instrumented methods: get, getWithMetadata, put, delete, and list. + */ +export function instrumentKV(kv: T, bindingName: string): T { + return new Proxy(kv, { + get(target, prop, receiver) { + if (prop === 'get' || prop === 'getWithMetadata' || prop === 'put' || prop === 'delete' || prop === 'list') { + const original = Reflect.get(target, prop, receiver); + + if (typeof original !== 'function') { + return original; + } + + return function (this: unknown, ...args: unknown[]) { + const operation = prop; + + return startSpan(createSpanOptions(bindingName, operation, args), async span => { + const result = await Reflect.apply(original, target, args); + + if (operation === 'get' || operation === 'getWithMetadata') { + span.setAttribute(CACHE_HIT, resolveCacheHit(operation, result)); + } + + return result; + }); + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); +} diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index e675807b8fd7..1747d7346905 100644 --- a/packages/cloudflare/src/utils/isBinding.ts +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -31,7 +31,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { Ai, D1Database, DurableObjectNamespace, Queue, R2Bucket } from '@cloudflare/workers-types'; +import type { Ai, D1Database, DurableObjectNamespace, KVNamespace, Queue, R2Bucket } from '@cloudflare/workers-types'; /** * Checks if a value is a JSRPC proxy (service binding). @@ -96,6 +96,21 @@ export function isAiBinding(item: unknown): item is Ai { ); } +/** + * Duck-type check for Workers KV namespace bindings. + * KVNamespace has `get`, `put`, `list`, and `getWithMetadata` methods. + */ +export function isKVNamespace(item: unknown): item is KVNamespace { + return ( + item != null && + isNotJSRPC(item) && + typeof item.get === 'function' && + typeof item.put === 'function' && + typeof item.list === 'function' && + typeof item.getWithMetadata === 'function' + ); +} + /** * Duck-type check for R2 Bucket bindings. * R2Bucket has `head`, `put`, and `createMultipartUpload` methods. diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 9219091d53bd..b5858d087008 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -1,3 +1,4 @@ +import { SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentEnv } from '../../src/instrumentations/worker/instrumentEnv'; @@ -222,6 +223,60 @@ describe('instrumentEnv', () => { expect(send.mock.calls[0]?.[0]).toBe('hello'); }); + it('wraps Workers KV bindings in a proxy', async () => { + const get = vi.fn().mockResolvedValue('value'); + const put = vi.fn().mockResolvedValue(undefined); + const list = vi.fn().mockResolvedValue({ keys: [], list_complete: true }); + const getWithMetadata = vi.fn().mockResolvedValue({ value: 'value', metadata: null }); + const kv = { get, put, list, getWithMetadata, delete: vi.fn() }; + const env = { MY_KV: kv }; + const instrumented = instrumentEnv(env); + + const wrapped = instrumented.MY_KV; + expect(wrapped).not.toBe(kv); + + await wrapped.get('test-key', 'json'); + expect(get).toHaveBeenCalledWith('test-key', 'json'); + + await wrapped.put('test-key', 'value'); + expect(put).toHaveBeenCalledWith('test-key', 'value'); + }); + + it('caches the wrapped Workers KV binding across repeated access', () => { + const kv = { get: vi.fn(), put: vi.fn(), list: vi.fn(), getWithMetadata: vi.fn() }; + const env = { MY_KV: kv }; + const instrumented = instrumentEnv(env); + + expect(instrumented.MY_KV).toBe(instrumented.MY_KV); + }); + + it('instruments Workers KV and R2 bindings as their own binding types', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const kv = { get: vi.fn().mockResolvedValue(null), put: vi.fn(), list: vi.fn(), getWithMetadata: vi.fn() }; + const r2 = { + head: vi.fn().mockResolvedValue(null), + get: vi.fn(), + put: vi.fn(), + list: vi.fn(), + createMultipartUpload: vi.fn(), + }; + const instrumented = instrumentEnv({ MY_KV: kv, MY_BUCKET: r2 }); + + await instrumented.MY_KV.get('kv-key'); + await instrumented.MY_BUCKET.head('r2-key'); + + expect(startSpanSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ attributes: expect.objectContaining({ [SENTRY_ORIGIN]: 'auto.cache.cloudflare.kv' }) }), + expect.any(Function), + ); + expect(startSpanSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ attributes: expect.objectContaining({ [SENTRY_ORIGIN]: 'auto.faas.cloudflare.r2' }) }), + expect.any(Function), + ); + }); + it('caches the wrapped Queue binding across repeated access', () => { const queue = { send: vi.fn(), sendBatch: vi.fn() }; const env = { MY_QUEUE: queue }; diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts new file mode 100644 index 000000000000..50e08dc1b69b --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts @@ -0,0 +1,189 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; +import { + CACHE_KEY, + CACHE_OPERATION, + DB_COLLECTION_NAME, + DB_OPERATION_NAME, + DB_SYSTEM_NAME, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { instrumentKV } from '../../../src/instrumentations/worker/instrumentKV'; + +function createMockKVNamespace(): KVNamespace { + return { + get: vi.fn().mockResolvedValue('value'), + getWithMetadata: vi.fn().mockResolvedValue({ value: 'value', metadata: null }), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue({ keys: [], list_complete: true }), + } as unknown as KVNamespace; +} + +describe('instrumentKV', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + + test('forwards get with the type argument', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + const result = await wrapped.get('test-key', 'json'); + + expect(result).toBe('value'); + expect(kv.get).toHaveBeenCalledWith('test-key', 'json'); + }); + + test('starts a cache.get span with KV attributes', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + await wrapped.get('test-key'); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: CACHE_GET, + attributes: { + [SENTRY_OP]: CACHE_GET, + [SENTRY_ORIGIN]: 'auto.cache.cloudflare.kv', + [CACHE_OPERATION]: SentryCore.CACHE_OPERATION_NAMES[CACHE_GET], + [CACHE_KEY]: ['test-key'], + [DB_OPERATION_NAME]: 'get', + [DB_COLLECTION_NAME]: 'MY_KV', + [DB_SYSTEM_NAME]: 'cloudflare-kv', + }, + }, + expect.any(Function), + ); + }); + + test('handles bulk get without changing the underlying argument shape', async () => { + const kv = createMockKVNamespace(); + const get = vi.fn().mockResolvedValue( + new Map([ + ['first', 'one'], + ['second', 'two'], + ]), + ); + kv.get = get as unknown as KVNamespace['get']; + const wrapped = instrumentKV(kv, 'MY_KV'); + + const keys = ['first', 'second']; + const result = await wrapped.get(keys, 'text'); + + expect(result).toBeInstanceOf(Map); + expect(get).toHaveBeenCalledWith(keys, 'text'); + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: CACHE_GET, + attributes: { + [SENTRY_OP]: CACHE_GET, + [SENTRY_ORIGIN]: 'auto.cache.cloudflare.kv', + [CACHE_OPERATION]: SentryCore.CACHE_OPERATION_NAMES[CACHE_GET], + [CACHE_KEY]: ['first', 'second'], + [DB_OPERATION_NAME]: 'get', + [DB_COLLECTION_NAME]: 'MY_KV', + [DB_SYSTEM_NAME]: 'cloudflare-kv', + }, + }, + expect.any(Function), + ); + }); + + test('handles getWithMetadata as a cache read', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + await wrapped.getWithMetadata('test-key', 'json'); + + expect(kv.getWithMetadata).toHaveBeenCalledWith('test-key', 'json'); + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: CACHE_GET, + attributes: { + [SENTRY_OP]: CACHE_GET, + [SENTRY_ORIGIN]: 'auto.cache.cloudflare.kv', + [CACHE_OPERATION]: SentryCore.CACHE_OPERATION_NAMES[CACHE_GET], + [CACHE_KEY]: ['test-key'], + [DB_OPERATION_NAME]: 'getWithMetadata', + [DB_COLLECTION_NAME]: 'MY_KV', + [DB_SYSTEM_NAME]: 'cloudflare-kv', + }, + }, + expect.any(Function), + ); + }); + + test('instruments put, delete, and list operations', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + await wrapped.put('put-key', 'value'); + await wrapped.delete('delete-key'); + await wrapped.list({ prefix: 'test-' }); + + expect(startSpanSpy).toHaveBeenCalledTimes(3); + expect(startSpanSpy).toHaveBeenNthCalledWith( + 1, + { + name: CACHE_PUT, + attributes: { + [SENTRY_OP]: CACHE_PUT, + [SENTRY_ORIGIN]: 'auto.cache.cloudflare.kv', + [CACHE_OPERATION]: SentryCore.CACHE_OPERATION_NAMES[CACHE_PUT], + [CACHE_KEY]: ['put-key'], + [DB_OPERATION_NAME]: 'put', + [DB_COLLECTION_NAME]: 'MY_KV', + [DB_SYSTEM_NAME]: 'cloudflare-kv', + }, + }, + expect.any(Function), + ); + expect(startSpanSpy).toHaveBeenNthCalledWith( + 2, + { + name: CACHE_REMOVE, + attributes: { + [SENTRY_OP]: CACHE_REMOVE, + [SENTRY_ORIGIN]: 'auto.cache.cloudflare.kv', + [CACHE_OPERATION]: SentryCore.CACHE_OPERATION_NAMES[CACHE_REMOVE], + [CACHE_KEY]: ['delete-key'], + [DB_OPERATION_NAME]: 'delete', + [DB_COLLECTION_NAME]: 'MY_KV', + [DB_SYSTEM_NAME]: 'cloudflare-kv', + }, + }, + expect.any(Function), + ); + expect(startSpanSpy).toHaveBeenNthCalledWith( + 3, + { + name: CACHE_GET, + attributes: { + [SENTRY_OP]: CACHE_GET, + [SENTRY_ORIGIN]: 'auto.cache.cloudflare.kv', + [CACHE_OPERATION]: SentryCore.CACHE_OPERATION_NAMES[CACHE_GET], + [DB_OPERATION_NAME]: 'list', + [DB_COLLECTION_NAME]: 'MY_KV', + [DB_SYSTEM_NAME]: 'cloudflare-kv', + }, + }, + expect.any(Function), + ); + }); + + test('forwards unknown property accesses transparently', () => { + const kv = Object.assign(createMockKVNamespace(), { + customMethod: vi.fn().mockReturnValue('ok'), + }) as KVNamespace & { customMethod: () => string }; + + const wrapped = instrumentKV(kv, 'MY_KV'); + expect(wrapped.customMethod()).toBe('ok'); + }); +}); diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts index 28d44fd9936d..9d6afe175b6b 100644 --- a/packages/cloudflare/test/utils/isBinding.test.ts +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue } from '../../src/utils/isBinding'; +import { isD1Database, isDurableObjectNamespace, isJSRPC, isKVNamespace, isQueue } from '../../src/utils/isBinding'; describe('isJSRPC', () => { it('returns false for a plain object', () => { @@ -210,3 +210,40 @@ describe('isD1Database', () => { expect(isD1Database(jsrpcProxy)).toBe(false); }); }); + +describe('isKVNamespace', () => { + it('returns true for an object with get, put, list, and getWithMetadata methods', () => { + const kv = { + get: async () => null, + put: async () => {}, + list: async () => ({ keys: [], list_complete: true }), + getWithMetadata: async () => ({ value: null, metadata: null }), + }; + expect(isKVNamespace(kv)).toBe(true); + }); + + it('returns false when getWithMetadata is missing', () => { + expect(isKVNamespace({ get: () => {}, put: () => {}, list: () => {} })).toBe(false); + }); + + it('returns false when a required method is not a function', () => { + expect(isKVNamespace({ get: 'nope', put: () => {}, list: () => {}, getWithMetadata: () => {} })).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(isKVNamespace(null)).toBe(false); + expect(isKVNamespace(undefined)).toBe(false); + }); + + it('returns false for a JSRPC proxy even though it has all KV methods', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isKVNamespace(jsrpcProxy)).toBe(false); + }); +}); From 00a2101e82ce8f44b2545e048ceb9d22ef0396eb Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:58:42 +0200 Subject: [PATCH 011/126] ci: Show an unknown status for optional E2E jobs without a final result (#25660) ## What The optional E2E report no longer shows a job as failing when its result is not known. Such jobs now show "Status unknown", and a job without a valid start or end time shows no duration. ## Why Jobs with other results (for example neutral or no result yet) showed "Failing after" with a wrong duration. Co-authored-by: Claude Opus 5.5 --- dev-packages/optional-e2e-gh-action/index.mjs | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/dev-packages/optional-e2e-gh-action/index.mjs b/dev-packages/optional-e2e-gh-action/index.mjs index 59194dbc4bd2..6df1dfc5d427 100644 --- a/dev-packages/optional-e2e-gh-action/index.mjs +++ b/dev-packages/optional-e2e-gh-action/index.mjs @@ -118,7 +118,7 @@ async function reportInComment(octokit, repo, prNumber, workflowRun) { ); const counts = [ - failing.length && `${failing.length} failing`, + failing.length && `${failing.length} not successful`, successful.length && `${successful.length} successful`, ].filter(Boolean); const runLink = `[See the run](${workflowRun.html_url})`; @@ -162,16 +162,33 @@ function formatJobs(jobs) { } function formatStatus({ conclusion, started_at, completed_at }) { - const seconds = Math.round((new Date(completed_at) - new Date(started_at)) / 1000); - const duration = seconds >= 60 ? `${Math.floor(seconds / 60)}m` : `${seconds}s`; + const duration = formatDuration(started_at, completed_at); + const withDuration = (status, preposition) => (duration ? `${status} ${preposition} ${duration}` : status); + + switch (conclusion) { + case 'success': + return withDuration('Successful', 'in'); + case 'failure': + return withDuration('Failing', 'after'); + case 'timed_out': + return withDuration('Timed out', 'after'); + case 'cancelled': + return withDuration('Cancelled', 'after'); + default: + return 'Status unknown'; + } +} - if (conclusion === 'success') { - return `Successful in ${duration}`; +// A job that never started has no `started_at`, and `new Date(null)` is the epoch, which gives a wrong duration. +function formatDuration(startedAt, completedAt) { + if (!startedAt || !completedAt) { + return undefined; } - if (conclusion === 'cancelled') { - return `Cancelled after ${duration}`; + const seconds = Math.round((new Date(completedAt) - new Date(startedAt)) / 1000); + if (!Number.isFinite(seconds) || seconds < 0) { + return undefined; } - return `Failing after ${duration}`; + return seconds >= 60 ? `${Math.floor(seconds / 60)}m` : `${seconds}s`; } run().catch(error => core.setFailed(error.message)); From 4149e9042304b1bbc3d7da692bd77342b6fbe3ac Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:43:05 +0200 Subject: [PATCH 012/126] ci: Stop uploading duplicate tarballs to the release artifact --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e202edc68667..38aaa04a7f28 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -458,12 +458,13 @@ jobs: with: name: ${{ github.sha }} retention-days: 90 + # Do not use `packages/**/*.tgz`: it follows the workspace links in `node_modules` and uploads duplicate tarballs. path: | ${{ github.workspace }}/packages/browser/build/bundles/** ${{ github.workspace }}/packages/replay-internal/build/bundles/** ${{ github.workspace }}/packages/replay-canvas/build/bundles/** ${{ github.workspace }}/packages/feedback/build/bundles/** - ${{ github.workspace }}/packages/**/*.tgz + ${{ github.workspace }}/packages/*/*.tgz ${{ github.workspace }}/packages/aws-serverless/build/aws/dist-serverless/*.zip job_browser_unit_tests: From a4cf21faa18d6a2fdc002371c9a4e0ea6abb9d67 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Fri, 23 Oct 2026 10:47:14 +0200 Subject: [PATCH 013/126] fix(replay): Apply attribute masking to breadcrumb messages (#25180) Replay's configured attribute masking covered DOM snapshots, but click and keyboard breadcrumb messages could still contain raw values. Apply the same masking rules to those messages, including ancestor attributes, so defaults and custom masks stay consistent while preserving explicit unmasking. Completes the breadcrumb handling missed by #16192. Browser integration expectations now assert masked attribute values. Fixes [JS-3832](https://linear.app/getsentry/issue/JS-3832). --------- Co-authored-by: GPT-6 --- .../suites/replay/customEvents/test.ts | 4 +- .../browser-utils/src/htmlTreeAsString.ts | 31 ++-- .../test/htmlTreeAsString.test.ts | 40 +++++ .../src/coreHandlers/handleDom.ts | 18 +- .../src/coreHandlers/handleKeyboardEvent.ts | 11 +- packages/replay-internal/src/replay.ts | 4 + packages/replay-internal/src/types/replay.ts | 1 + packages/replay-internal/src/types/rrweb.ts | 1 + .../breadcrumbAttributeMasking.test.ts | 158 ++++++++++++++++++ 9 files changed, 245 insertions(+), 23 deletions(-) create mode 100644 packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts diff --git a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts index a83f6e9fa5ce..8a9c0046f5b4 100644 --- a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts @@ -97,7 +97,7 @@ sentryTest( expect.arrayContaining([ { ...expectedClickBreadcrumb, - message: 'body > div#error.btn.btn-error[aria-label="An Error in aria-label"]', + message: 'body > div#error.btn.btn-error[aria-label="** ***** ** **********"]', data: { nodeId: expect.any(Number), node: { @@ -124,7 +124,7 @@ sentryTest( expect.arrayContaining([ { ...expectedClickBreadcrumb, - message: 'body > button#img-button[title="Button title"]', + message: 'body > button#img-button[title="****** *****"]', data: { nodeId: expect.any(Number), node: { diff --git a/packages/browser-utils/src/htmlTreeAsString.ts b/packages/browser-utils/src/htmlTreeAsString.ts index 1fd7f75590bf..46dcc493a431 100644 --- a/packages/browser-utils/src/htmlTreeAsString.ts +++ b/packages/browser-utils/src/htmlTreeAsString.ts @@ -2,6 +2,8 @@ import { isString } from '@sentry/core'; const DEFAULT_MAX_STRING_LENGTH = 80; +type MaskAttributeFn = (key: string, value: string, el: HTMLElement) => string; + type SimpleNode = { parentNode: SimpleNode; } | null; @@ -54,7 +56,7 @@ function _safeRead(el: unknown, prop: AccessorKey, arg?: string): T { */ export function htmlTreeAsString( elem: unknown, - options: string[] | { keyAttrs?: string[]; maxStringLength?: number } = {}, + options: string[] | { keyAttrs?: string[]; maxStringLength?: number; maskAttributeFn?: MaskAttributeFn } = {}, ): string { if (!elem) { return ''; @@ -74,10 +76,11 @@ export function htmlTreeAsString( const sepLength = separator.length; let nextStr; const keyAttrs = Array.isArray(options) ? options : options.keyAttrs; + const maskAttributeFn = Array.isArray(options) ? undefined : options.maskAttributeFn; const maxStringLength = (!Array.isArray(options) && options.maxStringLength) || DEFAULT_MAX_STRING_LENGTH; while (currentElem && height++ < MAX_TRAVERSE_HEIGHT) { - nextStr = _htmlElementAsString(currentElem, keyAttrs); + nextStr = _htmlElementAsString(currentElem, keyAttrs, maskAttributeFn); // bail out if // - nextStr is the 'html' element // - the length of the string that would be created exceeds maxStringLength @@ -103,7 +106,7 @@ export function htmlTreeAsString( * e.g. [HTMLElement] => input#foo.btn[name=baz] * @returns generated DOM path */ -function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { +function _htmlElementAsString(el: unknown, keyAttrs?: string[], maskAttributeFn?: MaskAttributeFn): string { const out = []; const tagName = _safeRead(el, 'tagName'); @@ -117,10 +120,14 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { const dataset = _safeRead(el, 'dataset'); if (dataset) { if (dataset['sentryComponent']) { - return dataset['sentryComponent']; + return maskAttributeFn + ? maskAttributeFn('data-sentry-component', dataset['sentryComponent'], el) + : dataset['sentryComponent']; } if (dataset['sentryElement']) { - return dataset['sentryElement']; + return maskAttributeFn + ? maskAttributeFn('data-sentry-element', dataset['sentryElement'], el) + : dataset['sentryElement']; } } } @@ -132,22 +139,24 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { const keyAttrPairs = keyAttrs?.length ? keyAttrs .filter(keyAttr => _safeRead(el, 'getAttribute', keyAttr)) - .map(keyAttr => [keyAttr, _safeRead(el, 'getAttribute', keyAttr)]) + .map(keyAttr => [keyAttr, _safeRead(el, 'getAttribute', keyAttr)] as const) : null; if (keyAttrPairs?.length) { - keyAttrPairs.forEach(keyAttrPair => { - out.push(`[${keyAttrPair[0]}="${keyAttrPair[1]}"]`); + keyAttrPairs.forEach(([key, value]) => { + out.push(`[${key}="${maskAttributeFn && value ? maskAttributeFn(key, value, el as HTMLElement) : value}"]`); }); } else { const id = _safeRead(el, 'id'); if (id) { - out.push(`#${id}`); + out.push(`#${maskAttributeFn ? maskAttributeFn('id', id, el as HTMLElement) : id}`); } const className = _safeRead(el, 'className'); if (className && isString(className)) { - const classes = className.split(/\s+/); + const classes = (maskAttributeFn ? maskAttributeFn('class', className, el as HTMLElement) : className).split( + /\s+/, + ); for (const c of classes) { out.push(`.${c}`); } @@ -156,7 +165,7 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { for (const k of ['aria-label', 'type', 'name', 'title', 'alt']) { const attr = _safeRead(el, 'getAttribute', k); if (attr) { - out.push(`[${k}="${attr}"]`); + out.push(`[${k}="${maskAttributeFn ? maskAttributeFn(k, attr, el as HTMLElement) : attr}"]`); } } diff --git a/packages/browser-utils/test/htmlTreeAsString.test.ts b/packages/browser-utils/test/htmlTreeAsString.test.ts index 86db19c4143a..02b2cc9090dc 100644 --- a/packages/browser-utils/test/htmlTreeAsString.test.ts +++ b/packages/browser-utils/test/htmlTreeAsString.test.ts @@ -6,6 +6,46 @@ import { describe, expect, it } from 'vitest'; import { htmlTreeAsString } from '../src/htmlTreeAsString'; describe('htmlTreeAsString', () => { + it('masks attributes on the target and its ancestors', () => { + const parent = document.createElement('div'); + parent.title = 'Account PIN'; + const target = document.createElement('button'); + target.setAttribute('aria-label', 'Pay Alice'); + target.setAttribute('type', 'button'); + parent.appendChild(target); + + const result = htmlTreeAsString(target, { + maskAttributeFn: (key, value) => (['title', 'aria-label'].includes(key) ? '[masked]' : value), + }); + + expect(result).toBe('div[title="[masked]"] > button[aria-label="[masked]"][type="button"]'); + }); + + it('masks custom key attributes', () => { + const target = document.createElement('button'); + target.setAttribute('data-account', 'alice@example.com'); + + const result = htmlTreeAsString(target, { + keyAttrs: ['data-account'], + maskAttributeFn: (_key, _value, el) => (el === target ? '[masked]' : '[unexpected element]'), + }); + + expect(result).toBe('button[data-account="[masked]"]'); + }); + + it('returns unknown when attribute masking throws', () => { + const target = document.createElement('button'); + target.title = 'Account PIN'; + + const result = htmlTreeAsString(target, { + maskAttributeFn: () => { + throw new Error('Invalid masking selector'); + }, + }); + + expect(result).toBe(''); + }); + it('returns a query-selector-like string for a single element', () => { const el = document.createElement('button'); el.id = 'submit'; diff --git a/packages/replay-internal/src/coreHandlers/handleDom.ts b/packages/replay-internal/src/coreHandlers/handleDom.ts index 4c3171aa3276..468080132715 100644 --- a/packages/replay-internal/src/coreHandlers/handleDom.ts +++ b/packages/replay-internal/src/coreHandlers/handleDom.ts @@ -4,7 +4,7 @@ import type { Breadcrumb } from '@sentry/core'; import { record } from '@sentry/rrweb'; import type { serializedElementNodeWithId, serializedNodeWithId } from '@sentry/rrweb-snapshot'; import { NodeType } from '@sentry/rrweb-snapshot'; -import type { ReplayContainer } from '../types'; +import type { RecordingOptions, ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; import { handleClick } from './handleClick'; import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; @@ -19,7 +19,7 @@ export const handleDomListener: (replay: ReplayContainer) => (handlerData: Handl return; } - const result = handleDom(handlerData); + const result = handleDom(handlerData, replay.getMaskAttributeFn()); if (!result) { return; @@ -75,8 +75,11 @@ export function getBaseDomBreadcrumb(target: Node | null, message: string): Brea * An event handler to react to DOM events. * Exported for tests. */ -export function handleDom(handlerData: HandlerDataDom): Breadcrumb | null { - const { target, message } = getDomTarget(handlerData); +export function handleDom( + handlerData: HandlerDataDom, + maskAttributeFn?: RecordingOptions['maskAttributeFn'], +): Breadcrumb | null { + const { target, message } = getDomTarget(handlerData, maskAttributeFn); return createBreadcrumb({ category: `ui.${handlerData.name}`, @@ -84,7 +87,10 @@ export function handleDom(handlerData: HandlerDataDom): Breadcrumb | null { }); } -function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; message: string } { +function getDomTarget( + handlerData: HandlerDataDom, + maskAttributeFn?: RecordingOptions['maskAttributeFn'], +): { target: Node | null; message: string } { const isClick = handlerData.name === 'click'; let message: string | undefined; @@ -93,7 +99,7 @@ function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; messa // Accessing event.target can throw (see getsentry/raven-js#838, #768) try { target = isClick ? getClickTargetNode(handlerData.event as Event) : getTargetNode(handlerData.event as Event); - message = htmlTreeAsString(target, { maxStringLength: 200 }) || ''; + message = htmlTreeAsString(target, { maxStringLength: 200, maskAttributeFn }) || ''; } catch { message = ''; } diff --git a/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts b/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts index aba74d6c8abc..4fc1d3553089 100644 --- a/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts @@ -1,6 +1,6 @@ import { htmlTreeAsString } from '@sentry/browser-utils'; import type { Breadcrumb } from '@sentry/core'; -import type { ReplayContainer } from '../types'; +import type { RecordingOptions, ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; import { getBaseDomBreadcrumb } from './handleDom'; import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; @@ -16,7 +16,7 @@ export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEven // session with a single "keydown" breadcrumb is created) replay.updateUserActivity(); - const breadcrumb = getKeyboardBreadcrumb(event); + const breadcrumb = getKeyboardBreadcrumb(event, replay.getMaskAttributeFn()); if (!breadcrumb) { return; @@ -26,7 +26,10 @@ export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEven } /** exported only for tests */ -export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null { +export function getKeyboardBreadcrumb( + event: KeyboardEvent, + maskAttributeFn?: RecordingOptions['maskAttributeFn'], +): Breadcrumb | null { const { metaKey, shiftKey, ctrlKey, altKey, key, target } = event; // never capture for input fields @@ -44,7 +47,7 @@ export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null { return null; } - const message = htmlTreeAsString(target, { maxStringLength: 200 }) || ''; + const message = htmlTreeAsString(target, { maxStringLength: 200, maskAttributeFn }) || ''; const baseBreadcrumb = getBaseDomBreadcrumb(target as Node, message); return createBreadcrumb({ diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 6d6ec213b653..b02133abdd0d 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -306,6 +306,10 @@ export class ReplayContainer implements ReplayContainerInterface { return this._options; } + public getMaskAttributeFn(): RecordingOptions['maskAttributeFn'] { + return this._recordingOptions.maskAttributeFn; + } + /** A wrapper to conditionally capture exceptions. */ public handleException(error: unknown): void { DEBUG_BUILD && debug.exception(error); diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 955393c2f225..f3a275ad95aa 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -538,6 +538,7 @@ export interface ReplayContainer { updateUserActivity(): void; addUpdate(cb: AddUpdateCallback): void; getOptions(): ReplayPluginOptions; + getMaskAttributeFn(): RecordingOptions['maskAttributeFn']; getSessionId(): string | undefined; checkAndHandleExpiredSession(): boolean | void; setInitialState(): void; diff --git a/packages/replay-internal/src/types/rrweb.ts b/packages/replay-internal/src/types/rrweb.ts index c6ca1333918d..caacbb515146 100644 --- a/packages/replay-internal/src/types/rrweb.ts +++ b/packages/replay-internal/src/types/rrweb.ts @@ -37,6 +37,7 @@ export type ReplayEventWithTime = { export type RrwebRecordOptions = { maskAllText?: boolean; maskAllInputs?: boolean; + maskAttributeFn?: (key: string, value: string, el: HTMLElement) => string; blockClass?: ClassOption; ignoreClass?: string; maskTextClass?: ClassOption; diff --git a/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts b/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts new file mode 100644 index 000000000000..c282e6f2ce7c --- /dev/null +++ b/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment jsdom + */ + +import '../utils/mock-internal-setTimeout'; +import type { Breadcrumb } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Replay } from '../../src/integration'; +import type { ReplayConfiguration } from '../../src/types'; +import { resetSdkMock } from '../mocks/resetSdkMock'; + +describe('Integration | breadcrumb attribute masking', () => { + let integration: Replay; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(async () => { + await integration?.stop(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe.each(['data-sentry-component', 'data-sentry-element'])('%s', attribute => { + it.each([ + { + label: 'configured masking', + options: { maskAttributes: [attribute] }, + expected: '******* *** > *** *****', + }, + { + label: 'explicit unmasking', + options: { maskAttributes: [attribute], unmask: ['.sentry-unmask'] }, + expected: 'Account PIN > Pay Alice', + }, + { + label: 'default masking', + options: {}, + expected: 'Account PIN > Pay Alice', + }, + ])('honors $label for annotated target and ancestor breadcrumbs', async ({ options, expected }) => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + ...options, + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + }, + }); + integration = sdk.integration; + const parent = document.createElement('div'); + parent.className = 'sentry-unmask'; + parent.setAttribute(attribute, 'Account PIN'); + const target = document.createElement('button'); + target.className = 'sentry-unmask'; + target.setAttribute(attribute, 'Pay Alice'); + parent.appendChild(target); + const event = new Event('click'); + target.dispatchEvent(event); + + sdk.domHandler({ name: 'click', event }); + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe(expected); + }); + }); + + it.each(['click', 'keypress', 'keydown'])('masks target and ancestor attributes in %s breadcrumbs', async name => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + }, + }); + integration = sdk.integration; + const parent = document.createElement('div'); + parent.title = 'Account PIN'; + const target = document.createElement('button'); + target.setAttribute('aria-label', 'Pay Alice'); + target.setAttribute('type', 'button'); + parent.appendChild(target); + const event = new KeyboardEvent(name, { key: 'Escape' }); + target.dispatchEvent(event); + + if (name === 'keydown') { + sdk.replay['_handleKeyboardEvent'](event); + } else { + sdk.domHandler({ name, event }); + } + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe('div[title="******* ***"] > button[aria-label="*** *****"][type="button"]'); + }); + + it.each([ + { + label: 'custom attributes with text masking disabled', + options: { maskAttributes: ['alt', 'name', 'id', 'class'], maskAllText: false }, + expected: 'button#******.*******[aria-label="Pay Alice"][name="*******"][title="Account PIN"][alt="*****"]', + }, + { + label: 'explicit unmasking', + options: { unmask: ['.sentry-unmask'] }, + expected: + 'button#secret.private.sentry-unmask[aria-label="Pay Alice"][name="account"][title="Account PIN"][alt="Alice"]', + }, + { + label: 'an empty attribute mask list', + options: { maskAttributes: [] }, + expected: 'button#secret.private[aria-label="Pay Alice"][name="account"][title="Account PIN"][alt="Alice"]', + }, + ])('honors $label in click breadcrumbs', async ({ options, expected }) => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + ...options, + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + } satisfies ReplayConfiguration, + }); + integration = sdk.integration; + const target = document.createElement('button'); + target.id = 'secret'; + target.className = 'private'; + target.setAttribute('aria-label', 'Pay Alice'); + target.setAttribute('name', 'account'); + target.title = 'Account PIN'; + target.setAttribute('alt', 'Alice'); + if ('unmask' in options) { + target.classList.add('sentry-unmask'); + } + const event = new Event('click'); + target.dispatchEvent(event); + + sdk.domHandler({ name: 'click', event }); + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe(expected); + }); +}); From 6df508ea2aa4edf0538bbabe2263686067320b89 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 23 Oct 2026 11:11:49 +0200 Subject: [PATCH 014/126] test(nextjs): Bump nextjs-16 E2E app to Next.js 16.4.2 (#25686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `nextjs-16 (webpack)` E2E job fails on every branch because prerendering throws `Could not find the module ".../app/global-error.tsx#default" in the React Client Manifest`. That comes from a bug in Next.js 16.3.8: its client reference manifest plugin skips a client component whose webpack concatenated module got the id `0`, because it checks the id for truthiness. Next.js fixed this in 16.4.0 (vercel/next.js#97936), so this bumps the app's pinned `next` to 16.4.2. 16.3.8 is the last 16.3 release, so there's no 16.3 patch to pin to instead. Production module ids are deterministic hashes of the module identifiers, and those include the pnpm directory names of the loaders. The job started failing on the same develop commit that had passed a few hours earlier. The only dependency in the lockfile published in between is webpack 5.112.1, which changes the peer suffix in the `@sentry/nextjs` directory name. That's very likely what moved `app/global-error.tsx` to id `0` on CI. Locally the paths differ, so the ids differ too. Forcing the id to `0` reproduces the failure on 16.3.8, and the same build passes on 16.4.2. The `latest` webpack variant already runs 16.4.x and kept passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- dev-packages/e2e-tests/test-applications/nextjs-16/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 34b8d7f62dc9..691cefcdc21a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -38,7 +38,7 @@ "ai": "^3.0.0", "import-in-the-middle": "^2", "ioredis": "5.10.1", - "next": "16.3.8", + "next": "16.4.2", "openai": "5.18.1", "pg": "^8.13.1", "react": "19.1.0", From 35402a9c7343b24be629c995758f48d23c652849 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:46:14 +0200 Subject: [PATCH 015/126] reword and improve --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 38aaa04a7f28..6391680ad959 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -458,7 +458,6 @@ jobs: with: name: ${{ github.sha }} retention-days: 90 - # Do not use `packages/**/*.tgz`: it follows the workspace links in `node_modules` and uploads duplicate tarballs. path: | ${{ github.workspace }}/packages/browser/build/bundles/** ${{ github.workspace }}/packages/replay-internal/build/bundles/** @@ -466,6 +465,8 @@ jobs: ${{ github.workspace }}/packages/feedback/build/bundles/** ${{ github.workspace }}/packages/*/*.tgz ${{ github.workspace }}/packages/aws-serverless/build/aws/dist-serverless/*.zip + # `node_modules` contains links to the workspace packages. Without this line, the globs above might upload duplicate tarballs. + !${{ github.workspace }}/packages/**/node_modules/** job_browser_unit_tests: name: Browser Unit Tests From 3a12a739380b24bcdfb5d45ae188f4b4fb475f83 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 23 Oct 2026 11:53:37 +0200 Subject: [PATCH 016/126] ci: Prepare required workflows for develop merge queue (#25642) First prep PR to enable merge queues. Add the `merge-group` trigger to our required CI workflows for any events targeting `develop`. Ref #24689 Co-authored-by: OpenAI Codex --- .github/workflows/build.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e202edc68667..7ed4afcba551 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,7 @@ on: - release/** pull_request: merge_group: - types: [checks_requested] + branches: [develop] workflow_dispatch: inputs: commit: diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 5d44ccb3497e..997cfc71ff30 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -16,6 +16,8 @@ on: - v10 - v9 - v8 + merge_group: + branches: [develop] # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value From da1ebe891bf8e5be795c6344355fa9eb06e5a109 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:54:01 +0200 Subject: [PATCH 017/126] chore: Add external contributor to CHANGELOG.md (#25675) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #25488 Co-authored-by: JPeer264 <10677263+JPeer264@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18182d0b8e1d..77fbbd31dcd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @4ktLuffy, @rubas, and @dasilvaluis. Thank you for your contributions! +Work in this release was contributed by @4ktLuffy, @rubas, @dasilvaluis, and @faizansaiyed123. Thank you for your contributions! ### Important Changes From ad7ce981fcbab317752137889841bbc0e8d83a3e Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:56:25 +0200 Subject: [PATCH 018/126] fix(build): Make CJS re-exports visible to Node ESM imports (#25599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When plain Node ESM code imports `@sentry/nextjs`, Node loads the CJS build, because the `node` export condition points to it. Namespace imports then miss about 180 exports (`captureException`, `setUser`, …), which are silently `undefined`. Rollup writes `export *` in CJS as a loop with an arrow function, but Node only recognizes that loop with a `function` callback. So Node can't see the `@sentry/node` re-exports. Fixed by turning off arrow functions in Rollup's generated helpers. ```js // before Object.keys(node).forEach(k => { if (k !== 'default' && ...) exports[k] = node[k]; }); // after Object.keys(browser).forEach(function (k) { /* ... */ } ``` Splitting the `node` condition is not needed for this fix. It is planned for later: https://github.com/getsentry/sentry-javascript/issues/25598 - Not breaking: this only makes hidden exports visible. `require()` and default imports work as before - Only that loop changes, and only in CJS output. ESM output and CDN bundles are unchanged (same bundle-size) - Also fixes `@sentry/remix`, which pins `node` to CJS too - Supersedes #25378, which re-exports only 8 of the missing names A unit test checks that Node sees the `@sentry/node` re-export. Fixes #22791 --- dev-packages/rollup-utils/npmHelpers.mjs | 2 ++ packages/nextjs/test/serverExports.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 64031564f127..a6ded756d615 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -139,6 +139,8 @@ export function makeBaseNPMConfig(options = {}) { // Allow wrappers or helper functions generated by rollup to use any ES2015 features generatedCode: { preset: 'es2015', + // With arrow functions, Node can't see `export *` exports when ESM code imports our CJS files. + arrowFunctions: false, }, // don't add `"use strict"` to the top of cjs files diff --git a/packages/nextjs/test/serverExports.test.ts b/packages/nextjs/test/serverExports.test.ts index 558c890e93b8..caeb99569498 100644 --- a/packages/nextjs/test/serverExports.test.ts +++ b/packages/nextjs/test/serverExports.test.ts @@ -35,3 +35,17 @@ describe('Node-only integrations are statically detectable exports from every ru }, ); }); + +/** + * Node loads this CJS file for ESM imports too, and must see that it re-exports everything from `@sentry/node`. + * + * Regression test for https://github.com/getsentry/sentry-javascript/issues/22791 + */ +describe('CJS server build re-exports are detectable by Node', () => { + it('detects the `@sentry/node` re-export', async () => { + await init(); + const { reexports } = parse(readFileSync(resolve(__dirname, '../build/cjs/index.server.js'), 'utf8')); + + expect(reexports).toContain('@sentry/node'); + }); +}); From 56f048986e30caa08f05b7868cd9ea37da675acc Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:59:10 +0200 Subject: [PATCH 019/126] fix(deps): Allow `@opentelemetry/api` 1.9 again (#25684) ## What Our packages accept `@opentelemetry/api` `^1.9.1` again instead of `^1.10.0`. We still install and test against 1.10.0. ## Why OTel SDK 2.x only allows `@opentelemetry/api` below 1.10. With `^1.10.0`, these users get a second API copy, and Sentry spans become no-op when their SDK registers first. --------- Co-authored-by: Claude Opus 5.5 --- .../node-otel-sdk-node/package.json | 2 +- packages/cloudflare/package.json | 2 +- packages/deno/package.json | 2 +- packages/hono/package.json | 2 +- packages/nextjs/package.json | 2 +- packages/node/package.json | 2 +- packages/opentelemetry/package.json | 2 +- packages/react-router/package.json | 2 +- packages/server-utils/package.json | 2 +- packages/sveltekit/package.json | 2 +- packages/tanstackstart-react/package.json | 2 +- packages/vercel-edge/package.json | 2 +- pnpm-lock.yaml | 22 +++++++++---------- 13 files changed, 23 insertions(+), 23 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json index 48bee311d805..ce771de4edba 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json @@ -9,7 +9,7 @@ "test:assert": "playwright test" }, "dependencies": { - "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api": "1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/instrumentation-http": "^0.220.0", diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 23534c205b03..f6ee446a3e36 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -52,7 +52,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.10.0", + "@opentelemetry/api": "^1.9.1", "@sentry/conventions": "^0.27.0", "@sentry/core": "11.8.0", "@sentry/opentelemetry": "11.8.0", diff --git a/packages/deno/package.json b/packages/deno/package.json index 871eb5cffe9a..c500d297f8a7 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -27,7 +27,7 @@ "/build" ], "dependencies": { - "@opentelemetry/api": "^1.10.0", + "@opentelemetry/api": "^1.9.1", "@sentry/conventions": "^0.27.0", "@sentry/core": "11.8.0", "@sentry/server-runtime-injection": "11.8.0", diff --git a/packages/hono/package.json b/packages/hono/package.json index bfe3c2f150dd..f04b15b5c5f8 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -72,7 +72,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.10.0", + "@opentelemetry/api": "^1.9.1", "@sentry/core": "11.8.0", "@sentry/conventions": "^0.27.0", "@sentry/server-utils": "11.8.0" diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index a28187f94076..8f322cd9290b 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -82,7 +82,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.10.0", + "@opentelemetry/api": "^1.9.1", "@rollup/plugin-commonjs": "28.0.1", "@sentry/browser-utils": "11.8.0", "@sentry/bundler-plugins": "11.8.0", diff --git a/packages/node/package.json b/packages/node/package.json index 66820c04a670..854c6667a2e0 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -77,7 +77,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.10.0", + "@opentelemetry/api": "^1.9.1", "@sentry/conventions": "^0.27.0", "@sentry/core": "11.8.0", "@sentry/opentelemetry": "11.8.0", diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index b9c0e6e4e2cc..b2995fe8173e 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -34,7 +34,7 @@ "@opentelemetry/api": "^1.9.0" }, "devDependencies": { - "@opentelemetry/api": "^1.10.0", + "@opentelemetry/api": "^1.9.1", "@sentry-internal/rollup-utils": "11.8.0" }, "scripts": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index f1d0304eea03..e4169d0fc812 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -55,7 +55,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.10.0", + "@opentelemetry/api": "^1.9.1", "@sentry/browser": "11.8.0", "@sentry/conventions": "^0.27.0", "@sentry/core": "11.8.0", diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 1ab0c2b80b41..3466cba33b39 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -96,7 +96,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.10.0", + "@opentelemetry/api": "^1.9.1", "@sentry/conventions": "^0.27.0", "@sentry/core": "11.8.0" }, diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 4cd737e752bd..70ae37d65d36 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -70,7 +70,7 @@ } }, "dependencies": { - "@opentelemetry/api": "^1.10.0", + "@opentelemetry/api": "^1.9.1", "@sentry/cloudflare": "11.8.0", "@sentry/core": "11.8.0", "@sentry/conventions": "^0.27.0", diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 67facf555cc5..1543b2cc293a 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -51,7 +51,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.10.0", + "@opentelemetry/api": "^1.9.1", "@sentry/browser-utils": "11.8.0", "@sentry/conventions": "^0.27.0", "@sentry/core": "11.8.0", diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index 01bac2e99907..26c91099670f 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -32,7 +32,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.10.0", + "@opentelemetry/api": "^1.9.1", "@sentry/core": "11.8.0", "@sentry/server-utils": "11.8.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff5278597ebd..73f114221f2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1221,7 +1221,7 @@ importers: packages/cloudflare: dependencies: '@opentelemetry/api': - specifier: ^1.10.0 + specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': specifier: ^0.27.0 @@ -1274,7 +1274,7 @@ importers: packages/deno: dependencies: '@opentelemetry/api': - specifier: ^1.10.0 + specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': specifier: ^0.27.0 @@ -1538,7 +1538,7 @@ importers: packages/hono: dependencies: '@opentelemetry/api': - specifier: ^1.10.0 + specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': specifier: ^0.27.0 @@ -1622,7 +1622,7 @@ importers: packages/nextjs: dependencies: '@opentelemetry/api': - specifier: ^1.10.0 + specifier: ^1.9.1 version: 1.10.0 '@rollup/plugin-commonjs': specifier: 28.0.1 @@ -1723,7 +1723,7 @@ importers: packages/node: dependencies: '@opentelemetry/api': - specifier: ^1.10.0 + specifier: ^1.9.1 version: 1.10.0 '@sentry/bundler-plugins': specifier: 11.8.0 @@ -1853,7 +1853,7 @@ importers: version: link:../core devDependencies: '@opentelemetry/api': - specifier: ^1.10.0 + specifier: ^1.9.1 version: 1.10.0 '@sentry-internal/rollup-utils': specifier: 11.8.0 @@ -1951,7 +1951,7 @@ importers: packages/react-router: dependencies: '@opentelemetry/api': - specifier: ^1.10.0 + specifier: ^1.9.1 version: 1.10.0 '@sentry/browser': specifier: 11.8.0 @@ -2188,7 +2188,7 @@ importers: packages/server-utils: dependencies: '@opentelemetry/api': - specifier: ^1.10.0 + specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': specifier: ^0.27.0 @@ -2363,7 +2363,7 @@ importers: packages/sveltekit: dependencies: '@opentelemetry/api': - specifier: ^1.10.0 + specifier: ^1.9.1 version: 1.10.0 '@sentry/bundler-plugins': specifier: 11.8.0 @@ -2427,7 +2427,7 @@ importers: packages/tanstackstart-react: dependencies: '@opentelemetry/api': - specifier: ^1.10.0 + specifier: ^1.9.1 version: 1.10.0 '@sentry/browser-utils': specifier: 11.8.0 @@ -2469,7 +2469,7 @@ importers: packages/vercel-edge: dependencies: '@opentelemetry/api': - specifier: ^1.10.0 + specifier: ^1.9.1 version: 1.10.0 '@sentry/core': specifier: 11.8.0 From dddb5320d1333ea6d82080177dd93612e371e643 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:54:49 +0200 Subject: [PATCH 020/126] chore(deps): Bump next from 16.3.8 to 16.4.1 in /dev-packages/e2e-tests/test-applications/vinext-cf-workers (#25672) Bumps [next](https://github.com/vercel/next.js) from 16.3.8 to 16.4.1.
Release notes

Sourced from next's releases.

v16.4.1

This release contains security fixes for the following advisories:

Critical:

High:

v16.4.0

Check out the 16.4 announcement post to get an overview of the changes.

Core Changes

  • Show compiler plugin warning in more situations: #75682
  • fix(scripts): correct typo in rm.mjs error message: #87015
  • docs: improve clarity and punctuation in README: #86096
  • Fix debug build paths Pages Router support entries: #93529
  • bundle-analyzer: record historical snapshots on each analyze run: #93520
  • fix: detect proxy.ts correctly with compound pageExtensions: #93246
  • feat(turbopack): support false values for resolveAlias config: #93331

Misc Changes

  • docs: correction to dynamicParams migration: #96624
  • docs: proxy event argument: #96435
  • docs: remove experimental note from runtime prefetching: #96615
  • Preserve per-segment prefetching after dynamic navigation: #96583
  • Rename static generation stream option to waitForAllReady: #96564
  • Remove obsolete static generation plumbing: #96563
  • [turbopack] Drop dead writes to exports: #96381
  • [turbopack] Ignore writes to exports after a module.exports = {} writes: #96380
  • [turbopack] Add test for sideEffects with optimizePackageImports: #96549
  • [turbopack] Strip leading BOM before parsing CSS: #96678
  • Upgrade React from cbb046ab-20260731 to 7dfc7ccd-20260803: #96550
  • docs: use relative doc links in instant-navigation error pages: #96672
  • Turbopack: terminate failed plugin worker threads: #96592
  • Fix HTML-limited bot matching in prerender bypass rules: #96584
  • [ci] Fix create_release_branch for new repo permissions: #96641
  • test: skip action module instance deploy test: #96629
  • docs: quote the dynamicParams build error in a blockquote: #96633
  • [react-sync] Open pull requests as the bot that authors the commits: #96682
  • [Bench] Fixes for pure Fizz bench: #96771
  • Derive foreground cache revalidation from the consumer: #96731
  • Fix race when navigating Back before hydration: #96252
  • docs: present each Skill as steps in the AI agents guide: #96751
  • Reuse completed cache entries for the rest of a request: #96727
  • Upgrade React from 7dfc7ccd-20260803 to 11eddecd-20260805: #96735
  • Remove WorkStore execution mode: #96674

... (truncated)

Commits
  • 7217011 v16.4.1
  • 1798e42 [cd] Allow branch to get published
  • 9571ec8 [lts-active][next/image] Bump sharp (#234)
  • a4cea6a [lts-active][react] Improve Server Component transport protocol (#193)
  • 8df0d97 [lts-active][test] Backport various deploy test fixes (#100114)
  • ac9afcc [lts-active] Fix google tag manager e2e test, assume GTM 200s instead of 404i...
  • e273d5b v16.4.0
  • fdf41d6 [ai-upgrade] discover upgrade models and reasoning efforts from agent CLIs (#...
  • c3e76ad v16.4.0-canary.63
  • 47c6cc8 Document next analyze export in the package bundling guide (#99735)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=next&package-manager=npm_and_yarn&previous-version=16.3.8&new-version=16.4.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../e2e-tests/test-applications/vinext-cf-workers/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json index 4833ede35725..b2872d7a62ae 100644 --- a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json @@ -13,7 +13,7 @@ "dependencies": { "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", - "next": "16.3.8", + "next": "16.4.1", "react": "^19.3.0", "react-dom": "^19.3.0" }, From 07b886867c4b9dde58b00a69c3e9e0cf8fda3ee4 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:31:01 +0200 Subject: [PATCH 021/126] Merge pull request #25706 from getsentry/sig/manual-develop-sync-11.9.0 chore: Merge master into develop --- .version.json | 2 +- CHANGELOG.md | 164 ++++++- .../browser-integration-tests/package.json | 16 +- .../bun-integration-tests/package.json | 12 +- .../fixtures/esbuild/package.json | 2 +- .../fixtures/esbuild/pnpm-workspace.yaml | 4 +- .../fixtures/rolldown/package.json | 2 +- .../fixtures/rolldown/pnpm-workspace.yaml | 4 +- .../fixtures/rollup3/package.json | 2 +- .../fixtures/rollup3/pnpm-workspace.yaml | 4 +- .../fixtures/rollup4/package.json | 2 +- .../fixtures/rollup4/pnpm-workspace.yaml | 4 +- .../fixtures/vite4/package.json | 2 +- .../fixtures/vite4/pnpm-workspace.yaml | 4 +- .../fixtures/vite6/package.json | 2 +- .../fixtures/vite6/pnpm-workspace.yaml | 4 +- .../fixtures/vite7/package.json | 2 +- .../fixtures/vite7/pnpm-workspace.yaml | 4 +- .../fixtures/vite8/package.json | 2 +- .../fixtures/vite8/pnpm-workspace.yaml | 4 +- .../fixtures/webpack5/package.json | 2 +- .../fixtures/webpack5/pnpm-workspace.yaml | 4 +- .../package.json | 4 +- dev-packages/bundler-tests/package.json | 4 +- .../clear-cache-gh-action/package.json | 2 +- .../cloudflare-integration-tests/package.json | 10 +- .../deno-integration-tests/package.json | 8 +- dev-packages/e2e-tests/package.json | 2 +- .../package.json | 2 +- .../node-integration-tests/package.json | 20 +- .../optional-e2e-gh-action/package.json | 2 +- dev-packages/rollup-utils/package.json | 2 +- .../size-limit-gh-action/package.json | 2 +- dev-packages/test-utils/package.json | 6 +- packages/angular/package.json | 6 +- packages/astro/package.json | 16 +- packages/aws-serverless/package.json | 12 +- packages/browser-utils/package.json | 6 +- packages/browser/package.json | 16 +- packages/bun/package.json | 10 +- packages/bundler-plugins/package.json | 8 +- packages/cloudflare/package.json | 10 +- packages/core/package.json | 4 +- packages/deno/package.json | 10 +- packages/effect/package.json | 10 +- packages/elysia/package.json | 8 +- packages/ember/package.json | 6 +- packages/esbuild-plugin/package.json | 6 +- packages/eslint-config-sdk/package.json | 6 +- packages/eslint-plugin-sdk/package.json | 2 +- packages/feedback/package.json | 6 +- packages/gatsby/package.json | 10 +- packages/google-cloud-serverless/package.json | 12 +- packages/hono/package.json | 24 +- packages/integration-shims/package.json | 6 +- packages/nestjs/package.json | 10 +- packages/nextjs/package.json | 24 +- packages/nitro/package.json | 14 +- packages/node-native/package.json | 8 +- packages/node/package.json | 14 +- packages/nuxt/package.json | 20 +- packages/opentelemetry/package.json | 6 +- packages/profiling-node/package.json | 8 +- packages/react-router/package.json | 18 +- packages/react/package.json | 8 +- packages/remix/package.json | 18 +- packages/replay-canvas/package.json | 8 +- packages/replay-internal/package.json | 10 +- packages/replay-worker/package.json | 2 +- packages/rollup-plugin/package.json | 6 +- .../server-runtime-injection/package.json | 8 +- packages/server-utils/package.json | 6 +- packages/solid/package.json | 8 +- packages/solidstart/package.json | 20 +- packages/svelte/package.json | 8 +- packages/sveltekit/package.json | 20 +- packages/tanstackstart-react/package.json | 18 +- packages/typescript/package.json | 2 +- packages/vercel-edge/package.json | 10 +- packages/vite-plugin/package.json | 6 +- packages/vue/package.json | 8 +- packages/wasm/package.json | 8 +- packages/webpack-plugin/package.json | 6 +- pnpm-lock.yaml | 454 +++++++++--------- 84 files changed, 703 insertions(+), 559 deletions(-) diff --git a/.version.json b/.version.json index e48172adfc6e..2ca5ab79d815 100644 --- a/.version.json +++ b/.version.json @@ -1,4 +1,4 @@ { "_comment": "Auto-generated by scripts/bump-version.js. Used by the gitflow sync workflow to detect version bumps. Do not edit manually.", - "version": "11.8.0" + "version": "11.9.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 77fbbd31dcd3..f6211bee8ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,38 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @4ktLuffy, @rubas, @dasilvaluis, and @faizansaiyed123. Thank you for your contributions! +Work in this release was contributed by @faizansaiyed123. Thank you for your contributions! + +## 11.9.0 ### Important Changes +- **feat: Rename `diagnostic_channel`, fetch and gRPC span origins ([#25601](https://github.com/getsentry/sentry-javascript/pull/25601))** + + Some span origins changed their names to match the other v11 origins: + - `auto.graphql.diagnostic_channel` is now `auto.graphql.graphql`. + - `auto.db.redis.diagnostic_channel`, `auto.db.mysql2.diagnostic_channel` and `auto.db.mongoose.diagnostic_channel` now end in `.native_channel`. + - `auto.http.wintercg_fetch` is now `auto.http.fetch`, as on Bun, Deno and Cloudflare. + - `auto.grpc.serverless` is now `auto.grpc.google_cloud`. + + If you have `ignoreSpans` rules, queries or alerts on the old origins, change them to use the new origins. + +- **feat(deno): Add incoming request options to `denoServeIntegration` ([#25493](https://github.com/getsentry/sentry-javascript/pull/25493))** + + `denoServeIntegration` now accepts `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`, with the same meaning as in `denoHttpIntegration`. The callbacks receive the web `Request`. + + `ignoreStaticAssets` defaults to `true`, so `Deno.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `denoServeIntegration({ ignoreStaticAssets: false })`. `Deno.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. + +- **feat(deno): Record request sessions for `Deno.serve` ([#25496](https://github.com/getsentry/sentry-javascript/pull/25496))** + + `denoServeIntegration` now records a request session for each incoming request, as `denoHttpIntegration` does for `node:http`. This gives release health and crash-free rates for `Deno.serve` apps. Sessions are sent only when a `release` is set. To turn them off, set `denoServeIntegration({ sessions: false })`. `sessionFlushingDelayMS` sets how long the SDK waits before it sends the session aggregate. + +- **feat(deno): Register channel injection hooks from `init()` ([#25546](https://github.com/getsentry/sentry-javascript/pull/25546))** + + `Sentry.init()` in `@sentry/deno` now registers the diagnostics-channel injection hooks, as `@sentry/node` does. The channel integrations now record spans without `--preload=npm:@sentry/deno/import`, for modules that you import after `init()`. To also instrument modules that load before `init()`, such as static imports in your entry module, keep the preload. With `debug: true`, the SDK logs a warning when no injection is active. + + On Node.js, tracing-hooks messages now show with `debug: true` instead of with `DEBUG=@apm-js-collab/*`. + - **feat(nextjs): Add a tunnel route handler that sends only the event payload to Sentry ([#25456](https://github.com/getsentry/sentry-javascript/pull/25456))** The `tunnelRoute` option can now send events through a route handler in your app. The handler forwards only the event payload to Sentry, and no request headers. To use it, add this file next to your `tunnelRoute` option: @@ -21,6 +49,12 @@ Work in this release was contributed by @4ktLuffy, @rubas, @dasilvaluis, and @fa Also, the server no longer sends spans of tunnel requests when span streaming is on, because these spans only show the SDK's own traffic. +- **feat(nextjs): Never emit trace meta tags from a prerender ([#25141](https://github.com/getsentry/sentry-javascript/pull/25141))** + + Pageloads of apps with `cacheComponents` enabled are connected to their server request again. Pages that Next.js prerenders no longer carry trace information, so a cached page can no longer connect visitors to a request that was not theirs. This also applies to ISR pages. The pageload waits for the trace information when it arrives with the part of the page that streams in after a cached shell. + + If you set up OpenTelemetry yourself and register `SentryPropagator`, register `NextSentryPropagator` instead and pass your tracer provider to `markPrerendersOnTracerProvider`, so that your prerendered pages carry no trace information either. + - **feat(nextjs): Send tunnel route requests with `credentials: 'omit'` ([#25509](https://github.com/getsentry/sentry-javascript/pull/25509))** With `tunnelRoute` and no tunnel route handler, `@sentry/nextjs` now sends events from the browser with `credentials: 'omit'`, because the tunnel route does not need cookies. @@ -33,23 +67,133 @@ Work in this release was contributed by @4ktLuffy, @rubas, @dasilvaluis, and @fa }); ``` -- **feat(nextjs): Never emit trace meta tags from a prerender ([#25141](https://github.com/getsentry/sentry-javascript/pull/25141))** +- **feat(nitro): Report a single `http.server` span per request ([#25295](https://github.com/getsentry/sentry-javascript/pull/25295))** - Pageloads of apps with `cacheComponents` enabled are connected to their server request again. Pages that Next.js prerenders no longer carry trace information, so a cached page can no longer connect visitors to a request that was not theirs. This also applies to ISR pages. The pageload waits for the trace information when it arrives with the part of the page that streams in after a cached shell. + A Nitro request now creates only one `http.server` span. The nested `http.server` spans with the origins `auto.http.nitro.srvx` and `auto.http.nitro.h3` are removed. h3 route handlers now get a `handler` span, and middleware spans are direct children of the `http.server` span. If you have queries or alerts on the removed spans, change them to use the `http.server` span or the `handler` span. - If you set up OpenTelemetry yourself and register `SentryPropagator`, register `NextSentryPropagator` instead and pass your tracer provider to `markPrerendersOnTracerProvider`, so that your prerendered pages carry no trace information either. +- **fix: Use `auto.http.http_server` origin for all `node:http` server spans ([#25604](https://github.com/getsentry/sentry-javascript/pull/25604))** -Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions! + On Bun and Deno, `node:http` server spans now have the origin `auto.http.http_server` instead of `auto.http.server`, as on Node.js. On Node.js, nothing changes. `Bun.serve` spans keep `auto.http.bun.serve` and `Deno.serve` spans keep `auto.http.deno`. If you have `ignoreSpans` or `beforeSendSpan` rules, queries or alerts on `auto.http.server`, change them to use `auto.http.http_server`. -- **feat(deno): Add incoming request options to `denoServeIntegration`** +### Other Changes - `denoServeIntegration` now accepts `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`, with the same meaning as in `denoHttpIntegration`. The callbacks receive the web `Request`. +- feat(browser): Add `LoaderSentry` type export ([#25357](https://github.com/getsentry/sentry-javascript/pull/25357)) +- feat(cloudflare): Add `isStreamingResponse` option and detect streamed RSC responses ([#24932](https://github.com/getsentry/sentry-javascript/pull/24932)) +- feat(core): Add `dataCollection.mcp` option ([#25553](https://github.com/getsentry/sentry-javascript/pull/25553)) +- feat(core): Limit the body size of tunnel requests ([#25515](https://github.com/getsentry/sentry-javascript/pull/25515)) +- feat(nextjs): Link `use cache` hits to their fill across instances ([#25448](https://github.com/getsentry/sentry-javascript/pull/25448)) +- feat(nextjs): Wait for the resumed part of a document before starting the pageload ([#25142](https://github.com/getsentry/sentry-javascript/pull/25142)) +- feat(nitro): Merge the nitro integrations into a single `nitroIntegration` ([#25349](https://github.com/getsentry/sentry-javascript/pull/25349)) +- feat(server-utils): Emit `execute_tool` spans for Anthropic's `beta.messages.toolRunner()` ([#25320](https://github.com/getsentry/sentry-javascript/pull/25320)) +- fix(astro): defer to `@sentry/cloudflare/vite` on Cloudflare Workers ([#25347](https://github.com/getsentry/sentry-javascript/pull/25347)) +- fix(aws-serverless): End the `function.aws` span when a callback-style handler calls back ([#25543](https://github.com/getsentry/sentry-javascript/pull/25543)) +- fix(browser): Skip profiling for unsampled active root spans ([#25498](https://github.com/getsentry/sentry-javascript/pull/25498)) +- fix(bundler-plugins): Check `globalThis` before `global` in the injected snippet ([#25365](https://github.com/getsentry/sentry-javascript/pull/25365)) +- fix(bundler-plugins): Inject the release only into chunks that load at startup ([#25541](https://github.com/getsentry/sentry-javascript/pull/25541)) +- fix(cloudflare): auto-instrument a re-exported default export ([#25345](https://github.com/getsentry/sentry-javascript/pull/25345)) +- fix(cloudflare): Deliver check-ins and events from forked isolation scopes ([#25374](https://github.com/getsentry/sentry-javascript/pull/25374)) +- fix(cloudflare): Don't hold Durable Object invocations open with the segment-end flush timer ([#25490](https://github.com/getsentry/sentry-javascript/pull/25490)) +- fix(cloudflare): keep default exports intact when auto-instrumenting ([#25372](https://github.com/getsentry/sentry-javascript/pull/25372)) +- fix(core): Use `trace_metric` data category for metrics rate limits and client reports ([#25606](https://github.com/getsentry/sentry-javascript/pull/25606)) +- fix(deps): Allow `@opentelemetry/api` 1.9 again ([#25684](https://github.com/getsentry/sentry-javascript/pull/25684)) +- fix(deno): Re-export portable helpers that Cloudflare exposes ([#25491](https://github.com/getsentry/sentry-javascript/pull/25491)) +- fix(elysia): Set SDK metadata on the options passed to init ([#24521](https://github.com/getsentry/sentry-javascript/pull/24521)) +- fix(elysia): use Node transport on Node runtimes ([#25015](https://github.com/getsentry/sentry-javascript/pull/25015)) +- fix(hono): Read connection info from the runtime instead of Hono helpers ([#25552](https://github.com/getsentry/sentry-javascript/pull/25552)) +- fix(nextjs): Only inject applicationKey metadata into browser bundles ([#25565](https://github.com/getsentry/sentry-javascript/pull/25565)) +- fix(nitro): Use forward slashes for resolved plugin paths on Windows ([#25023](https://github.com/getsentry/sentry-javascript/pull/25023)) +- fix(node,vercel-edge,bundler-plugins): Ignore commit env vars set to their own name ([#25244](https://github.com/getsentry/sentry-javascript/pull/25244)) +- fix(node): Guard user callbacks in the `node:http` server instrumentation ([#25592](https://github.com/getsentry/sentry-javascript/pull/25592)) +- fix(node): Only read JavaScript and TypeScript source files in ContextLines ([#25535](https://github.com/getsentry/sentry-javascript/pull/25535)) +- fix(node): restore tsx runtime instrumentation ([#25351](https://github.com/getsentry/sentry-javascript/pull/25351)) +- fix(nuxt): Delete Sentry-enabled source maps from Nitro output directories ([#25450](https://github.com/getsentry/sentry-javascript/pull/25450)) +- fix(nuxt): Keep sourcesContent in Nuxt 5 server source maps ([#25325](https://github.com/getsentry/sentry-javascript/pull/25325)) +- fix(react): Don't crash ErrorBoundary when `error.cause` is not an Error ([#25542](https://github.com/getsentry/sentry-javascript/pull/25542)) +- fix(react): Match React Router navigations against root routes only ([#25336](https://github.com/getsentry/sentry-javascript/pull/25336)) +- fix(server-runtime-injection): Load ESM hook without the build/esm package.json marker ([#25562](https://github.com/getsentry/sentry-javascript/pull/25562)) +- fix(server-utils): Bundle the CommonJS dependencies of instrumented packages on Vite 8 ([#25523](https://github.com/getsentry/sentry-javascript/pull/25523)) +- fix(server-utils): Keep OpenAI decision confidence on Vercel AI decide spans ([#25457](https://github.com/getsentry/sentry-javascript/pull/25457)) +- fix(server-utils): Nest LangChain run spans under their parent run ([#25364](https://github.com/getsentry/sentry-javascript/pull/25364)) +- fix(server-utils): Stop the orchestrion Vite plugin from bundling the CJS build of @sentry/server-utils ([#25359](https://github.com/getsentry/sentry-javascript/pull/25359)) +- fix(solidstart): Read server action route from the request's root span ([#25513](https://github.com/getsentry/sentry-javascript/pull/25513)) +- fix(tanstackstart-react): Delete only the source maps that Sentry enabled ([#25362](https://github.com/getsentry/sentry-javascript/pull/25362)) +- fix(vercel-edge): expose portable tracing and feature flag helpers ([#25377](https://github.com/getsentry/sentry-javascript/pull/25377)) +- ref(bundler-plugins): Drop @babel/core peer dependency ([#25406](https://github.com/getsentry/sentry-javascript/pull/25406)) +- ref(nitro): Move channel-based instrumentation into @sentry/server-utils ([#24861](https://github.com/getsentry/sentry-javascript/pull/24861)) - `ignoreStaticAssets` defaults to `true`, so `Deno.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `denoServeIntegration({ ignoreStaticAssets: false })`. `Deno.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. +
+ Internal Changes -- **feat(deno): Record request sessions for `Deno.serve`** +- build(node): Bump Node to 24.22.1 ([#25634](https://github.com/getsentry/sentry-javascript/pull/25634)) +- chore: Add external contributor to CHANGELOG.md ([#25367](https://github.com/getsentry/sentry-javascript/pull/25367)) +- chore: Add external contributor to CHANGELOG.md ([#25468](https://github.com/getsentry/sentry-javascript/pull/25468)) +- chore: Add external contributor to CHANGELOG.md ([#25483](https://github.com/getsentry/sentry-javascript/pull/25483)) +- chore: Add external contributor to CHANGELOG.md ([#25485](https://github.com/getsentry/sentry-javascript/pull/25485)) +- chore: Add external contributor to CHANGELOG.md ([#25487](https://github.com/getsentry/sentry-javascript/pull/25487)) +- chore: Add external contributor to CHANGELOG.md ([#25500](https://github.com/getsentry/sentry-javascript/pull/25500)) +- chore: Add external contributor to CHANGELOG.md ([#25505](https://github.com/getsentry/sentry-javascript/pull/25505)) +- chore: Add external contributor to CHANGELOG.md ([#25596](https://github.com/getsentry/sentry-javascript/pull/25596)) +- chore: Add external contributor to CHANGELOG.md ([#25597](https://github.com/getsentry/sentry-javascript/pull/25597)) +- chore: Add external contributor to CHANGELOG.md ([#25611](https://github.com/getsentry/sentry-javascript/pull/25611)) +- chore: Move the monorepo from Yarn 1 to pnpm 12 ([#25354](https://github.com/getsentry/sentry-javascript/pull/25354)) +- chore: Update comments related to span links with latest info ([#25135](https://github.com/getsentry/sentry-javascript/pull/25135)) +- chore(bugbot): Flag new Cloudflare tests that wrap the worker by hand ([#25619](https://github.com/getsentry/sentry-javascript/pull/25619)) +- chore(deps-dev): Bump oxlint and oxfmt ([#25578](https://github.com/getsentry/sentry-javascript/pull/25578)) +- chore(deps-dev): Bump vue from 3.2.45 to 3.5.43 ([#25413](https://github.com/getsentry/sentry-javascript/pull/25413)) +- chore(deps): Bump `@types/node` to 20 ([#25501](https://github.com/getsentry/sentry-javascript/pull/25501)) +- chore(deps): Bump Playwright to 1.64.1 and pin it exactly ([#25382](https://github.com/getsentry/sentry-javascript/pull/25382)) +- chore(hono): Remove "ALPHA" from package.json description ([#25521](https://github.com/getsentry/sentry-javascript/pull/25521)) +- chore(size-limit): weekly auto-bump ([#25442](https://github.com/getsentry/sentry-javascript/pull/25442)) +- ci: Check bundle size increases per PR ([#24737](https://github.com/getsentry/sentry-javascript/pull/24737)) +- ci: Empty cached node_modules that have broken `.bin` scripts ([#25635](https://github.com/getsentry/sentry-javascript/pull/25635)) +- ci: Fail when a Playwright version drifts from the pinned one ([#25383](https://github.com/getsentry/sentry-javascript/pull/25383)) +- ci: Keep Namespace cache volumes complete and cache the pnpm binary ([#25569](https://github.com/getsentry/sentry-javascript/pull/25569)) +- ci: Move size check reruns into a local action ([#25600](https://github.com/getsentry/sentry-javascript/pull/25600)) +- ci: Read the Playwright version from package.json in install-playwright ([#25380](https://github.com/getsentry/sentry-javascript/pull/25380)) +- ci: Run GitHub Actions jobs on Namespace runners ([#25547](https://github.com/getsentry/sentry-javascript/pull/25547)) +- ci: Run optional E2E tests in a separate workflow ([#25548](https://github.com/getsentry/sentry-javascript/pull/25548)) +- ci: Run the Playwright install's npx outside the repo ([#25384](https://github.com/getsentry/sentry-javascript/pull/25384)) +- ci: Share the E2E test setup between build.yml and optional-e2e.yml ([#25616](https://github.com/getsentry/sentry-javascript/pull/25616)) +- ci: Stop uploading duplicate tarballs to the release artifact ([#25688](https://github.com/getsentry/sentry-javascript/pull/25688)) +- ci(cloudflare): Run the integration tests on the latest wrangler too ([#25508](https://github.com/getsentry/sentry-javascript/pull/25508)) +- ci(e2e): Run all E2E tests when CI config changes ([#25391](https://github.com/getsentry/sentry-javascript/pull/25391)) +- ci(e2e): Run E2E tests with pnpm 12 ([#25334](https://github.com/getsentry/sentry-javascript/pull/25334)) +- ci(e2e): Run E2E tests without restoring the monorepo's node_modules ([#25403](https://github.com/getsentry/sentry-javascript/pull/25403)) +- ci(e2e): Stop pnpm run from installing the workspace in E2E jobs ([#25514](https://github.com/getsentry/sentry-javascript/pull/25514)) +- ci(size-limit): Don't enforce size limits on release PRs ([#25631](https://github.com/getsentry/sentry-javascript/pull/25631)) +- docs: Add guide for testing SDK packages with pnpm link ([#25512](https://github.com/getsentry/sentry-javascript/pull/25512)) +- feat(deps): Bump @actions/glob from 0.6.1 to 0.7.0 ([#25411](https://github.com/getsentry/sentry-javascript/pull/25411)) +- feat(deps): Bump @anthropic-ai/sdk from 0.63.0 to 0.133.0 ([#25417](https://github.com/getsentry/sentry-javascript/pull/25417)) +- feat(deps): Bump recast from 0.20.5 to 0.24.0 ([#25416](https://github.com/getsentry/sentry-javascript/pull/25416)) +- ref(core): Share the commit env var list used for release detection ([#25478](https://github.com/getsentry/sentry-javascript/pull/25478)) +- test(browser): Use blocking scripts in loader integration tests ([#25395](https://github.com/getsentry/sentry-javascript/pull/25395)) +- test(deps): Upgrade TanStack Start, Router, and Nitro ([#25445](https://github.com/getsentry/sentry-javascript/pull/25445)) +- test(e2e): Add node-otel-sdk-node-v3 e2e app for OpenTelemetry JS v3 ([#25630](https://github.com/getsentry/sentry-javascript/pull/25630)) +- test(e2e): Align Playwright versions with the root version ([#25381](https://github.com/getsentry/sentry-javascript/pull/25381)) +- test(e2e): Cover Vercel AI decisions in node-openai-decisions-send-to-sentry ([#25458](https://github.com/getsentry/sentry-javascript/pull/25458)) +- test(e2e): Drop OpenRouter's [DONE] frame in the Anthropic test app ([#25594](https://github.com/getsentry/sentry-javascript/pull/25594)) +- test(e2e): Drop unused @sentry/bundler-plugins dependency from bundler apps ([#25399](https://github.com/getsentry/sentry-javascript/pull/25399)) +- test(e2e): Fix peer dependency issues in hydrogen-react-router-7 ([#25400](https://github.com/getsentry/sentry-javascript/pull/25400)) +- test(e2e): Install test-utils from a tarball ([#25402](https://github.com/getsentry/sentry-javascript/pull/25402)) +- test(e2e): Move app port 38787 out of the ephemeral port range ([#25504](https://github.com/getsentry/sentry-javascript/pull/25504)) +- test(e2e): Remove event dumps and their normalization script ([#25404](https://github.com/getsentry/sentry-javascript/pull/25404)) +- test(e2e): Retry tests in all dev-mode variants ([#25640](https://github.com/getsentry/sentry-javascript/pull/25640)) +- test(e2e): Use nitro beta and Vite 8 in tanstackstart apps ([#25451](https://github.com/getsentry/sentry-javascript/pull/25451)) +- test(nestjs): Fix flaky schedule instrumentation error tests ([#25447](https://github.com/getsentry/sentry-javascript/pull/25447)) +- test(nextjs): Bump nextjs-16 E2E app to Next.js 16.4.2 ([#25686](https://github.com/getsentry/sentry-javascript/pull/25686)) +- test(nextjs): Fix flaky nextjs-16 metrics tests ([#25517](https://github.com/getsentry/sentry-javascript/pull/25517)) +- test(nextjs): Use React 19.3.1 in the vinext e2e variants ([#25449](https://github.com/getsentry/sentry-javascript/pull/25449)) +- test(nextjs): Warm the module graph before the tunnel route tests ([#25593](https://github.com/getsentry/sentry-javascript/pull/25593)) +- test(node): skip the Prisma 8 suite on Node 20 ([#25421](https://github.com/getsentry/sentry-javascript/pull/25421)) +- test(nuxt): Pin the nuxt-4 e2e MongoDB image to 8.0 ([#25557](https://github.com/getsentry/sentry-javascript/pull/25557)) +- test(nuxt): Pin the nuxt-5 e2e MongoDB image to 8.0 ([#25564](https://github.com/getsentry/sentry-javascript/pull/25564)) +- test(nuxt): Wait for hydration before clicking in error tests ([#25520](https://github.com/getsentry/sentry-javascript/pull/25520)) +- test(react): Fix flaky react-router-6 aborted SSE console assertion ([#25398](https://github.com/getsentry/sentry-javascript/pull/25398)) +- test(replay): Fix flaky bufferStalledRequests segmentId assertion ([#25394](https://github.com/getsentry/sentry-javascript/pull/25394)) - `denoServeIntegration` now records a request session for each incoming request, as `denoHttpIntegration` does for `node:http`. This gives release health and crash-free rates for `Deno.serve` apps. Sessions are sent only when a `release` is set. To turn them off, set `denoServeIntegration({ sessions: false })`. `sessionFlushingDelayMS` sets how long the SDK waits before it sends the session aggregate. +
+ +Work in this release was contributed by @4ktLuffy, @adityareddy-dev, @dasilvaluis, @faizansaiyed123, @jpdefo, @Jxxunnn, @mikamikasuki, @philipatkinson, @rubas, and @Shubham-Padkonde. Thank you for your contributions! ## 11.8.0 diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 1185f988c11e..2aa015cab0a8 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-integration-tests", - "version": "11.8.0", + "version": "11.9.0", "main": "index.js", "license": "MIT", "engines": { @@ -60,9 +60,9 @@ "@babel/preset-typescript": "^7.16.7", "@playwright/test": "1.64.1", "@sentry/rrweb": "2.44.1", - "@sentry/browser": "11.8.0", - "@sentry/replay": "11.8.0", - "@sentry/opentelemetry": "11.8.0", + "@sentry/browser": "11.9.0", + "@sentry/replay": "11.9.0", + "@sentry/opentelemetry": "11.9.0", "@sentry/conventions": "0.27.0", "@supabase/supabase-js": "2.109.0", "axios": "1.20.0", @@ -72,11 +72,11 @@ "webpack": "^5.95.0" }, "devDependencies": { - "@sentry/browser-utils": "11.8.0", - "@sentry/core": "11.8.0", - "@sentry/replay-canvas": "11.8.0", + "@sentry/browser-utils": "11.9.0", + "@sentry/core": "11.9.0", + "@sentry/replay-canvas": "11.9.0", "@sentry/rrweb-types": "^2.44.1", - "@sentry/wasm": "11.8.0", + "@sentry/wasm": "11.9.0", "@types/node": "^20.19.0", "eslint-plugin-regexp": "^3.1.0", "glob": "^13.0.6" diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index a6c4fae7993e..c12e25598915 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/bun-integration-tests", - "version": "11.8.0", + "version": "11.9.0", "license": "MIT", "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -14,16 +14,16 @@ "test:watch": "pnpm run test --watch" }, "dependencies": { - "@sentry/bun": "11.8.0", - "@sentry/hono": "11.8.0", + "@sentry/bun": "11.9.0", + "@sentry/hono": "11.9.0", "hono": "^4.13.7", "mysql": "^2.18.1", "pg": "8.16.0" }, "devDependencies": { - "@sentry-internal/node-integration-tests": "11.8.0", - "@sentry-internal/test-utils": "11.8.0", - "@sentry/core": "11.8.0", + "@sentry-internal/node-integration-tests": "11.9.0", + "@sentry-internal/test-utils": "11.9.0", + "@sentry/core": "11.9.0", "@types/mysql": "^2.15.27", "@types/pg": "^8.20.0", "bun-types": "^1.2.9", diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/package.json b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/package.json index 9a48e4fc081b..387dcb2fe572 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/package.json +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/package.json @@ -5,6 +5,6 @@ "type": "module", "dependencies": { "esbuild": "0.28.0", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" } } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/pnpm-workspace.yaml b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/pnpm-workspace.yaml index 4e17e5e78c85..bf34bdbbcc8d 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/pnpm-workspace.yaml +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/pnpm-workspace.yaml @@ -2,6 +2,6 @@ allowBuilds: esbuild: true minimumReleaseAge: 0 overrides: - '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.8.0.tgz' - '@sentry/core': 'file:../../../../packages/core/sentry-core-11.8.0.tgz' + '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.9.0.tgz' + '@sentry/core': 'file:../../../../packages/core/sentry-core-11.9.0.tgz' 'sentry': 'file:../sentry-stub' diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/package.json b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/package.json index c2f541411809..8a37dde21da6 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/package.json +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "dependencies": { - "@sentry/bundler-plugins": "11.8.0", + "@sentry/bundler-plugins": "11.9.0", "react": "19.2.4", "rolldown": "1.0.0" } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/pnpm-workspace.yaml b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/pnpm-workspace.yaml index 7544b21efe12..3571a083a5a9 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/pnpm-workspace.yaml +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/pnpm-workspace.yaml @@ -1,5 +1,5 @@ minimumReleaseAge: 0 overrides: - '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.8.0.tgz' - '@sentry/core': 'file:../../../../packages/core/sentry-core-11.8.0.tgz' + '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.9.0.tgz' + '@sentry/core': 'file:../../../../packages/core/sentry-core-11.9.0.tgz' 'sentry': 'file:../sentry-stub' diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/package.json b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/package.json index 259da7525f83..c6f38d393f1e 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/package.json +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/package.json @@ -9,6 +9,6 @@ "@babel/preset-react": "7.23.3", "@rollup/plugin-babel": "6.0.4", "@rollup/plugin-node-resolve": "15.2.3", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" } } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/pnpm-workspace.yaml b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/pnpm-workspace.yaml index 7544b21efe12..3571a083a5a9 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/pnpm-workspace.yaml +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/pnpm-workspace.yaml @@ -1,5 +1,5 @@ minimumReleaseAge: 0 overrides: - '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.8.0.tgz' - '@sentry/core': 'file:../../../../packages/core/sentry-core-11.8.0.tgz' + '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.9.0.tgz' + '@sentry/core': 'file:../../../../packages/core/sentry-core-11.9.0.tgz' 'sentry': 'file:../sentry-stub' diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/package.json b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/package.json index 7cf69f24210a..65248dd78f5b 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/package.json +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/package.json @@ -9,6 +9,6 @@ "@babel/preset-react": "7.23.3", "@rollup/plugin-babel": "6.0.4", "@rollup/plugin-node-resolve": "16.0.3", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" } } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/pnpm-workspace.yaml b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/pnpm-workspace.yaml index 7544b21efe12..3571a083a5a9 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/pnpm-workspace.yaml +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/pnpm-workspace.yaml @@ -1,5 +1,5 @@ minimumReleaseAge: 0 overrides: - '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.8.0.tgz' - '@sentry/core': 'file:../../../../packages/core/sentry-core-11.8.0.tgz' + '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.9.0.tgz' + '@sentry/core': 'file:../../../../packages/core/sentry-core-11.9.0.tgz' 'sentry': 'file:../sentry-stub' diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/package.json b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/package.json index d67974b71dec..6643776704cf 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/package.json +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/package.json @@ -7,6 +7,6 @@ "react": "19.2.4", "vite": "4.5.14", "@vitejs/plugin-react": "5.2.0", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" } } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/pnpm-workspace.yaml b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/pnpm-workspace.yaml index 4e17e5e78c85..bf34bdbbcc8d 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/pnpm-workspace.yaml +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/pnpm-workspace.yaml @@ -2,6 +2,6 @@ allowBuilds: esbuild: true minimumReleaseAge: 0 overrides: - '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.8.0.tgz' - '@sentry/core': 'file:../../../../packages/core/sentry-core-11.8.0.tgz' + '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.9.0.tgz' + '@sentry/core': 'file:../../../../packages/core/sentry-core-11.9.0.tgz' 'sentry': 'file:../sentry-stub' diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite6/package.json b/dev-packages/bundler-plugin-integration-tests/fixtures/vite6/package.json index c25f754eaf7a..26d39502e553 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite6/package.json +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite6/package.json @@ -5,6 +5,6 @@ "type": "module", "dependencies": { "vite": "6.4.1", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" } } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite6/pnpm-workspace.yaml b/dev-packages/bundler-plugin-integration-tests/fixtures/vite6/pnpm-workspace.yaml index 4e17e5e78c85..bf34bdbbcc8d 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite6/pnpm-workspace.yaml +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite6/pnpm-workspace.yaml @@ -2,6 +2,6 @@ allowBuilds: esbuild: true minimumReleaseAge: 0 overrides: - '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.8.0.tgz' - '@sentry/core': 'file:../../../../packages/core/sentry-core-11.8.0.tgz' + '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.9.0.tgz' + '@sentry/core': 'file:../../../../packages/core/sentry-core-11.9.0.tgz' 'sentry': 'file:../sentry-stub' diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/package.json b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/package.json index 3a8e666c0dba..c8013141ff39 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/package.json +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/package.json @@ -7,6 +7,6 @@ "react": "19.2.4", "vite": "7.3.1", "@vitejs/plugin-react": "5.2.0", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" } } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/pnpm-workspace.yaml b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/pnpm-workspace.yaml index 4e17e5e78c85..bf34bdbbcc8d 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/pnpm-workspace.yaml +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/pnpm-workspace.yaml @@ -2,6 +2,6 @@ allowBuilds: esbuild: true minimumReleaseAge: 0 overrides: - '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.8.0.tgz' - '@sentry/core': 'file:../../../../packages/core/sentry-core-11.8.0.tgz' + '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.9.0.tgz' + '@sentry/core': 'file:../../../../packages/core/sentry-core-11.9.0.tgz' 'sentry': 'file:../sentry-stub' diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/package.json b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/package.json index 38249b7cbfc8..43c7ce4dd140 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/package.json +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/package.json @@ -7,6 +7,6 @@ "react": "19.2.4", "vite": "8.0.16", "@vitejs/plugin-react": "6.0.1", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" } } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/pnpm-workspace.yaml b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/pnpm-workspace.yaml index 7544b21efe12..3571a083a5a9 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/pnpm-workspace.yaml +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/pnpm-workspace.yaml @@ -1,5 +1,5 @@ minimumReleaseAge: 0 overrides: - '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.8.0.tgz' - '@sentry/core': 'file:../../../../packages/core/sentry-core-11.8.0.tgz' + '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.9.0.tgz' + '@sentry/core': 'file:../../../../packages/core/sentry-core-11.9.0.tgz' 'sentry': 'file:../sentry-stub' diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/package.json b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/package.json index 885d0a4b7f86..2dd98d537a54 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/package.json +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/package.json @@ -8,6 +8,6 @@ "webpack": "5.105.4", "webpack-cli": "6.0.1", "@babel/preset-react": "7.23.3", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" } } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/pnpm-workspace.yaml b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/pnpm-workspace.yaml index 7544b21efe12..3571a083a5a9 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/pnpm-workspace.yaml +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/pnpm-workspace.yaml @@ -1,5 +1,5 @@ minimumReleaseAge: 0 overrides: - '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.8.0.tgz' - '@sentry/core': 'file:../../../../packages/core/sentry-core-11.8.0.tgz' + '@sentry/bundler-plugins': 'file:../../../../packages/bundler-plugins/sentry-bundler-plugins-11.9.0.tgz' + '@sentry/core': 'file:../../../../packages/core/sentry-core-11.9.0.tgz' 'sentry': 'file:../sentry-stub' diff --git a/dev-packages/bundler-plugin-integration-tests/package.json b/dev-packages/bundler-plugin-integration-tests/package.json index 7486705905d0..c29c03e65b82 100644 --- a/dev-packages/bundler-plugin-integration-tests/package.json +++ b/dev-packages/bundler-plugin-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/bundler-plugin-integration-tests", - "version": "11.8.0", + "version": "11.9.0", "license": "MIT", "private": true, "scripts": { @@ -12,7 +12,7 @@ "clean:deps": "premove node_modules" }, "dependencies": { - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" }, "devDependencies": { "premove": "^4.0.0", diff --git a/dev-packages/bundler-tests/package.json b/dev-packages/bundler-tests/package.json index 4e17e6c405c0..1210085b32ef 100644 --- a/dev-packages/bundler-tests/package.json +++ b/dev-packages/bundler-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/bundler-tests", - "version": "11.8.0", + "version": "11.9.0", "description": "Bundler tests for Sentry Browser SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bundler-tests", @@ -13,7 +13,7 @@ }, "dependencies": { "@rollup/plugin-node-resolve": "^16.0.3", - "@sentry/browser": "11.8.0", + "@sentry/browser": "11.9.0", "rollup": "^4.60.3", "vite": "^8.3.1", "vitest": "^4.1.11", diff --git a/dev-packages/clear-cache-gh-action/package.json b/dev-packages/clear-cache-gh-action/package.json index 86fca53374ab..40ead9d8be60 100644 --- a/dev-packages/clear-cache-gh-action/package.json +++ b/dev-packages/clear-cache-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/clear-cache-gh-action", "description": "An internal Github Action to clear GitHub caches.", - "version": "11.8.0", + "version": "11.9.0", "license": "MIT", "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index cbfdb93e96a4..89b6a98e97cb 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/cloudflare-integration-tests", - "version": "11.8.0", + "version": "11.9.0", "license": "MIT", "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -23,9 +23,9 @@ "@langchain/typesafe": "^0.0.2", "@prisma/adapter-d1": "6.15.0", "@prisma/client": "6.15.0", - "@sentry/cloudflare": "11.8.0", - "@sentry/core": "11.8.0", - "@sentry/hono": "11.8.0", + "@sentry/cloudflare": "11.9.0", + "@sentry/core": "11.9.0", + "@sentry/hono": "11.9.0", "hono": "^4.13.7", "langchain": "^1.5.16", "openai": "5.18.1" @@ -35,7 +35,7 @@ "@cloudflare/workers-types": "^4.20260426.0", "@cloudflare/workers-types-v5": "npm:@cloudflare/workers-types@5.20260710.1", "@opentelemetry/api": "^1.9.1", - "@sentry-internal/test-utils": "11.8.0", + "@sentry-internal/test-utils": "11.9.0", "@sentry/conventions": "0.27.0", "eslint-plugin-regexp": "^3.1.0", "prisma": "6.15.0", diff --git a/dev-packages/deno-integration-tests/package.json b/dev-packages/deno-integration-tests/package.json index e8e8d3bddaa6..27eafb9e1f95 100644 --- a/dev-packages/deno-integration-tests/package.json +++ b/dev-packages/deno-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/deno-integration-tests", - "version": "11.8.0", + "version": "11.9.0", "license": "MIT", "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -16,13 +16,13 @@ "test:node-suites": "vitest run" }, "dependencies": { - "@sentry/core": "11.8.0", - "@sentry/deno": "11.8.0", + "@sentry/core": "11.9.0", + "@sentry/deno": "11.9.0", "mysql": "^2.18.1", "pg": "^8.22.0" }, "devDependencies": { - "@sentry-internal/node-integration-tests": "11.8.0", + "@sentry-internal/node-integration-tests": "11.9.0", "@sentry/conventions": "^0.27.0", "vitest": "^4.1.11" }, diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index a19a97879fc3..c5e2662d08d6 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/e2e-tests", - "version": "11.8.0", + "version": "11.9.0", "license": "MIT", "private": true, "scripts": { diff --git a/dev-packages/external-contributor-gh-action/package.json b/dev-packages/external-contributor-gh-action/package.json index bad7796d72b1..7a87bc18db07 100644 --- a/dev-packages/external-contributor-gh-action/package.json +++ b/dev-packages/external-contributor-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/external-contributor-gh-action", "description": "An internal Github Action to add external contributors to the CHANGELOG.md file.", - "version": "11.8.0", + "version": "11.9.0", "license": "MIT", "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 5118d28f1d46..9726621238a7 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-integration-tests", - "version": "11.8.0", + "version": "11.9.0", "license": "MIT", "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -52,12 +52,12 @@ "@nestjs/platform-express": "^11", "@prisma/adapter-pg": "7.9.1", "@prisma/client": "6.15.0", - "@sentry/aws-serverless": "11.8.0", - "@sentry/core": "11.8.0", - "@sentry/hono": "11.8.0", - "@sentry/node": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0", + "@sentry/aws-serverless": "11.9.0", + "@sentry/core": "11.9.0", + "@sentry/hono": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0", "@smithy/node-http-handler": "4.12.1", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", @@ -117,10 +117,10 @@ "@hapi/boom": "^10.0.1", "@neondatabase/serverless": "1.2.0", "@opentelemetry/instrumentation-http": "0.220.0", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@sentry/conventions": "0.27.0", - "@sentry-internal/test-utils": "11.8.0", - "@sentry/node-native": "11.8.0", + "@sentry-internal/test-utils": "11.9.0", + "@sentry/node-native": "11.9.0", "@types/amqplib": "^0.10.5", "@types/express": "^4.17.21", "@types/node-cron": "^3.0.11", diff --git a/dev-packages/optional-e2e-gh-action/package.json b/dev-packages/optional-e2e-gh-action/package.json index 98e11476d393..ffc3e38564cf 100644 --- a/dev-packages/optional-e2e-gh-action/package.json +++ b/dev-packages/optional-e2e-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/optional-e2e-gh-action", "description": "An internal Github Action to run the optional E2E tests without failing the PR.", - "version": "11.8.0", + "version": "11.9.0", "license": "MIT", "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" diff --git a/dev-packages/rollup-utils/package.json b/dev-packages/rollup-utils/package.json index dae0678b90d8..0c8602f49eff 100644 --- a/dev-packages/rollup-utils/package.json +++ b/dev-packages/rollup-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/rollup-utils", - "version": "11.8.0", + "version": "11.9.0", "description": "Rollup utilities used at Sentry for the Sentry JavaScript SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/rollup-utils", diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json index cc5f98509f15..6c7848539fa2 100644 --- a/dev-packages/size-limit-gh-action/package.json +++ b/dev-packages/size-limit-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/size-limit-gh-action", "description": "An internal Github Action to compare the current size of a PR against the one on develop.", - "version": "11.8.0", + "version": "11.9.0", "license": "MIT", "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index 18d15c905a70..893950f01376 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "11.8.0", + "version": "11.9.0", "name": "@sentry-internal/test-utils", "author": "Sentry", "license": "MIT", @@ -66,8 +66,8 @@ }, "devDependencies": { "@playwright/test": "1.64.1", - "@sentry-internal/rollup-utils": "11.8.0", - "@sentry/core": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", + "@sentry/core": "11.9.0", "@types/express": "^4.17.21", "@types/ws": "^8.18.1", "eslint-plugin-regexp": "^3.1.0" diff --git a/packages/angular/package.json b/packages/angular/package.json index a7fbf4ac6e0e..562a1caf08e7 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/angular", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Angular", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/angular", @@ -21,8 +21,8 @@ "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { - "@sentry/browser": "11.8.0", - "@sentry/core": "11.8.0", + "@sentry/browser": "11.9.0", + "@sentry/core": "11.9.0", "@sentry/conventions": "^0.27.0", "tslib": "^2.4.1" }, diff --git a/packages/astro/package.json b/packages/astro/package.json index e4b3504a0281..d09e52560f5c 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/astro", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Astro", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/astro", @@ -51,16 +51,16 @@ "astro": ">=4.0.0-beta || >=7.0.0-beta" }, "dependencies": { - "@sentry/browser": "11.8.0", - "@sentry/core": "11.8.0", + "@sentry/browser": "11.9.0", + "@sentry/core": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/node": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/node": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0", + "@sentry/bundler-plugins": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "astro": "^4.16.19", "vite": "^8.3.1" }, diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 4ea85a300a3a..23830a5b878f 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/aws-serverless", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for AWS Lambda and AWS Serverless Environments", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/aws-serverless", @@ -57,14 +57,14 @@ }, "dependencies": { "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0", + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0", "@types/aws-lambda": "^8.10.161" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@types/node": "^20.19.0", "@vercel/nft": "^1.11.0" }, diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index 98de486ea380..0a07b81ac0ac 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/browser-utils", - "version": "11.8.0", + "version": "11.9.0", "description": "Browser Utilities for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser-utils", @@ -33,12 +33,12 @@ "access": "public" }, "dependencies": { - "@sentry/core": "11.8.0", + "@sentry/core": "11.9.0", "@sentry/conventions": "^0.27.0", "web-vitals": "^6.2.3" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0" + "@sentry-internal/rollup-utils": "11.9.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/browser/package.json b/packages/browser/package.json index 5bc097965ab3..1bc8bcdb2c13 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/browser", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for browsers", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser", @@ -38,16 +38,16 @@ "access": "public" }, "dependencies": { - "@sentry/browser-utils": "11.8.0", + "@sentry/browser-utils": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/feedback": "11.8.0", - "@sentry/replay": "11.8.0", - "@sentry/replay-canvas": "11.8.0", - "@sentry/core": "11.8.0" + "@sentry/feedback": "11.9.0", + "@sentry/replay": "11.9.0", + "@sentry/replay-canvas": "11.9.0", + "@sentry/core": "11.9.0" }, "devDependencies": { - "@sentry-internal/integration-shims": "11.8.0", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/integration-shims": "11.9.0", + "@sentry-internal/rollup-utils": "11.9.0", "fake-indexeddb": "^6.2.5" }, "scripts": { diff --git a/packages/bun/package.json b/packages/bun/package.json index ee8236541ed9..75aba73712a7 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/bun", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for bun", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bun", @@ -43,13 +43,13 @@ "access": "public" }, "dependencies": { - "@sentry/core": "11.8.0", + "@sentry/core": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/node": "11.8.0", - "@sentry/server-utils": "11.8.0" + "@sentry/node": "11.9.0", + "@sentry/server-utils": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "bun-types": "^1.2.9" }, "scripts": { diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index bd56dfc15eaf..12bbe2ce482d 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/bundler-plugins", - "version": "11.8.0", + "version": "11.9.0", "description": "Sentry Bundler Plugins", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/main/packages/bundler-plugins", @@ -110,7 +110,7 @@ } }, "dependencies": { - "@sentry/core": "11.8.0", + "@sentry/core": "11.9.0", "dotenv": "^17.4.2", "glob": "^13.0.6", "magic-string": "~0.30.8 || ^1.0.0", @@ -134,8 +134,8 @@ "@babel/core": "^7.18.5", "@babel/parser": "^7.29.8", "@babel/preset-react": "^7.23.3", - "@sentry-internal/rollup-utils": "11.8.0", - "@sentry-internal/test-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", + "@sentry-internal/test-utils": "11.9.0", "@types/babel__core": "^7.20.5", "@types/node": "^20.19.0", "premove": "^4.0.0", diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index f6ee446a3e36..49eb185bd6da 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/cloudflare", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Cloudflare Workers and Pages", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare", @@ -54,9 +54,9 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/opentelemetry": "11.8.0", - "@sentry/server-utils": "11.8.0", + "@sentry/core": "11.9.0", + "@sentry/opentelemetry": "11.9.0", + "@sentry/server-utils": "11.9.0", "magic-string": "~0.30.21" }, "peerDependencies": { @@ -77,7 +77,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "5.20260710.1", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@types/node": "^20.19.0", "acorn": "^8.14.0", "vite": "^8.3.1", diff --git a/packages/core/package.json b/packages/core/package.json index 8199fda2b144..65061a77bae5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/core", - "version": "11.8.0", + "version": "11.9.0", "description": "Base implementation for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/core", @@ -91,7 +91,7 @@ }, "sideEffects": false, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "zod": "^3.24.1" }, "dependencies": { diff --git a/packages/deno/package.json b/packages/deno/package.json index c500d297f8a7..270ca28cd70d 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/deno", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Deno", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/deno", @@ -29,12 +29,12 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0" + "@sentry-internal/rollup-utils": "11.9.0" }, "scripts": { "deno-types": "node ./scripts/download-deno-types.mjs", diff --git a/packages/effect/package.json b/packages/effect/package.json index e66f914fcf33..e39b720820ca 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/effect", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Effect", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/effect", @@ -44,10 +44,10 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "11.8.0", + "@sentry/browser": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0" }, "peerDependencies": { "effect": "^3.0.0 || ^4.0.0" @@ -59,7 +59,7 @@ }, "devDependencies": { "@effect/vitest": "^4.0.0", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "effect": "^4.0.0" }, "scripts": { diff --git a/packages/elysia/package.json b/packages/elysia/package.json index a178d5faaa4e..ac6dda7debbf 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/elysia", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Elysia", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/elysia", @@ -32,15 +32,15 @@ "access": "public" }, "dependencies": { - "@sentry/bun": "11.8.0", - "@sentry/core": "11.8.0", + "@sentry/bun": "11.9.0", + "@sentry/core": "11.9.0", "@sentry/conventions": "^0.27.0" }, "peerDependencies": { "elysia": "^1.4.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "bun-types": "^1.2.9", "elysia": "^1.4.29" }, diff --git a/packages/ember/package.json b/packages/ember/package.json index cca89e8345aa..5ddf731ab1f0 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/ember", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Ember.js", "keywords": [ "ember-addon" @@ -44,9 +44,9 @@ "dependencies": { "@embroider/addon-shim": "^1.10.2", "@embroider/macros": "^1.20.2", - "@sentry/browser": "11.8.0", + "@sentry/browser": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", + "@sentry/core": "11.9.0", "decorator-transforms": "^2.3.2" }, "devDependencies": { diff --git a/packages/esbuild-plugin/package.json b/packages/esbuild-plugin/package.json index 131cf0fae96d..450a38bd2c08 100644 --- a/packages/esbuild-plugin/package.json +++ b/packages/esbuild-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/esbuild-plugin", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry esbuild plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/esbuild-plugin", @@ -48,10 +48,10 @@ "test:unit": "vitest run" }, "dependencies": { - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0" + "@sentry-internal/rollup-utils": "11.9.0" }, "volta": { "extends": "../../package.json" diff --git a/packages/eslint-config-sdk/package.json b/packages/eslint-config-sdk/package.json index d9b45c0d88a3..618db9e1b79c 100644 --- a/packages/eslint-config-sdk/package.json +++ b/packages/eslint-config-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/eslint-config-sdk", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK eslint config", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-config-sdk", @@ -22,8 +22,8 @@ "access": "public" }, "dependencies": { - "@sentry/eslint-plugin-sdk": "11.8.0", - "@sentry/typescript": "11.8.0", + "@sentry/eslint-plugin-sdk": "11.9.0", + "@sentry/typescript": "11.9.0", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "eslint-config-prettier": "^9.1.0", diff --git a/packages/eslint-plugin-sdk/package.json b/packages/eslint-plugin-sdk/package.json index 34ea51ff22d5..50d8234631fc 100644 --- a/packages/eslint-plugin-sdk/package.json +++ b/packages/eslint-plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/eslint-plugin-sdk", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK eslint plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-plugin-sdk", diff --git a/packages/feedback/package.json b/packages/feedback/package.json index 6e5f831b8a47..8f98f3805576 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/feedback", - "version": "11.8.0", + "version": "11.9.0", "description": "Sentry SDK integration for user feedback", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback", @@ -33,10 +33,10 @@ "access": "public" }, "dependencies": { - "@sentry/core": "11.8.0" + "@sentry/core": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "preact": "^10.29.8" }, "scripts": { diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 24116594896d..5d1a5b81b380 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/gatsby", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Gatsby.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/gatsby", @@ -38,16 +38,16 @@ "access": "public" }, "dependencies": { - "@sentry/core": "11.8.0", - "@sentry/react": "11.8.0", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/react": "11.9.0", + "@sentry/bundler-plugins": "11.9.0" }, "peerDependencies": { "gatsby": "^3.0.0 || ^4.0.0 || ^5.0.0", "react": "17.x || 18.x || 19.x" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@testing-library/react": "^15.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index 0a3aef657e75..ac82b82cba84 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/google-cloud-serverless", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Google Cloud Functions", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/google-cloud-serverless", @@ -37,15 +37,15 @@ }, "dependencies": { "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0" }, "devDependencies": { "@google-cloud/bigquery": "^5.3.0", "@google-cloud/common": "^3.4.1", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@types/express": "^5.0.6", "@types/node": "^20.19.0", "nock": "^14.0.17" diff --git a/packages/hono/package.json b/packages/hono/package.json index f04b15b5c5f8..80db3ccd0369 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/hono", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Hono", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/hono", @@ -73,16 +73,16 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/core": "11.8.0", + "@sentry/core": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/server-utils": "11.8.0" + "@sentry/server-utils": "11.9.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x || ^5.x", - "@sentry/bun": "11.8.0", - "@sentry/cloudflare": "11.8.0", - "@sentry/deno": "11.8.0", - "@sentry/node": "11.8.0", + "@sentry/bun": "11.9.0", + "@sentry/cloudflare": "11.9.0", + "@sentry/deno": "11.9.0", + "@sentry/node": "11.9.0", "hono": "^4.x" }, "peerDependenciesMeta": { @@ -104,11 +104,11 @@ }, "devDependencies": { "@cloudflare/workers-types": "4.20250922.0", - "@sentry-internal/rollup-utils": "11.8.0", - "@sentry/bun": "11.8.0", - "@sentry/cloudflare": "11.8.0", - "@sentry/deno": "11.8.0", - "@sentry/node": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", + "@sentry/bun": "11.9.0", + "@sentry/cloudflare": "11.9.0", + "@sentry/deno": "11.9.0", + "@sentry/node": "11.9.0", "@types/node": "^20.19.0", "hono": "^4.13.7", "wrangler": "4.62.0" diff --git a/packages/integration-shims/package.json b/packages/integration-shims/package.json index c9f7ddcd1f99..9112f5a81165 100644 --- a/packages/integration-shims/package.json +++ b/packages/integration-shims/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/integration-shims", - "version": "11.8.0", + "version": "11.9.0", "description": "Shims for integrations in Sentry SDK.", "main": "build/cjs/index.js", "module": "build/esm/index.js", @@ -48,10 +48,10 @@ "url": "https://github.com/getsentry/sentry-javascript/issues" }, "dependencies": { - "@sentry/core": "11.8.0" + "@sentry/core": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0" + "@sentry-internal/rollup-utils": "11.9.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 801ec046e0b8..c9d875b26b9b 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nestjs", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for NestJS", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nestjs", @@ -45,14 +45,14 @@ }, "dependencies": { "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0", - "@sentry/server-utils": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/server-utils": "11.9.0" }, "devDependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" }, diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 8f322cd9290b..037f9a7b2891 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nextjs", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Next.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nextjs", @@ -84,22 +84,22 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@rollup/plugin-commonjs": "28.0.1", - "@sentry/browser-utils": "11.8.0", - "@sentry/bundler-plugins": "11.8.0", - "@sentry/cloudflare": "11.8.0", + "@sentry/browser-utils": "11.9.0", + "@sentry/bundler-plugins": "11.9.0", + "@sentry/cloudflare": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0", - "@sentry/opentelemetry": "11.8.0", - "@sentry/react": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0", - "@sentry/vercel-edge": "11.8.0", + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/opentelemetry": "11.9.0", + "@sentry/react": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0", + "@sentry/vercel-edge": "11.9.0", "rollup": "^4.60.3", "stacktrace-parser": "^0.1.11" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "cjs-module-lexer": "^2.2.0", "next": "15.5.27", "react": "^18.3.1", diff --git a/packages/nitro/package.json b/packages/nitro/package.json index bd0d6f007142..bb79d0837284 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nitro", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Nitro", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nitro", @@ -35,15 +35,15 @@ "nitro": ">=3.0.0-0 <4.0.0 || 3.0.260311-beta || 3.0.260415-beta" }, "dependencies": { - "@sentry/bundler-plugins": "11.8.0", + "@sentry/bundler-plugins": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "h3": "^2.0.1-rc.20", "nitro": "^3.0.260415-beta", "srvx": "^0.11.22", diff --git a/packages/node-native/package.json b/packages/node-native/package.json index d7b6889674e9..4099a08d8e07 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-native", - "version": "11.8.0", + "version": "11.9.0", "description": "Native Tools for the Official Sentry Node.js SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-native", @@ -56,11 +56,11 @@ }, "dependencies": { "@sentry/node-native-stacktrace": "^0.5.1", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@types/node": "^20.19.0" }, "volta": { diff --git a/packages/node/package.json b/packages/node/package.json index 854c6667a2e0..46677e88e13e 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node", - "version": "11.8.0", + "version": "11.9.0", "description": "Sentry Node SDK using OpenTelemetry for performance instrumentation", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node", @@ -79,14 +79,14 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/opentelemetry": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/opentelemetry": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0", + "@sentry/bundler-plugins": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@types/node": "^20.19.0" }, "scripts": { diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 840a64a77d1c..05555cd282f2 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nuxt", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Nuxt", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nuxt", @@ -54,21 +54,21 @@ }, "dependencies": { "@nuxt/kit": "^4.6.0", - "@sentry/browser": "11.8.0", - "@sentry/cloudflare": "11.8.0", + "@sentry/browser": "11.9.0", + "@sentry/cloudflare": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0", - "@sentry/bundler-plugins": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0", - "@sentry/vue": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/bundler-plugins": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0", + "@sentry/vue": "11.9.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260519.1", "@nuxt/nitro-server": "^3.21.11", "@nuxt/schema": "^3.21.11", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "db0": "^0.3.4", "h3": "^1.15.10", "nitro": "^3.0.260311-beta", diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index b2995fe8173e..a67caf18421d 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/opentelemetry", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry utilities for OpenTelemetry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/opentelemetry", @@ -28,14 +28,14 @@ }, "dependencies": { "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0" + "@sentry/core": "11.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" }, "devDependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry-internal/rollup-utils": "11.8.0" + "@sentry-internal/rollup-utils": "11.9.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/profiling-node/package.json b/packages/profiling-node/package.json index aede93e4f503..7f1adab6456c 100644 --- a/packages/profiling-node/package.json +++ b/packages/profiling-node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/profiling-node", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Node.js Profiling", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/profiling-node", @@ -51,11 +51,11 @@ }, "dependencies": { "@sentry/node-cpu-profiler": "^2.4.4", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@types/node": "^20.19.0" }, "volta": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index e4169d0fc812..93ae07d2dd99 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react-router", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for React Router (Framework)", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react-router", @@ -56,21 +56,21 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/browser": "11.8.0", + "@sentry/browser": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0", - "@sentry/react": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0", - "@sentry/bundler-plugins": "11.8.0", + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/react": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0", + "@sentry/bundler-plugins": "11.9.0", "glob": "^13.0.6", "sentry": "^0.44.0" }, "devDependencies": { "@react-router/dev": "^7.17.0", "@react-router/node": "^7.15.0", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@testing-library/react": "^15.0.5", "@types/node": "^20.19.0", "@types/react": "17.0.3", diff --git a/packages/react/package.json b/packages/react/package.json index 65fd22a6673c..ce565c42d22c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for React.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react", @@ -57,8 +57,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "11.8.0", - "@sentry/core": "11.8.0", + "@sentry/browser": "11.9.0", + "@sentry/core": "11.9.0", "@sentry/conventions": "^0.27.0" }, "peerDependencies": { @@ -71,7 +71,7 @@ } }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@testing-library/react": "^15.0.5", "@testing-library/react-hooks": "^7.0.2", "@types/history-4": "npm:@types/history@4.7.8", diff --git a/packages/remix/package.json b/packages/remix/package.json index 7418326e78df..67c80fdb17bc 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/remix", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Remix", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/remix", @@ -76,14 +76,14 @@ }, "dependencies": { "@remix-run/router": "^1.23.4", - "@sentry/browser": "11.8.0", - "@sentry/bundler-plugins": "11.8.0", + "@sentry/browser": "11.9.0", + "@sentry/bundler-plugins": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0", - "@sentry/react": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0", + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/react": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0", "sentry": "^0.44.0", "yargs": "^17.6.0" }, @@ -92,7 +92,7 @@ "@remix-run/react": "^2.17.5", "@remix-run/route-pattern": "^0.24.0", "@remix-run/server-runtime": "^2.17.4", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@types/express": "^4.17.14", "@types/react": "17.0.3", "@types/semver": "^7.5.3", diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index 09e8e7753563..0dc711b0784d 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/replay-canvas", - "version": "11.8.0", + "version": "11.9.0", "description": "Replay canvas integration", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -58,12 +58,12 @@ }, "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@sentry/rrweb": "2.44.1" }, "dependencies": { - "@sentry/replay": "11.8.0", - "@sentry/core": "11.8.0" + "@sentry/replay": "11.9.0", + "@sentry/core": "11.9.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index b666d3a29c89..3a23f5f892d7 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/replay", - "version": "11.8.0", + "version": "11.9.0", "description": "User replays for Sentry", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -72,8 +72,8 @@ "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { "@babel/core": "^7.29.6", - "@sentry-internal/replay-worker": "11.8.0", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/replay-worker": "11.9.0", + "@sentry-internal/rollup-utils": "11.9.0", "@sentry/conventions": "^0.27.0", "@sentry/rrweb": "2.44.1", "@sentry/rrweb-snapshot": "2.44.1", @@ -82,8 +82,8 @@ "jsdom-worker": "^0.3.0" }, "dependencies": { - "@sentry/browser-utils": "11.8.0", - "@sentry/core": "11.8.0" + "@sentry/browser-utils": "11.9.0", + "@sentry/core": "11.9.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json index 6063eb96e201..a4c6b3d61ac2 100644 --- a/packages/replay-worker/package.json +++ b/packages/replay-worker/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-worker", - "version": "11.8.0", + "version": "11.9.0", "description": "Worker for @sentry/replay", "main": "build/esm/index.js", "module": "build/esm/index.js", diff --git a/packages/rollup-plugin/package.json b/packages/rollup-plugin/package.json index 283545012928..070ca14eb146 100644 --- a/packages/rollup-plugin/package.json +++ b/packages/rollup-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/rollup-plugin", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry Rollup plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/rollup-plugin", @@ -49,10 +49,10 @@ "test:unit": "vitest run" }, "dependencies": { - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0" + "@sentry-internal/rollup-utils": "11.9.0" }, "peerDependencies": { "rollup": ">=3.2.0" diff --git a/packages/server-runtime-injection/package.json b/packages/server-runtime-injection/package.json index 6ed2721683d3..df48d3d90400 100644 --- a/packages/server-runtime-injection/package.json +++ b/packages/server-runtime-injection/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/server-runtime-injection", - "version": "11.8.0", + "version": "11.9.0", "description": "Runtime diagnostics-channel injection hooks for the Sentry JavaScript SDKs. Must be kept external (not bundled) when bundling a server.", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/server-runtime-injection", @@ -45,13 +45,13 @@ "access": "public" }, "dependencies": { - "@sentry/core": "11.8.0", - "@sentry/server-utils": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/server-utils": "11.9.0" }, "devDependencies": { "@apm-js-collab/code-transformer": "^0.18.1", "@apm-js-collab/tracing-hooks": "^0.13.3", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@types/node": "^20.19.0", "@vercel/nft": "^1.11.0", "meriyah": "^6.1.4", diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 3466cba33b39..8d4d024e4acb 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/server-utils", - "version": "11.8.0", + "version": "11.9.0", "description": "Server Utilities for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/server-utils", @@ -98,12 +98,12 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0" + "@sentry/core": "11.9.0" }, "devDependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4", "@neondatabase/serverless": "1.2.0", - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@types/node": "^20.19.0", "meriyah": "^6.1.4", "vite": "^8.3.1", diff --git a/packages/solid/package.json b/packages/solid/package.json index 600c74fae0d5..10a3f301ab29 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solid", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Solid", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solid", @@ -54,8 +54,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "11.8.0", - "@sentry/core": "11.8.0", + "@sentry/browser": "11.9.0", + "@sentry/core": "11.9.0", "@sentry/conventions": "^0.27.0" }, "peerDependencies": { @@ -72,7 +72,7 @@ } }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@solidjs/router": "^1.0.0", "@solidjs/testing-library": "0.8.5", "@tanstack/solid-router": "^1.169.2", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 75d6f528fa79..a472053487d6 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solidstart", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Solid Start", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solidstart", @@ -80,18 +80,18 @@ } }, "dependencies": { - "@sentry/core": "11.8.0", - "@sentry/nitro": "11.8.0", - "@sentry/node": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0", - "@sentry/solid": "11.8.0", - "@sentry/bundler-plugins": "11.8.0", + "@sentry/core": "11.9.0", + "@sentry/nitro": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0", + "@sentry/solid": "11.9.0", + "@sentry/bundler-plugins": "11.9.0", "@sentry/conventions": "0.27.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", - "@sentry/browser": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", + "@sentry/browser": "11.9.0", "@solidjs/router": "^1.0.0", "@solidjs/start": "^1.3.2", "@solidjs/testing-library": "0.8.5", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 24bdc1111c23..5582a5c254df 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/svelte", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Svelte", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/svelte", @@ -32,16 +32,16 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "11.8.0", + "@sentry/browser": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", + "@sentry/core": "11.9.0", "magic-string": "~0.30.0" }, "peerDependencies": { "svelte": "3.x || 4.x || 5.x" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@sveltejs/vite-plugin-svelte": "1.4.0", "@testing-library/svelte": "^3.2.1", "svelte": "3.59.2", diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 70ae37d65d36..110f75715a1d 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/sveltekit", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for SvelteKit", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/sveltekit", @@ -71,22 +71,22 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/cloudflare": "11.8.0", - "@sentry/core": "11.8.0", + "@sentry/cloudflare": "11.9.0", + "@sentry/core": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/node": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0", - "@sentry/svelte": "11.8.0", - "@sentry/bundler-plugins": "11.8.0", + "@sentry/node": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0", + "@sentry/svelte": "11.9.0", + "@sentry/bundler-plugins": "11.9.0", "@sveltejs/acorn-typescript": "^1.0.9", "acorn": "^8.14.0", "magic-string": "~0.30.0", "sorcery": "1.0.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", - "@sentry/browser": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", + "@sentry/browser": "11.9.0", "@sveltejs/kit": "^2.70.2", "@sveltejs/vite-plugin-svelte": "^3.0.0", "@types/node": "^20.19.0", diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 1543b2cc293a..6edd650c665c 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart-react", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for TanStack Start React", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart-react", @@ -52,17 +52,17 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/browser-utils": "11.8.0", + "@sentry/browser-utils": "11.9.0", "@sentry/conventions": "^0.27.0", - "@sentry/core": "11.8.0", - "@sentry/node": "11.8.0", - "@sentry/react": "11.8.0", - "@sentry/server-runtime-injection": "11.8.0", - "@sentry/server-utils": "11.8.0", - "@sentry/bundler-plugins": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/react": "11.9.0", + "@sentry/server-runtime-injection": "11.9.0", + "@sentry/server-utils": "11.9.0", + "@sentry/bundler-plugins": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@types/node": "^20.19.0", "vite": "^8.3.1" }, diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 5cace186f876..840abb140dad 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/typescript", - "version": "11.8.0", + "version": "11.9.0", "description": "Typescript configuration used at Sentry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/typescript", diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index 26c91099670f..d8722dbc5dd9 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vercel-edge", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for the Vercel Edge Runtime", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vercel-edge", @@ -33,13 +33,13 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/core": "11.8.0", - "@sentry/server-utils": "11.8.0" + "@sentry/core": "11.9.0", + "@sentry/server-utils": "11.9.0" }, "devDependencies": { "@edge-runtime/types": "4.0.0", - "@sentry-internal/rollup-utils": "11.8.0", - "@sentry/opentelemetry": "11.8.0" + "@sentry-internal/rollup-utils": "11.9.0", + "@sentry/opentelemetry": "11.9.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index ace4ac4508a5..3153da639359 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vite-plugin", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry Vite plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/vite-plugin", @@ -48,10 +48,10 @@ "test:unit": "vitest run" }, "dependencies": { - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0" + "@sentry-internal/rollup-utils": "11.9.0" }, "volta": { "extends": "../../package.json" diff --git a/packages/vue/package.json b/packages/vue/package.json index da81ef86597e..f4536ada9786 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vue", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry SDK for Vue.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vue", @@ -44,8 +44,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "11.8.0", - "@sentry/core": "11.8.0", + "@sentry/browser": "11.9.0", + "@sentry/core": "11.9.0", "@sentry/conventions": "^0.27.0" }, "peerDependencies": { @@ -62,7 +62,7 @@ } }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0", + "@sentry-internal/rollup-utils": "11.9.0", "@tanstack/vue-router": "^1.64.0", "vue": "~3.5.44" }, diff --git a/packages/wasm/package.json b/packages/wasm/package.json index 7f502ea58770..6ecc3cb96d5c 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/wasm", - "version": "11.8.0", + "version": "11.9.0", "description": "Support for WASM.", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/wasm", @@ -32,11 +32,11 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "11.8.0", - "@sentry/core": "11.8.0" + "@sentry/browser": "11.9.0", + "@sentry/core": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0" + "@sentry-internal/rollup-utils": "11.9.0" }, "scripts": { "build": "run-p build:transpile build:bundle build:types", diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json index 9a538e9734df..3418c9b17c36 100644 --- a/packages/webpack-plugin/package.json +++ b/packages/webpack-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/webpack-plugin", - "version": "11.8.0", + "version": "11.9.0", "description": "Official Sentry Webpack plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/webpack-plugin", @@ -53,10 +53,10 @@ "test:unit": "vitest run" }, "dependencies": { - "@sentry/bundler-plugins": "11.8.0" + "@sentry/bundler-plugins": "11.9.0" }, "devDependencies": { - "@sentry-internal/rollup-utils": "11.8.0" + "@sentry-internal/rollup-utils": "11.9.0" }, "peerDependencies": { "webpack": ">=5.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73f114221f2d..ae19ea768647 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,16 +295,16 @@ importers: specifier: 1.64.1 version: 1.64.1 '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/browser '@sentry/conventions': specifier: 0.27.0 version: 0.27.0 '@sentry/opentelemetry': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/opentelemetry '@sentry/replay': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/replay-internal '@sentry/rrweb': specifier: 2.44.1 @@ -329,19 +329,19 @@ importers: version: 5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3) devDependencies: '@sentry/browser-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/browser-utils '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/core '@sentry/replay-canvas': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/replay-canvas '@sentry/rrweb-types': specifier: ^2.44.1 version: 2.44.1 '@sentry/wasm': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/wasm '@types/node': specifier: ^20.19.0 @@ -356,10 +356,10 @@ importers: dev-packages/bun-integration-tests: dependencies: '@sentry/bun': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/bun '@sentry/hono': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/hono hono: specifier: ^4.13.7 @@ -372,13 +372,13 @@ importers: version: 8.16.0 devDependencies: '@sentry-internal/node-integration-tests': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node-integration-tests '@sentry-internal/test-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../test-utils '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/core '@types/mysql': specifier: ^2.15.27 @@ -396,7 +396,7 @@ importers: dev-packages/bundler-plugin-integration-tests: dependencies: '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/bundler-plugins devDependencies: premove: @@ -418,7 +418,7 @@ importers: specifier: ^16.0.3 version: 16.0.3(rollup@4.62.3) '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/browser rollup: specifier: ^4.60.3 @@ -473,13 +473,13 @@ importers: specifier: 6.15.0 version: 6.15.0(prisma@6.15.0(magicast@0.3.5)(typescript@7.0.2))(typescript@7.0.2) '@sentry/cloudflare': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/cloudflare '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/core '@sentry/hono': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/hono ai: specifier: ^6.0.0 @@ -507,7 +507,7 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry-internal/test-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../test-utils '@sentry/conventions': specifier: 0.27.0 @@ -531,10 +531,10 @@ importers: dev-packages/deno-integration-tests: dependencies: '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/core '@sentry/deno': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/deno mysql: specifier: ^2.18.1 @@ -544,7 +544,7 @@ importers: version: 8.22.0 devDependencies: '@sentry-internal/node-integration-tests': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node-integration-tests '@sentry/conventions': specifier: ^0.27.0 @@ -656,22 +656,22 @@ importers: specifier: 6.15.0 version: 6.15.0(prisma@6.15.0(magicast@0.3.5)(typescript@7.0.2))(typescript@7.0.2) '@sentry/aws-serverless': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/aws-serverless '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/core '@sentry/hono': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/hono '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/node '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/server-utils '@smithy/node-http-handler': specifier: 4.12.1 @@ -846,16 +846,16 @@ importers: specifier: 0.220.0 version: 0.220.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0) '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../rollup-utils '@sentry-internal/test-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../test-utils '@sentry/conventions': specifier: 0.27.0 version: 0.27.0 '@sentry/node-native': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/node-native '@types/amqplib': specifier: ^0.10.5 @@ -953,10 +953,10 @@ importers: specifier: 1.64.1 version: 1.64.1 '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../rollup-utils '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../packages/core '@types/express': specifier: ^4.17.21 @@ -971,13 +971,13 @@ importers: packages/angular: dependencies: '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core tslib: specifier: ^2.4.1 @@ -1029,29 +1029,29 @@ importers: packages/astro: dependencies: '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils astro: specifier: ^4.16.19 @@ -1066,23 +1066,23 @@ importers: specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils '@types/aws-lambda': specifier: ^8.10.161 version: 8.10.161 devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 @@ -1094,29 +1094,29 @@ importers: packages/browser: dependencies: '@sentry/browser-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser-utils '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/feedback': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../feedback '@sentry/replay': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../replay-internal '@sentry/replay-canvas': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../replay-canvas devDependencies: '@sentry-internal/integration-shims': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../integration-shims '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils fake-indexeddb: specifier: ^6.2.5 @@ -1128,14 +1128,14 @@ importers: specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core web-vitals: specifier: ^6.2.3 version: 6.2.3 devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils packages/bun: @@ -1144,17 +1144,17 @@ importers: specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils bun-types: specifier: ^1.2.9 @@ -1163,7 +1163,7 @@ importers: packages/bundler-plugins: dependencies: '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core dotenv: specifier: ^17.4.2 @@ -1194,10 +1194,10 @@ importers: specifier: ^7.23.3 version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@sentry-internal/test-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/test-utils '@types/babel__core': specifier: ^7.20.5 @@ -1227,13 +1227,13 @@ importers: specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/opentelemetry': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../opentelemetry '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils magic-string: specifier: ~0.30.21 @@ -1243,7 +1243,7 @@ importers: specifier: 5.20260710.1 version: 5.20260710.1 '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 @@ -1265,7 +1265,7 @@ importers: version: 0.27.0 devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils zod: specifier: ^3.24.1 @@ -1280,39 +1280,39 @@ importers: specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils packages/effect: dependencies: '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node devDependencies: '@effect/vitest': specifier: ^4.0.0 version: 4.0.0(effect@4.0.0)(vitest@4.1.11) '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils effect: specifier: ^4.0.0 @@ -1321,17 +1321,17 @@ importers: packages/elysia: dependencies: '@sentry/bun': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bun '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils bun-types: specifier: ^1.2.9 @@ -1349,13 +1349,13 @@ importers: specifier: ^1.20.2 version: 1.20.6(@babel/core@7.29.7(supports-color@10.0.0))(@glint/template@1.8.0)(supports-color@10.0.0) '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core decorator-transforms: specifier: ^2.3.2 @@ -1407,20 +1407,20 @@ importers: packages/esbuild-plugin: dependencies: '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils packages/eslint-config-sdk: dependencies: '@sentry/eslint-plugin-sdk': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../eslint-plugin-sdk '@sentry/typescript': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../typescript '@typescript-eslint/eslint-plugin': specifier: ^5.62.0 @@ -1460,11 +1460,11 @@ importers: packages/feedback: dependencies: '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils preact: specifier: ^10.29.8 @@ -1473,17 +1473,17 @@ importers: packages/gatsby: dependencies: '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/react': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../react devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@testing-library/react': specifier: ^15.0.5 @@ -1504,16 +1504,16 @@ importers: specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils devDependencies: '@google-cloud/bigquery': @@ -1523,7 +1523,7 @@ importers: specifier: ^3.4.1 version: 3.6.0(encoding@0.1.13)(supports-color@10.0.0) '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@types/express': specifier: ^5.0.6 @@ -1544,29 +1544,29 @@ importers: specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils devDependencies: '@cloudflare/workers-types': specifier: 4.20250922.0 version: 4.20250922.0 '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@sentry/bun': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bun '@sentry/cloudflare': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../cloudflare '@sentry/deno': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../deno '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@types/node': specifier: ^20.19.0 @@ -1581,11 +1581,11 @@ importers: packages/integration-shims: dependencies: '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils packages/nestjs: @@ -1594,13 +1594,13 @@ importers: specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils devDependencies: '@nestjs/common': @@ -1610,7 +1610,7 @@ importers: specifier: ^10.0.0 version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.2))(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils reflect-metadata: specifier: ^0.2.2 @@ -1628,37 +1628,37 @@ importers: specifier: 28.0.1 version: 28.0.1(rollup@4.62.3) '@sentry/browser-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser-utils '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins '@sentry/cloudflare': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../cloudflare '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/opentelemetry': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../opentelemetry '@sentry/react': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../react '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils '@sentry/vercel-edge': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../vercel-edge rollup: specifier: ^4.60.3 @@ -1668,7 +1668,7 @@ importers: version: 0.1.11 devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils cjs-module-lexer: specifier: ^2.2.0 @@ -1686,26 +1686,26 @@ importers: packages/nitro: dependencies: '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils h3: specifier: ^2.0.1-rc.20 @@ -1726,26 +1726,26 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/opentelemetry': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../opentelemetry '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 @@ -1754,17 +1754,17 @@ importers: packages/node-native: dependencies: '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/node-native-stacktrace': specifier: ^0.5.1 version: 0.5.1 devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 @@ -1776,31 +1776,31 @@ importers: specifier: ^4.6.0 version: 4.6.0(@nuxt/schema@3.21.11)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.1.0)(mlly@1.8.2)(oxc-parser@0.152.0)(rolldown@1.2.11)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.11)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))) '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins '@sentry/cloudflare': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../cloudflare '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils '@sentry/vue': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../vue devDependencies: '@cloudflare/workers-types': @@ -1813,7 +1813,7 @@ importers: specifier: ^3.21.11 version: 3.21.11 '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils db0: specifier: ^0.3.4 @@ -1849,30 +1849,30 @@ importers: specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core devDependencies: '@opentelemetry/api': specifier: ^1.9.1 version: 1.10.0 '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils packages/profiling-node: dependencies: '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/node-cpu-profiler': specifier: ^2.4.4 version: 2.4.4 devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 @@ -1881,17 +1881,17 @@ importers: packages/react: dependencies: '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@testing-library/react': specifier: ^15.0.5 @@ -1954,28 +1954,28 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/react': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../react '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils glob: specifier: ^13.0.6 @@ -1991,7 +1991,7 @@ importers: specifier: ^7.15.0 version: 7.18.1(react-router@7.18.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@6.0.3) '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@testing-library/react': specifier: ^15.0.5 @@ -2018,28 +2018,28 @@ importers: specifier: ^1.23.4 version: 1.23.4 '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/react': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../react '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils sentry: specifier: ^0.44.0 @@ -2061,7 +2061,7 @@ importers: specifier: ^2.17.4 version: 2.17.5(typescript@5.8.3) '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@types/express': specifier: ^4.17.14 @@ -2088,14 +2088,14 @@ importers: packages/replay-canvas: dependencies: '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/replay': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../replay-internal devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@sentry/rrweb': specifier: 2.44.1 @@ -2104,20 +2104,20 @@ importers: packages/replay-internal: dependencies: '@sentry/browser-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser-utils '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core devDependencies: '@babel/core': specifier: ^7.29.6 version: 7.29.7(supports-color@10.0.0) '@sentry-internal/replay-worker': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../replay-worker '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@sentry/conventions': specifier: ^0.27.0 @@ -2147,20 +2147,20 @@ importers: packages/rollup-plugin: dependencies: '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils packages/server-runtime-injection: dependencies: '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils devDependencies: '@apm-js-collab/code-transformer': @@ -2170,7 +2170,7 @@ importers: specifier: ^0.13.3 version: 0.13.3(supports-color@7.2.0) '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 @@ -2194,7 +2194,7 @@ importers: specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core devDependencies: '@apm-js-collab/code-transformer-bundler-plugins': @@ -2204,7 +2204,7 @@ importers: specifier: 1.2.0 version: 1.2.0 '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 @@ -2222,17 +2222,17 @@ importers: packages/solid: dependencies: '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@solidjs/router': specifier: ^1.0.0 @@ -2265,35 +2265,35 @@ importers: packages/solidstart: dependencies: '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': specifier: 0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/nitro': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../nitro '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils '@sentry/solid': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../solid devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@solidjs/router': specifier: ^1.0.0 @@ -2332,20 +2332,20 @@ importers: packages/svelte: dependencies: '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core magic-string: specifier: ~0.30.0 version: 0.30.21 devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@sveltejs/vite-plugin-svelte': specifier: 1.4.0 @@ -2366,28 +2366,28 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins '@sentry/cloudflare': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../cloudflare '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils '@sentry/svelte': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../svelte '@sveltejs/acorn-typescript': specifier: ^1.0.9 @@ -2403,10 +2403,10 @@ importers: version: 1.0.0 devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sveltejs/kit': specifier: ^2.70.2 @@ -2430,32 +2430,32 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/browser-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser-utils '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/node': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../node '@sentry/react': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../react '@sentry/server-runtime-injection': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-runtime-injection '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@types/node': specifier: ^20.19.0 @@ -2472,46 +2472,46 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core '@sentry/server-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../server-utils devDependencies: '@edge-runtime/types': specifier: 4.0.0 version: 4.0.0 '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@sentry/opentelemetry': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../opentelemetry packages/vite-plugin: dependencies: '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils packages/vue: dependencies: '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/conventions': specifier: ^0.27.0 version: 0.27.0 '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@tanstack/vue-router': specifier: ^1.64.0 @@ -2523,24 +2523,24 @@ importers: packages/wasm: dependencies: '@sentry/browser': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../browser '@sentry/core': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../core devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils packages/webpack-plugin: dependencies: '@sentry/bundler-plugins': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../bundler-plugins devDependencies: '@sentry-internal/rollup-utils': - specifier: 11.8.0 + specifier: 11.9.0 version: link:../../dev-packages/rollup-utils packages: From 8758c5724d07b2f976090b4916ba4ae836b54ee6 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Fri, 23 Oct 2026 20:45:38 +0900 Subject: [PATCH 022/126] Merge pull request #25639 from seongwon030/fix/react-router-encoded-pathname fix(react): Parameterize React Router transactions with encoded params --- .../src/reactrouter-compat-utils/utils.ts | 40 +++++- packages/react/test/react-router.test.tsx | 65 +++++++++ .../reactrouter-compat-utils/utils.test.ts | 128 ++++++++++++++++++ .../reactrouter-descendant-routes.test.tsx | 75 ++++++++++ 4 files changed, 301 insertions(+), 7 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index ce0a0699352a..b243ad4bf421 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -137,6 +137,31 @@ export function prefixWithSlash(path: string): string { return path[0] === '/' ? path : `/${path}`; } +/** + * Removes as many leading segments from `pathname` as `pathnameBase` has, the way `useRoutes` strips the parent + * `pathnameBase` for descendant routes. + * + * `location.pathname` is percent-encoded as the browser holds it, while React Router decodes the pathname before + * matching, so `pathnameBase` on a `RouteMatch` is decoded (older 6.x routers decoded only partially, or not at all). + * Stripping by segment count works regardless of which side is encoded, and keeps the remainder encoded so that + * matching it again decodes it exactly once. + */ +function stripPathnameBaseSegments(pathname: string, pathnameBase: string): string { + const baseSegmentCount = countPathnameSegments(pathnameBase); + if (baseSegmentCount === 0) { + return pathname; + } + return prefixWithSlash(pathname.split('/').filter(Boolean).slice(baseSegmentCount).join('/')); +} + +/** + * Counts the segments of a pathname with a plain split (`getNumberOfUrlSegments` also drops `,` segments, which a + * route parameter can legitimately be). + */ +function countPathnameSegments(pathname: string): number { + return pathname.split('/').filter(Boolean).length; +} + /** * Rebuilds the route path from all available routes by matching against the current location. */ @@ -154,7 +179,7 @@ export function rebuildRoutePathFromAllRoutes( for (const match of matchedRoutes) { if (match.route.path && match.route.path !== '*') { const path = pickPath(match); - const strippedPath = stripBasenameFromPathname(location.pathname, prefixWithSlash(match.pathnameBase)); + const strippedPath = stripPathnameBaseSegments(location.pathname, prefixWithSlash(match.pathnameBase)); if (location.pathname === strippedPath) { return trimSlash(strippedPath); @@ -220,8 +245,7 @@ function reconstructNameFromDescendantParent( return undefined; } - const remainingPathname = - stripBasenameFromPathname(location.pathname, prefixWithSlash(parentMatch.pathnameBase)) || '/'; + const remainingPathname = stripPathnameBaseSegments(location.pathname, prefixWithSlash(parentMatch.pathnameBase)); const remainingName = rebuildRoutePathFromAllRoutes( allRoutes.filter(route => route !== parentMatch.route), { pathname: remainingPathname }, @@ -302,8 +326,10 @@ export function getNormalizedName( const newPath = path[0] === '/' || pathBuilder[pathBuilder.length - 1] === '/' ? path : `/${path}`; pathBuilder = trimSlash(pathBuilder) + prefixWithSlash(newPath); - // Check if this path matches the current location - if (trimSlash(location.pathname) !== trimSlash(basename + branch.pathname)) { + // Check if this branch spans the whole location. Compare segment counts rather than strings: the location + // pathname is percent-encoded while React Router decoded the branch pathname, and decoding never changes the + // number of segments. + if (countPathnameSegments(location.pathname) !== countPathnameSegments(basename + branch.pathname)) { continue; } @@ -312,7 +338,7 @@ export function getNormalizedName( getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) && !pathEndsWithWildcard(pathBuilder) ) { - return [(stripBasename ? '' : basename) + newPath, 'route']; + return [(stripBasename ? '' : trimSlash(basename)) + newPath, 'route']; } // Handle wildcard routes with children - strip trailing wildcard @@ -320,7 +346,7 @@ export function getNormalizedName( pathBuilder = pathBuilder.slice(0, -1); } - return [(stripBasename ? '' : basename) + pathBuilder, 'route']; + return [(stripBasename ? '' : trimSlash(basename)) + pathBuilder, 'route']; } // Fallback when no matching route found diff --git a/packages/react/test/react-router.test.tsx b/packages/react/test/react-router.test.tsx index b25323e691c2..23ee153c2bd8 100644 --- a/packages/react/test/react-router.test.tsx +++ b/packages/react/test/react-router.test.tsx @@ -80,6 +80,71 @@ describe('@sentry/react/react-router', () => { expect(getCurrentScope().getScopeData().transactionName).toEqual('/about'); }); + it.each([ + ['/clubs/%ED%95%9C%EA%B8%80', 'non-ASCII, what a browser holds for `/clubs/한글`'], + ['/clubs/%2541', 'still looks percent-encoded after decoding (`%41`)'], + ['/clubs/100%25', 'not valid percent-encoding after decoding (`100%`)'], + ])('parameterizes a pageload whose pathname has a percent-encoded parameter (%s: %s)', pathname => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration(reactRouterBrowserTracingIntegration()); + + const SentryRoutes = wrapReactRouterRouting(Routes); + + render( + + + Home} /> + Club} /> + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(getCurrentScope().getScopeData().transactionName).toEqual('/clubs/:clubId'); + }); + + it('parameterizes a navigation whose pathname has a percent-encoded parameter', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration(reactRouterBrowserTracingIntegration()); + + const SentryRoutes = wrapReactRouterRouting(Routes); + + function Home(): React.ReactElement { + const navigate = useNavigate(); + return ( + + ); + } + + const { getByText } = render( + + + } /> + Club} /> + + , + ); + + fireEvent.click(getByText('to club')); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/clubs/:clubId', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [URL_TEMPLATE]: '/clubs/:clubId', + [SENTRY_OP]: 'navigation', + [SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + }, + }); + }); + it('reactRouterBrowserTracingIntegration() instruments a navigation without passing router hooks', () => { const client = createMockBrowserClient(); setCurrentClient(client); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 7e1435521dc7..a5cca4030fac 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -185,6 +185,28 @@ describe('reactrouter-compat-utils/utils', () => { }); describe('rebuildRoutePathFromAllRoutes', () => { + it('should match a percent-encoded pathname against the decoded pathnameBase', () => { + const allRoutes: RouteObject[] = [{ path: ':clubId', element: null }]; + + // What a browser's `location.pathname` holds for `/한글` + const location: Location = { pathname: '/%ED%95%9C%EA%B8%80' }; + + const mockMatches: RouteMatch[] = [ + { + route: { path: ':clubId', element: null }, + params: { clubId: '한글' }, + pathname: '/한글', + pathnameBase: '/한글', + }, + ]; + + // The recursive call matches the remaining routes (none) against the stripped pathname + mockMatchRoutes.mockImplementation((routes: RouteObject[]) => (routes.length ? mockMatches : [])); + + const result = rebuildRoutePathFromAllRoutes(allRoutes, location, mockMatchRoutes as MatchRoutes); + expect(result).toBe(':clubId'); + }); + it('should return pathname when it matches stripped path', () => { const allRoutes: RouteObject[] = [{ path: '/users', element: null }]; @@ -423,6 +445,112 @@ describe('reactrouter-compat-utils/utils', () => { }); describe('getNormalizedName', () => { + it('should parameterize a percent-encoded pathname whose branch pathname is decoded', () => { + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + // What a browser's `location.pathname` holds for `/clubs/한글` + const location: Location = { pathname: '/clubs/%ED%95%9C%EA%B8%80' }; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: '한글' }, + pathname: '/clubs/한글', + pathnameBase: '/clubs/한글', + }, + ]; + + const result = getNormalizedName(routes, location, branches, ''); + expect(result).toEqual(['/clubs/:clubId', 'route']); + }); + + it('should keep matching when the basename itself is percent-encoded', () => { + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + // React Router strips the basename from the raw pathname, so an encoded basename is passed encoded + const basename = '/%ED%95%9C%EA%B5%AD'; + const location: Location = { pathname: `${basename}/clubs/abc` }; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: 'abc' }, + pathname: '/clubs/abc', + pathnameBase: '/clubs/abc', + }, + ]; + + expect(getNormalizedName(routes, location, branches, basename)).toEqual([`${basename}/clubs/:clubId`, 'route']); + expect(getNormalizedName(routes, location, branches, basename, true)).toEqual(['/clubs/:clubId', 'route']); + }); + + it('should not double the slash when the basename ends with one', () => { + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: 'abc' }, + pathname: '/clubs/abc', + pathnameBase: '/clubs/abc', + }, + ]; + + // e.g. Vite's `import.meta.env.BASE_URL` always ends with a slash + expect(getNormalizedName(routes, { pathname: '/clubs/abc' }, branches, '/')).toEqual(['/clubs/:clubId', 'route']); + expect(getNormalizedName(routes, { pathname: '/app/clubs/abc' }, branches, '/app/')).toEqual([ + '/app/clubs/:clubId', + 'route', + ]); + expect(getNormalizedName(routes, { pathname: '/app/clubs/abc' }, branches, '/app/', true)).toEqual([ + '/clubs/:clubId', + 'route', + ]); + }); + + it('should keep matching when the router left the branch pathname encoded', () => { + // react-router < 6.5 matched against the raw pathname, 6.5 - 6.22 used `decodeURI` (keeps `%40`) + const routes: RouteObject[] = [{ path: '/users/:email', element: null }]; + const location: Location = { pathname: '/users/a%40b.com' }; + const branches: RouteMatch[] = [ + { + route: { path: '/users/:email', element: null }, + params: { email: 'a%40b.com' }, + pathname: '/users/a%40b.com', + pathnameBase: '/users/a%40b.com', + }, + ]; + + expect(getNormalizedName(routes, location, branches, '')).toEqual(['/users/:email', 'route']); + }); + + it('should parameterize a pathname whose parameter still looks percent-encoded after decoding', () => { + // `%2541` decodes to the literal `%41`; a second decode would turn it into `A` + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + const location: Location = { pathname: '/clubs/%2541' }; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: '%41' }, + pathname: '/clubs/%41', + pathnameBase: '/clubs/%41', + }, + ]; + + expect(getNormalizedName(routes, location, branches, '')).toEqual(['/clubs/:clubId', 'route']); + }); + + it('should parameterize a pathname whose decoded parameter is not valid percent-encoding', () => { + // `100%25` decodes to `100%`, which cannot be decoded again + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + const location: Location = { pathname: '/clubs/100%25' }; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: '100%' }, + pathname: '/clubs/100%', + pathnameBase: '/clubs/100%', + }, + ]; + + expect(getNormalizedName(routes, location, branches, '')).toEqual(['/clubs/:clubId', 'route']); + }); + it('should return pathname with url source when no routes provided', () => { const routes: RouteObject[] = []; const location: Location = { pathname: '/test' }; diff --git a/packages/react/test/reactrouter-descendant-routes.test.tsx b/packages/react/test/reactrouter-descendant-routes.test.tsx index 7f712ad0f5d7..64b810c6f7ab 100644 --- a/packages/react/test/reactrouter-descendant-routes.test.tsx +++ b/packages/react/test/reactrouter-descendant-routes.test.tsx @@ -84,6 +84,81 @@ describe('React Router Descendant Routes', () => { }); describe('withSentryReactRouterV6Routing', () => { + it('keeps the parent path prefix for a descendant route with a percent-encoded parameter', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const ChildRouter = () => ( + + Child} /> + + ); + + const { container } = render( + // What a browser's `location.pathname` holds for `/child/한글` + + + } /> + + , + ); + + expect(container.innerHTML).toContain('Child'); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(getCurrentScope().getScopeData().transactionName).toEqual('/child/:id'); + }); + + it('re-matches the descendant remainder without decoding it twice', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const ChildRouter = () => ( + + Child} /> + + ); + + try { + const { container } = render( + // `100%25` decodes to `100%`, which React Router cannot decode a second time + + + } /> + + , + ); + + expect(container.innerHTML).toContain('Child'); + expect(getCurrentScope().getScopeData().transactionName).toEqual('/child/:id'); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('could not be decoded')); + } finally { + warnSpy.mockRestore(); + } + }); + it('keeps the parent path prefix for descendant routes with non-wildcard nested children - pageload', () => { const client = createMockBrowserClient(); setCurrentClient(client); From b59c0eabf57ae30a485e3a5b40fb21bde1865256 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:48:17 +0200 Subject: [PATCH 023/126] chore: Add external contributor to CHANGELOG.md (#25710) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #25639 Co-authored-by: s1gr1d <32902192+s1gr1d@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6211bee8ca0..e99eafc08f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @faizansaiyed123. Thank you for your contributions! +Work in this release was contributed by @faizansaiyed123 and @seongwon030. Thank you for your contributions! ## 11.9.0 From 067ae237fb2bac9ed5774fef2e03a4c952dc2f00 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 23 Oct 2026 13:56:16 +0200 Subject: [PATCH 024/126] fix(node): Skip spans for outgoing requests to Sentry via `undici` fetch and `node:http` (#25441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix a bug where undici and `http` client instrumentation created spans for requests to the sentry ingest endpoint. We now skip requests to the client's DSN or tunnel via `isSentryRequestUrl`, like we already do in our `fetch` integrations (node, cloudflare, vercel edge). This checks the raw URL, so it works regardless of `dataCollection.urlQueryParams`. This surfaced in NextJS while working on https://github.com/getsentry/sentry-javascript/pull/25386, where apparently we'd start spans in standalone (node, preview, dev) Next environments. Based on Claude research this happened because of NextJS sandboxing the edge runtime requests in a sub process of the Node process and our instrumentation catching those calls in both, the sandbox and the main process. The fix therefore replaces the Next.js `beforeSampling` drop for these spans, which only applied to root spans and has accidentally not been enforced since `SentrySampler` was removed. --------- Co-authored-by: Jan Peer Stöcklmair Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- .../node-suites/excludes.ts | 1 + .../node-suites/excludes.ts | 1 + .../fetch-sentry-requests/instrument.mjs | 9 ++++++ .../fetch-sentry-requests/scenario.mjs | 11 ++++++++ .../requests/fetch-sentry-requests/test.ts | 25 +++++++++++++++++ .../http-sentry-requests/instrument.mjs | 9 ++++++ .../http-sentry-requests/scenario.mjs | 25 +++++++++++++++++ .../requests/http-sentry-requests/test.ts | 28 +++++++++++++++++++ .../integrations/http/client-subscriptions.ts | 7 +++-- packages/nextjs/src/server/serverSpanHooks.ts | 22 --------------- .../node-fetch/undici-instrumentation.ts | 21 ++++++++------ .../node/test/integrations/undici.test.ts | 6 ++-- 12 files changed, 131 insertions(+), 34 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/test.ts diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index c8a8bf93a1a9..f11e0b2e553d 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -54,6 +54,7 @@ const NO_FETCH_INSTRUMENTATION = [ 'suites/tracing/requests/fetch-no-tracing-no-spans/test.ts', 'suites/tracing/requests/fetch-no-tracing/test.ts', 'suites/tracing/requests/fetch-sampled-no-active-span/test.ts', + 'suites/tracing/requests/fetch-sentry-requests/test.ts', 'suites/tracing/requests/fetch-unsampled/test.ts', 'suites/tracing/requests/traceparent/test.ts', ]; diff --git a/dev-packages/deno-integration-tests/node-suites/excludes.ts b/dev-packages/deno-integration-tests/node-suites/excludes.ts index a6ad2a290f16..b93a8ff63a65 100644 --- a/dev-packages/deno-integration-tests/node-suites/excludes.ts +++ b/dev-packages/deno-integration-tests/node-suites/excludes.ts @@ -26,6 +26,7 @@ const NO_FETCH_INSTRUMENTATION = [ 'suites/tracing/requests/fetch-no-tracing-no-spans/test.ts', 'suites/tracing/requests/fetch-no-tracing/test.ts', 'suites/tracing/requests/fetch-sampled-no-active-span/test.ts', + 'suites/tracing/requests/fetch-sentry-requests/test.ts', 'suites/tracing/requests/fetch-unsampled/test.ts', 'suites/tracing/requests/traceparent/test.ts', 'suites/tracing/sample-rand-propagation/test.ts', diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/instrument.mjs new file mode 100644 index 000000000000..83be6e35634b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/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@localhost/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/scenario.mjs new file mode 100644 index 000000000000..5e4abdda9c92 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/scenario.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; + +// Another SDK in this process (e.g. the Next.js Edge Runtime SDK) sends to Sentry without suppressing our tracing +const sentryUrl = `${process.env.SERVER_URL}/api/1337/envelope/?sentry_version=7&sentry_key=public&sentry_client=other`; + +Sentry.startSpan({ name: 'test_span' }, async () => { + await fetch(sentryUrl).then(res => res.text()); + const response = await fetch(`${process.env.SERVER_URL}/api/v0`); + + await response.text(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/test.ts new file mode 100644 index 000000000000..afdd01bab691 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sentry-requests/test.ts @@ -0,0 +1,25 @@ +import { SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes'; +import { HTTP_CLIENT } from '@sentry/conventions/op'; +import { createTestServer } from '@sentry-internal/test-utils'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('outgoing fetch requests to Sentry', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('does not create spans for requests to Sentry', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer().start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + span: container => { + const requestSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === HTTP_CLIENT); + expect(requestSpans.map(span => span.attributes[URL_FULL]?.value)).toEqual([`${SERVER_URL}/api/v0`]); + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/instrument.mjs new file mode 100644 index 000000000000..83be6e35634b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/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@localhost/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/scenario.mjs new file mode 100644 index 000000000000..99666e006794 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/scenario.mjs @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/node'; +import * as http from 'http'; + +// Another SDK in this process (e.g. the Next.js Edge Runtime SDK) sends to Sentry without suppressing our tracing +const sentryUrl = `${process.env.SERVER_URL}/api/1337/envelope/?sentry_version=7&sentry_key=public&sentry_client=other`; + +Sentry.startSpan({ name: 'test_span' }, async () => { + await makeHttpRequest(sentryUrl); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); +}); + +function makeHttpRequest(url) { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/test.ts new file mode 100644 index 000000000000..f2c0c4d106ec --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sentry-requests/test.ts @@ -0,0 +1,28 @@ +import { SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes'; +import { HTTP_CLIENT } from '@sentry/conventions/op'; +import { createTestServer } from '@sentry-internal/test-utils'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { supports } from '../../../../utils'; + +// Bun before 1.4 does not instrument outgoing `node:http` requests. +// See https://github.com/getsentry/sentry-javascript/issues/23881 +describe.runIf(supports({ bunMin: '1.4.0' }))('outgoing http requests to Sentry', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('does not create spans for requests to Sentry', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer().start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + span: container => { + const requestSpans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === HTTP_CLIENT); + expect(requestSpans.map(span => span.attributes[URL_FULL]?.value)).toEqual([`${SERVER_URL}/api/v0`]); + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/packages/core/src/integrations/http/client-subscriptions.ts b/packages/core/src/integrations/http/client-subscriptions.ts index 554445eb8053..0137fba2a9cb 100644 --- a/packages/core/src/integrations/http/client-subscriptions.ts +++ b/packages/core/src/integrations/http/client-subscriptions.ts @@ -35,6 +35,7 @@ import { DEBUG_BUILD } from '../../debug-build'; import { LOG_PREFIX, HTTP_ON_CLIENT_REQUEST } from './constants'; import type { ClientSubscriptionName } from './constants'; import { getClient } from '../../currentScopes'; +import { isSentryRequestUrl } from '../../utils/isSentryRequestUrl'; import { hasSpansEnabled } from '../../utils/hasSpansEnabled'; import { doubleWrapWarning } from './double-wrap-warning'; @@ -66,8 +67,10 @@ export function getHttpClientSubscriptions(options: HttpInstrumentationOptions): const { request } = data as { request: HttpClientRequest }; - // check if request is ignored. if so, we do nothing at all. - if (options.ignoreOutgoingRequests?.(getRequestUrlFromClientRequest(request), request)) { + // check if request is ignored or goes to Sentry. if so, we do nothing at all. + // Our own transport suppresses tracing, but another SDK in this process (e.g. the Next.js Edge Runtime SDK) may not. + const url = getRequestUrlFromClientRequest(request); + if (isSentryRequestUrl(url, getClient()) || options.ignoreOutgoingRequests?.(url, request)) { return; } diff --git a/packages/nextjs/src/server/serverSpanHooks.ts b/packages/nextjs/src/server/serverSpanHooks.ts index 3d21bb094eae..f243813d7a45 100644 --- a/packages/nextjs/src/server/serverSpanHooks.ts +++ b/packages/nextjs/src/server/serverSpanHooks.ts @@ -3,10 +3,8 @@ import { HTTP_METHOD, HTTP_REQUEST_METHOD, HTTP_ROUTE, - HTTP_TARGET, SENTRY_OP, SENTRY_ORIGIN, - URL_QUERY, } from '@sentry/conventions/attributes'; import { MIDDLEWARE } from '@sentry/conventions/op'; import type { Client, Options, Span } from '@sentry/core'; @@ -57,26 +55,6 @@ export const NEXTJS_SERVER_IGNORE_SPANS: NonNullable = [ * Registers the hooks that name, enrich and filter the spans and events of the Next.js server on `client`. */ export function addNextjsServerSpanHooks(client: Client): void { - client.on('beforeSampling', ({ spanAttributes }, samplingDecision) => { - // There are situations where the Next.js Node.js server forwards requests for the Edge Runtime server (e.g. in - // middleware) and this causes spans for Sentry ingest requests to be created. These are not exempt from our tracing - // because we didn't get the chance to do `suppressTracing`, since this happens outside of userland. - // We need to drop these spans. - if ( - // eslint-disable-next-line typescript/no-deprecated - (typeof spanAttributes[HTTP_TARGET] === 'string' && - // eslint-disable-next-line typescript/no-deprecated - spanAttributes[HTTP_TARGET].includes('sentry_key') && - // eslint-disable-next-line typescript/no-deprecated - spanAttributes[HTTP_TARGET].includes('sentry_client')) || - (typeof spanAttributes[URL_QUERY] === 'string' && - spanAttributes[URL_QUERY].includes('sentry_key') && - spanAttributes[URL_QUERY].includes('sentry_client')) - ) { - samplingDecision.decision = false; - } - }); - client.on('spanStart', span => handleOnSpanStart(span, client)); // Normalize name/op/source/status on the request root span at span end, before it is serialized into diff --git a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts index 9d1617bbac9e..83a8b31bbe39 100644 --- a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts +++ b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts @@ -22,6 +22,7 @@ import type { Span, SpanAttributes } from '@sentry/core'; import { debug, getClient, + isSentryRequestUrl, getSanitizedUrlString, getSpanStatusFromHttpCode, hasSpanStreamingEnabled, @@ -171,15 +172,19 @@ function parseRequestHeaders(request: UndiciRequest): Map !!config.ignoreOutgoingRequests?.(url), - () => false, - ); + // Requests to Sentry are only traced here when another SDK in this process (e.g. the Next.js Edge Runtime SDK) sends + // them, since our own transport suppresses tracing + const isIgnored = + isSentryRequestUrl(url, getClient()) || + safeCallback( + DEBUG_BUILD ? 'The `ignoreOutgoingRequests` callback threw an error, not ignoring the request:' : '', + () => !!config.ignoreOutgoingRequests?.(url), + () => false, + ); // Breadcrumbs & span-less trace propagation are additionally skipped when tracing is suppressed. // We store this here because the active context is no longer correct in later channel events. - const ignoreForBreadcrumbs = isTracingSuppressed() || !!ignoredByCallback; + const ignoreForBreadcrumbs = isTracingSuppressed() || isIgnored; ignoreRequestMap.set(request, ignoreForBreadcrumbs); // When spans are disabled we do not create a span, but we still inject trace propagation headers @@ -192,9 +197,9 @@ function onRequestCreated(config: NodeFetchOptions, { request }: RequestMessage) } // Ignore span creation if: - // - the outgoing request is ignored via config + // - the outgoing request is ignored via config or goes to Sentry // - method is 'CONNECT' - if (request.method === 'CONNECT' || ignoredByCallback) { + if (request.method === 'CONNECT' || isIgnored) { return; } diff --git a/packages/node/test/integrations/undici.test.ts b/packages/node/test/integrations/undici.test.ts index a387cec9b6f1..1ee1e6ac3251 100644 --- a/packages/node/test/integrations/undici.test.ts +++ b/packages/node/test/integrations/undici.test.ts @@ -81,7 +81,9 @@ describe('instrumentUndici', () => { }); it.each([true, false])('falls back to `HTTP` for an unknown method (span streaming: %s)', streaming => { - getClient.mockReturnValue(streaming ? { getOptions: () => ({ traceLifecycle: 'stream' }) } : undefined); + getClient.mockReturnValue( + streaming ? { getOptions: () => ({ traceLifecycle: 'stream' }), getDsn: () => undefined } : undefined, + ); const request = { method: 'FROBNICATE', @@ -96,7 +98,7 @@ describe('instrumentUndici', () => { }); it('drops the URL path but keeps the domain with span streaming enabled', () => { - getClient.mockReturnValue({ getOptions: () => ({ traceLifecycle: 'stream' }) }); + getClient.mockReturnValue({ getOptions: () => ({ traceLifecycle: 'stream' }), getDsn: () => undefined }); const request = { method: 'QUERY', From 9927b7c0b4ea67226ab603807a4c5ce9eac43496 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:04:53 +0200 Subject: [PATCH 025/126] Merge pull request #25652 from getsentry/dependabot/npm_and_yarn/remix-4e3a47c4c4 chore(deps-dev): Bump @remix-run/route-pattern from 0.24.1 to 1.0.0 in the remix group across 1 directory --- packages/remix/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/remix/package.json b/packages/remix/package.json index 67c80fdb17bc..7e51bbee4d0c 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -90,7 +90,7 @@ "devDependencies": { "@remix-run/node": "^2.17.5", "@remix-run/react": "^2.17.5", - "@remix-run/route-pattern": "^0.24.0", + "@remix-run/route-pattern": "^1.0.0", "@remix-run/server-runtime": "^2.17.4", "@sentry-internal/rollup-utils": "11.9.0", "@types/express": "^4.17.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae19ea768647..20f62385c01c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2055,8 +2055,8 @@ importers: specifier: ^2.17.5 version: 2.17.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3) '@remix-run/route-pattern': - specifier: ^0.24.0 - version: 0.24.1 + specifier: ^1.0.0 + version: 1.0.0 '@remix-run/server-runtime': specifier: ^2.17.4 version: 2.17.5(typescript@5.8.3) @@ -7748,8 +7748,8 @@ packages: typescript: optional: true - '@remix-run/route-pattern@0.24.1': - resolution: {integrity: sha512-HRee7tz2Ct7QdTQGDErHcVTAlXa5BBV6GOLEa/INw21ceTWTWlaU13FJupYVX0k1mh608rod880+SA5NlTPPfw==} + '@remix-run/route-pattern@1.0.0': + resolution: {integrity: sha512-EsI8J5AcDpnV76lGcpwPCqCN1Vj7X2eNPqgUr+RjInTsm4iTnrGzn0iIUxpUjQ5/Ba1cAd8CMMbsA2bjEjZiyQ==} '@remix-run/router@1.23.3': resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} @@ -25838,7 +25838,7 @@ snapshots: optionalDependencies: typescript: 5.8.3 - '@remix-run/route-pattern@0.24.1': {} + '@remix-run/route-pattern@1.0.0': {} '@remix-run/router@1.23.3': {} From 86758ce74bc8bd100751fe26834c7ab7d11de632 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:25:23 +0200 Subject: [PATCH 026/126] chore(deps-dev): Bump next from 15.5.27 to 15.5.28 in /packages/nextjs (#25661) Bumps [next](https://github.com/vercel/next.js) from 15.5.27 to 15.5.28.
Release notes

Sourced from next's releases.

v15.5.28

This release contains security fixes for the following advisories:

Critical:

High:

Commits
  • 195cd6a v15.5.28
  • 1b53604 [cd] Allow branch to get published
  • 5e3b687 [lts-maintenance][next/image] Bump sharp (#232)
  • a2021b3 [lts-maintenance][react] Improve Server Component transport protocol (#194)
  • a836b53 [lts-maintenance] Fix google tag manager e2e test, assume GTM 200s instead of...
  • 9c1c1b1 [lts-maintenance][ci] Wire preview builds variables consistently (#99325)
  • See full diff in compare view

--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: s1gr1d <32902192+s1gr1d@users.noreply.github.com> --- packages/nextjs/package.json | 2 +- pnpm-lock.yaml | 82 ++++++++++++++++++------------------ 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 037f9a7b2891..36fdd0df09b2 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -101,7 +101,7 @@ "devDependencies": { "@sentry-internal/rollup-utils": "11.9.0", "cjs-module-lexer": "^2.2.0", - "next": "15.5.27", + "next": "15.5.28", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20f62385c01c..842e0d81fae7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1674,8 +1674,8 @@ importers: specifier: ^2.2.0 version: 2.2.0 next: - specifier: 15.5.27 - version: 15.5.27(@babel/core@7.29.7(supports-color@10.0.0))(@opentelemetry/api@1.10.0)(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) + specifier: 15.5.28 + version: 15.5.28(@babel/core@7.29.7(supports-color@10.0.0))(@opentelemetry/api@1.10.0)(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0) react: specifier: ^18.3.1 version: 18.3.1 @@ -6184,57 +6184,57 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 - '@next/env@15.5.27': - resolution: {integrity: sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==} + '@next/env@15.5.28': + resolution: {integrity: sha512-xxW5EBI8fMPuVTPBW7LL980IRbuDXlbkUAwp9DDhtb4Lnich3nJ13uYpvrX4VE74hWPf6AvHHAMmGCidcHjbfg==} - '@next/swc-darwin-arm64@15.5.27': - resolution: {integrity: sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==} + '@next/swc-darwin-arm64@15.5.28': + resolution: {integrity: sha512-dPoQYXcfSIqx5o+TOXVXO7kONK5qbtNRDeiTfII0bT3puGIT6VF5e0QwmxNZfEZ5Zety5f3m9P/p4xqwfiLlPg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.27': - resolution: {integrity: sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==} + '@next/swc-darwin-x64@15.5.28': + resolution: {integrity: sha512-RgCJ7buBivTH+6sddqaVm+/v8kwMHlFkobvdt3DfETutws5HrxnToMIWSN9X3MZ3aAi7BbKrGZZWUDQLEycXqw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.27': - resolution: {integrity: sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==} + '@next/swc-linux-arm64-gnu@15.5.28': + resolution: {integrity: sha512-OHXQe13olE59aKe++zHavGcJPh5SLHXi6Anret3BBeq1+qhSuOJDNAli/EDDfxs6UTqA4Qddo4uyYry2slyjiQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@15.5.27': - resolution: {integrity: sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==} + '@next/swc-linux-arm64-musl@15.5.28': + resolution: {integrity: sha512-nTB7bB6vfn4ezCJNYP43ukt2o8L3+WxKF/sTcTxepdXsJh3SM9RhlU7wrZYbH7L4HO5M87dqDTwm8587vAkCiw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@15.5.27': - resolution: {integrity: sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==} + '@next/swc-linux-x64-gnu@15.5.28': + resolution: {integrity: sha512-WXCfVI32rW3xCusoUX1RpZKtyChWU2Zwe27JUDmM3KrXwcDNc9vV4zEq0GKFAURnuQ/Ouo/XqPvB0yh445XZCg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@15.5.27': - resolution: {integrity: sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==} + '@next/swc-linux-x64-musl@15.5.28': + resolution: {integrity: sha512-Miq+NDb/HWsXuxseYxMM6fuEOQXgTziarkKIRpVKCn8W69cEfhQaQr+90pO+u4q2CbJR2wzXmUuCFyZaJs/Nng==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@15.5.27': - resolution: {integrity: sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==} + '@next/swc-win32-arm64-msvc@15.5.28': + resolution: {integrity: sha512-5LR79lemgDlgxvG7MQJ+zHCkVcrUoQuO7CiIvq3I58vNW4goSOE2b+19Z6FujRWMouvni4gvudYFuFuNxolGWA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.27': - resolution: {integrity: sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==} + '@next/swc-win32-x64-msvc@15.5.28': + resolution: {integrity: sha512-GNipvXUYROSTf4PXEyH95FrWImIB5zdaK6uEOg0RP1/X2drnvoMwiBkJ0VWdzX7w3iiBm3y5g2TxkKDGVTOQcg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -14797,8 +14797,8 @@ packages: resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==} engines: {node: '>=12.22.0'} - next@15.5.27: - resolution: {integrity: sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==} + next@15.5.28: + resolution: {integrity: sha512-JjhLBBY1PNnuwPtcZ7t8yxVeJVYvD2Jyp633J+gRCNbzSt7N0E+xqCf6uQiff3+3qmUkwcF/ksfSsK1xsZ76Tw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -24309,30 +24309,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@next/env@15.5.27': {} + '@next/env@15.5.28': {} - '@next/swc-darwin-arm64@15.5.27': + '@next/swc-darwin-arm64@15.5.28': optional: true - '@next/swc-darwin-x64@15.5.27': + '@next/swc-darwin-x64@15.5.28': optional: true - '@next/swc-linux-arm64-gnu@15.5.27': + '@next/swc-linux-arm64-gnu@15.5.28': optional: true - '@next/swc-linux-arm64-musl@15.5.27': + '@next/swc-linux-arm64-musl@15.5.28': optional: true - '@next/swc-linux-x64-gnu@15.5.27': + '@next/swc-linux-x64-gnu@15.5.28': optional: true - '@next/swc-linux-x64-musl@15.5.27': + '@next/swc-linux-x64-musl@15.5.28': optional: true - '@next/swc-win32-arm64-msvc@15.5.27': + '@next/swc-win32-arm64-msvc@15.5.28': optional: true - '@next/swc-win32-x64-msvc@15.5.27': + '@next/swc-win32-x64-msvc@15.5.28': optional: true '@ngtools/webpack@14.2.13(@angular/compiler-cli@14.3.0(@angular/compiler@14.3.0(@angular/core@14.3.0(rxjs@7.8.2)(zone.js@0.12.0)))(supports-color@10.0.0)(typescript@4.6.4))(typescript@4.6.4)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3))': @@ -34462,9 +34462,9 @@ snapshots: transitivePeerDependencies: - supports-color - next@15.5.27(@babel/core@7.29.7(supports-color@10.0.0))(@opentelemetry/api@1.10.0)(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0): + next@15.5.28(@babel/core@7.29.7(supports-color@10.0.0))(@opentelemetry/api@1.10.0)(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.72.0): dependencies: - '@next/env': 15.5.27 + '@next/env': 15.5.28 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001809 postcss: 8.4.31 @@ -34472,14 +34472,14 @@ snapshots: react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@10.0.0))(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.27 - '@next/swc-darwin-x64': 15.5.27 - '@next/swc-linux-arm64-gnu': 15.5.27 - '@next/swc-linux-arm64-musl': 15.5.27 - '@next/swc-linux-x64-gnu': 15.5.27 - '@next/swc-linux-x64-musl': 15.5.27 - '@next/swc-win32-arm64-msvc': 15.5.27 - '@next/swc-win32-x64-msvc': 15.5.27 + '@next/swc-darwin-arm64': 15.5.28 + '@next/swc-darwin-x64': 15.5.28 + '@next/swc-linux-arm64-gnu': 15.5.28 + '@next/swc-linux-arm64-musl': 15.5.28 + '@next/swc-linux-x64-gnu': 15.5.28 + '@next/swc-linux-x64-musl': 15.5.28 + '@next/swc-win32-arm64-msvc': 15.5.28 + '@next/swc-win32-x64-msvc': 15.5.28 '@opentelemetry/api': 1.10.0 '@playwright/test': 1.64.1 sass: 1.72.0 From 469262e2156efdde9f7cfe198aaee3bba0bfe613 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Fri, 23 Oct 2026 14:39:16 +0200 Subject: [PATCH 027/126] fix(server-utils): Keep OpenRouter evaluation confidence and legend on Vercel AI spans (#25644) --- .../vercel-ai/vercel-ai-dc-subscriber.ts | 42 ++++--- .../integrations/vercel-ai/decide.test.ts | 116 ++++++++++++++---- 2 files changed, 120 insertions(+), 38 deletions(-) diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index f16d1f5eee02..e4280a8cef54 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -52,7 +52,7 @@ import { import type { TracingChannel } from 'node:diagnostics_channel'; import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../ai/core/gen-ai-attributes'; import { isEveGenAiRecordingDefault } from './gen-ai-recording-mode'; -import type { GenAiOptions } from '../../ai/core/utils'; +import { getEvaluationOutputMessages, type GenAiOptions } from '../../ai/core/utils'; import { getProviderMetadataAttributes, LAST_STEP_ONLY_USAGE_KEYS } from '../../ai/vercel-ai'; import { WORKERS_AI_INTEGRATION_NAME } from '../../ai/workers-ai/constants'; import { bindTracingChannelToSpan } from '../../tracing-channel'; @@ -689,7 +689,7 @@ function getOutputMessages( finishReason: string | undefined, ): string | undefined { if (type === 'experimental_evaluate' || type === 'experimental_decide') { - return stringify([{ type: 'evaluation', answers: withProviderConfidence(result) }]); + return getEvaluationOutputMessages(withProviderAnswerFields(result)); } // `languageModelCall` exposes the response as a `content` parts array; top-level results expose // `text` + `toolCalls`. Both normalize into the OTel `gen_ai.output.messages` assistant message. @@ -701,26 +701,36 @@ function getOutputMessages( } /** - * The AI SDK TypeSafe and OpenAI providers move each answer's `confidence` out of the answers into - * `providerMetadata..confidence` (keyed by question id). Put it back so decide answers keep it. + * Providers move answer fields into `providerMetadata`: TypeSafe and OpenAI as `.confidence[id]`, + * OpenRouter as `openrouter.answers[id].{confidence,legend}`. Put them back on the answers. */ -function withProviderConfidence(result: Record): unknown { +function withProviderAnswerFields(result: Record): unknown { const { answers, providerMetadata } = result; - const metadata = isObjectLike(providerMetadata) - ? [providerMetadata.typesafe, providerMetadata.openai].find(m => isObjectLike(m) && isObjectLike(m.confidence)) - : undefined; - const confidence = isObjectLike(metadata) && isObjectLike(metadata.confidence) ? metadata.confidence : undefined; - if (!confidence || !isObjectLike(answers)) { + if (!isObjectLike(answers) || !isObjectLike(providerMetadata)) { + return answers; + } + const confidenceById = [providerMetadata.typesafe, providerMetadata.openai] + .map(metadata => (isObjectLike(metadata) ? metadata.confidence : undefined)) + .find(isObjectLike); + const { openrouter } = providerMetadata; + const openrouterById = isObjectLike(openrouter) && isObjectLike(openrouter.answers) ? openrouter.answers : undefined; + if (!confidenceById && !openrouterById) { return answers; } return Object.fromEntries( - Object.entries(answers).map(([id, answer]) => [ - id, - isObjectLike(answer) && typeof confidence[id] === 'number' && answer.confidence === undefined - ? { ...answer, confidence: confidence[id] } - : answer, - ]), + Object.entries(answers).map(([id, answer]) => { + if (!isObjectLike(answer)) { + return [id, answer]; + } + const openrouterAnswer = openrouterById?.[id]; + const fields = isObjectLike(openrouterAnswer) ? openrouterAnswer : {}; + const { + confidence = asNumber(confidenceById?.[id]) ?? asNumber(fields.confidence), + legend = isObjectLike(fields.legend) ? fields.legend : undefined, + } = answer; + return [id, { ...answer, confidence, legend }]; + }), ); } diff --git a/packages/server-utils/test/integrations/vercel-ai/decide.test.ts b/packages/server-utils/test/integrations/vercel-ai/decide.test.ts index ae668d64e338..8a225cab123b 100644 --- a/packages/server-utils/test/integrations/vercel-ai/decide.test.ts +++ b/packages/server-utils/test/integrations/vercel-ai/decide.test.ts @@ -29,6 +29,27 @@ describe('Vercel AI decide spans', () => { getMainCarrier().__SENTRY__ = undefined; }); + function recordedAnswers(provider: string, result: Message['result']): unknown { + const message = { + type: 'experimental_decide', + event: { + provider, + modelId: 'typesafe/jev-1.13', + state: 'I was charged twice and the app keeps crashing', + questions: {}, + recordInputs: true, + recordOutputs: true, + }, + result, + } satisfies Message; + + const span = createSpanFromMessage(message, {} as Parameters[1])!; + enrichSpanOnEnd(span, message, {} as Parameters[2]); + span.end(); + + return JSON.parse(spanToStaticSpanJSON(endedSpans[0]!).data?.[GEN_AI_OUTPUT_MESSAGES] as string)[0].answers; + } + // `ai` 7.0.128 renamed the channel event from `experimental_evaluate` to `experimental_decide`. it.each(['experimental_evaluate', 'experimental_decide'] as const)('creates a decide span for `%s`', type => { const message = { @@ -58,29 +79,80 @@ describe('Vercel AI decide spans', () => { }); it.each(['typesafe', 'openai'])('restores answer confidence from `providerMetadata.%s`', provider => { - const message = { - type: 'experimental_decide', - event: { - provider, - modelId: 'gpt-6-luna', - state: 'I was charged twice', - questions: { department: { type: 'choice', criteria: { billing: 'Charges', support: 'Other' } } }, - recordInputs: true, - recordOutputs: true, - }, - result: { - answers: { department: { type: 'choice', choice: 'billing' } }, - providerMetadata: { [provider]: { confidence: { department: 0.91 } } }, - }, - } satisfies Message; + const answers = recordedAnswers(provider, { + answers: { department: { type: 'choice', choice: 'billing' } }, + providerMetadata: { [provider]: { confidence: { department: 0.91 } } }, + }); - const span = createSpanFromMessage(message, {} as Parameters[1])!; - enrichSpanOnEnd(span, message, {} as Parameters[2]); - span.end(); + expect(answers).toEqual({ department: { type: 'choice', choice: 'billing', confidence: 0.91 } }); + }); - const json = spanToStaticSpanJSON(endedSpans[0]!); - expect(JSON.parse(json.data?.[GEN_AI_OUTPUT_MESSAGES] as string)).toEqual([ - { type: 'evaluation', answers: { department: { type: 'choice', choice: 'billing', confidence: 0.91 } } }, - ]); + describe('OpenRouter evaluation models', () => { + it('restores choice confidence from `providerMetadata.openrouter.answers`', () => { + const answers = recordedAnswers('openrouter', { + answers: { + department: { type: 'choice', choice: 'billing', probabilities: { billing: 0.82, technical: 0.18 } }, + wantsRefund: { type: 'boolean', probability: 0.97 }, + }, + providerMetadata: { + openrouter: { provider: 'TypeSafe', answers: { department: { confidence: 0.64 }, wantsRefund: {} } }, + }, + }); + + expect(answers).toEqual({ + department: { + type: 'choice', + choice: 'billing', + probabilities: { billing: 0.82, technical: 0.18 }, + confidence: 0.64, + }, + wantsRefund: { type: 'boolean', probability: 0.97 }, + }); + }); + + it('restores score confidence and legend from `providerMetadata.openrouter.answers`', () => { + const legend = { '0': 'Not urgent', '1': 'Handle today', '2': 'Page on-call' }; + + const answers = recordedAnswers('openrouter', { + answers: { urgency: { type: 'score', score: 1.8, probabilities: { '0': 0, '1': 0.2, '2': 0.8 } } }, + providerMetadata: { openrouter: { answers: { urgency: { confidence: 0.31, legend } } } }, + }); + + expect(answers).toEqual({ + urgency: { + type: 'score', + score: 1.8, + probabilities: { '0': 0, '1': 0.2, '2': 0.8 }, + confidence: 0.31, + legend, + }, + }); + }); + + it.each([ + ['no provider metadata', undefined], + ['no `answers` entry', { openrouter: { provider: 'TypeSafe', usage: { cost: 0.0004 } } }], + ['no entry for the question', { openrouter: { answers: { department: { confidence: 0.64 } } } }], + ])('leaves answers unchanged with %s', (_, providerMetadata) => { + const answers = recordedAnswers('openrouter', { + answers: { urgency: { type: 'score', score: 1.8 } }, + providerMetadata, + }); + + expect(answers).toEqual({ urgency: { type: 'score', score: 1.8 } }); + }); + + it('keeps confidence and legend already on the answer', () => { + const answers = recordedAnswers('openrouter', { + answers: { urgency: { type: 'score', score: 1.8, confidence: 0.9, legend: { '2': 'Page on-call' } } }, + providerMetadata: { + openrouter: { answers: { urgency: { confidence: 0.31, legend: { '2': 'Escalate' } } } }, + }, + }); + + expect(answers).toEqual({ + urgency: { type: 'score', score: 1.8, confidence: 0.9, legend: { '2': 'Page on-call' } }, + }); + }); }); }); From aa9ffcc09a2e785a606f9e2dcf1226e23e499131 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:01:00 +0200 Subject: [PATCH 028/126] feat(nextjs): Warn when Next.js production builds miss the instrumentation file (#25603) With `src/app` and a root `instrumentation.ts` (or `app/` and `src/instrumentation.ts`), `next build` does not pick up the file. Dev and `next start` with Turbopack still load it, so local testing passes. But on Vercel and in standalone output, the server SDK never initializes. - Warns at build time when there is no instrumentation file next to `pages`/`app`. It uses the same lookup as Next.js ([`next build`](https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/build/index.ts#L1486), with `pageExtensions`). - Replaces the webpack-only "Could not find a Next.js instrumentation file" warning, which accepted both folders. Turbopack builds now get the warning too. `SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING` still turns it off. - Warns once, even when Next.js loads the config in child processes. This is only a warning because Next.js decides which file it deploys. We can only warn users. Upstream: vercel/next.js#68740 (other users hit the same cause) Fixes #17024 Docs: https://github.com/getsentry/sentry-docs/pull/20125 --- packages/nextjs/src/config/webpack.ts | 15 +-- .../withSentryConfig/getFinalConfigObject.ts | 2 + .../getFinalConfigObjectUtils.ts | 41 ++++++++- .../getFinalConfigObjectUtils.test.ts | 91 +++++++++++++++++++ 4 files changed, 135 insertions(+), 14 deletions(-) diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 32bd6bee28e7..b7c1efa5cb65 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -515,19 +515,8 @@ function getInstrumentationFile(projectDir: string, dotPrefixedExtensions: strin * Make sure the instrumentation file has a `onRequestError` Handler */ function warnAboutMissingOnRequestErrorHandler(instrumentationFile: string | null, silent?: boolean): void { - const logger = getBuildLogger(silent); - - if (!instrumentationFile) { - if (!process.env.SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING) { - logger.warn( - '[@sentry/nextjs] Could not find a Next.js instrumentation file. This indicates an incomplete configuration of the Sentry SDK. An instrumentation file is required for the Sentry SDK to be initialized on the server: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#create-initialization-config-files (you can suppress this warning by setting SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING=1 as environment variable)', - ); - } - return; - } - - if (!instrumentationFile.includes('onRequestError')) { - logger.warn( + if (instrumentationFile && !instrumentationFile.includes('onRequestError')) { + getBuildLogger(silent).warn( '[@sentry/nextjs] Could not find `onRequestError` hook in instrumentation file. This indicates outdated configuration of the Sentry SDK. Use `Sentry.captureRequestError` to instrument the `onRequestError` hook: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#errors-from-nested-react-server-components', ); } diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts index fb1b9f616aeb..e855041202e1 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts @@ -25,6 +25,7 @@ import { maybeSetUpTunnelRouteRewriteRules, resolveReleaseName, shouldReturnEarlyInExperimentalBuildMode, + warnIfMissingInstrumentationFile, warnIfMissingOnRouterTransitionStartHook, } from './getFinalConfigObjectUtils'; @@ -55,6 +56,7 @@ export function getFinalConfigObject( maybeSetClientTraceMetadataOption(incomingUserNextConfigObject, nextJsVersion, userSentryOptions.silent); maybeSetInstrumentationHookOption(incomingUserNextConfigObject, nextJsVersion, userSentryOptions.silent); warnIfMissingOnRouterTransitionStartHook(userSentryOptions); + warnIfMissingInstrumentationFile(incomingUserNextConfigObject, userSentryOptions); const bundlerInfo = getBundlerInfo(nextJsVersion); maybeWarnAboutUnsupportedTurbopack(nextJsVersion, bundlerInfo, userSentryOptions.silent); diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index 68520bc1e16d..7ca66c51d1f7 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -7,7 +7,7 @@ import { getBuildLogger } from '../buildLogger'; import { createRouteManifest } from '../manifest/createRouteManifest'; import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, SentryBuildOptions } from '../types'; -import { requiresInstrumentationHook } from '../util'; +import { DEFAULT_PAGE_EXTENSIONS, requiresInstrumentationHook } from '../util'; import { getGitRevision, getInstrumentationClientFileContents } from './buildTime'; import { findTunnelRouteHandlerFile, @@ -21,6 +21,8 @@ let showedUnusedTunnelRouteHandlerWarning = false; let showedTunnelRouteHandlerModeLog = false; let showedExperimentalBuildModeWarning = false; +const MISSING_INSTRUMENTATION_FILE_WARNING_SHOWN = '__SENTRY_MISSING_INSTRUMENTATION_FILE_WARNING_SHOWN__'; + /** * Resolves the Sentry release name to use for build-time behavior. * @@ -266,6 +268,43 @@ export function warnIfMissingOnRouterTransitionStartHook(userSentryOptions: Sent } } +/** + * Warns if there is no `instrumentation` file next to the `app` or `pages` directory. + * `next build` only looks there, even though dev and Turbopack also find the file in other places. + */ +export function warnIfMissingInstrumentationFile( + incomingUserNextConfigObject: NextConfigObject, + userSentryOptions: SentryBuildOptions, +): void { + if ( + process.env[MISSING_INSTRUMENTATION_FILE_WARNING_SHOWN] || + process.env.SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING + ) { + return; + } + + const projectDir = process.cwd(); + const exists = (relativePath: string): boolean => fs.existsSync(path.join(projectDir, relativePath)); + + // Same order as Next.js' `findPagesDir` + const routerDir = ['pages', path.join('src', 'pages'), 'app', path.join('src', 'app')].find(exists); + if (!routerDir) { + return; + } + + const instrumentationDir = path.dirname(routerDir); + const pageExtensions = incomingUserNextConfigObject.pageExtensions ?? DEFAULT_PAGE_EXTENSIONS; + if (pageExtensions.some(extension => exists(path.join(instrumentationDir, `instrumentation.${extension}`)))) { + return; + } + + // Next.js may evaluate its config in child processes, which inherit this state from their parent. + process.env[MISSING_INSTRUMENTATION_FILE_WARNING_SHOWN] = '1'; + getBuildLogger(userSentryOptions.silent).warn( + `[@sentry/nextjs] Could not find an instrumentation file next to your ${routerDir} directory. Next.js production builds only load it from there, so the Sentry SDK will not be initialized on the server. Create ${path.join(instrumentationDir, 'instrumentation.ts')}, or move your existing instrumentation file there: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#register-server-side-sdk (you can suppress this warning by setting SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING=1 as environment variable)`, + ); +} + /** * Parses the major Next.js version number from a semver string. */ diff --git a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts index dde7fbf85066..9cdc46448750 100644 --- a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { NextConfigObject } from '../../../src/config/types'; +import { warnIfMissingInstrumentationFile } from '../../../src/config/withSentryConfig/getFinalConfigObjectUtils'; import { makeProject, removeProjects } from './projectFixture'; const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; @@ -129,3 +130,93 @@ describe('maybeSetUpTunnelRouteRewriteRules', () => { expect(warnSpy).not.toHaveBeenCalled(); }); }); + +describe('warnIfMissingInstrumentationFile', () => { + const ROOT_FILE_WITH_SRC_APP = ['src/app/page.tsx', 'instrumentation.ts']; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.stubEnv('__SENTRY_MISSING_INSTRUMENTATION_FILE_WARNING_SHOWN__', undefined); + }); + + function setUp(files: string[]) { + vi.spyOn(process, 'cwd').mockReturnValue(makeProject(files)); + return vi.spyOn(console, 'warn').mockImplementation(() => undefined); + } + + function missingFileWarning(routerDir: string, target: string): string { + return `[@sentry/nextjs] Could not find an instrumentation file next to your ${routerDir} directory. Next.js production builds only load it from there, so the Sentry SDK will not be initialized on the server. Create ${target}, or move your existing instrumentation file there: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#register-server-side-sdk (you can suppress this warning by setting SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING=1 as environment variable)`; + } + + it.each([ + ['the file is in the project root with `src/app`', ROOT_FILE_WITH_SRC_APP, 'src/app', 'src/instrumentation.ts'], + [ + 'the file is in `src` with `app`', + ['app/page.tsx', 'src/lib/db.ts', 'src/instrumentation.ts'], + 'app', + 'instrumentation.ts', + ], + ['there is no file with `src/pages`', ['src/pages/index.tsx'], 'src/pages', 'src/instrumentation.ts'], + [ + 'the file is next to `src/app`, but a root `pages` directory takes precedence', + ['pages/index.tsx', 'src/app/page.tsx', 'src/instrumentation.ts'], + 'pages', + 'instrumentation.ts', + ], + ])('warns when %s', (_setup, files, routerDir, target) => { + const warnSpy = setUp(files); + + warnIfMissingInstrumentationFile({}, {}); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(missingFileWarning(routerDir, target)); + }); + + it('warns only once', () => { + const warnSpy = setUp(ROOT_FILE_WITH_SRC_APP); + + warnIfMissingInstrumentationFile({}, {}); + warnIfMissingInstrumentationFile({}, {}); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['the file is next to `src/app`', ['src/app/page.tsx', 'src/instrumentation.ts']], + ['the file is next to `app`', ['app/page.tsx', 'instrumentation.js']], + ['a file is in both folders', ['src/app/page.tsx', 'src/instrumentation.ts', 'instrumentation.ts']], + ['the project has no `app` or `pages` directory', []], + ])('does not warn when %s', (_setup, files) => { + const warnSpy = setUp(files); + + warnIfMissingInstrumentationFile({}, {}); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('looks for the file with the configured `pageExtensions`', () => { + const warnSpy = setUp(['src/app/page.page.tsx', 'src/instrumentation.page.ts']); + + warnIfMissingInstrumentationFile({ pageExtensions: ['page.tsx', 'page.ts'] }, {}); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn with `silent`', () => { + const warnSpy = setUp(ROOT_FILE_WITH_SRC_APP); + + warnIfMissingInstrumentationFile({}, { silent: true }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn when SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING is set', () => { + vi.stubEnv('SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING', '1'); + const warnSpy = setUp(ROOT_FILE_WITH_SRC_APP); + + warnIfMissingInstrumentationFile({}, {}); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); From 8dae2b1d6b982351a64c8ecffdabe5ac502f97e3 Mon Sep 17 00:00:00 2001 From: whatever_kunaal <143793407+kunal649@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:33:39 +0530 Subject: [PATCH 029/126] Merge pull request #25555 from kunal649/kunal/fix/fetch-error-relative-url fix(core): Resolve relative URLs when enhancing fetch error messages --- packages/core/src/instrument/fetch.ts | 18 ++++---- packages/core/src/utils/worldwide.ts | 1 + .../core/test/lib/instrument/fetch.test.ts | 42 +++++++++++++++++++ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/core/src/instrument/fetch.ts b/packages/core/src/instrument/fetch.ts index 443c71095e67..613262caee67 100644 --- a/packages/core/src/instrument/fetch.ts +++ b/packages/core/src/instrument/fetch.ts @@ -136,16 +136,18 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void): void { error.message === 'NetworkError when attempting to fetch resource.') ) { try { - const url = new URL(handlerData.fetchData.url); + const url = new URL(handlerData.fetchData.url, GLOBAL_OBJ.location?.href); const hostname = url.host; - if (enhanceOption === 'always') { - // Modify the error message directly - error.message = `${error.message} (${hostname})`; - } else { - // Store hostname as non-enumerable property for Sentry-only enhancement - // This preserves the original error message for third-party packages - addNonEnumerableProperty(error, '__sentry_fetch_url_host__', hostname); + if (hostname) { + if (enhanceOption === 'always') { + // Modify the error message directly + error.message = `${error.message} (${hostname})`; + } else { + // Store hostname as non-enumerable property for Sentry-only enhancement + // This preserves the original error message for third-party packages + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', hostname); + } } } catch { // ignore it if errors happen here diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 597174c3f94d..2ca3d60bc21d 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -19,6 +19,7 @@ import type { SdkSource } from './env'; /** Internal global with common properties and Sentry extensions */ export type InternalGlobal = { navigator?: { userAgent?: string; maxTouchPoints?: number }; + location?: { href?: string }; console: Console; PerformanceObserver?: any; Sentry?: any; diff --git a/packages/core/test/lib/instrument/fetch.test.ts b/packages/core/test/lib/instrument/fetch.test.ts index e0f31ca02c82..68252f99485a 100644 --- a/packages/core/test/lib/instrument/fetch.test.ts +++ b/packages/core/test/lib/instrument/fetch.test.ts @@ -121,4 +121,46 @@ describe('instrument > addFetchInstrumentationHandler', () => { expect(error.message).toBe('Failed to fetch (example.com)'); }); + + describe('relative URLs', () => { + const globalWithLocation = GLOBAL_OBJ as typeof GLOBAL_OBJ & { location?: { href: string } }; + + afterEach(() => { + Reflect.deleteProperty(globalWithLocation, 'location'); + }); + + async function failWith(target: string): Promise { + const error = new TypeError('Failed to fetch'); + globalThis.fetch = vi.fn().mockRejectedValue(error); + addFetchInstrumentationHandler(() => undefined); + await expect(globalThis.fetch(target)).rejects.toBe(error); + return error; + } + + it.each([ + ['a relative path', 'https://app.example.com/some/page?x=1', '/api/users', 'Failed to fetch (app.example.com)'], + ['an empty URL (`fetch("")`)', 'https://app.example.com/some/page?x=1', '', 'Failed to fetch (app.example.com)'], + ['a relative path with no current page', undefined, '/api/users', 'Failed to fetch'], + ['a relative path on a `file://` page', 'file:///C:/app/index.html', '/api/users', 'Failed to fetch'], + ['a relative path in a sandboxed srcdoc iframe', 'about:srcdoc', '/api/users', 'Failed to fetch'], + [ + 'an absolute URL in a sandboxed srcdoc iframe', + 'about:srcdoc', + 'https://api.example.org/v1/users', + 'Failed to fetch (api.example.org)', + ], + [ + 'an absolute URL, ignoring the current page', + 'https://app.example.com/some/page', + 'https://other.example.org/x', + 'Failed to fetch (other.example.org)', + ], + ])('handles %s', async (_name, href, target, expected) => { + if (href) { + globalWithLocation.location = { href }; + } + + expect((await failWith(target)).message).toBe(expected); + }); + }); }); From 6def2d73493cdbf0d569e8d2826e23244b79d173 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:18:30 +0200 Subject: [PATCH 030/126] fix(tanstackstart-react): Unminify server errors in Nitro builds (#25360) Server errors in TanStack Start stayed minified: Nitro's source maps were off, or stopped at its intermediate SSR bundle in `node_modules/.nitro`. - Loads the SSR source maps into Nitro's build, so the final maps point to `src/`. - Turns on `'hidden'` Nitro source maps only if no config sets them, and always keeps `sourcesContent` (`sourcemapMinify: false`). - Deletes the Nitro source maps that Sentry turned on after the upload. Nuxt 5 has the same Nitro issues: #25325 fixes `sourcesContent`, and the map composition can be reused there. Based on #24356 by @kyrylolvov. Stacked on #25362. Fixes #21012 --------- Co-authored-by: kyrylolvov --- CHANGELOG.md | 2 +- .../src/vite/nitroSourceMaps.ts | 160 ++++++++++++++ .../src/vite/sentryTanstackStart.ts | 5 +- .../src/vite/sourceMaps.ts | 33 +-- .../test/vite/nitroSourceMaps.test.ts | 209 ++++++++++++++++++ .../test/vite/sentryTanstackStart.test.ts | 26 +++ .../test/vite/sourceMapScenarios.test.ts | 189 ++++++++++++++++ .../test/vite/sourceMaps.test.ts | 20 +- 8 files changed, 626 insertions(+), 18 deletions(-) create mode 100644 packages/tanstackstart-react/src/vite/nitroSourceMaps.ts create mode 100644 packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts create mode 100644 packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e99eafc08f4b..1eea89155e7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @faizansaiyed123 and @seongwon030. Thank you for your contributions! +Work in this release was contributed by @faizansaiyed123, @seongwon030, and @kyrylolvov. Thank you for your contributions! ## 11.9.0 diff --git a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts new file mode 100644 index 000000000000..f759cc125fc2 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts @@ -0,0 +1,160 @@ +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import type { BuildEnvironmentOptions, Plugin, ResolvedConfig } from 'vite'; + +/** `nitro/vite` builds the final server in an environment with this name. */ +const NITRO_ENVIRONMENT = 'nitro'; + +/** The environment Vite passes to `applyToEnvironment`. */ +export type PluginEnvironment = Parameters>[0]; + +type BundlerSourceMapConfig = { output?: { sourcemap?: unknown } }; + +type NitroSourceMapConfig = { + sourcemap?: unknown; + rollupConfig?: BundlerSourceMapConfig; + rolldownConfig?: BundlerSourceMapConfig; +}; + +/** The parts of the Nitro instance that this plugin uses. */ +type Nitro = { + options: { + sourcemap: unknown; + experimental: { sourcemapMinify?: false }; + // c12 keeps each config source that Nitro merged: the `nitro()` options, `nitro.config.ts`, presets and more. + // It is internal until Nitro exposes the layers: https://github.com/nitrojs/nitro/issues/4771 + _c12?: { layers?: { config?: NitroSourceMapConfig | null }[] }; + }; +}; + +/** + * Tells the plugin that deletes source maps after the upload whether the source maps of an environment are only + * there because Sentry enabled them. + */ +export type SourceMapsEnablerApi = { hasSentryEnabledSourceMaps(environmentName: string): boolean }; + +/** + * Whether Nitro bundles the output of this environment again. With `nitro/vite`, every server environment besides + * `nitro` is a Nitro service, such as TanStack Start's `ssr` environment. + */ +export function isNitroService(environment: PluginEnvironment): boolean { + return ( + environment.name !== NITRO_ENVIRONMENT && + environment.config.consumer === 'server' && + NITRO_ENVIRONMENT in environment.config.environments + ); +} + +/** + * Keeps the sources in the source maps of Nitro's server build. Enables these source maps, unless a Nitro config + * source or `build.rolldownOptions.output.sourcemap` of the Vite config sets them, or the Vite config disables them. + */ +export function makeEnableNitroSourceMapsPlugin({ debug }: { debug?: boolean }): Plugin & { + api: SourceMapsEnablerApi; + nitro: { name: string; setup: (nitro: Nitro) => void }; +} { + let enabledSourceMaps = false; + let viteConfigPreventsSourceMaps = false; + + return { + name: 'sentry-tanstackstart-nitro-source-maps', + apply: 'build', + // The `config` hook must run before the one of `nitro/vite`, which runs the Nitro modules. + enforce: 'pre', + api: { + hasSentryEnabledSourceMaps: environmentName => enabledSourceMaps && environmentName === NITRO_ENVIRONMENT, + }, + config(viteConfig) { + // Nitro overrides `build.sourcemap` for its environments, but `false` shows that the user wants no source maps. + viteConfigPreventsSourceMaps = + viteConfig.build?.sourcemap === false || hasOutputSourceMapSetting(viteConfig.build); + }, + // `nitro/vite` installs the Nitro module of each Vite plugin after it merged all config sources. + nitro: { + name: 'sentry-tanstackstart-nitro-source-maps', + setup(nitro) { + // Otherwise Nitro removes `sourcesContent` from its source maps, and `mappings` from maps with only + // `node_modules` sources. Nitro only allows `false`. + nitro.options.experimental.sourcemapMinify = false; + + const layers = nitro.options._c12?.layers; + // Without the config sources, Sentry cannot tell if the user disabled source maps. + if (!layers || viteConfigPreventsSourceMaps || layers.some(layer => hasNitroSourceMapSetting(layer.config))) { + return; + } + + nitro.options.sourcemap = 'hidden'; + enabledSourceMaps = true; + logDebug(debug, "Enabled source map generation for the Nitro server build with `nitro.sourcemap: 'hidden'`."); + }, + }, + }; +} + +/** + * Nitro reads the output of its services as plain code, so its source maps would end at that output. Loading the + * service's source map with the code lets the bundler compose both maps to the original source files. + */ +export function makeComposeNitroServiceSourceMapsPlugin(): Plugin { + return { + name: 'sentry-tanstackstart-nitro-service-source-maps', + apply: 'build', + applyToEnvironment: environment => environment.name === NITRO_ENVIRONMENT, + async load(id) { + const serviceOutDirs = getNitroServiceOutDirs(this.environment.config); + if (!serviceOutDirs.some(outDir => isInDirectory(id, outDir))) { + return null; + } + + try { + const [code, map] = await Promise.all([readFile(id, 'utf8'), readFile(`${id}.map`, 'utf8')]); + return { code, map }; + } catch { + // A chunk without a source map: let Nitro load the code as usual. + return null; + } + }, + }; +} + +/** The output directories of the environments that Nitro bundles again. Empty in a build without Nitro. */ +export function getNitroServiceOutDirs(config: ResolvedConfig): string[] { + if (!(NITRO_ENVIRONMENT in config.environments)) { + return []; + } + + return Object.entries(config.environments) + .filter(([name, environment]) => name !== NITRO_ENVIRONMENT && environment.consumer === 'server') + .map(([, environment]) => path.resolve(config.root, environment.build.outDir)); +} + +function isInDirectory(file: string, directory: string): boolean { + const relativePath = path.relative(directory, file); + return ( + relativePath !== '' && + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ); +} + +/** `output.sourcemap` of the bundler config overrides `build.sourcemap` in the build output. */ +export function hasOutputSourceMapSetting( + build: Pick | undefined, +): boolean { + return [build?.rolldownOptions?.output ?? []].flat().some(output => output.sourcemap !== undefined); +} + +/** In a Nitro config, `output.sourcemap` of the bundler config overrides `sourcemap` in the build output. */ +function hasNitroSourceMapSetting(config: NitroSourceMapConfig | null | undefined): boolean { + return [config?.sourcemap, config?.rollupConfig?.output?.sourcemap, config?.rolldownConfig?.output?.sourcemap].some( + setting => setting !== undefined, + ); +} + +function logDebug(debug: boolean | undefined, message: string): void { + if (debug) { + // eslint-disable-next-line no-console + console.log(`[Sentry] ${message}`); + } +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index c3359c077d1b..3c1c28db9bd6 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -2,6 +2,7 @@ import type { BuildTimeOptionsBase, ReactComponentAnnotationOptions } from '@sen import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; +import { makeComposeNitroServiceSourceMapsPlugin, makeEnableNitroSourceMapsPlugin } from './nitroSourceMaps'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; @@ -92,7 +93,7 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P // (CommonJS) deps via `ssr.noExternal`, which the `vite dev` SSR module runner can't evaluate. plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); - plugins.push(...makeAddSentryVitePlugin(options)); + plugins.push(...makeAddSentryVitePlugin(options), makeComposeNitroServiceSourceMapsPlugin()); // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { @@ -102,7 +103,7 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P // source maps const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload'; if (!sourceMapsDisabled) { - plugins.push(...makeEnableSourceMapsVitePlugin(options)); + plugins.push(...makeEnableSourceMapsVitePlugin(options), makeEnableNitroSourceMapsPlugin(options)); } return plugins; diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index f84c52692899..c263011142ae 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,6 +1,13 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; import * as path from 'node:path'; -import type { BuildEnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; +import { + getNitroServiceOutDirs, + hasOutputSourceMapSetting, + isNitroService, + type PluginEnvironment, + type SourceMapsEnablerApi, +} from './nitroSourceMaps'; import type { SentryTanstackStartOptions } from './sentryTanstackStart'; type FilesToDeleteAfterUpload = string | string[] | undefined; @@ -30,12 +37,6 @@ export function rewriteTanstackStartSource(source: string, _map: unknown, contex return path.relative(process.cwd(), absolutePath).split(path.sep).join('/'); } -/** - * Tells the plugin that deletes source maps after the upload whether the source maps of an environment are only - * there because Sentry enabled them. - */ -type SourceMapsEnablerApi = { hasSentryEnabledSourceMaps(environmentName: string): boolean }; - /** * A Sentry plugin for adding the @sentry/bundler-plugins/vite to automatically upload source maps to Sentry. */ @@ -127,7 +128,14 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl }, }); - return [configPlugin, ...sentryPlugins]; + // Nitro bundles the output of its services again. Debug IDs from a service would end up in Nitro's output and point + // to the service's source maps instead of Nitro's. + const scopedSentryPlugins = sentryPlugins.map(plugin => ({ + ...plugin, + applyToEnvironment: (environment: PluginEnvironment) => !isNitroService(environment), + })); + + return [configPlugin, ...scopedSentryPlugins]; } /** @@ -135,10 +143,12 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl * such as `./**\/*.map` does not enter dot directories like `.output`, where Nitro puts the client build. */ function getSentryEnabledSourceMapGlobs(config: ResolvedConfig): string[] { + const serviceOutDirs = getNitroServiceOutDirs(config); const outDirs = Object.entries(config.environments) .filter(([name]) => hasSentryEnabledSourceMaps(config, name)) .filter(([, environment]) => !hasOutputSourceMapSetting(environment.build)) - .flatMap(([, environment]) => getOutputDirs(config.root, environment.build)); + .flatMap(([, environment]) => getOutputDirs(config.root, environment.build)) + .filter(outDir => !serviceOutDirs.includes(outDir)); return [...new Set(outDirs)].map(outDir => { const relativeOutDir = path.relative(process.cwd(), outDir).split(path.sep).join('/'); @@ -153,11 +163,6 @@ function getOutputDirs(root: string, build: ResolvedConfig['environments'][strin return outputs.map(output => path.resolve(root, output.dir ?? build.outDir)); } -/** `output.sourcemap` of the bundler config overrides `build.sourcemap` in the build output. */ -function hasOutputSourceMapSetting(build: Pick | undefined): boolean { - return [build?.rolldownOptions?.output ?? []].flat().some(output => output.sourcemap !== undefined); -} - /** Vite tells plugins its version since Vite 7. */ function isVite8OrLater(viteVersion: string | undefined): boolean { return Number(viteVersion?.split('.')[0]) >= 8; diff --git a/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts b/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts new file mode 100644 index 000000000000..c3deb2e93e57 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts @@ -0,0 +1,209 @@ +// @vitest-environment node +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { UserConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + isNitroService, + makeComposeNitroServiceSourceMapsPlugin, + makeEnableNitroSourceMapsPlugin, + type PluginEnvironment, +} from '../../src/vite/nitroSourceMaps'; + +const SSR_OUT_DIR = 'node_modules/.nitro/vite/services/ssr'; + +function createEnvironment(name: string, consumer: 'client' | 'server', environmentNames: string[]): PluginEnvironment { + const environments = Object.fromEntries(environmentNames.map(environmentName => [environmentName, {}])); + return { name, config: { consumer, environments } } as unknown as PluginEnvironment; +} + +describe('isNitroService', () => { + it.each([ + { + label: 'the ssr environment of a Nitro build', + name: 'ssr', + consumer: 'server', + environments: ['client', 'ssr', 'nitro'], + expected: true, + }, + { + label: 'the nitro environment', + name: 'nitro', + consumer: 'server', + environments: ['client', 'ssr', 'nitro'], + expected: false, + }, + { + label: 'the client environment', + name: 'client', + consumer: 'client', + environments: ['client', 'ssr', 'nitro'], + expected: false, + }, + { + label: 'the ssr environment without Nitro', + name: 'ssr', + consumer: 'server', + environments: ['client', 'ssr'], + expected: false, + }, + ] as const)('returns $expected for $label', ({ name, consumer, environments, expected }) => { + expect(isNitroService(createEnvironment(name, consumer, [...environments]))).toBe(expected); + }); +}); + +describe('Nitro source map config', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + type NitroConfig = { + sourcemap?: unknown; + rollupConfig?: { output?: { sourcemap?: unknown } }; + rolldownConfig?: { output?: { sourcemap?: unknown } }; + }; + + /** Runs the Nitro module with the config sources that Nitro merged, e.g. the `nitro()` options and `nitro.config.ts`. */ + function setup( + layers: NitroConfig[] | undefined, + { debug, viteConfig = {} }: { debug?: boolean; viteConfig?: UserConfig } = {}, + ) { + const plugin = makeEnableNitroSourceMapsPlugin({ debug }); + (plugin.config as (config: UserConfig) => void)(viteConfig); + const nitro = { + options: { sourcemap: false, experimental: {}, _c12: layers && { layers: layers.map(config => ({ config })) } }, + }; + plugin.nitro.setup(nitro); + + return { plugin, nitroOptions: { sourcemap: nitro.options.sourcemap, experimental: nitro.options.experimental } }; + } + + it('enables hidden source maps with their sources when no config source sets them', () => { + const { plugin, nitroOptions } = setup([{}, {}]); + + expect(nitroOptions).toEqual({ sourcemap: 'hidden', experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(true); + expect(plugin.api.hasSentryEnabledSourceMaps('client')).toBe(false); + }); + + it.each([false, true, 'hidden'])( + 'keeps the setting `sourcemap: %s` of a config source, with the sources', + sourcemap => { + const { plugin, nitroOptions } = setup([{}, { sourcemap }]); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }, + ); + + it.each([ + { label: '`rollupConfig.output.sourcemap`', layer: { rollupConfig: { output: { sourcemap: true } } } }, + { label: '`rolldownConfig.output.sourcemap`', layer: { rolldownConfig: { output: { sourcemap: false } } } }, + ])('keeps the source maps when a config source sets $label', ({ layer }) => { + const { plugin, nitroOptions } = setup([{}, layer]); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }); + + it.each([ + { label: '`build.sourcemap: false`', build: { sourcemap: false } }, + { label: '`build.rolldownOptions.output.sourcemap`', build: { rolldownOptions: { output: { sourcemap: true } } } }, + ])('keeps the source maps when the Vite config sets $label', ({ build }) => { + const { plugin, nitroOptions } = setup([{}], { viteConfig: { build } }); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }); + + it('enables source maps when the Vite config sets `build.sourcemap: true`, which Nitro overrides', () => { + const { plugin, nitroOptions } = setup([{}], { viteConfig: { build: { sourcemap: true } } }); + + expect(nitroOptions).toEqual({ sourcemap: 'hidden', experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(true); + }); + + it('does not enable source maps when Nitro does not expose its config sources', () => { + const { plugin, nitroOptions } = setup(undefined); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }); + + it('logs that it enabled source maps in debug mode', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + setup([], { debug: true }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + "[Sentry] Enabled source map generation for the Nitro server build with `nitro.sourcemap: 'hidden'`.", + ); + }); +}); + +describe('Nitro service source maps', () => { + let rootDir: string; + + beforeEach(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-tanstackstart-nitro-')); + fs.mkdirSync(path.join(rootDir, SSR_OUT_DIR, 'assets'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }); + + function writeFile(relativePath: string, contents: string): string { + const file = path.join(rootDir, relativePath); + fs.writeFileSync(file, contents); + return file; + } + + function load(id: string): Promise { + const plugin = makeComposeNitroServiceSourceMapsPlugin(); + const context = { + environment: { + config: { + root: rootDir, + environments: { + client: { consumer: 'client', build: { outDir: '.output/public' } }, + ssr: { consumer: 'server', build: { outDir: SSR_OUT_DIR } }, + nitro: { consumer: 'server', build: { outDir: '.output/server' } }, + }, + }, + }, + }; + return (plugin.load as unknown as (this: typeof context, id: string) => Promise).call(context, id); + } + + it('applies only to the nitro environment', () => { + const plugin = makeComposeNitroServiceSourceMapsPlugin(); + const applyToEnvironment = plugin.applyToEnvironment as (environment: PluginEnvironment) => boolean; + + expect(applyToEnvironment(createEnvironment('nitro', 'server', ['ssr', 'nitro']))).toBe(true); + expect(applyToEnvironment(createEnvironment('ssr', 'server', ['ssr', 'nitro']))).toBe(false); + }); + + it('loads a service chunk together with its source map', async () => { + const map = '{"version":3,"sources":["../../../../../src/routes/index.tsx"],"mappings":"AAAA"}'; + const chunk = writeFile(`${SSR_OUT_DIR}/assets/index-BKeMB_FB.js`, 'export const handler = () => {};'); + writeFile(`${SSR_OUT_DIR}/assets/index-BKeMB_FB.js.map`, map); + + await expect(load(chunk)).resolves.toEqual({ code: 'export const handler = () => {};', map }); + }); + + it('leaves a service chunk without a source map to Nitro', async () => { + const chunk = writeFile(`${SSR_OUT_DIR}/assets/empty-plugin-adapters-BJX69BzA.js`, 'export {};'); + + await expect(load(chunk)).resolves.toBeNull(); + }); + + it('leaves modules outside the service output to Nitro', async () => { + fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true }); + const module = writeFile('src/server.ts', 'export default {};'); + writeFile('src/server.ts.map', '{"version":3,"sources":[],"mappings":""}'); + + await expect(load(module)).resolves.toBeNull(); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 0715873cdfa5..106c8fb633ae 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -23,6 +23,17 @@ const mockEnableSourceMapsPlugin: Plugin = { config: vi.fn(), }; +const mockComposeNitroServiceSourceMapsPlugin: Plugin = { + name: 'sentry-tanstackstart-nitro-service-source-maps', + apply: 'build', + load: vi.fn(), +}; + +const mockEnableNitroSourceMapsPlugin: Plugin = { + name: 'sentry-tanstackstart-nitro-source-maps', + apply: 'build', +}; + const mockMiddlewarePlugin: Plugin = { name: 'sentry-tanstack-middleware-auto-instrument', apply: 'build', @@ -61,6 +72,11 @@ vi.mock('../../src/vite/sourceMaps', () => ({ makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), })); +vi.mock('../../src/vite/nitroSourceMaps', () => ({ + makeComposeNitroServiceSourceMapsPlugin: vi.fn(() => mockComposeNitroServiceSourceMapsPlugin), + makeEnableNitroSourceMapsPlugin: vi.fn(() => mockEnableNitroSourceMapsPlugin), +})); + vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ makeAutoInstrumentMiddlewarePlugin: vi.fn(() => mockMiddlewarePlugin), })); @@ -88,7 +104,9 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockEnableSourceMapsPlugin, + mockEnableNitroSourceMapsPlugin, ]); }); @@ -122,6 +140,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); @@ -136,6 +155,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); @@ -150,7 +170,9 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockEnableSourceMapsPlugin, + mockEnableNitroSourceMapsPlugin, ]); }); }); @@ -164,6 +186,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, ]); }); @@ -179,6 +202,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, ]); }); @@ -194,6 +218,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); @@ -226,6 +251,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, ]); }); diff --git a/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts b/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts new file mode 100644 index 000000000000..7effcf3012f7 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts @@ -0,0 +1,189 @@ +// @vitest-environment node +import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; +import type { BuildEnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import { mergeConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryTanstackStart } from '../../src/vite/sentryTanstackStart'; + +let sentryVitePluginOptions: SentryVitePluginOptions | undefined; + +vi.mock('@sentry/bundler-plugins/vite', () => ({ + sentryVitePlugin: (options: SentryVitePluginOptions) => { + sentryVitePluginOptions = options; + return []; + }, +})); + +type SourceMapSetting = boolean | 'hidden' | 'inline' | undefined; + +type NitroOptions = { + sourcemap?: SourceMapSetting; + rollupConfig?: { output?: { sourcemap?: SourceMapSetting } }; + rolldownConfig?: { output?: { sourcemap?: SourceMapSetting } }; +}; + +type NitroModulePlugin = Plugin & { nitro: { setup(nitro: unknown): void } }; + +const ENVIRONMENT_NAMES = ['client', 'ssr', 'nitro'] as const; + +function findPlugin(plugins: Plugin[], name: string): T { + const plugin = plugins.find(plugin => plugin.name === name); + expect(plugin).toBeDefined(); + return plugin as T; +} + +function callConfigHook(plugin: Plugin, config: UserConfig): UserConfig { + return (plugin.config as (config: UserConfig) => UserConfig | undefined)(config) ?? {}; +} + +/** The source map setting that applies to the output of an environment. */ +function getOutputSourceMapSetting(build: BuildEnvironmentOptions): unknown { + const output = build.rolldownOptions?.output; + return (Array.isArray(output) ? undefined : output?.sourcemap) ?? build.sourcemap; +} + +/** + * Runs Sentry's source map plugins in the order of a build with `nitro/vite`, which configures Nitro between the + * `config` hooks of the Sentry plugins with `enforce: 'pre'` and `enforce: 'post'`. + */ +async function build({ vite = {}, nitro: nitroOptions = {} }: { vite?: UserConfig; nitro?: NitroOptions }) { + const plugins = sentryTanstackStart({}); + const nitroPlugin = findPlugin(plugins, 'sentry-tanstackstart-nitro-source-maps'); + + const viteConfig = structuredClone(vite); + callConfigHook(nitroPlugin, viteConfig); + + const nitro = { + options: { + sourcemap: nitroOptions.sourcemap ?? false, + experimental: {}, + _c12: { layers: [{ config: nitroOptions }] }, + }, + }; + nitroPlugin.nitro.setup(nitro); + + const nitroOutput = { ...nitroOptions.rollupConfig?.output, ...nitroOptions.rolldownConfig?.output }; + const configWithNitro: UserConfig = mergeConfig(viteConfig, { + environments: { + client: { build: { outDir: '.output/public' } }, + ssr: { build: { sourcemap: nitro.options.sourcemap, outDir: 'node_modules/.nitro/vite/services/ssr' } }, + nitro: { + build: { + sourcemap: nitro.options.sourcemap, + rolldownOptions: { output: { ...nitroOutput, dir: '.output/server' } }, + }, + }, + }, + }); + const userConfig: UserConfig = mergeConfig( + configWithNitro, + callConfigHook(findPlugin(plugins, 'sentry-tanstackstart-react-source-maps'), configWithNitro), + ); + + const environments = Object.fromEntries( + ENVIRONMENT_NAMES.map(name => [ + name, + { + consumer: name === 'client' ? 'client' : 'server', + build: mergeConfig({ outDir: 'dist', ...userConfig.build }, userConfig.environments?.[name]?.build ?? {}), + }, + ]), + ); + const resolvedConfig = { root: process.cwd(), plugins, environments } as unknown as ResolvedConfig; + const deletionPlugin = findPlugin(plugins, 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); + (deletionPlugin.configResolved as (this: unknown, config: ResolvedConfig) => void).call( + { meta: { viteVersion: '8.3.4' } }, + resolvedConfig, + ); + + return { + client: getOutputSourceMapSetting(environments.client!.build), + ssr: getOutputSourceMapSetting(environments.ssr!.build), + nitro: getOutputSourceMapSetting(environments.nitro!.build), + filesToDelete: await sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload, + }; +} + +const CLIENT_MAPS = '.output/public/**/*.map'; +const SERVER_MAPS = '.output/server/**/*.map'; + +describe('source map scenarios of a build with Nitro', () => { + beforeEach(() => { + sentryVitePluginOptions = undefined; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each<{ label: string; vite?: UserConfig; nitro?: NitroOptions; expected: Awaited> }>([ + { + label: 'nothing', + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', filesToDelete: [CLIENT_MAPS, SERVER_MAPS] }, + }, + { + label: 'nitro({ sourcemap: true })', + nitro: { sourcemap: true }, + expected: { client: 'hidden', ssr: true, nitro: true, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'nitro({ sourcemap: false })', + nitro: { sourcemap: false }, + expected: { client: 'hidden', ssr: false, nitro: false, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'nitro({ rollupConfig: { output: { sourcemap: true } } })', + nitro: { rollupConfig: { output: { sourcemap: true } } }, + expected: { client: 'hidden', ssr: false, nitro: true, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'nitro({ rolldownConfig: { output: { sourcemap: true } } })', + nitro: { rolldownConfig: { output: { sourcemap: true } } }, + expected: { client: 'hidden', ssr: false, nitro: true, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'build.sourcemap: true', + vite: { build: { sourcemap: true } }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: "build.sourcemap: 'hidden'", + vite: { build: { sourcemap: 'hidden' } }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: 'build.sourcemap: false', + vite: { build: { sourcemap: false } }, + expected: { client: false, ssr: false, nitro: false, filesToDelete: [] }, + }, + { + label: 'environments.client.build.sourcemap: true', + vite: { environments: { client: { build: { sourcemap: true } } } }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: 'environments.client.build.sourcemap: false', + vite: { environments: { client: { build: { sourcemap: false } } } }, + expected: { client: false, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: 'build.rolldownOptions.output.sourcemap: true', + vite: { build: { rolldownOptions: { output: { sourcemap: true } } } }, + expected: { client: true, ssr: true, nitro: true, filesToDelete: [] }, + }, + { + label: 'build.rolldownOptions.output.sourcemap: false', + vite: { build: { rolldownOptions: { output: { sourcemap: false } } } }, + expected: { client: false, ssr: false, nitro: false, filesToDelete: [] }, + }, + { + label: 'environments.client.build.rolldownOptions.output.sourcemap: true', + vite: { environments: { client: { build: { rolldownOptions: { output: { sourcemap: true } } } } } }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + ])('keeps the source maps of the user and deletes the others when the user sets $label', async scenario => { + await expect(build(scenario)).resolves.toEqual(scenario.expected); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index 0f41d8d261ef..12ccae09beb2 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -210,6 +210,23 @@ describe('makeAddSentryVitePlugin()', () => { expect(typeof configPlugin?.configResolved).toBe('function'); }); + it.each([ + { name: 'client', consumer: 'client', expected: true }, + { name: 'ssr', consumer: 'server', expected: false }, + { name: 'nitro', consumer: 'server', expected: true }, + ] as const)( + 'applies the Sentry Vite plugin to the $name environment of a Nitro build: $expected', + ({ name, consumer, expected }) => { + const sentryPlugin = makeAddSentryVitePlugin({}).find(plugin => plugin.name === mockedSentryVitePlugin.name); + const environment = { name, config: { consumer, environments: { client: {}, ssr: {}, nitro: {} } } }; + + expect(sentryPlugin).toBeDefined(); + expect((sentryPlugin?.applyToEnvironment as unknown as (env: typeof environment) => boolean)(environment)).toBe( + expected, + ); + }, + ); + describe('default filesToDeleteAfterUpload', () => { type TestEnvironment = { consumer: 'client' | 'server'; @@ -220,6 +237,7 @@ describe('makeAddSentryVitePlugin()', () => { const NITRO_ENVIRONMENTS: Record = { client: { consumer: 'client', outDir: '.output/public' }, + ssr: { consumer: 'server', outDir: 'node_modules/.nitro/vite/services/ssr' }, // Nitro sets the output directory of its server build with `output.dir` only. nitro: { consumer: 'server', outDir: 'dist', outputDir: '.output/server' }, }; @@ -270,7 +288,7 @@ describe('makeAddSentryVitePlugin()', () => { it.each([ { - label: 'a Nitro build', + label: 'a Nitro build, but not in the Nitro service output', environments: NITRO_ENVIRONMENTS, expected: ['.output/public/**/*.map', '.output/server/**/*.map'], }, From e1026aa7bb6598913232016dd139483315eb7038 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:23:53 +0200 Subject: [PATCH 031/126] test(tanstackstart-react): Add source map E2E app (#25363) Adds the `tanstackstart-react-sourcemaps` E2E app. It builds a TanStack Start app with Nitro against a mock Sentry server and checks the upload: - The source maps carry the client and server source. - Their source paths are relative to the project root. - Every debug ID has an uploaded source map. - No source map stays in the build output. The app is optional in CI because Nitro 3 is in beta. Stacked on #25360. Refs #21012 --- .../tanstackstart-react-sourcemaps/.gitignore | 11 + .../assert-build.ts | 191 ++++++++++++++++++ .../package.json | 37 ++++ .../pnpm-workspace.yaml | 3 + .../src/router.tsx | 4 + .../src/routes/__root.tsx | 20 ++ .../src/routes/index.tsx | 21 ++ .../start-mock-sentry-server.mjs | 3 + .../tsconfig.json | 16 ++ .../vite.config.ts | 21 ++ 10 files changed, 327 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore new file mode 100644 index 000000000000..4187ae735082 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore @@ -0,0 +1,11 @@ +node_modules +.output +.nitro +.tanstack +src/routeTree.gen.ts + +# Mock Sentry server artifacts +.tmp_mock_uploads.json +.tmp_chunks +.tmp_build_stdout +.tmp_build_stderr diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..2aa44e66f20e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts @@ -0,0 +1,191 @@ +import * as assert from 'assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + findDebugIdsWithConflictingSourcemaps, + findFilesWithForeignSourcemaps, + findFilesWithMultipleDebugIds, + findInjectedDebugIds, + getArtifactBundles, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemapSources, + getSourcemaps, + loadMockServerResults, + startNodeServer, + type SourcemapEntry, +} from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ +const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; +const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; + +const DEBUG_ID_PATTERN = /sentry-dbid-([\da-f-]{36})/gi; + +function readDebugIds(file: string): string[] { + return [...fs.readFileSync(file, 'utf8').matchAll(DEBUG_ID_PATTERN)].map(match => (match[1] as string).toLowerCase()); +} + +function emittedJsFiles(dir: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && /\.m?js$/.test(entry.name) && !entry.parentPath.includes('node_modules')) + .map(entry => path.join(entry.parentPath, entry.name)); +} + +/** In debug mode, the Sentry plugin logs each file it deletes after the upload. */ +const deletedFiles = new Set( + [...fs.readFileSync('.tmp_build_stdout', 'utf8').matchAll(/Deleting asset after upload: (.+)$/gm)].map(match => + path.relative(fs.realpathSync('.'), match[1] as string), + ), +); + +/** A chunk without mappable source, such as Nitro's runtime, gets a debug ID but no source map to upload. */ +function debugIdsOfChunksWithoutSourceMap(dir: string): Set { + return new Set( + emittedJsFiles(dir) + .filter(file => !fs.existsSync(`${file}.map`) && !deletedFiles.has(`${file}.map`)) + .flatMap(readDebugIds), + ); +} + +function sourceMapsIn(dir: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.map')) + .map(entry => path.join(entry.parentPath, entry.name)); +} + +// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob +// characters, like Nitro's `_libs/@tanstack/react-router+[...].mjs`. Remove this when the uploader matches paths literally. +function debugIdsOfFilesWithGlobCharacters(dir: string): Set { + return new Set( + emittedJsFiles(dir) + .filter(file => /[[\]{}()*?]/.test(path.relative(dir, file))) + .flatMap(readDebugIds), + ); +} + +const requests = loadMockServerResults(); + +// --- The upload reached Sentry --- + +assert.ok( + getChunkUploadPosts(requests).some(request => request.bodySize > 0), + 'Expected at least one chunk upload with a non-empty body', +); + +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); + +const sourcemaps = getSourcemaps(bundles); + +/** The uploaded source maps of the files in an output directory, matched by debug ID. */ +function sourceMapsOf(outputDir: string): SourcemapEntry[] { + const debugIds = new Set(findInjectedDebugIds({ outputDir })); + return sourcemaps.filter(map => map.debugId && debugIds.has(map.debugId)); +} + +const clientSourceMaps = sourceMapsOf(CLIENT_OUTPUT); +const serverSourceMaps = sourceMapsOf(SERVER_OUTPUT); +const containsMarker = (maps: SourcemapEntry[], marker: string): boolean => + maps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +// Both markers are in the same route file, which client and server source maps embed completely. So each marker only +// proves the upload of its side if the check only looks at the source maps of that side. +assert.ok(containsMarker(clientSourceMaps, CLIENT_MARKER), 'Expected a client source map carrying the client source'); +assert.ok(containsMarker(serverSourceMaps, SERVER_MARKER), 'Expected a server source map carrying the server source'); + +// --- Source paths match the files in the project --- + +const unresolvedSources = [...new Set(sourcemaps.flatMap(map => getSourcemapSources(map.sourcemap)))].filter( + source => source.startsWith('../') || path.isAbsolute(source) || /\?ts[rs]-/.test(source), +); +assert.deepEqual(unresolvedSources, [], 'Expected every uploaded source path to be relative to the project root'); + +// --- Server source maps reach the original source --- + +// Nitro bundles the SSR output again. Its maps must lead through that output to the file the server function is in. +const serverFunctionSources = serverSourceMaps + .filter(map => map.sourcemap.sourcesContent?.some(source => source?.includes(SERVER_MARKER))) + .flatMap(map => getSourcemapSources(map.sourcemap)); +assert.ok( + serverFunctionSources.includes('src/routes/index.tsx'), + `Expected a server source map to resolve to src/routes/index.tsx, got: ${serverFunctionSources.join(', ')}`, +); + +// --- Debug IDs tie the shipped bundles to the uploaded maps --- + +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); + +for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) { + const injectedDebugIds = findInjectedDebugIds({ outputDir }); + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + + const exemptDebugIds = new Set([ + ...debugIdsOfChunksWithoutSourceMap(outputDir), + ...debugIdsOfFilesWithGlobCharacters(outputDir), + ]); + const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.has(debugId) && !exemptDebugIds.has(debugId)); + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded source map`); + + // A file of Nitro's output can carry the debug ID of the SSR chunk it was built from. + assert.deepEqual( + findFilesWithMultipleDebugIds({ outputDir }), + [], + `Expected at most one debug ID per file in ${outputDir}`, + ); + assert.deepEqual( + findFilesWithForeignSourcemaps({ outputDir }, sourcemaps), + [], + `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, + ); + + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); +} + +assert.deepEqual( + findDebugIdsWithConflictingSourcemaps(sourcemaps), + [], + 'Expected every uploaded debug ID to resolve to exactly one source map', +); + +// --- Sentry deletes the source maps it enabled after the upload --- + +for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) { + assert.deepEqual(sourceMapsIn(outputDir), [], `Expected no source maps in ${outputDir}`); +} + +const clientChunkUrls = emittedJsFiles(CLIENT_OUTPUT).map( + file => `/${path.relative(CLIENT_OUTPUT, file).split(path.sep).join('/')}`, +); +assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`); + +const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 }); + +try { + // Without this, a server that serves no public files at all would pass the 404 checks. + const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status; + assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`); + + // Nitro serves public files from a list it writes into the server bundle. A map deleted after that + // stays on the list, and Nitro answers its request with a 500 instead of a 404. + const responses = await Promise.all( + clientChunkUrls.map(async chunkUrl => ({ + mapUrl: `${chunkUrl}.map`, + status: (await fetch(`${server.baseUrl}${chunkUrl}.map`)).status, + })), + ); + assert.deepEqual( + responses.filter(({ status }) => status !== 404).map(({ mapUrl, status }) => `${mapUrl} → ${status}`), + [], + 'Expected the server to answer each client source map URL with 404', + ); +} finally { + await server.close(); +} + +console.log('All source map assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json new file mode 100644 index 000000000000..865f7ace7a17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json @@ -0,0 +1,37 @@ +{ + "name": "tanstackstart-react-sourcemaps", + "description": "E2E test app asserting what the TanStack Start SDK uploads to Sentry when Nitro builds the server.", + "private": true, + "type": "module", + "scripts": { + "build": "node start-mock-sentry-server.mjs & vite build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", + "clean": "npx rimraf node_modules pnpm-lock.yaml .output", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm ts-node --script-mode assert-build.ts" + }, + "dependencies": { + "@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz", + "@tanstack/react-router": "1.170.44", + "@tanstack/react-start": "1.169.1", + "nitro": "latest", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@playwright/test": "1.64.1", + "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@types/node": "^22.20.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react-swc": "^4.2.0", + "ts-node": "10.9.1", + "typescript": "^5.9.0", + "vite": "^8.3.0" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml new file mode 100644 index 000000000000..4969718e7f8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@swc/core': true +minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx new file mode 100644 index 000000000000..741d1aade5d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx @@ -0,0 +1,4 @@ +import { createRouter } from '@tanstack/react-router'; +import { routeTree } from './routeTree.gen'; + +export const getRouter = () => createRouter({ routeTree }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx new file mode 100644 index 000000000000..09432c62c40d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'; + +export const Route = createRootRoute({ + component: RootComponent, +}); + +function RootComponent(): ReactNode { + return ( + + + + + + + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx new file mode 100644 index 000000000000..51012573f928 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; + +// SOURCEMAP_MARKER_CLIENT — a comment, so it is stripped from the emitted bundles but survives in `sourcesContent`. +// `assert-build.ts` uses the markers to check that the original source reached the uploaded maps. +const throwServerError = createServerFn().handler(async () => { + // SOURCEMAP_MARKER_SERVER + throw new Error('Server function error from the TanStack Start source map E2E app'); +}); + +export const Route = createFileRoute('/')({ + component: Home, +}); + +function Home() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..1680a6360165 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ org: 'test-org' }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..02faa5762420 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", ".output"] +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts new file mode 100644 index 000000000000..b43b2441b453 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts @@ -0,0 +1,21 @@ +import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; +import { tanstackStart } from '@tanstack/react-start/plugin/vite'; +import viteReact from '@vitejs/plugin-react-swc'; +import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + tanstackStart(), + nitro(), + viteReact(), + sentryTanstackStart({ + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + debug: true, + }), + ], +}); From e8f4a7172ed6049f20a717ab38af20bb2ec81fc5 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:29:19 +0200 Subject: [PATCH 032/126] fix(node): Use the request method for Hapi routes with multiple methods (#25527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hapi routes registered with several methods (`method: ['GET', 'POST']`) returned a 500 with `traceLifecycle: 'static'`. Router spans now use the request method. This also fixes `*` as the method on wildcard routes. The Hapi attributes (`hapi.type`, `hapi.plugin.name`, `server.ext.type`) are not in the [Sentry conventions](https://getsentry.github.io/sentry-conventions/attributes) yet. ⚠️ This conventions PR needs to be merged and released first: https://github.com/getsentry/sentry-conventions/pull/741 Fixes #25481 --------- Co-authored-by: Rahul Kashyap Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../browser-integration-tests/package.json | 2 +- .../cloudflare-integration-tests/package.json | 2 +- .../deno-integration-tests/package.json | 2 +- .../test-applications/angular-19/package.json | 2 +- .../cloudflare-pi-durable/package.json | 2 +- .../package.json | 2 +- .../nextjs-16-tunnel/package.json | 2 +- .../test-applications/nitro-3/package.json | 2 +- .../test-applications/node-eve/package.json | 2 +- .../test-applications/node-hapi/package.json | 1 + .../node-hapi/tests/spans.test.ts | 7 +- .../node-mastra/package.json | 2 +- .../package.json | 2 +- .../node-pi-durable/package.json | 2 +- .../nuxt-4-static/package.json | 2 +- .../solidstart-static/package.json | 2 +- .../node-integration-tests/package.json | 2 +- .../suites/tracing/hapi/instrument-static.mjs | 10 ++ .../suites/tracing/hapi/scenario.mjs | 20 +++ .../suites/tracing/hapi/test.ts | 84 +++++++++- packages/angular/package.json | 2 +- packages/astro/package.json | 2 +- packages/aws-serverless/package.json | 2 +- packages/browser-utils/package.json | 2 +- packages/browser/package.json | 2 +- packages/bun/package.json | 2 +- packages/cloudflare/package.json | 2 +- packages/core/package.json | 2 +- packages/deno/package.json | 2 +- packages/effect/package.json | 2 +- packages/elysia/package.json | 2 +- packages/ember/package.json | 2 +- packages/google-cloud-serverless/package.json | 2 +- packages/hono/package.json | 2 +- packages/nestjs/package.json | 2 +- packages/nextjs/package.json | 2 +- packages/nitro/package.json | 2 +- packages/node/package.json | 2 +- packages/nuxt/package.json | 2 +- packages/opentelemetry/package.json | 2 +- packages/react-router/package.json | 2 +- packages/react/package.json | 2 +- packages/remix/package.json | 2 +- packages/replay-internal/package.json | 2 +- packages/server-utils/package.json | 2 +- .../integrations/hapi/hapi-error-handler.ts | 2 +- .../src/integrations/hapi/hapi-types.ts | 9 +- .../src/integrations/hapi/hapi-utils.ts | 69 ++++++--- .../test/integrations/hapi-utils.test.ts | 101 +++++++++--- packages/solid/package.json | 2 +- packages/solidstart/package.json | 2 +- packages/svelte/package.json | 2 +- packages/sveltekit/package.json | 2 +- packages/tanstackstart-react/package.json | 2 +- packages/vue/package.json | 2 +- pnpm-lock.yaml | 146 +++++++++--------- pnpm-workspace.yaml | 1 + 58 files changed, 368 insertions(+), 176 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/hapi/instrument-static.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eea89155e7d..437c62ce0575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @faizansaiyed123, @seongwon030, and @kyrylolvov. Thank you for your contributions! +Work in this release was contributed by @faizansaiyed123, @itisrmk, @seongwon030, and @kyrylolvov. Thank you for your contributions! ## 11.9.0 diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 2aa015cab0a8..bd00cdd2638e 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -63,7 +63,7 @@ "@sentry/browser": "11.9.0", "@sentry/replay": "11.9.0", "@sentry/opentelemetry": "11.9.0", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@supabase/supabase-js": "2.109.0", "axios": "1.20.0", "babel-loader": "^10.1.1", diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 89b6a98e97cb..110e90f3d240 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -36,7 +36,7 @@ "@cloudflare/workers-types-v5": "npm:@cloudflare/workers-types@5.20260710.1", "@opentelemetry/api": "^1.9.1", "@sentry-internal/test-utils": "11.9.0", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "eslint-plugin-regexp": "^3.1.0", "prisma": "6.15.0", "vite": "^8.3.1", diff --git a/dev-packages/deno-integration-tests/package.json b/dev-packages/deno-integration-tests/package.json index 27eafb9e1f95..693e80db1d5c 100644 --- a/dev-packages/deno-integration-tests/package.json +++ b/dev-packages/deno-integration-tests/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "@sentry-internal/node-integration-tests": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "vitest": "^4.1.11" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/angular-19/package.json b/dev-packages/e2e-tests/test-applications/angular-19/package.json index b9c60d6042cc..f17e7f9b42c4 100644 --- a/dev-packages/e2e-tests/test-applications/angular-19/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-19/package.json @@ -36,7 +36,7 @@ "@angular/cli": "^19.0.0", "@angular/compiler-cli": "^19.0.0", "@playwright/test": "1.64.1", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@types/jasmine": "~5.1.0", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json index b182a4f209d8..11e6f89ec741 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json @@ -22,7 +22,7 @@ "@cloudflare/workers-types": "^4.20260426.0", "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "typescript": "^5.5.2", "vite": "7.3.5", "wrangler": "^4.136.2" diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json index 8650666b7870..13f859972700 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json @@ -19,7 +19,7 @@ "@cloudflare/workers-types": "^5.20260727.1", "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@types/node": "^26.1.2", "sentry": "~0.44.1", "typescript": "~6.0.3", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index 53c5adcc5761..148f8af3c086 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -41,7 +41,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index 82836a613593..d27379abecd6 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry/core": "latest || *", "nitro": "^3.0.260522-beta", "rolldown": "latest", diff --git a/dev-packages/e2e-tests/test-applications/node-eve/package.json b/dev-packages/e2e-tests/test-applications/node-eve/package.json index e41b30dc7fb4..ddb28a2f89a9 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/package.json +++ b/dev-packages/e2e-tests/test-applications/node-eve/package.json @@ -32,7 +32,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@types/node": "24.x", "typescript": "~5.9.0" diff --git a/dev-packages/e2e-tests/test-applications/node-hapi/package.json b/dev-packages/e2e-tests/test-applications/node-hapi/package.json index 57fa79727712..65ed01564dbd 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi/package.json +++ b/dev-packages/e2e-tests/test-applications/node-hapi/package.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@playwright/test": "1.64.1", + "@sentry/conventions": "0.29.0", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/node-hapi/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/node-hapi/tests/spans.test.ts index 6f69c36ae622..55c563a68ad9 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-hapi/tests/spans.test.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { HAPI_EXT_TYPE, HAPI_TYPE, SERVER_EXT_TYPE } from '@sentry/conventions/attributes'; import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; test('Sends successful span', async ({ baseURL }) => { @@ -97,10 +98,12 @@ test('Sends successful span', async ({ baseURL }) => { end_timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), attributes: expect.objectContaining({ - 'hapi.type': { value: 'server.ext', type: 'string' }, + [HAPI_TYPE]: { value: 'server.ext', type: 'string' }, + [HAPI_EXT_TYPE]: { value: 'onPreResponse', type: 'string' }, 'sentry.op': { value: 'middleware', type: 'string' }, 'sentry.origin': { value: 'auto.http.hapi', type: 'string' }, - 'server.ext.type': { value: 'onPreResponse', type: 'string' }, + // oxlint-disable-next-line typescript/no-deprecated + [SERVER_EXT_TYPE]: { value: 'onPreResponse', type: 'string' }, }), }), ]); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/package.json b/dev-packages/e2e-tests/test-applications/node-mastra/package.json index 67ffbde6c0d5..2e40f1fff9ca 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/package.json +++ b/dev-packages/e2e-tests/test-applications/node-mastra/package.json @@ -28,7 +28,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@types/node": "24.x", "mastra": "~1.28.0", "typescript": "~5.9.0" diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json index 8f6ee984c8a1..00870e099c73 100644 --- a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json @@ -24,7 +24,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@types/express": "^4.17.21", "@types/node": "^24.0.0", "sentry": "~0.44.1", diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json index 7d6f0d26e879..1b68765330c8 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json @@ -19,7 +19,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@types/node": "24.x" }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json index 798eef35e989..6edeb2673788 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json @@ -26,7 +26,7 @@ }, "devDependencies": { "@playwright/test": "1.64.1", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json index b3ff8de5660a..4bcb9b4a8214 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@playwright/test": "1.64.1", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^1.0.0", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 9726621238a7..a3dfca6c43a2 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -118,7 +118,7 @@ "@neondatabase/serverless": "1.2.0", "@opentelemetry/instrumentation-http": "0.220.0", "@sentry-internal/rollup-utils": "11.9.0", - "@sentry/conventions": "0.27.0", + "@sentry/conventions": "0.29.0", "@sentry-internal/test-utils": "11.9.0", "@sentry/node-native": "11.9.0", "@types/amqplib": "^0.10.5", diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/instrument-static.mjs b/dev-packages/node-integration-tests/suites/tracing/hapi/instrument-static.mjs new file mode 100644 index 000000000000..d799a8900e59 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/instrument-static.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'static', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs index d081af0a4295..33a3df241a66 100644 --- a/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs @@ -18,6 +18,18 @@ const run = async () => { }, }); + server.route({ + method: ['GET', 'POST'], + path: '/multi', + handler: () => 'Hello World!', + }); + + server.route({ + method: '*', + path: '/wildcard', + handler: () => 'Hello World!', + }); + server.route({ method: 'GET', path: '/error', @@ -34,6 +46,14 @@ const run = async () => { }, }); + server.route({ + method: '*', + path: '/wildcard-error', + handler: () => { + return new Error('Sentry Test Error'); + }, + }); + server.route({ method: 'GET', path: '/boom-error', diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts index 24a9d1b2adba..c608ea2cbfac 100644 --- a/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts @@ -1,3 +1,11 @@ +import { + HAPI_EXT_TYPE, + HAPI_TYPE, + HTTP_REQUEST_METHOD, + SENTRY_OP, + SERVER_EXT_TYPE, +} from '@sentry/conventions/attributes'; +import { ROUTER } from '@sentry/conventions/op'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -19,7 +27,36 @@ describe('hapi auto-instrumentation', () => { }, }; + const MULTI_METHOD_REQUESTS = [ + { method: 'get', path: '/multi' }, + { method: 'post', path: '/multi' }, + { method: 'put', path: '/wildcard' }, + ] as const; + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('should record the request method for multi-method and wildcard routes.', async () => { + const runner = createRunner().unordered(); + for (const { method, path } of MULTI_METHOD_REQUESTS) { + const httpMethod = method.toUpperCase(); + runner.expect({ + span: container => { + expect(container.items.find(item => item.is_segment)?.name).toBe(`${httpMethod} ${path}`); + + const routerSpans = container.items.filter(item => item.attributes[SENTRY_OP]?.value === ROUTER); + expect(routerSpans).toHaveLength(1); + expect(routerSpans[0]?.name).toBe(path); + expect(routerSpans[0]?.attributes[HTTP_REQUEST_METHOD]).toEqual({ type: 'string', value: httpMethod }); + }, + }); + } + + const started = runner.start(); + for (const { method, path } of MULTI_METHOD_REQUESTS) { + await started.makeRequest(method, path); + } + await started.completed(); + }); + test('should auto-instrument `@hapi/hapi` package.', async () => { const runner = createRunner() .expect({ @@ -72,8 +109,10 @@ describe('hapi auto-instrumentation', () => { expect.objectContaining({ name: 'ext - onPreResponse', attributes: expect.objectContaining({ - 'hapi.type': { type: 'string', value: 'server.ext' }, - 'server.ext.type': { type: 'string', value: 'onPreResponse' }, + [HAPI_TYPE]: { type: 'string', value: 'server.ext' }, + [HAPI_EXT_TYPE]: { type: 'string', value: 'onPreResponse' }, + // oxlint-disable-next-line typescript/no-deprecated + [SERVER_EXT_TYPE]: { type: 'string', value: 'onPreResponse' }, 'sentry.op': { type: 'string', value: 'middleware' }, 'sentry.origin': { type: 'string', value: origin }, }), @@ -114,6 +153,20 @@ describe('hapi auto-instrumentation', () => { await runner.completed(); }); + test('should assign the request method to the transactionName of errors in wildcard routes.', async () => { + const runner = createRunner() + .expect({ + event: { + ...EXPECTED_ERROR_EVENT, + transaction: 'PUT /wildcard-error', + }, + }) + .ignore('span') + .start(); + runner.makeRequest('put', '/wildcard-error', { expectError: true }); + await runner.completed(); + }); + test('should handle returned Boom errors in routes.', async () => { const runner = createRunner() .unordered() @@ -143,6 +196,33 @@ describe('hapi auto-instrumentation', () => { }); }); + // Only static spans put the method into the router span name, which crashed for multi-method routes. + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-static.mjs', (createRunner, test) => { + test('should name router spans after the request method for multi-method and wildcard routes.', async () => { + const runner = createRunner().unordered(); + for (const { method, path } of MULTI_METHOD_REQUESTS) { + const httpMethod = method.toUpperCase(); + const spanName = `${httpMethod} ${path}`; + runner.expect({ + transaction: transaction => { + expect(transaction.transaction).toBe(spanName); + + const routerSpans = transaction.spans?.filter(span => span.op === ROUTER) ?? []; + expect(routerSpans).toHaveLength(1); + expect(routerSpans[0]?.description).toBe(spanName); + expect(routerSpans[0]?.data[HTTP_REQUEST_METHOD]).toBe(httpMethod); + }, + }); + } + + const started = runner.start(); + for (const { method, path } of MULTI_METHOD_REQUESTS) { + await started.makeRequest(method, path); + } + await started.completed(); + }); + }); + // Regression test: a `setupHapiErrorHandler` call before `server.start()` installs the default // predicate. The integration's own auto-registration (with a custom `shouldHandleError`) fires later, // at `server.start()`, and must still win. The custom predicate drops "Dropped error" (which the diff --git a/packages/angular/package.json b/packages/angular/package.json index 562a1caf08e7..5409c07d9c72 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -23,7 +23,7 @@ "dependencies": { "@sentry/browser": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/astro/package.json b/packages/astro/package.json index d09e52560f5c..780ef8ff260f 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -53,7 +53,7 @@ "dependencies": { "@sentry/browser": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/node": "11.9.0", "@sentry/server-runtime-injection": "11.9.0", "@sentry/server-utils": "11.9.0", diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 23830a5b878f..033fb1258c31 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -56,7 +56,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/server-runtime-injection": "11.9.0", diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index 0a07b81ac0ac..1273fd0e5305 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "web-vitals": "^6.2.3" }, "devDependencies": { diff --git a/packages/browser/package.json b/packages/browser/package.json index 1bc8bcdb2c13..fc788e404af0 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@sentry/browser-utils": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/feedback": "11.9.0", "@sentry/replay": "11.9.0", "@sentry/replay-canvas": "11.9.0", diff --git a/packages/bun/package.json b/packages/bun/package.json index 75aba73712a7..28f3d98b62b5 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -44,7 +44,7 @@ }, "dependencies": { "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/node": "11.9.0", "@sentry/server-utils": "11.9.0" }, diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 49eb185bd6da..8abefbbfe8c6 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -53,7 +53,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/opentelemetry": "11.9.0", "@sentry/server-utils": "11.9.0", diff --git a/packages/core/package.json b/packages/core/package.json index 65061a77bae5..6c1b92ca7c37 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,6 +95,6 @@ "zod": "^3.24.1" }, "dependencies": { - "@sentry/conventions": "^0.27.0" + "@sentry/conventions": "^0.29.0" } } diff --git a/packages/deno/package.json b/packages/deno/package.json index 270ca28cd70d..51dc5fa7f706 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -28,7 +28,7 @@ ], "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/server-runtime-injection": "11.9.0", "@sentry/server-utils": "11.9.0" diff --git a/packages/effect/package.json b/packages/effect/package.json index e39b720820ca..732ea58a2197 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -45,7 +45,7 @@ }, "dependencies": { "@sentry/browser": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0" }, diff --git a/packages/elysia/package.json b/packages/elysia/package.json index ac6dda7debbf..b4bef8c55b5f 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -34,7 +34,7 @@ "dependencies": { "@sentry/bun": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0" + "@sentry/conventions": "^0.29.0" }, "peerDependencies": { "elysia": "^1.4.0" diff --git a/packages/ember/package.json b/packages/ember/package.json index 5ddf731ab1f0..3f1ee98bd614 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -45,7 +45,7 @@ "@embroider/addon-shim": "^1.10.2", "@embroider/macros": "^1.20.2", "@sentry/browser": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "decorator-transforms": "^2.3.2" }, diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index ac82b82cba84..a6ee05e9a43a 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -36,7 +36,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/server-runtime-injection": "11.9.0", diff --git a/packages/hono/package.json b/packages/hono/package.json index 80db3ccd0369..7ff0d20e315c 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -74,7 +74,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/server-utils": "11.9.0" }, "peerDependencies": { diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index c9d875b26b9b..f8d4dbe9888f 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -44,7 +44,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/server-utils": "11.9.0" diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 36fdd0df09b2..1eb5fc66c040 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -87,7 +87,7 @@ "@sentry/browser-utils": "11.9.0", "@sentry/bundler-plugins": "11.9.0", "@sentry/cloudflare": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/opentelemetry": "11.9.0", diff --git a/packages/nitro/package.json b/packages/nitro/package.json index bb79d0837284..335c3f864067 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -36,7 +36,7 @@ }, "dependencies": { "@sentry/bundler-plugins": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/server-runtime-injection": "11.9.0", diff --git a/packages/node/package.json b/packages/node/package.json index 46677e88e13e..d0e8ca29a58c 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -78,7 +78,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/opentelemetry": "11.9.0", "@sentry/server-runtime-injection": "11.9.0", diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 05555cd282f2..770d513ba8c5 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -56,7 +56,7 @@ "@nuxt/kit": "^4.6.0", "@sentry/browser": "11.9.0", "@sentry/cloudflare": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/bundler-plugins": "11.9.0", diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index a67caf18421d..e86737f32a10 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -27,7 +27,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0" }, "peerDependencies": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 93ae07d2dd99..640dff1199e6 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -57,7 +57,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/browser": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/react": "11.9.0", diff --git a/packages/react/package.json b/packages/react/package.json index ce565c42d22c..37455d4c5218 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -59,7 +59,7 @@ "dependencies": { "@sentry/browser": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0" + "@sentry/conventions": "^0.29.0" }, "peerDependencies": { "react": "17.x || 18.x || 19.x", diff --git a/packages/remix/package.json b/packages/remix/package.json index 7e51bbee4d0c..e2a4d8e8270a 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -78,7 +78,7 @@ "@remix-run/router": "^1.23.4", "@sentry/browser": "11.9.0", "@sentry/bundler-plugins": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/react": "11.9.0", diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index 3a23f5f892d7..c5e224036ea3 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -74,7 +74,7 @@ "@babel/core": "^7.29.6", "@sentry-internal/replay-worker": "11.9.0", "@sentry-internal/rollup-utils": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/rrweb": "2.44.1", "@sentry/rrweb-snapshot": "2.44.1", "fflate": "0.8.3", diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 8d4d024e4acb..c4fcc740ec4f 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -97,7 +97,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0" }, "devDependencies": { diff --git a/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts b/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts index d79b35b4502a..b6dbf152fc46 100644 --- a/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts @@ -73,7 +73,7 @@ export function attachHapiErrorHandler(server: HapiServer, shouldHandleError?: H if (getIsolationScope() !== getDefaultIsolationScope()) { const route = request.route; if (route?.path) { - getIsolationScope().setTransactionName(`${route.method.toUpperCase()} ${route.path}`); + getIsolationScope().setTransactionName(`${request.method.toUpperCase()} ${route.path}`); } } else { DEBUG_BUILD && diff --git a/packages/server-utils/src/integrations/hapi/hapi-types.ts b/packages/server-utils/src/integrations/hapi/hapi-types.ts index 0afb23cc1448..d1ee5f3739b8 100644 --- a/packages/server-utils/src/integrations/hapi/hapi-types.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-types.ts @@ -29,7 +29,7 @@ export interface ServerRouteOptions { export interface ServerRoute { path: string; - method: string; + method: string | string[]; handler?: LifecycleMethod | unknown; options?: ((server: unknown) => ServerRouteOptions) | ServerRouteOptions; [key: string]: unknown; @@ -96,6 +96,7 @@ export interface HapiResponse { /** The subset of a hapi request the error listener reads. */ export interface HapiRequest { + method: string; route: { path?: string; method: string }; response?: HapiResponse; [key: string]: unknown; @@ -122,9 +123,3 @@ export interface HapiServer { events: HapiServerEvents; [key: string]: unknown; } - -export enum AttributeNames { - HAPI_TYPE = 'hapi.type', - PLUGIN_NAME = 'hapi.plugin.name', - EXT_TYPE = 'server.ext.type', -} diff --git a/packages/server-utils/src/integrations/hapi/hapi-utils.ts b/packages/server-utils/src/integrations/hapi/hapi-utils.ts index 5c7afa11a268..e5d8f7cd8279 100644 --- a/packages/server-utils/src/integrations/hapi/hapi-utils.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-utils.ts @@ -18,7 +18,16 @@ import { ROUTER_SPAN_NAME_FALLBACK, startSpan, } from '@sentry/core'; -import { SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { + HAPI_EXT_TYPE, + HAPI_PLUGIN_NAME, + HAPI_TYPE, + HTTP_REQUEST_METHOD, + HTTP_ROUTE, + SENTRY_OP, + SENTRY_ORIGIN, + SERVER_EXT_TYPE, +} from '@sentry/conventions/attributes'; import { HANDLER, MIDDLEWARE, ROUTER } from '@sentry/conventions/op'; import type { HapiRequest, @@ -29,11 +38,9 @@ import type { ServerExtEventsObject, ServerExtEventsRequestObject, ServerRequestExtType, - ServerRoute, ServerRouteOptions, } from './hapi-types'; -import { HTTP_REQUEST_METHOD, HTTP_ROUTE } from '@sentry/conventions/attributes'; -import { AttributeNames, handlerPatched, HapiLayerType, HapiLifecycleMethodNames } from './hapi-types'; +import { handlerPatched, HapiLayerType, HapiLifecycleMethodNames } from './hapi-types'; import { setHttpServerSpanRouteAttribute } from '../../utils/setHttpServerSpanRouteAttribute'; /** @@ -108,17 +115,25 @@ const isPatchableExtMethod = ( }; /** Build the span name and attributes for a Hapi route. */ -export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanMetadata => { +export const getRouteMetadata = ({ + path, + method, + pluginName, +}: { + path: string; + method?: string; + pluginName?: string; +}): SpanMetadata => { const attributes: SpanAttributes = { - [HTTP_ROUTE]: route.path, - [HTTP_REQUEST_METHOD]: route.method, + [HTTP_ROUTE]: path, + [HTTP_REQUEST_METHOD]: method, }; if (pluginName) { - attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.PLUGIN; - attributes[AttributeNames.PLUGIN_NAME] = pluginName; + attributes[HAPI_TYPE] = HapiLayerType.PLUGIN; + attributes[HAPI_PLUGIN_NAME] = pluginName; } else { - attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.ROUTER; + attributes[HAPI_TYPE] = HapiLayerType.ROUTER; } const client = getClient(); @@ -126,10 +141,11 @@ export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanM // handler spans are named after their route alone, without the method prefix. const isStreamedSpan = !!client && hasSpanStreamingEnabled(client); const fallbackName = pluginName ? REQUEST_HANDLER_SPAN_NAME_FALLBACK : ROUTER_SPAN_NAME_FALLBACK; + const staticName = method ? `${method} ${path}` : path; return { attributes, - name: isStreamedSpan ? route.path || fallbackName : `${route.method.toUpperCase()} ${route.path}`, + name: isStreamedSpan ? path || fallbackName : staticName, }; }; @@ -144,23 +160,20 @@ export const getExtMetadata = ( // `method` is the default name for the extension in the ServerExtEventsObject format. baseName = `ext - ${extPoint} - ${methodName}`; } + const attributes: SpanAttributes = { + [HAPI_EXT_TYPE]: extPoint, + // TODO(v12): Remove `server.ext.type`, which `hapi.ext.type` replaces. + // oxlint-disable-next-line typescript/no-deprecated + [SERVER_EXT_TYPE]: extPoint, + [HAPI_TYPE]: HapiLayerType.EXT, + }; if (pluginName) { return { - attributes: { - [AttributeNames.EXT_TYPE]: extPoint, - [AttributeNames.HAPI_TYPE]: HapiLayerType.EXT, - [AttributeNames.PLUGIN_NAME]: pluginName, - }, + attributes: { ...attributes, [HAPI_PLUGIN_NAME]: pluginName }, name: `${pluginName}: ${baseName}`, }; } - return { - attributes: { - [AttributeNames.EXT_TYPE]: extPoint, - [AttributeNames.HAPI_TYPE]: HapiLayerType.EXT, - }, - name: baseName, - }; + return { attributes, name: baseName }; }; const HAPI_TYPE_TO_SPAN_OP: Record = { @@ -170,7 +183,7 @@ const HAPI_TYPE_TO_SPAN_OP: Record = { }; function startMetadataSpan(metadata: SpanMetadata, original: () => unknown): unknown { - const hapiType = metadata.attributes[AttributeNames.HAPI_TYPE]; + const hapiType = metadata.attributes[HAPI_TYPE]; const op = hapiType ? HAPI_TYPE_TO_SPAN_OP[hapiType] : undefined; return startSpan( { @@ -185,6 +198,12 @@ function startMetadataSpan(metadata: SpanMetadata, original: () => unknown): unk ); } +// The route config can register several methods (or `'*'`), so read the method +// Hapi resolved for this request instead. +function getRequestMethod(request: unknown): string | undefined { + return isObjectLike(request) && typeof request.method === 'string' ? request.method.toUpperCase() : undefined; +} + /** * Patches each individual route handler method in order to create the span. It * does not create spans when there is no parent span. @@ -199,7 +218,7 @@ function wrapRouteHandler(route: PatchableServerRoute, pluginName?: string): Pat return oldHandler.call(this, ...params); } setHttpServerSpanRouteAttribute(route.path); - const metadata = getRouteMetadata(route, pluginName); + const metadata = getRouteMetadata({ path: route.path, method: getRequestMethod(params[0]), pluginName }); return startMetadataSpan(metadata, () => oldHandler.call(this, ...params)); }; }; diff --git a/packages/server-utils/test/integrations/hapi-utils.test.ts b/packages/server-utils/test/integrations/hapi-utils.test.ts index a0a7a43971fa..f8a606864a9f 100644 --- a/packages/server-utils/test/integrations/hapi-utils.test.ts +++ b/packages/server-utils/test/integrations/hapi-utils.test.ts @@ -1,10 +1,22 @@ -import { setCurrentClient } from '@sentry/core'; +import { + HAPI_EXT_TYPE, + HAPI_PLUGIN_NAME, + HAPI_TYPE, + HTTP_REQUEST_METHOD, + HTTP_ROUTE, + SENTRY_OP, + SENTRY_ORIGIN, + SERVER_EXT_TYPE, +} from '@sentry/conventions/attributes'; +import { ROUTER } from '@sentry/conventions/op'; +import type { Span } from '@sentry/core'; +import { setCurrentClient, spanToJSON, startSpan } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; -import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi/hapi-utils'; +import { getExtMetadata, getRouteMetadata, wrapRouteArguments } from '../../src/integrations/hapi/hapi-utils'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; describe('getRouteMetadata', () => { - const route = { path: '/users/{id}', method: 'get' } as any; + const route = { path: '/users/{id}', method: 'GET' }; afterEach(() => { setCurrentClient(undefined as unknown as TestClient); @@ -14,21 +26,31 @@ describe('getRouteMetadata', () => { expect(getRouteMetadata(route)).toEqual({ name: 'GET /users/{id}', attributes: { - 'http.route': '/users/{id}', - 'http.request.method': 'get', - 'hapi.type': 'router', + [HTTP_ROUTE]: '/users/{id}', + [HTTP_REQUEST_METHOD]: 'GET', + [HAPI_TYPE]: 'router', }, }); }); it('describes a plugin-registered route as a plugin layer', () => { - expect(getRouteMetadata(route, 'my-plugin')).toEqual({ + expect(getRouteMetadata({ ...route, pluginName: 'my-plugin' })).toEqual({ name: 'GET /users/{id}', attributes: { - 'http.route': '/users/{id}', - 'http.request.method': 'get', - 'hapi.type': 'plugin', - 'hapi.plugin.name': 'my-plugin', + [HTTP_ROUTE]: '/users/{id}', + [HTTP_REQUEST_METHOD]: 'GET', + [HAPI_TYPE]: 'plugin', + [HAPI_PLUGIN_NAME]: 'my-plugin', + }, + }); + }); + + it('names the span after the path alone when the request method is unknown', () => { + expect(getRouteMetadata({ path: '/users/{id}' })).toEqual({ + name: '/users/{id}', + attributes: { + [HTTP_ROUTE]: '/users/{id}', + [HAPI_TYPE]: 'router', }, }); }); @@ -44,17 +66,51 @@ describe('getRouteMetadata', () => { const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' })); setCurrentClient(client); - expect(getRouteMetadata(route, 'my-plugin').name).toBe('/users/{id}'); + expect(getRouteMetadata({ ...route, pluginName: 'my-plugin' }).name).toBe('/users/{id}'); }); it('falls back to a static span name when the route has no path', () => { const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' })); setCurrentClient(client); - const pathlessRoute = { path: '', method: 'get' } as any; + expect(getRouteMetadata({ path: '', method: 'GET' }).name).toBe('Router'); + expect(getRouteMetadata({ path: '', method: 'GET', pluginName: 'my-plugin' }).name).toBe('Request handler'); + }); +}); - expect(getRouteMetadata(pathlessRoute).name).toBe('Router'); - expect(getRouteMetadata(pathlessRoute, 'my-plugin').name).toBe('Request handler'); +describe('wrapRouteArguments', () => { + afterEach(() => { + setCurrentClient(undefined as unknown as TestClient); + }); + + it.each([ + { request: { method: 'post' }, name: 'POST /multi', method: 'POST' }, + { request: {}, name: '/multi', method: undefined }, + ])('names the router span $name for a multi-method route', ({ request, name, method }) => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + client.init(); + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + const route = { + method: ['GET', 'POST'], + path: '/multi', + handler: (_request: unknown, _h: unknown): unknown => 'ok', + }; + wrapRouteArguments([route]); + + const response = startSpan({ name: 'incoming' }, () => route.handler(request, {})); + + expect(response).toBe('ok'); + const routerSpan = endedSpans.map(span => spanToJSON(span)).find(span => span.attributes[SENTRY_OP] === ROUTER); + expect(routerSpan?.name).toBe(name); + expect(routerSpan?.attributes).toEqual({ + [HTTP_ROUTE]: '/multi', + [HTTP_REQUEST_METHOD]: method, + [HAPI_TYPE]: 'router', + [SENTRY_OP]: ROUTER, + [SENTRY_ORIGIN]: 'auto.http.hapi', + }); }); }); @@ -62,7 +118,12 @@ describe('getExtMetadata', () => { it('names an extension by its point', () => { expect(getExtMetadata('onPreHandler')).toEqual({ name: 'ext - onPreHandler', - attributes: { 'server.ext.type': 'onPreHandler', 'hapi.type': 'server.ext' }, + attributes: { + [HAPI_EXT_TYPE]: 'onPreHandler', + // oxlint-disable-next-line typescript/no-deprecated + [SERVER_EXT_TYPE]: 'onPreHandler', + [HAPI_TYPE]: 'server.ext', + }, }); }); @@ -75,9 +136,11 @@ describe('getExtMetadata', () => { expect(getExtMetadata('onPreHandler', 'my-plugin')).toEqual({ name: 'my-plugin: ext - onPreHandler', attributes: { - 'server.ext.type': 'onPreHandler', - 'hapi.type': 'server.ext', - 'hapi.plugin.name': 'my-plugin', + [HAPI_EXT_TYPE]: 'onPreHandler', + // oxlint-disable-next-line typescript/no-deprecated + [SERVER_EXT_TYPE]: 'onPreHandler', + [HAPI_TYPE]: 'server.ext', + [HAPI_PLUGIN_NAME]: 'my-plugin', }, }); }); diff --git a/packages/solid/package.json b/packages/solid/package.json index 10a3f301ab29..825ce076aa3c 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -56,7 +56,7 @@ "dependencies": { "@sentry/browser": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0" + "@sentry/conventions": "^0.29.0" }, "peerDependencies": { "@solidjs/router": ">=0.13.4 <2.0.0-0", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index a472053487d6..595be92402a3 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -87,7 +87,7 @@ "@sentry/server-utils": "11.9.0", "@sentry/solid": "11.9.0", "@sentry/bundler-plugins": "11.9.0", - "@sentry/conventions": "0.27.0" + "@sentry/conventions": "0.29.0" }, "devDependencies": { "@sentry-internal/rollup-utils": "11.9.0", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 5582a5c254df..f2c7c4b0451f 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@sentry/browser": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "magic-string": "~0.30.0" }, diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 110f75715a1d..6396e9bcf2a7 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -73,7 +73,7 @@ "@opentelemetry/api": "^1.9.1", "@sentry/cloudflare": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/node": "11.9.0", "@sentry/server-runtime-injection": "11.9.0", "@sentry/server-utils": "11.9.0", diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 6edd650c665c..13d211f6fff8 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -53,7 +53,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/browser-utils": "11.9.0", - "@sentry/conventions": "^0.27.0", + "@sentry/conventions": "^0.29.0", "@sentry/core": "11.9.0", "@sentry/node": "11.9.0", "@sentry/react": "11.9.0", diff --git a/packages/vue/package.json b/packages/vue/package.json index f4536ada9786..84ba6bedd7d8 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -46,7 +46,7 @@ "dependencies": { "@sentry/browser": "11.9.0", "@sentry/core": "11.9.0", - "@sentry/conventions": "^0.27.0" + "@sentry/conventions": "^0.29.0" }, "peerDependencies": { "@tanstack/vue-router": "^1.64.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 842e0d81fae7..16f41a164478 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -298,8 +298,8 @@ importers: specifier: 11.9.0 version: link:../../packages/browser '@sentry/conventions': - specifier: 0.27.0 - version: 0.27.0 + specifier: 0.29.0 + version: 0.29.0 '@sentry/opentelemetry': specifier: 11.9.0 version: link:../../packages/opentelemetry @@ -510,8 +510,8 @@ importers: specifier: 11.9.0 version: link:../test-utils '@sentry/conventions': - specifier: 0.27.0 - version: 0.27.0 + specifier: 0.29.0 + version: 0.29.0 eslint-plugin-regexp: specifier: ^3.1.0 version: 3.1.0 @@ -547,8 +547,8 @@ importers: specifier: 11.9.0 version: link:../node-integration-tests '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 vitest: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@21.1.2(supports-color@10.0.0))(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) @@ -852,8 +852,8 @@ importers: specifier: 11.9.0 version: link:../test-utils '@sentry/conventions': - specifier: 0.27.0 - version: 0.27.0 + specifier: 0.29.0 + version: 0.29.0 '@sentry/node-native': specifier: 11.9.0 version: link:../../packages/node-native @@ -974,8 +974,8 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1035,8 +1035,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1063,8 +1063,8 @@ importers: packages/aws-serverless: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1097,8 +1097,8 @@ importers: specifier: 11.9.0 version: link:../browser-utils '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1125,8 +1125,8 @@ importers: packages/browser-utils: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1141,8 +1141,8 @@ importers: packages/bun: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1224,8 +1224,8 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1261,8 +1261,8 @@ importers: packages/core: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 devDependencies: '@sentry-internal/rollup-utils': specifier: 11.9.0 @@ -1277,8 +1277,8 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1299,8 +1299,8 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1324,8 +1324,8 @@ importers: specifier: 11.9.0 version: link:../bun '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1352,8 +1352,8 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1501,8 +1501,8 @@ importers: packages/google-cloud-serverless: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1541,8 +1541,8 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1591,8 +1591,8 @@ importers: packages/nestjs: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1637,8 +1637,8 @@ importers: specifier: 11.9.0 version: link:../cloudflare '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1689,8 +1689,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1729,8 +1729,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1785,8 +1785,8 @@ importers: specifier: 11.9.0 version: link:../cloudflare '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1846,8 +1846,8 @@ importers: packages/opentelemetry: dependencies: '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1884,8 +1884,8 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -1960,8 +1960,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2024,8 +2024,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2120,8 +2120,8 @@ importers: specifier: 11.9.0 version: link:../../dev-packages/rollup-utils '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/rrweb': specifier: 2.44.1 version: 2.44.1 @@ -2191,8 +2191,8 @@ importers: specifier: ^1.9.1 version: 1.10.0 '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2225,8 +2225,8 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2268,8 +2268,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: 0.27.0 - version: 0.27.0 + specifier: 0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2335,8 +2335,8 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2372,8 +2372,8 @@ importers: specifier: 11.9.0 version: link:../cloudflare '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2436,8 +2436,8 @@ importers: specifier: 11.9.0 version: link:../bundler-plugins '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -2504,8 +2504,8 @@ importers: specifier: 11.9.0 version: link:../browser '@sentry/conventions': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.29.0 + version: 0.29.0 '@sentry/core': specifier: 11.9.0 version: link:../core @@ -8166,8 +8166,8 @@ packages: resolution: {integrity: sha512-MLxTpTU3E8QACQ/5c0sENMR2gRiMXpGaKeD5IHY+3wyU2fUSJVB0QPU/l1WhoyZbX8N9ospBgf5UEG7taVF9rg==} engines: {node: ^14.15.0 || >=16.10.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - '@sentry/conventions@0.27.0': - resolution: {integrity: sha512-Mrqd/o7n/z7qWSYfQQA7x4fFTA0IqOG6JNsbBpnC97khPwZk7n6DPtOO7YgaYplSl8R8QLRbBz9Xv2lVhToSAg==} + '@sentry/conventions@0.29.0': + resolution: {integrity: sha512-kYq9/HJ/l6MYP/srIc20dy1W4mWyJfCF0OWFF+RhBGZwyr/wCmtabRFKyw7kqM+DTwZXQOjSTSZUcJhDowcInQ==} engines: {node: '>=14'} '@sentry/node-cpu-profiler@2.4.4': @@ -26151,7 +26151,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@sentry/conventions@0.27.0': {} + '@sentry/conventions@0.29.0': {} '@sentry/node-cpu-profiler@2.4.4': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 19563e5cb4a2..3359607240fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -116,3 +116,4 @@ allowBuilds: workerd: true minimumReleaseAgeExclude: - '@opentelemetry/api@1.10.0' + - '@sentry/conventions@0.29.0' From 250547c1feb37c9a5ccec60b4a21c9e28ccc2614 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:55:13 +0200 Subject: [PATCH 033/126] fix(hono): Make the Hono middleware types compile on Hono 5 (#25452) On Hono 5, `app.use(Sentry.honoMiddleware(app))` fails with "TS2769: No overload matches this call". Hono 5 removes `c.req.matchedRoutes` and `c.req.routePath` ([honojs/hono#5500](https://github.com/honojs/hono/pull/5500)), but our vendored `HonoRequest` type in `honoTypes.ts` required both. - `matchedRoutes` is now optional, and `routePath` is gone (SDK never reads it) - New type tests run against Hono 4 and the Hono 5 RC (`test/types.test-d.ts`) - The `hono-4` and `hono-4-legacy` E2E apps type-check their SDK calls in `test:build` - A lint rule blocks `hono` imports in `@sentry/server-utils`. Ref: https://github.com/getsentry/sentry-javascript/issues/25223 --------- Co-authored-by: Claude Opus 5.5 --- .../hono-4-legacy/package.json | 3 +- .../hono-4-legacy/src/sentry.typecheck.ts | 19 +++++++ .../hono-4-legacy/tsconfig.typecheck.json | 5 ++ .../test-applications/hono-4/package.json | 3 +- .../hono-4/src/honoMiddleware.typecheck.ts | 12 ++++ .../hono-4/tsconfig.typecheck.json | 5 ++ packages/hono/package.json | 3 +- packages/hono/test/types.test-d.ts | 57 +++++++++++++++++++ packages/hono/vite.config.ts | 8 +++ packages/server-utils/.oxlintrc.json | 10 ++++ .../src/integrations/hono/honoTypes.ts | 6 +- .../src/orchestrion/config/hono.ts | 2 + pnpm-lock.yaml | 31 ++++++---- 13 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/hono-4-legacy/src/sentry.typecheck.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.typecheck.json create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/honoMiddleware.typecheck.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tsconfig.typecheck.json create mode 100644 packages/hono/test/types.test-d.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json index 1864b16c27f4..99f96170110f 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json @@ -9,7 +9,8 @@ "dev:bun": "bun src/entry.bun.ts", "dev:deno": "deno run --allow-net --allow-env --allow-read src/entry.deno.ts", "build": "wrangler deploy --dry-run", - "test:build": "pnpm install && pnpm build", + "test:build": "pnpm install && pnpm typecheck && pnpm build", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "test:assert": "TEST_ENV=production playwright test" }, "dependencies": { diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/sentry.typecheck.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/sentry.typecheck.ts new file mode 100644 index 000000000000..9df86b4b4b85 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/sentry.typecheck.ts @@ -0,0 +1,19 @@ +// Only `tsc` in `test:build` reads this file. Nothing runs it. +import { sentry as sentryBun } from '@sentry/hono/bun'; +import { sentry as sentryCloudflare } from '@sentry/hono/cloudflare'; +import { sentry as sentryDeno } from '@sentry/hono/deno'; +import { sentry as sentryNode } from '@sentry/hono/node'; +import { Hono } from 'hono'; + +type AppEnv = { Bindings: { E2E_TEST_DSN: string }; Variables: { user: string } }; + +const dsn = 'https://public@dsn.ingest.sentry.io/1337'; + +const app = new Hono(); +app.use(sentryNode(app)); +app.use(sentryBun(app, { dsn })); +app.use(sentryDeno(app, { dsn })); +app.use(sentryCloudflare(app, env => ({ dsn: env.E2E_TEST_DSN }))); + +const api = app.basePath('/api'); +api.use(sentryNode(api)); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.typecheck.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.typecheck.json new file mode 100644 index 000000000000..9131a4be8ad3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.typecheck.json @@ -0,0 +1,5 @@ +{ + // The rest of `src` needs Node, Bun and Deno types, which this app does not install. + "extends": "./tsconfig.json", + "include": ["src/*.typecheck.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index 17467a9fe90e..82f47a8b7368 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -8,7 +8,8 @@ "dev:bun": "bun run dist/entry.bun.js", "dev:deno": "deno run --allow-net --allow-env --allow-read --preload=npm:@sentry/deno/import src/entry.deno.ts", "dev:cloudflare": "wrangler dev --config ./dist/hono_4/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", - "test:build": "pnpm install", + "test:build": "pnpm install && pnpm typecheck", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "test": "TEST_ENV=production playwright test", "test:assert": "pnpm test:assert:node", "test:assert:node": "RUNTIME=node pnpm test", diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/honoMiddleware.typecheck.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/honoMiddleware.typecheck.ts new file mode 100644 index 000000000000..396d90942d11 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/honoMiddleware.typecheck.ts @@ -0,0 +1,12 @@ +// Only `tsc` in `test:build` reads this file. Nothing runs it. +import * as Sentry from '@sentry/node'; +import { Hono } from 'hono'; + +type AppEnv = { Bindings: { E2E_TEST_DSN: string }; Variables: { user: string } }; + +const app = new Hono(); +app.use(Sentry.honoMiddleware(app)); +app.use('*', Sentry.honoMiddleware(app)); + +const api = app.basePath('/api'); +api.use(Sentry.honoMiddleware(api)); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.typecheck.json b/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.typecheck.json new file mode 100644 index 000000000000..9131a4be8ad3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.typecheck.json @@ -0,0 +1,5 @@ +{ + // The rest of `src` needs Node, Bun and Deno types, which this app does not install. + "extends": "./tsconfig.json", + "include": ["src/*.typecheck.ts"] +} diff --git a/packages/hono/package.json b/packages/hono/package.json index 7ff0d20e315c..fb63a04a8a5f 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -110,7 +110,8 @@ "@sentry/deno": "11.9.0", "@sentry/node": "11.9.0", "@types/node": "^20.19.0", - "hono": "^4.13.7", + "hono": "4.13.9", + "hono-5": "npm:hono@5.0.0-rc.1", "wrangler": "4.62.0" }, "scripts": { diff --git a/packages/hono/test/types.test-d.ts b/packages/hono/test/types.test-d.ts new file mode 100644 index 000000000000..3d069bd46138 --- /dev/null +++ b/packages/hono/test/types.test-d.ts @@ -0,0 +1,57 @@ +import { honoMiddleware } from '@sentry/server-utils'; +import { Hono } from 'hono'; +import type { Hono as Hono5 } from 'hono-5'; +import { describe, it } from 'vitest'; +import { sentry as sentryBun } from '../src/bun/middleware'; +import { sentry as sentryCloudflare } from '../src/cloudflare/middleware'; +import { sentry as sentryDeno } from '../src/deno/middleware'; +import { sentry as sentryNode } from '../src/node/middleware'; + +type AppEnv = { Bindings: { SENTRY_DSN: string }; Variables: { user: string } }; + +declare const app5: Hono5; + +const dsn = 'https://public@dsn.ingest.sentry.io/1337'; + +describe('Hono 4', () => { + it('accepts honoMiddleware(app)', () => { + const app = new Hono(); + app.use(honoMiddleware(app)); + + const api = app.basePath('/api'); + api.use(honoMiddleware(api)); + }); + + it('accepts sentry(app) from each runtime entry', () => { + const app = new Hono(); + app.use(sentryNode(app)); + app.use(sentryBun(app, { dsn })); + app.use(sentryDeno(app, { dsn })); + app.use(sentryCloudflare(app, env => ({ dsn: env.SENTRY_DSN }))); + + const api = app.basePath('/api'); + api.use(sentryNode(api)); + }); +}); + +describe('Hono 5', () => { + it('accepts honoMiddleware(app)', () => { + app5.use(honoMiddleware(app5)); + + const api = app5.basePath('/api'); + api.use(honoMiddleware(api)); + }); + + // In this package, `sentry()` gets its `Hono` type from Hono 4. That class has private members, so a + // Hono 5 app cannot match it. In a real Hono 5 app, `sentry()` gets the Hono 5 types. + it('rejects sentry(app) here, because `hono` is Hono 4 in this package', () => { + // @ts-expect-error -- Hono 5 app, Hono 4 types + app5.use(sentryNode(app5)); + // @ts-expect-error -- Hono 5 app, Hono 4 types + app5.use(sentryBun(app5, { dsn })); + // @ts-expect-error -- Hono 5 app, Hono 4 types + app5.use(sentryDeno(app5, { dsn })); + // @ts-expect-error -- Hono 5 app, Hono 4 types + app5.use(sentryCloudflare(app5, env => ({ dsn: env.SENTRY_DSN }))); + }); +}); diff --git a/packages/hono/vite.config.ts b/packages/hono/vite.config.ts index b2150cd225a4..bac0e296db14 100644 --- a/packages/hono/vite.config.ts +++ b/packages/hono/vite.config.ts @@ -3,4 +3,12 @@ import baseConfig from '../../vite/vite.config'; export default defineConfig({ ...baseConfig, + test: { + ...baseConfig.test, + typecheck: { + enabled: true, + tsconfig: './tsconfig.test.json', + ignoreSourceErrors: true, + }, + }, }); diff --git a/packages/server-utils/.oxlintrc.json b/packages/server-utils/.oxlintrc.json index d9e07c9dd0cd..001bf4e0b298 100644 --- a/packages/server-utils/.oxlintrc.json +++ b/packages/server-utils/.oxlintrc.json @@ -31,6 +31,16 @@ "name": "diagnostics_channel", "allowTypeImports": true, "message": "Bun garbage-collects a channel that no code references, together with its subscribers. Import the wrappers from `src/utils/diagnosticsChannel.ts` instead. See https://github.com/oven-sh/bun/issues/43086" + }, + { + "name": "hono", + "message": "`@sentry/server-utils` has no `hono` dependency and must work on every Hono major. Use the vendored types in `src/integrations/hono/honoTypes.ts`." + } + ], + "patterns": [ + { + "group": ["hono/*"], + "message": "`@sentry/server-utils` has no `hono` dependency and must work on every Hono major. Use the vendored types in `src/integrations/hono/honoTypes.ts`." } ] } diff --git a/packages/server-utils/src/integrations/hono/honoTypes.ts b/packages/server-utils/src/integrations/hono/honoTypes.ts index eadbe688e42a..65133f52d9bf 100644 --- a/packages/server-utils/src/integrations/hono/honoTypes.ts +++ b/packages/server-utils/src/integrations/hono/honoTypes.ts @@ -34,10 +34,8 @@ export interface HonoRequest { method: string; path: string; routeIndex: number; - // These are public (though deprecated) getters on Hono's request. Reading them avoids a runtime - // import of the `hono/route` helpers, which are their non-deprecated replacements. - matchedRoutes: HonoRoute[]; - routePath: string; + // Deprecated getter that Hono 5 removes. + matchedRoutes?: HonoRoute[]; [key: string]: any; } diff --git a/packages/server-utils/src/orchestrion/config/hono.ts b/packages/server-utils/src/orchestrion/config/hono.ts index eda1a8084ff3..f56ff5a12039 100644 --- a/packages/server-utils/src/orchestrion/config/hono.ts +++ b/packages/server-utils/src/orchestrion/config/hono.ts @@ -8,6 +8,8 @@ const honoInstrumentationConfig: InstrumentationConfig[] = [ { // `new Context()` runs once per request: where the Sentry middleware is injected and matched // middleware handlers are wrapped for spans. + // TODO(v12): Raise the minimum Hono version to 4.4.9, here and in the `@sentry/hono` peer range. + // Before 4.4.9, Hono passes `matchResult` to `new HonoRequest()`, so this hook does nothing. channelName: 'context', module: { name: 'hono', versionRange: '>=4.0.0 <5', filePath: /^dist\/(?:cjs\/)?context\.js$/ }, functionQuery: { className: 'Context' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16f41a164478..617e70dc091a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -363,7 +363,7 @@ importers: version: link:../../packages/hono hono: specifier: ^4.13.7 - version: 4.13.7 + version: 4.13.9 mysql: specifier: ^2.18.1 version: 2.18.1 @@ -486,7 +486,7 @@ importers: version: 6.0.218(zod@4.6.5) hono: specifier: ^4.13.7 - version: 4.13.7 + version: 4.13.9 langchain: specifier: ^1.5.16 version: 1.5.16(@langchain/core@1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.5))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.21.3) @@ -615,7 +615,7 @@ importers: version: 21.3.10 '@hono/node-server': specifier: ^2.0.10 - version: 2.0.10(hono@4.13.7) + version: 2.0.10(hono@4.13.9) '@koa/router': specifier: ^12.0.1 version: 12.0.2(supports-color@10.0.0) @@ -729,7 +729,7 @@ importers: version: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) hono: specifier: ^4.13.7 - version: 4.13.7 + version: 4.13.9 http-terminator: specifier: ^3.2.0 version: 3.2.0 @@ -1572,8 +1572,11 @@ importers: specifier: ^20.19.0 version: 20.19.43 hono: - specifier: ^4.13.7 - version: 4.13.7 + specifier: 4.13.9 + version: 4.13.9 + hono-5: + specifier: npm:hono@5.0.0-rc.1 + version: hono@5.0.0-rc.1 wrangler: specifier: 4.62.0 version: 4.62.0(@cloudflare/workers-types@4.20250922.0) @@ -12880,10 +12883,14 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} - hono@4.13.7: - resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + hono@4.13.9: + resolution: {integrity: sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==} engines: {node: '>=16.9.0'} + hono@5.0.0-rc.1: + resolution: {integrity: sha512-vTcfU9yAggXh/+ZSi8zpYbzYDubCgFnSt8d5afCAlgkd38dyYb3CVtOh8YWhS65NkEqBseCsTj/P8mceJ+7KNA==} + engines: {node: '>=22.12.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -23632,9 +23639,9 @@ snapshots: '@hapi/bourne': 3.0.0 '@hapi/hoek': 11.0.4 - '@hono/node-server@2.0.10(hono@4.13.7)': + '@hono/node-server@2.0.10(hono@4.13.9)': dependencies: - hono: 4.13.7 + hono: 4.13.9 '@humanwhocodes/config-array@0.11.14(supports-color@10.0.0)': dependencies: @@ -32195,7 +32202,9 @@ snapshots: dependencies: parse-passwd: 1.0.0 - hono@4.13.7: {} + hono@4.13.9: {} + + hono@5.0.0-rc.1: {} hookable@5.5.3: {} From fd6c0e17f5fe7e030ff4453992c26e4c71c157f2 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:06:20 +0200 Subject: [PATCH 034/126] ref(hono): Share the middleware classification (#25453) Preparation for #25454. No behavior change. - The Context hook and `wrapSubAppMiddleware` had their own copies of the rule that tells middleware from route handlers. Both now call `getMiddlewareFlags()` - The hook checks the shape of the match result entries once (`isMatchedHandlerList()`), instead of optional chaining at each step Ref #25223 --------- Co-authored-by: Claude Opus 5.5 --- .../src/integrations/hono/honoIntegration.ts | 55 ++++++------------- .../src/integrations/hono/honoTypes.ts | 3 + .../src/integrations/hono/isMiddleware.ts | 20 ++++++- .../src/integrations/hono/matchResult.ts | 26 +++++++++ .../src/integrations/hono/patchRoute.ts | 27 ++------- 5 files changed, 68 insertions(+), 63 deletions(-) create mode 100644 packages/server-utils/src/integrations/hono/matchResult.ts diff --git a/packages/server-utils/src/integrations/hono/honoIntegration.ts b/packages/server-utils/src/integrations/hono/honoIntegration.ts index b66fe6b50de4..575462d6d018 100644 --- a/packages/server-utils/src/integrations/hono/honoIntegration.ts +++ b/packages/server-utils/src/integrations/hono/honoIntegration.ts @@ -4,7 +4,7 @@ import { HTTP_SERVER } from '@sentry/conventions/op'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, startInactiveSpan } from '@sentry/core'; import type { HonoRuntime } from './connInfo'; -import type { Env, Hono, MiddlewareHandler } from './honoTypes'; +import type { Env, Hono, MatchedHandler, MiddlewareHandler } from './honoTypes'; import type { OrchestrionChannelContext } from '../../orchestrion/types'; import { CHANNELS } from '../../orchestrion/channels'; import { honoModuleNames } from '../../orchestrion/config/hono'; @@ -13,7 +13,8 @@ import { bindTracingChannelToSpan, safeChannelCallback } from '../../tracing-cha import { applyPatches } from './applyPatches'; import { INTERNAL_REQUEST_ORIGIN } from './constants'; import { createHonoRequestMiddleware } from './createHonoMiddleware'; -import { isMiddleware } from './isMiddleware'; +import { getMiddlewareFlags } from './isMiddleware'; +import { isMatchedHandlerList } from './matchResult'; import { extractPathname, isInternalRequestSpanActive } from './patchAppRequest'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; import type { SentryHonoMiddlewareOptions } from './types'; @@ -49,10 +50,14 @@ export function honoMiddleware(app: Hono, options: HonoIntegra }); } -// A Hono `matchResult[0]` entry: `[[handler, routeMeta], paramIndexMap]` — `compose` runs the -// handler (`entry[0][0]`) and the `matchedRoutes` getter reads `routeMeta` (`entry[0][1]`). -// oxlint-disable-next-line typescript/no-explicit-any -type MatchedHandlerEntry = [[any, any], any]; +function wrapMatchedMiddleware(handlers: MatchedHandler[]): void { + const middlewareFlags = getMiddlewareFlags(handlers.map(([[, route]]) => route)); + for (const [i, [pair]] of handlers.entries()) { + if (middlewareFlags[i]) { + pair[0] = wrapMiddlewareWithSpan(pair[0]); + } + } +} /** * Per-request Context hook. `#dispatch` builds `new Context(req, { matchResult })` before its @@ -69,47 +74,19 @@ function injectHonoInstrumentation( requestMiddleware: MiddlewareHandler, injectedHandlerLists: WeakSet, ): void { - const ctorOptions = message.arguments?.[1] as { matchResult?: [MatchedHandlerEntry[], unknown] } | undefined; + const ctorOptions = message.arguments?.[1] as { matchResult?: [unknown[], unknown] } | undefined; const handlers = ctorOptions?.matchResult?.[0]; if (!Array.isArray(handlers) || injectedHandlerLists.has(handlers)) { return; } injectedHandlerLists.add(handlers); - // Classify matched handlers with the same positional heuristic as `wrapSubAppMiddleware`: within a - // method+path group the last handler is the route handler and earlier ones are middleware; `.use()` - // registers as method 'ALL' with a lone genuine-middleware entry, so fall back to arity there. - // Position is needed because a route handler declared with an unused `next` param has middleware arity. - const lastIndexByKey = new Map(); - for (const [i, entry] of handlers.entries()) { - const routeMeta = entry?.[0]?.[1] as { method?: string; path?: string } | undefined; - if (routeMeta?.method != null && routeMeta.path != null) { - // \0 is a collision-free delimiter: it cannot appear in a valid HTTP method or URL path. - lastIndexByKey.set(`${routeMeta.method}\0${routeMeta.path}`, i); - } - } - - for (const [i, entry] of handlers.entries()) { - const pair = entry?.[0]; - const handler = pair?.[0]; - if (typeof handler !== 'function') { - continue; - } - - const routeMeta = pair?.[1] as { method?: string; path?: string } | undefined; - const isMW = - routeMeta?.method != null && routeMeta.path != null - ? lastIndexByKey.get(`${routeMeta.method}\0${routeMeta.path}`) !== i || - (routeMeta.method === 'ALL' && isMiddleware(handler)) - : isMiddleware(handler); - - if (isMW) { - pair[0] = wrapMiddlewareWithSpan(handler as MiddlewareHandler); - } + if (isMatchedHandlerList(handlers)) { + wrapMatchedMiddleware(handlers); } - // Prepend the Sentry request/response middleware. `routeMeta` is what the `matchedRoutes` getter - // exposes; its middleware arity keeps route-name resolution from picking it. + // Prepend the Sentry request/response middleware. Its arity marks it as a middleware, so its `/*` + // never becomes the route name. const routeMeta = { basePath: '/', path: '/*', method: 'ALL', handler: requestMiddleware }; handlers.unshift([[requestMiddleware, routeMeta], {}]); } diff --git a/packages/server-utils/src/integrations/hono/honoTypes.ts b/packages/server-utils/src/integrations/hono/honoTypes.ts index 65133f52d9bf..ce8661a073f8 100644 --- a/packages/server-utils/src/integrations/hono/honoTypes.ts +++ b/packages/server-utils/src/integrations/hono/honoTypes.ts @@ -29,6 +29,9 @@ export interface HonoRoute { handler: (...args: any[]) => any; } +// One entry of Hono's `matchResult[0]`. +export type MatchedHandler = [pair: [handler: HonoRoute['handler'], route: HonoRoute], paramIndexMap: unknown]; + export interface HonoRequest { raw: Request; method: string; diff --git a/packages/server-utils/src/integrations/hono/isMiddleware.ts b/packages/server-utils/src/integrations/hono/isMiddleware.ts index 9c18e43e8602..ee7ad8d08afa 100644 --- a/packages/server-utils/src/integrations/hono/isMiddleware.ts +++ b/packages/server-utils/src/integrations/hono/isMiddleware.ts @@ -1,3 +1,5 @@ +import type { HonoRoute } from './honoTypes'; + // Hono stores the original (unwrapped) handler under this key when it wraps a sub-app's handlers // for a custom `onError`. Unwrapping it lets us inspect the real handler instead of the wrapper. // See https://github.com/honojs/hono/blob/9f0dadf141a3242a6c3b77462c7d33c6ce0f599d/src/hono-base.ts#L224-L226 @@ -12,7 +14,7 @@ const COMPOSED_HANDLER = '__COMPOSED_HANDLER'; * https://github.com/honojs/hono/blob/97c6fe1f12298c715eb7b2da65b4b6e0d81682bb/src/utils/handler.ts#L8 * * This is only one signal — callers that must classify inline middleware sharing a method+path with - * their handler (e.g. `wrapSubAppMiddleware`) additionally need positional information. + * their handler additionally need positional information (`getMiddlewareFlags`). */ export function isMiddleware(handler: unknown): boolean { if (typeof handler !== 'function') { @@ -24,3 +26,19 @@ export function isMiddleware(handler: unknown): boolean { return (original as (...args: unknown[]) => unknown).length >= 2; } + +/** + * Tells for each route if it is a middleware. Hono has no flag for this. In a method+path group, the + * last handler is the route handler (`app.get('/path', mw, handler)`). `.use()` adds middleware with + * method `ALL` and no route handler, so for `ALL` routes the arity also counts. Arity alone is not + * enough, because a route handler can declare an unused `next`. + */ +export function getMiddlewareFlags(routes: HonoRoute[]): boolean[] { + // `\0` cannot appear in an HTTP method or a URL path. + const groupKey = (route: HonoRoute): string => `${route.method}\0${route.path}`; + const lastIndexByKey = new Map(routes.map((route, i) => [groupKey(route), i])); + + return routes.map( + (route, i) => lastIndexByKey.get(groupKey(route)) !== i || (route.method === 'ALL' && isMiddleware(route.handler)), + ); +} diff --git a/packages/server-utils/src/integrations/hono/matchResult.ts b/packages/server-utils/src/integrations/hono/matchResult.ts new file mode 100644 index 000000000000..e6bd298f1206 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/matchResult.ts @@ -0,0 +1,26 @@ +import { isPlainObject } from '@sentry/core'; +import type { MatchedHandler } from './honoTypes'; + +function isMatchedHandler(entry: unknown): entry is MatchedHandler { + const pair: unknown = Array.isArray(entry) ? entry[0] : undefined; + if (!Array.isArray(pair)) { + return false; + } + + const [handler, route]: unknown[] = pair; + return ( + typeof handler === 'function' && + isPlainObject(route) && + typeof route.method === 'string' && + typeof route.path === 'string' && + typeof route.handler === 'function' + ); +} + +/** + * Checks the entry shape that `compose` and `hono/route` read. The match result is a Hono internal, so + * the SDK leaves other shapes alone. + */ +export function isMatchedHandlerList(handlers: unknown): handlers is MatchedHandler[] { + return Array.isArray(handlers) && handlers.every(isMatchedHandler); +} diff --git a/packages/server-utils/src/integrations/hono/patchRoute.ts b/packages/server-utils/src/integrations/hono/patchRoute.ts index 4f13b1b760ad..398df72fb924 100644 --- a/packages/server-utils/src/integrations/hono/patchRoute.ts +++ b/packages/server-utils/src/integrations/hono/patchRoute.ts @@ -2,7 +2,7 @@ import { debug, getOriginalFunction } from '@sentry/core'; import type { WrappedFunction } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { Hono, HonoRoute } from './honoTypes'; -import { isMiddleware } from './isMiddleware'; +import { getMiddlewareFlags } from './isMiddleware'; import { patchAppRequest } from './patchAppRequest'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; @@ -103,31 +103,12 @@ export function installRouteHookOnPrototype(honoBaseProto: HonoBaseProto): Route } /** - * Identifies middleware handlers in a sub-app's flat routes array and wraps them in spans. - * - * Heuristics (since Hono has no "isMiddleware" flag): - * 1. Position: `app.get('/path', mw, handler)` produces entries with the same method+path. - * All but the LAST are middleware (they call `next()`). - * 2. Arity (# of params) for method 'ALL': `.use()` handlers always have 2+ params (context, next), - * while `.all()` route handlers typically have 1 (`context` only). - * See: https://github.com/honojs/hono/blob/18fe604c8cefc2628240651b1af219692e1918c1/src/hono-base.ts#L156-L168 + * Wraps the middleware handlers of a sub-app's routes in spans. */ export function wrapSubAppMiddleware(routes: HonoRoute[]): void { - const lastIndexByKey = new Map(); - for (const [i, route] of routes.entries()) { - // \0 (null byte) is a collision-free delimiter: it cannot appear in a valid HTTP method name or URL path - lastIndexByKey.set(`${route.method}\0${route.path}`, i); - } - + const middlewareFlags = getMiddlewareFlags(routes); for (const [i, route] of routes.entries()) { - if (typeof route.handler !== 'function') { - continue; - } - - const isLastForGroup = lastIndexByKey.get(`${route.method}\0${route.path}`) === i; - - const isMW = !isLastForGroup || (route.method === 'ALL' && isMiddleware(route.handler)); - if (isMW) { + if (middlewareFlags[i] && typeof route.handler === 'function') { route.handler = wrapMiddlewareWithSpan(route.handler); } } From 8496ec157c6c8e2a5d0131a171b8e9f020b56f34 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 23 Oct 2026 11:29:24 -0400 Subject: [PATCH 035/126] fix(node): Flush buffered streamed spans on process exit (#25539) Spans waiting in the stream buffer were dropped when a Node process exited on its own, fixed by flushing the buffer on `beforeExit` like we already do for logs and metrics. The test emits `beforeExit` with a buffered span and checks it gets sent. closes #25531 --- .../suites/tracing/flush-on-exit/scenario.ts | 13 +++++ .../suites/tracing/flush-on-exit/test.ts | 14 ++++++ .../utils/runner/createRunner.ts | 8 ++- packages/deno/src/client.ts | 32 +++++------- packages/deno/test/mod.test.ts | 49 ++++++++++++++++--- packages/node/src/sdk/client.ts | 26 +++------- packages/node/test/sdk/client.test.ts | 37 ++++++++++++-- 7 files changed, 129 insertions(+), 50 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/flush-on-exit/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/flush-on-exit/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/scenario.ts new file mode 100644 index 000000000000..3f0b8ee4ec20 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/scenario.ts @@ -0,0 +1,13 @@ +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, +}); + +Sentry.startSpan({ name: 'cron-job' }, () => { + Sentry.startSpan({ name: 'step' }, () => {}); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/test.ts b/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/test.ts new file mode 100644 index 000000000000..adf3aae1a124 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/flush-on-exit/test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest'; +import { createRunner } from '../../../utils/runner'; + +test('sends buffered spans when the process exits on its own', async () => { + await createRunner(__dirname, 'scenario.ts') + .withoutAutoFlush() + .expect({ + span: container => { + expect(container.items.map(span => span.name).sort()).toEqual(['cron-job', 'step']); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts index b3d519cd3751..b592869e9aca 100644 --- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts +++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts @@ -180,6 +180,7 @@ export function createRunner(...paths: string[]) { let withEnv: Record = {}; let withSentryServer = false; let ensureNoErrorOutput = false; + let autoFlush = true; // When set, the test using this runner expects `completed()` to reject (e.g. `test.fails` variants // created via `createEsmAndCjsTests` with `failsOnEsm`/`failsOnCjs`). We suppress the captured-log // dump in that case, since the failure is expected and the output would just be noise. @@ -330,6 +331,11 @@ export function createRunner(...paths: string[]) { unordered = true; return this; }, + /** Skip the injected `beforeExit` flush, so the test sees only what the SDK sends on its own. */ + withoutAutoFlush: function () { + autoFlush = false; + return this; + }, ensureNoErrorOutput: function () { if (expectedEnvelopes.length > 0) { throw new Error('You should not use `ensureNoErrorOutput` when using `expect`!'); @@ -630,7 +636,7 @@ export function createRunner(...paths: string[]) { // `should-exit`, `ensureNoErrorOutput`) verify the child exits // naturally and auto-flush would delay that with retrying HTTP // requests to the fake DSN. - const wantsAutoFlush = !ensureNoErrorOutput && hasExpectations; + const wantsAutoFlush = autoFlush && !ensureNoErrorOutput && hasExpectations; const runtime = getRuntime(); const childFlags = wantsAutoFlush ? [...buildAutoFlushFlags(flags, testPath, runtime), ...flags] : flags; const entryPath = buildScenario(runtime, testPath); diff --git a/packages/deno/src/client.ts b/packages/deno/src/client.ts index 4e00cf8d83c9..8dbbd144f9c7 100644 --- a/packages/deno/src/client.ts +++ b/packages/deno/src/client.ts @@ -1,5 +1,5 @@ import type { ServerRuntimeClientOptions } from '@sentry/core/server'; -import { _INTERNAL_flushLogsBuffer, _INTERNAL_flushMetricsBuffer, SDK_VERSION } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { ServerRuntimeClient } from '@sentry/core/server'; import { registerDiagnosticsChannelInjection } from '@sentry/server-runtime-injection/register'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; @@ -25,8 +25,7 @@ function getHostName(): string | undefined { * @see SentryClient for usage documentation. */ export class DenoClient extends ServerRuntimeClient { - private _logOnExitFlushListener: (() => void) | undefined; - private _metricsOnExitFlushListener: (() => void) | undefined; + private _onExitFlushListener: (() => void) | undefined; /** * Creates a new Deno SDK instance. @@ -56,12 +55,10 @@ export class DenoClient extends ServerRuntimeClient { super(clientOptions); - this._logOnExitFlushListener = () => { - _INTERNAL_flushLogsBuffer(this); - }; - - this._metricsOnExitFlushListener = () => { - _INTERNAL_flushMetricsBuffer(this); + // The log, metric and span buffers drain on `flush`. Their timers are unref'd, so a natural exit + // would drop what they hold. + this._onExitFlushListener = () => { + this.emit('flush'); }; if (serverName) { @@ -74,10 +71,8 @@ export class DenoClient extends ServerRuntimeClient { } // Unlike unload, beforeExit lets the transport finish asynchronous sends. - process.on('beforeExit', this._logOnExitFlushListener); - process.on('beforeExit', this._metricsOnExitFlushListener); - globalThis.addEventListener('unload', this._logOnExitFlushListener); - globalThis.addEventListener('unload', this._metricsOnExitFlushListener); + process.on('beforeExit', this._onExitFlushListener); + globalThis.addEventListener('unload', this._onExitFlushListener); // In the constructor, as in `NodeClient`, so a directly constructed client installs the hooks // too. Registration is idempotent. @@ -102,14 +97,9 @@ export class DenoClient extends ServerRuntimeClient { /** @inheritDoc */ // @ts-expect-error - PromiseLike is a subset of Promise public async close(timeout?: number | undefined): PromiseLike { - if (this._logOnExitFlushListener) { - process.off('beforeExit', this._logOnExitFlushListener); - globalThis.removeEventListener('unload', this._logOnExitFlushListener); - } - - if (this._metricsOnExitFlushListener) { - process.off('beforeExit', this._metricsOnExitFlushListener); - globalThis.removeEventListener('unload', this._metricsOnExitFlushListener); + if (this._onExitFlushListener) { + process.off('beforeExit', this._onExitFlushListener); + globalThis.removeEventListener('unload', this._onExitFlushListener); } return super.close(timeout); diff --git a/packages/deno/test/mod.test.ts b/packages/deno/test/mod.test.ts index ad5e226b9661..b5e2f80dd819 100644 --- a/packages/deno/test/mod.test.ts +++ b/packages/deno/test/mod.test.ts @@ -4,7 +4,15 @@ import { createStackParser, forEachEnvelopeItem } from '@sentry/core'; import { nodeStackLineParser } from '@sentry/core/server'; import { assertEquals } from 'https://deno.land/std@0.202.0/assert/assert_equals.ts'; import { assertSnapshot } from 'https://deno.land/std@0.202.0/testing/snapshot.ts'; -import { DenoClient, getCurrentScope, getDefaultIntegrations, logger, metrics, Scope } from '../build/esm/index.js'; +import { + DenoClient, + getCurrentScope, + getDefaultIntegrations, + logger, + metrics, + Scope, + startSpan, +} from '../build/esm/index.js'; import { getNormalizedEvent } from './normalize.ts'; import { makeTestTransport } from './transport.ts'; @@ -70,7 +78,7 @@ function expectCaptureExceptionEvent(event: Event | undefined): void { filename: 'app:///test/mod.test.ts', function: '?', in_app: true, - lineno: 46, + lineno: 54, }, { colno: 12, @@ -78,7 +86,7 @@ function expectCaptureExceptionEvent(event: Event | undefined): void { filename: 'app:///test/mod.test.ts', function: 'something', in_app: true, - lineno: 43, + lineno: 51, }, ], ); @@ -232,7 +240,36 @@ Deno.test('preserves existing log attributes when adding server.address', () => assertEquals(log.attributes?.['server.address'], 'test-server'); }); -Deno.test('close() removes log and metric exit listeners', async () => { +Deno.test('sends buffered streamed spans before the process exits', async () => { + const itemTypes: string[] = []; + const client = new DenoClient({ + dsn: 'https://233a45e5efe34c47a3536797ce15dafa@nothing.here/5650507', + integrations: [], + stackParser: createStackParser(nodeStackLineParser()), + tracesSampleRate: 1, + traceLifecycle: 'stream', + transport: makeTestTransport(envelope => { + forEachEnvelopeItem(envelope, (_, type) => { + itemTypes.push(type); + }); + }), + }); + client.init(); + getCurrentScope().setClient(client); + + startSpan({ name: 'cron-job' }, () => {}); + await delay(0); + assertEquals(itemTypes, []); + + process.emit('beforeExit', 0); + await delay(0); + + assertEquals(itemTypes, ['span']); + + await client.close(); +}); + +Deno.test('close() removes the exit flush listener', async () => { const originalListeners = process.listeners('beforeExit'); const removeEventListenerCalls: Array = []; const originalRemoveEventListener = globalThis.removeEventListener; @@ -254,11 +291,11 @@ Deno.test('close() removes log and metric exit listeners', async () => { await client.close(); - assertEquals(addedListeners.length, 2); + assertEquals(addedListeners.length, 1); assertEquals(process.listeners('beforeExit'), originalListeners); assertEquals( removeEventListenerCalls.filter(event => event === 'unload'), - ['unload', 'unload'], + ['unload'], ); } finally { globalThis.removeEventListener = originalRemoveEventListener; diff --git a/packages/node/src/sdk/client.ts b/packages/node/src/sdk/client.ts index 4936a92f0ef6..e9dd30bf7bac 100644 --- a/packages/node/src/sdk/client.ts +++ b/packages/node/src/sdk/client.ts @@ -4,8 +4,6 @@ import { trace } from '@opentelemetry/api'; import type { ServerRuntimeClientOptions } from '@sentry/core/server'; import { _INTERNAL_clearAiProviderSkips, - _INTERNAL_flushLogsBuffer, - _INTERNAL_flushMetricsBuffer, _INTERNAL_setDeferSegmentSpanCapture, applySdkMetadata, debug, @@ -38,8 +36,7 @@ export class NodeClient extends ServerRuntimeClient { private _tracer: Tracer | undefined; private _clientReportInterval: NodeJS.Timeout | undefined; private _clientReportOnExitFlushListener: (() => void) | undefined; - private _logOnExitFlushListener: (() => void) | undefined; - private _metricsOnExitFlushListener: (() => void) | undefined; + private _onExitFlushListener: (() => void) | undefined; public constructor(options: NodeClientOptions) { const serverName = @@ -61,12 +58,10 @@ export class NodeClient extends ServerRuntimeClient { super(clientOptions); - this._logOnExitFlushListener = () => { - _INTERNAL_flushLogsBuffer(this); - }; - - this._metricsOnExitFlushListener = () => { - _INTERNAL_flushMetricsBuffer(this); + // The log, metric and span buffers drain on `flush`. Their timers are unref'd, so a natural exit + // would drop what they hold. + this._onExitFlushListener = () => { + this.emit('flush'); }; if (serverName) { @@ -78,8 +73,7 @@ export class NodeClient extends ServerRuntimeClient { }); } - process.on('beforeExit', this._logOnExitFlushListener); - process.on('beforeExit', this._metricsOnExitFlushListener); + process.on('beforeExit', this._onExitFlushListener); // Enable deferred segment-span transaction capture here, in the constructor, rather than in // `initOtel`. Every client runs its constructor exactly once, whereas `initOtel` only runs on @@ -161,12 +155,8 @@ export class NodeClient extends ServerRuntimeClient { process.off('beforeExit', this._clientReportOnExitFlushListener); } - if (this._logOnExitFlushListener) { - process.off('beforeExit', this._logOnExitFlushListener); - } - - if (this._metricsOnExitFlushListener) { - process.off('beforeExit', this._metricsOnExitFlushListener); + if (this._onExitFlushListener) { + process.off('beforeExit', this._onExitFlushListener); } const allEventsSent = await super.close(timeout); diff --git a/packages/node/test/sdk/client.test.ts b/packages/node/test/sdk/client.test.ts index 03994b43c73d..72ec97b4b7a6 100644 --- a/packages/node/test/sdk/client.test.ts +++ b/packages/node/test/sdk/client.test.ts @@ -1,7 +1,16 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { ProxyTracer } from '@opentelemetry/api'; import type { Event, EventHint, Log } from '@sentry/core'; -import { getAsyncContextStrategy, getMainCarrier, Scope, SDK_VERSION } from '@sentry/core'; +import { + createTransport, + getAsyncContextStrategy, + getClient, + getMainCarrier, + resolvedSyncPromise, + Scope, + SDK_VERSION, + startSpan, +} from '@sentry/core'; import type { SentryTracerProvider } from '@sentry/opentelemetry'; import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; import * as SentryOpentelemetry from '@sentry/opentelemetry'; @@ -10,7 +19,7 @@ import * as os from 'os'; import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; import { NodeClient } from '../../src'; import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions'; -import { cleanupOtel } from '../helpers/mockSdkInit'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; describe('NodeClient', () => { beforeEach(() => { @@ -420,7 +429,7 @@ describe('NodeClient', () => { expect(clearIntervalSpy).toHaveBeenCalledWith(intervalResult?.value); }); - it('removes log and metric exit listeners when closed', async () => { + it('removes the exit flush listener when closed', async () => { const originalListeners = process.listeners('beforeExit'); const client = new NodeClient(getDefaultNodeClientOptions({ sendClientReports: false })); @@ -428,12 +437,32 @@ describe('NodeClient', () => { await client.close(); - expect(addedListeners).toHaveLength(2); + expect(addedListeners).toHaveLength(1); expect(process.listeners('beforeExit')).toEqual(originalListeners); }); }); describe('flush', () => { + it('sends buffered streamed spans before the process exits', async () => { + const send = vi.fn(() => resolvedSyncPromise({})); + mockSdkInit({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + transport: () => createTransport({ recordDroppedEvent: () => undefined }, send), + }); + + startSpan({ name: 'cron-job' }, () => {}); + await new Promise(resolve => setImmediate(resolve)); + expect(send).not.toHaveBeenCalled(); + + process.emit('beforeExit', 0); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ body: expect.stringContaining('"type":"span"') })); + + await getClient()?.close(); + }); + it('flush returns immediately when nothing is processing', async () => { const options = getDefaultNodeClientOptions(); const client = new NodeClient(options); From ff07646f42e28dd7505e12397cbf1c62dbe7ba5a Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:15:54 +0200 Subject: [PATCH 036/126] test(e2e): Pull Supabase images from GHCR (#25718) ## What The `supabase-nextjs` E2E app now pulls the local Supabase images from GHCR instead of the public AWS registry, and no longer starts `postgres-meta`, which the test does not use. ## Why Anonymous pulls from `public.ecr.aws` often fail on CI with `toomanyrequests: Data limit exceeded`, because GitHub runners share the per-IP quota with other projects. Co-authored-by: Claude Opus 5.5 --- .../e2e-tests/test-applications/supabase-nextjs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json index 7fc3d45d4a08..8ef29339429e 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "clean": "npx rimraf node_modules pnpm-lock.yaml .next", - "start-local-supabase": "supabase stop --no-backup 2>/dev/null || true && supabase start -o env", + "start-local-supabase": "supabase stop --no-backup 2>/dev/null || true && SUPABASE_INTERNAL_IMAGE_REGISTRY=ghcr.io supabase start -x postgres-meta -o env", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm start-local-supabase && pnpm build", "test:assert": "pnpm test:prod" From e7782f99325b4673ec65d4e156680563be1e6989 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:18:53 +0000 Subject: [PATCH 037/126] chore: Add external contributor to CHANGELOG.md (#25713) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #25555 Co-authored-by: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Co-authored-by: Lukas Stracke --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 437c62ce0575..1a8c6381be03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @faizansaiyed123, @itisrmk, @seongwon030, and @kyrylolvov. Thank you for your contributions! +Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @kyrylolvov and @seongwon030. Thank you for your contributions! ## 11.9.0 From 9d42fea0d1a568028fb8932f9fbbed814f5b6283 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 23 Oct 2026 13:36:22 -0400 Subject: [PATCH 038/126] feat(core): Add managed tunnel helpers that derive the tunnel path from the DSN (#25199) ### Overview SDK Tunnel is a feature that allows client SDKs (e.g: browser) to send their requests through an endpoint that lives on the user's own application that forwards to Sentry. This bypasses adblockers and similar extensions. Tunnels could be configured as a fixed endpoint defined by the user (defaulted to `/monitoring`) but that usually ends up in global adblocker lists. Alternatively, users can tell the SDK to use a random endpoint to bypass that. Previously the tunnel logic mostly lived in client bundles and in server frameworks build time, this had two issues: - Both client and server bundles needed to share a single ENV during build, or a single process. So both can agree on the random URL generated. - CI/CD workflows means assets can drift, so older client bundles might be sending requests to older server bundles and vice versa, what happens then is during the rollout/phase-out, the random endpoints won't match and thus these envelopes will be getting 404'ed. This stack of PRs re-works the idea by moving to more stable yet random URL generation based on the client's DSN and moves the tunnel handling into the runtime of the core SDK. Meaning tunneling now is a core SDK capability rather than a meta framework feature. Here is how it looks for users: ```ts // client, posts to a path hashed from the DSN, e.g. `/k3x9q2a` Sentry.init({ dsn, tunnel: true }); // server, nothing to do in a meta-framework, the SDK answers that path from its request hook. // You only need this if the browser uses a different DSN than the server. Sentry.init({ dsn, tunnel: { allowedDsns: [browserDsn] } }); ``` And a tunneled request goes like this: the framework hook calls `handleTunnelRequestIfMatched`, if the path matches it checks the envelope's DSN against the allowed ones and forwards it to Sentry, otherwise the request continues as normal. ### This PR This sets up the core for the new tunnel mechanism that the rest of the stack will be implemented against, it does the following: - Sets up the tunnel generation and matching logic. - Sets up global span/breadcrumb ignore rules for tunneled requests. - Adds request checking, header forwarding, and the proxying to the Sentry SaaS DSN. It also sets up a couple of key decisions that will carry over throughout the stack: - **Base paths are respected always:** if a user sets `/app` as a base path our tunnels handlers will expect that prefix. - **Servers always allow their own DSNs:** This avoids the footgun where a user misconfigures `allowedDSNs` on the server, this correction only applies if the client and server share the same DSN, if there is a different DSN involved then it is on the user to configure that correctly. Will work on documenting this. ### Reviewing the stack This PR and #25200 are where the actual behavior lives so these need the closest look, mainly `forwardEnvelope` and the base path handling in `tunnel.ts`. The framework PRs all follow the same pattern: find the earliest request hook the framework gives us, call the core handler there, and pass the framework's base path along. They are small, so ideally whoever knows that framework best picks it up: - TanStack Start #25408 - SvelteKit #25415 - Nuxt and Nitro #25418, this one has more going on since it has to run before the user's own server middleware - Astro #25419 - Remix #25420 - React Router #25422 - SolidStart #25423 #25414 is tests only, and Next.js #25424 is on hold, I will split it up before it's ready. --- packages/core/src/index.ts | 17 +- packages/core/src/integrations/fetch.ts | 4 +- packages/core/src/server-runtime-client.ts | 2 + packages/core/src/server.ts | 11 + packages/core/src/types/options.ts | 43 +- .../core/src/utils/managedTunnelDefaults.ts | 80 ++ packages/core/src/utils/tunnel.ts | 417 +++++++++- .../core/test/lib/integrations/fetch.test.ts | 18 + packages/core/test/lib/utils/tunnel.test.ts | 770 ++++++++++++++++-- packages/nextjs/test/tunnel/index.test.ts | 8 +- 10 files changed, 1290 insertions(+), 80 deletions(-) create mode 100644 packages/core/src/utils/managedTunnelDefaults.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aae98061c25d..1eb1d0ff4d72 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,7 +102,13 @@ export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions } from './utils/tunnel'; -export { handleTunnelRequest } from './utils/tunnel'; +export { + applyTunnelDefaults as _INTERNAL_applyTunnelDefaults, + getTunnelPath, + handleTunnelRequest, + normalizeTunnelBasePath as _INTERNAL_normalizeTunnelBasePath, + TUNNEL_RESPONSE_HEADER, +} from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { convertSpanLinksForEnvelope, @@ -415,7 +421,14 @@ export type { HttpHeadersCollection, ResolvedDataCollection, } from './types/datacollection'; -export type { ClientOptions, CoreOptions as Options } from './types/options'; +export type { + BrowserTunnelOptions, + ClientOptions, + CoreOptions as Options, + ServerTunnelOptions, + TunnelInit, + TunnelOptions, +} from './types/options'; export type { Package } from './types/package'; export type { PolymorphicEvent, PolymorphicRequest } from './types/polymorphics'; export type { diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts index 5cf7a0489719..cc57d5edca45 100644 --- a/packages/core/src/integrations/fetch.ts +++ b/packages/core/src/integrations/fetch.ts @@ -5,6 +5,7 @@ import { getClient } from '../currentScopes'; import { instrumentFetchRequest } from '../fetch'; import { defineIntegration } from '../integration'; import { addFetchInstrumentationHandler } from '../instrument/fetch'; +import { isTracingSuppressed } from '../tracing/trace'; import type { FetchBreadcrumbData, FetchBreadcrumbHint } from '../types/breadcrumb'; import type { HandlerDataFetch } from '../types/instrument'; import type { Integration, IntegrationFn } from '../types/integration'; @@ -92,7 +93,8 @@ export function createFetchIntegration({ return; } - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + // Like Node's http and undici instrumentation: SDK-internal requests leave no breadcrumb. + if (isTracingSuppressed() || isSentryRequestUrl(handlerData.fetchData.url, client)) { return; } diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 159c4aeb3b93..9dcc4512bea4 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -21,6 +21,7 @@ import { uuid4 } from './utils/misc'; import { makePromiseBuffer } from './utils/promisebuffer'; import { resolvedSyncPromise } from './utils/syncpromise'; import { _getTraceInfoFromScope } from './utils/trace-info'; +import { applyManagedTunnelDefaults } from './utils/managedTunnelDefaults'; export interface ServerRuntimeClientOptions extends ClientOptions { platform?: string; @@ -58,6 +59,7 @@ export class ServerRuntimeClient< super(options); + applyManagedTunnelDefaults(this); this._setUpMetricsProcessing(); } diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index c6d78b00f52d..d032d9c49a87 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -6,6 +6,17 @@ export type { ServerRuntimeClientOptions } from './server-runtime-client'; export { ServerRuntimeClient } from './server-runtime-client'; +export type { TunnelRequestLike } from './utils/tunnel'; +export { + getTunnelPaths as _INTERNAL_getTunnelPaths, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption as _INTERNAL_resolveServerTunnelOption, +} from './utils/tunnel'; +export { + _INTERNAL_resetManagedTunnelDefaults, + setManagedTunnelDefaults as _INTERNAL_setManagedTunnelDefaults, +} from './utils/managedTunnelDefaults'; export type { ServerRuntimeOptions } from './types/options'; export { trpcMiddleware } from './trpc'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 8ea3944f1613..4f8bf39f117a 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -96,7 +96,7 @@ export type IgnoreSpanAttributeValue = string | boolean | number | string[] | bo * At least one of the properties (`name`, `op`, or `attributes`) must be set. * If multiple are set, all must match for the span to be ignored. */ -type IgnoreSpanFilter = +export type IgnoreSpanFilter = | { /** * Spans with a name matching this pattern will be ignored. @@ -373,6 +373,13 @@ export interface ClientOptions Streame */ export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; +/** Managed tunnel options shared by the browser and the server `init`, see `tunnel`. */ +export interface TunnelOptions { + /** A fixed tunnel path instead of the one derived from the DSN. A relative `tunnel` string sets this. */ + path?: string; + + /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */ + basePath?: string; +} + +/** Managed tunnel options for the browser `init`. */ +export interface BrowserTunnelOptions extends TunnelOptions { + /** Append a trailing slash to the tunnel path, for servers that redirect paths without one. */ + trailingSlash?: boolean; +} + +/** Managed tunnel options for a server `init`. */ +export interface ServerTunnelOptions extends TunnelOptions { + /** DSNs to forward besides the server's own, e.g. a different browser DSN. */ + allowedDsns?: string[]; +} + +/** + * The `tunnel` option, where to send envelopes instead of the Sentry ingest URL: + * + * - A URL, e.g. `https://example.com/tunnel`, sends envelopes to your own tunnel endpoint. + * - A relative path, e.g. `/monitor`, enables the managed tunnel at that fixed path. The browser sends envelopes to + * it and the server SDK serves it, so set the same path in both `init`s. + * - `true` enables the managed tunnel at a same-origin path derived from the DSN. Set it in both the browser and the + * server `init`: the browser sends envelopes to that path and the server SDK forwards them to Sentry. This keeps + * ad-blockers from dropping events. + * - Options, like `true`, with the browser or server options of the managed tunnel. + */ +export type TunnelInit = string | true | T; + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts new file mode 100644 index 000000000000..6b775cce247f --- /dev/null +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -0,0 +1,80 @@ +import type { Client } from '../client'; +import { getClient } from '../currentScopes'; +import type { IgnoreSpanFilter, ServerTunnelOptions } from '../types/options'; +import { + getTunnelIgnoreSpanFilters, + normalizeTunnelBasePath, + resolveAllowedDsns, + resolveTunnelPaths, + setServedTunnel, + stripBasePathFromTunnelPath, +} from './tunnel'; + +/** + * Managed tunnel settings a framework SDK knows outside `init`. + */ +interface ManagedTunnelDefaults { + /** + * Enables the managed tunnel when `init` didn't, e.g. from TanStack Start's `tunnelRoute` build option. Its base + * path is `basePath`, so it has none of its own. + */ + tunnel?: Omit; + /** The path prefix the app is served under. Applies however the tunnel was enabled, unless `init` set its own. */ + basePath?: string; +} + +let managedTunnelDefaults: ManagedTunnelDefaults | undefined; + +// So re-applying defaults replaces a client's filters instead of adding more. +const installedSpanFilters = new WeakMap(); + +/** + * Sets framework-known tunnel settings for the current and every later server client, so per-request clients + * (Cloudflare) start with them. Settings from `init` win. Framework SDKs call this once at load. + */ +export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void { + managedTunnelDefaults = { + ...managedTunnelDefaults, + ...(defaults.tunnel && { tunnel: defaults.tunnel }), + ...(defaults.basePath !== undefined && { basePath: defaults.basePath }), + }; + applyManagedTunnelDefaults(getClient()); +} + +/** + * Resolves a server client's managed tunnel (from `init` or the defaults), computes its paths and allowed DSNs, and + * installs its span filters. Runs when a server client is created and from {@link setManagedTunnelDefaults}. + */ +export function applyManagedTunnelDefaults(client: Client | undefined): void { + const options = client?.getOptions(); + const tunnelOptions: ServerTunnelOptions | undefined = options?._managedTunnel ?? managedTunnelDefaults?.tunnel; + if (!client || !options || !tunnelOptions) { + return; + } + + const basePath = normalizeTunnelBasePath(managedTunnelDefaults?.basePath); + const resolved = stripBasePathFromTunnelPath( + tunnelOptions.basePath === undefined && basePath ? { ...tunnelOptions, basePath } : tunnelOptions, + ); + options._managedTunnel = resolved; + const allowedDsns = resolveAllowedDsns(resolved.allowedDsns, client.getDsn()); + const paths = resolveTunnelPaths(resolved, allowedDsns); + setServedTunnel(client, { paths, allowedDsns }); + + const previousFilters = installedSpanFilters.get(client) ?? []; + const spanFilters = getTunnelIgnoreSpanFilters(resolved, paths); + options.ignoreSpans = [ + ...(options.ignoreSpans ?? []).filter(filter => !(previousFilters as unknown[]).includes(filter)), + ...spanFilters, + ]; + installedSpanFilters.set(client, spanFilters); +} + +/** + * Resets {@link setManagedTunnelDefaults}, for tests. + * + * @internal + */ +export function _INTERNAL_resetManagedTunnelDefaults(): void { + managedTunnelDefaults = undefined; +} diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 4831d6f48ea8..36f78d91f291 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,16 +1,188 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import type { Client } from '../client'; +import { getClient } from '../currentScopes'; +import { suppressTracing } from '../tracing/trace'; +import { URL_PATH } from '@sentry/conventions/attributes'; +import type { DsnComponents, DsnLike } from '../types/dsn'; +import type { IgnoreSpanFilter, ServerTunnelOptions, TunnelInit, TunnelOptions } from '../types/options'; +import { escapeStringForRegex } from '../vendor/escapeStringForRegex'; import { debug } from './debug-logger'; -import { makeDsn } from './dsn'; -import { parseEnvelope } from './envelope'; +import { dsnToString, makeDsn } from './dsn'; +import { parseUrl } from './url'; -// Bounds the memory of each request. SDK envelopes stay far below it. -export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; +/** + * A server client's tunnel paths and allowed DSNs, computed once per client. + */ +interface ServedTunnel { + paths: string[]; + allowedDsns: string[]; +} + +const servedTunnels = new WeakMap(); + +/** + * Stores a server client's computed tunnel paths and allowed DSNs. + */ +export function setServedTunnel(client: Client, servedTunnel: ServedTunnel): void { + servedTunnels.set(client, servedTunnel); +} + +function getServedTunnel(): ServedTunnel | undefined { + const client = getClient(); + + return client && servedTunnels.get(client); +} export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; - /** Pre-parsed array of allowed DSN strings */ - allowedDsns: Array; + /** DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. */ + allowedDsns?: Array; +} + +/** + * Splits a server `tunnel` option: a URL stays the transport tunnel, anything else enables the managed tunnel. + */ +export function resolveServerTunnelOption(tunnel: TunnelInit | undefined): { + tunnel: string | undefined; + _managedTunnel?: ServerTunnelOptions; +} { + if (!tunnel) { + return { tunnel }; + } + + if (typeof tunnel === 'string') { + // A relative path can't be a transport URL on the server, so it's the path to serve. + return tunnel.startsWith('/') ? { tunnel: undefined, _managedTunnel: { path: tunnel } } : { tunnel }; + } + + return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; +} + +/** + * Fills in framework defaults (like the base path) for a managed tunnel. User options win; URLs pass through. + * The base path is normalized, and a root one is no default at all. + */ +export function applyTunnelDefaults( + tunnel: TunnelInit | undefined, + defaults: TunnelOptions | undefined, +): TunnelInit | undefined { + const { basePath, ...rest } = defaults ?? {}; + const normalizedBasePath = normalizeTunnelBasePath(basePath); + const resolvedDefaults: TunnelOptions = normalizedBasePath ? { ...rest, basePath: normalizedBasePath } : rest; + if (!tunnel || !Object.keys(resolvedDefaults).length) { + return tunnel; + } + + if (typeof tunnel === 'string') { + return tunnel.startsWith('/') ? stripBasePathFromTunnelPath({ ...resolvedDefaults, path: tunnel }) : tunnel; + } + + return stripBasePathFromTunnelPath({ ...resolvedDefaults, ...(tunnel === true ? {} : tunnel) }); +} + +/** + * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. Browser and server derive it independently, + * and as a hash it gives ad-blocker filter lists nothing to match. + */ +export function getTunnelPath(dsn: DsnLike): string | undefined { + const components = makeDsn(dsn); + + return components ? `/${fnv1a32(dsnToString(components)).toString(36)}` : undefined; +} + +/** + * The parts of a request the tunnel match reads. Satisfied by a fetch `Request` and Node's `IncomingMessage`. + */ +export interface TunnelRequestLike { + method?: string; + /** Absolute URL or path with query, e.g. Node's `IncomingMessage.url`. */ + url?: string; + headers?: Headers | Record; +} + +/** + * Returns the active server client's tunnel paths, relative to its base path. For frameworks that can only register + * routes; everything else uses {@link handleTunnelRequestIfMatched}. + */ +export function getTunnelPaths(): string[] { + return getServedTunnel()?.paths ?? []; +} + +/** + * The paths a managed tunnel is served at: the fixed `path`, or else one per allowed DSN. + */ +export function resolveTunnelPaths(tunnelOptions: ServerTunnelOptions, allowedDsns: Array): string[] { + // A root path would take over every POST to the app's root, so it falls back to the DSN-derived ones. + const fixedPath = tunnelOptions.path?.replace(/\/$/, ''); + if (fixedPath) { + return [fixedPath]; + } + + // Differently written DSNs can hash to the same path. + const paths = allowedDsns.map(dsn => getTunnelPath(dsn)); + + return [...new Set(paths.filter((path): path is string => !!path))]; +} + +/** + * `ignoreSpans` filters for the server spans of tunnel requests. They match `url.path` and the legacy `http.url`, + * which some framework spans start with (SvelteKit's `sveltekit.handle.root`). + */ +export function getTunnelIgnoreSpanFilters(tunnelOptions: TunnelOptions, paths: string[]): IgnoreSpanFilter[] { + if (!paths.length) { + return []; + } + + const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); + const pathPattern = `${basePath}(?:${paths.map(escapeStringForRegex).join('|')})/?`; + + return [ + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } }, + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { 'http.url': new RegExp(`^[a-z][a-z\\d+.-]*://[^/]+${pathPattern}(?:[?#].*)?$`) } }, + ]; +} + +/** + * Normalizes a base path to `/app`, or `''` for the root. + */ +export function normalizeTunnelBasePath(basePath: string | undefined): string { + const path = (basePath?.startsWith('./') ? basePath.slice(1) : (basePath ?? '')).replace(/\/$/, ''); + + return !path || path.startsWith('/') ? path : `/${path}`; +} + +/** + * Returns true if the request targets the active server client's managed tunnel. + */ +export function isTunnelRequest(request: TunnelRequestLike): boolean { + if (request.method !== 'POST' || !request.url) { + return false; + } + + const tunnelOptions = getClient()?.getOptions()._managedTunnel; + if (!tunnelOptions) { + return false; + } + + const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath); + + return path !== undefined && getTunnelPaths().includes(path.replace(/\/$/, '')); +} + +/** + * Forwards the request if it targets the managed tunnel, otherwise returns `undefined` for the app to handle. + */ +export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { + const allowedDsns = getServedTunnel()?.allowedDsns; + // Without a DSN to forward to, the app's 404 makes the browser send directly. + if (!allowedDsns?.length || !isTunnelRequest(request)) { + return undefined; + } + + return forwardTunnelRequest(request, allowedDsns); } /** @@ -21,27 +193,76 @@ export interface HandleTunnelRequestOptions { * * @returns A `Response` — either the upstream Sentry response on success, or an error response. */ -export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { - const { request, allowedDsns } = options; +export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { + return forwardTunnelRequest(options.request, resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn())); +} + +/** + * Marks responses from the tunnel handler, so the browser can tell them from e.g. a static host's `index.html`. + */ +export const TUNNEL_RESPONSE_HEADER = 'X-Sentry-Tunnel'; + +function markTunnelResponse(response: Response): Response { + const headers = new Headers(response.headers); + // `fetch` already decoded the body, so its original encoding and length no longer describe it. + headers.delete('content-encoding'); + headers.delete('content-length'); + headers.set(TUNNEL_RESPONSE_HEADER, '1'); + + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +async function forwardTunnelRequest(request: Request, allowedDsns: Array): Promise { + return markTunnelResponse(await forwardEnvelope(request, allowedDsns)); +} +async function forwardEnvelope(request: Request, allowedDsns: Array): Promise { if (allowedDsns.length === 0) { return new Response('Tunnel not configured', { status: 500 }); } - // The HTTP server ends the body at `Content-Length`, so the header bounds what `arrayBuffer()` reads. + // The HTTP server ends the body at `Content-Length`, so the header bounds what the forward streams. const contentLength = Number(request.headers.get('content-length')); if (!contentLength) { return new Response('Length required', { status: 411 }); } - if (contentLength > MAX_TUNNEL_BODY_SIZE) { - return new Response('Envelope too large', { status: 413 }); + + const reader = request.body?.getReader(); + // A client aborting mid-upload makes the read throw. + const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined)); + if (!reader || !head) { + return new Response('Invalid envelope', { status: 400 }); } - const body = new Uint8Array(await request.arrayBuffer()); + const sentryIngestUrl = resolveIngestUrl(head.line, allowedDsns); + if (typeof sentryIngestUrl !== 'string') { + reader.cancel().catch(() => undefined); + + return sentryIngestUrl; + } + + try { + return await suppressTracing(() => + fetch(sentryIngestUrl, { + method: 'POST', + headers: forwardHeaders(request), + body: prependToStream(head.read, reader), + duplex: 'half', + } as RequestInit), + ); + } catch (error) { + debug.error('Sentry tunnel: failed to forward envelope', error); + return new Response('Failed to forward envelope to Sentry', { status: 500 }); + } +} - let envelopeHeader; +/** + * Returns the ingest URL for an envelope header line, or the rejecting response. + */ +function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { + let envelopeHeader: { dsn?: string } | null; try { - [envelopeHeader] = parseEnvelope(body); + envelopeHeader = JSON.parse(new TextDecoder().decode(headerLine)); } catch { return new Response('Invalid envelope', { status: 400 }); } @@ -71,18 +292,162 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Invalid DSN', { status: 403 }); } - const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); + return getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); +} - try { - return await fetch(sentryIngestUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body, - }); - } catch (error) { - debug.error('Sentry tunnel: failed to forward envelope', error); - return new Response('Failed to forward envelope to Sentry', { status: 500 }); +/** + * A header line longer than this is not an envelope. + */ +const MAX_ENVELOPE_HEADER_BYTES = 64 * 1024; + +/** + * Client headers Relay reads from a browser's own request: the user agent and its client hints to tell the browser, + * and `Origin` or `Referer` for the project's allowed domains check, which Relay skips without them. + */ +const FORWARDED_CLIENT_HEADERS = [ + 'user-agent', + 'sec-ch-ua', + 'sec-ch-ua-model', + 'sec-ch-ua-platform', + 'sec-ch-ua-platform-version', + 'origin', + 'referer', +]; + +/** + * Reads the body up to the envelope header line. Returns the line and every byte read, or `undefined`. + */ +async function readEnvelopeHeaderLine( + reader: ReadableStreamDefaultReader, +): Promise<{ line: Uint8Array; read: Uint8Array } | undefined> { + let read: Uint8Array = new Uint8Array(0); + + while (read.length <= MAX_ENVELOPE_HEADER_BYTES) { + const { done, value } = await reader.read(); + if (done) { + return read.length ? { line: read, read } : undefined; + } + + const searchFrom = read.length; + read = concatBytes(read, value); + + const newline = read.indexOf(0x0a, searchFrom); + if (newline !== -1) { + return { line: read.subarray(0, newline), read }; + } } + + await reader.cancel(); + + return undefined; +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const merged = new Uint8Array(a.length + b.length); + merged.set(a); + merged.set(b, a.length); + + return merged; +} + +function prependToStream( + prefix: Uint8Array, + reader: ReadableStreamDefaultReader, +): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(prefix); + }, + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + + controller.enqueue(value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); +} + +function forwardHeaders(request: Request): Record { + // No `Content-Length`: some runtimes reject it on a streamed body (Next.js edge in dev). + const headers: Record = { 'Content-Type': 'application/x-sentry-envelope' }; + + // Relay would otherwise attribute the server's IP. Same precedence Relay uses. + const clientIp = + request.headers.get('cf-connecting-ip') || + request.headers.get('x-vercel-forwarded-for') || + request.headers.get('x-forwarded-for'); + if (clientIp) { + headers['X-Sentry-Forwarded-For'] = clientIp; + } + + for (const name of FORWARDED_CLIENT_HEADERS) { + const value = request.headers.get(name); + if (value) { + headers[name] = value; + } + } + + return headers; +} + +/** + * `allowedDsns` plus the server's own DSN, which is always allowed. + */ +export function resolveAllowedDsns( + allowedDsns: Array | undefined, + ownDsn: DsnComponents | undefined, +): Array { + // Normalized like the envelope header's DSN, which has no secret key. + const dsns = new Set(); + for (const components of [...(allowedDsns ?? []).map(dsn => makeDsn(dsn)), ownDsn]) { + if (components) { + dsns.add(dsnToString(components)); + } + } + + return [...dsns]; +} + +/** + * Makes a fixed `path` relative to `basePath`, so one written with the base path isn't served under it twice. + */ +export function stripBasePathFromTunnelPath(tunnelOptions: T): T { + const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); + + return path ? { ...tunnelOptions, path } : tunnelOptions; +} + +/** + * Returns the path below `basePath`, or `undefined` outside it. + */ +function stripBasePath(pathname: string, basePath: string | undefined): string | undefined { + const base = normalizeTunnelBasePath(basePath); + if (!base) { + return pathname; + } + + return pathname === base || pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : undefined; +} + +/** + * 32-bit FNV-1a (https://www.ietf.org/archive/id/draft-eastlake-fnv-22.html#name-fnv32-code): synchronous (the + * browser needs it in `init`) and dependency free. Changing it breaks mixed SDK versions, since browser and server + * compute the path separately. + */ +function fnv1a32(input: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + // eslint-disable-next-line no-bitwise + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + + // eslint-disable-next-line no-bitwise + return hash >>> 0; } diff --git a/packages/core/test/lib/integrations/fetch.test.ts b/packages/core/test/lib/integrations/fetch.test.ts index 0cdd7bf223e4..d309fcf00321 100644 --- a/packages/core/test/lib/integrations/fetch.test.ts +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -4,6 +4,7 @@ import * as currentScopesModule from '../../../src/currentScopes'; import * as fetchModule from '../../../src/fetch'; import { createFetchIntegration } from '../../../src/integrations/fetch'; import * as instrumentFetchModule from '../../../src/instrument/fetch'; +import { suppressTracing } from '../../../src/tracing/trace'; import type { HandlerDataFetch } from '../../../src/types/instrument'; import type { Integration } from '../../../src/types/integration'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -136,6 +137,23 @@ describe('createFetchIntegration', () => { expect(addBreadcrumbSpy).not.toHaveBeenCalled(); }); + it('does not instrument requests made while tracing is suppressed', () => { + const handler = setupIntegration(fetchIntegration(), client); + + suppressTracing(() => + handler({ + fetchData: { url: 'https://o1.ingest.us.sentry.io/api/2/envelope/', method: 'POST' }, + args: ['https://o1.ingest.us.sentry.io/api/2/envelope/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }), + ); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + it('applies the `shouldCreateSpanForRequest` option', () => { const handler = setupIntegration( fetchIntegration({ shouldCreateSpanForRequest: url => url === 'http://only-this-one.com/' }), diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 7e56a5badfca..33ce33115a3a 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,20 +1,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; +import { getCurrentScope, setCurrentClient } from '../../../src'; +import { isTracingSuppressed } from '../../../src/tracing/trace'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; -import { handleTunnelRequest, MAX_TUNNEL_BODY_SIZE } from '../../../src/utils/tunnel'; +import { + applyTunnelDefaults, + getTunnelPath, + getTunnelIgnoreSpanFilters, + getTunnelPaths, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, + normalizeTunnelBasePath, + resolveAllowedDsns, + resolveServerTunnelOption, + resolveTunnelPaths, +} from '../../../src/utils/tunnel'; +import { + _INTERNAL_resetManagedTunnelDefaults, + applyManagedTunnelDefaults, + setManagedTunnelDefaults, +} from '../../../src/utils/managedTunnelDefaults'; +import type { DsnComponents } from '../../../src/types/dsn'; +import type { IgnoreSpanFilter, ServerTunnelOptions } from '../../../src/types/options'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +import { ServerRuntimeClient } from '../../../src/server-runtime-client'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; -function makeEnvelopeRequest(envelopeHeader: Record, contentLength?: number): Request { - const envelope = createEnvelope(envelopeHeader, []); - const body = serializeEnvelope(envelope); - const byteLength = typeof body === 'string' ? new TextEncoder().encode(body).byteLength : body.byteLength; +/** The `url.path` filter, the first of the tunnel span filters. */ +function getTunnelIgnoreSpanFilter( + tunnelOptions: ServerTunnelOptions, + dsn: DsnComponents | undefined, +): IgnoreSpanFilter | undefined { + const paths = resolveTunnelPaths(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)); + + return getTunnelIgnoreSpanFilters(tunnelOptions, paths)[0]; +} + +function makeEnvelopeRequest( + envelopeHeader: Record, + url = 'http://localhost/tunnel', + contentLength?: number, +): Request { + const body = serializeEnvelope(createEnvelope(envelopeHeader, [])); + const length = contentLength ?? new TextEncoder().encode(body as string).length; + + return new Request(url, { method: 'POST', body, headers: { 'content-length': String(length) } }); +} + +/** + * A request whose body starts with `head` and then never ends, so buffering it would hang the test. + */ +function makeEndlessRequest(head: string, onCancel?: () => void): Request { + const filler = new Uint8Array(16 * 1024).fill(0x61); + let sentHead = false; + return new Request('http://localhost/tunnel', { method: 'POST', - body, - headers: { 'content-length': String(contentLength ?? byteLength) }, - }); + duplex: 'half', + headers: { 'content-length': String(1024 * 1024 * 1024) }, + body: new ReadableStream({ + pull(controller) { + controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); + sentHead = true; + }, + cancel: onCancel, + }), + } as RequestInit); } describe('handleTunnelRequest', () => { @@ -25,8 +79,34 @@ describe('handleTunnelRequest', () => { vi.stubGlobal('fetch', fetchMock); }); + it('leaves the size limit to Sentry and passes its 413 back', async () => { + fetchMock.mockResolvedValueOnce(new Response('Envelope too large', { status: 413 })); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, 1024 * 1024 * 1024), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.status).toBe(413); + }); + + it('returns 411 when the request has no Content-Length', async () => { + const result = await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, + }), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(411); + expect(fetchMock).not.toHaveBeenCalled(); + }); + afterEach(() => { vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); }); it('forwards the envelope to Sentry and returns the upstream response', async () => { @@ -43,9 +123,128 @@ describe('handleTunnelRequest', () => { expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); expect(init.method).toBe('POST'); expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); - expect(init.body).toBeInstanceOf(Uint8Array); + expect(await new Response(init.body).text()).toBe(serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, []))); + + expect(result.status).toBe(200); + expect(await result.text()).toBe('ok'); + }); + + it("marks every response it returns as the tunnel's, so the browser can tell it from a static host", async () => { + fetchMock.mockResolvedValueOnce( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } }), + ); + + const forwarded = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN], + }); + const rejected = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://public@other.ingest.sentry.io/1' }), + allowedDsns: [TEST_DSN], + }); + + expect(forwarded.headers.get('X-Sentry-Tunnel')).toBe('1'); + expect(forwarded.headers.get('Content-Type')).toBe('application/json'); + // `fetch` already decoded the body, so the original encoding would make the browser decode it twice. + expect(forwarded.headers.get('Content-Encoding')).toBeNull(); + expect(rejected.status).toBe(403); + expect(rejected.headers.get('X-Sentry-Tunnel')).toBe('1'); + }); + + it.each([ + ['cf-connecting-ip', { 'cf-connecting-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, '1.1.1.1'], + ['x-vercel-forwarded-for', { 'x-vercel-forwarded-for': '3.3.3.3', 'x-forwarded-for': '2.2.2.2' }, '3.3.3.3'], + ['x-forwarded-for', { 'x-forwarded-for': '2.2.2.2, 10.0.0.1' }, '2.2.2.2, 10.0.0.1'], + ])('forwards the client IP from %s', async (_, incomingHeaders, forwardedFor) => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + + await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, + headers: { ...incomingHeaders, 'content-length': '1' }, + }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Sentry-Forwarded-For': forwardedFor }), + }), + ); + }); + + it('forwards the client headers Relay reads the browser and allowed domains from', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + const clientHeaders = { + 'user-agent': 'Mozilla/5.0', + 'sec-ch-ua': '"Chromium";v="140"', + 'sec-ch-ua-model': '""', + 'sec-ch-ua-platform': '"macOS"', + 'sec-ch-ua-platform-version': '"15.0.0"', + origin: 'https://example.com', + referer: 'https://example.com/page', + }; + + await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, + headers: { ...clientHeaders, cookie: 'session=secret', 'content-length': '1' }, + }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { 'Content-Type': 'application/x-sentry-envelope', ...clientHeaders }, + }), + ); + }); + + it('rejects a disallowed DSN after reading only the envelope header', async () => { + const result = await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('cancels the body of a rejected request', async () => { + const onCancel = vi.fn(); + + await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`, onCancel), + allowedDsns: [TEST_DSN], + }); - expect(result).toBe(upstreamResponse); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('streams an allowed envelope to Sentry without reading the rest of the body', async () => { + const upstreamResponse = new Response('ok'); + fetchMock.mockResolvedValueOnce(upstreamResponse); + + const result = await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: TEST_DSN })}\n`), + allowedDsns: [TEST_DSN], + }); + + expect(await result.text()).toBe('ok'); + }); + + it('returns 400 when the envelope header line exceeds the limit', async () => { + const result = await handleTunnelRequest({ + request: makeEndlessRequest(''), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); }); it('returns 500 when allowedDsns is empty', async () => { @@ -100,7 +299,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('returns 403 when the DSN string cannot be parsed into components', async () => { + it('ignores an allowed DSN that cannot be parsed', async () => { const malformedDsn = 'not-a-valid-dsn'; const result = await handleTunnelRequest({ @@ -108,12 +307,22 @@ describe('handleTunnelRequest', () => { allowedDsns: [malformedDsn], }); - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(403); - expect(await result.text()).toBe('Invalid DSN'); + expect(result.status).toBe(500); expect(fetchMock).not.toHaveBeenCalled(); }); + it('matches an allowed DSN written with a secret key, which the envelope header leaves out', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN.replace('@', ':secret@')], + }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it('forwards the envelope when multiple DSNs are configured', async () => { const otherDsn = 'https://other@example.com/9999'; const upstreamResponse = new Response('ok', { status: 200 }); @@ -127,7 +336,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url] = fetchMock.mock.calls[0]!; expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); - expect(result).toBe(upstreamResponse); + expect(await result.text()).toBe('ok'); }); it('returns 500 when fetch throws a network error', async () => { @@ -143,47 +352,514 @@ describe('handleTunnelRequest', () => { expect(await result.text()).toBe('Failed to forward envelope to Sentry'); }); - describe('body size limit', () => { - it('forwards the envelope when Content-Length equals the limit', async () => { - fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); + it('forwards with tracing suppressed so the forward does not create a span', async () => { + let suppressed: boolean | undefined; + fetchMock.mockImplementationOnce(() => { + suppressed = isTracingSuppressed(); + + return Promise.resolve(new Response('ok')); + }); + + await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN] }); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE), - allowedDsns: [TEST_DSN], - }); + expect(suppressed).toBe(true); + }); + + it('accepts the DSN of the active client on top of allowedDsns', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValue(new Response('ok')); - expect(result.status).toBe(200); + const ownDsnResult = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], + }); + const unlistedDsnResult = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://other@o3.ingest.sentry.io/7' }), + allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], }); - it('returns 413 when Content-Length is above the limit', async () => { - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE + 1), - allowedDsns: [TEST_DSN], - }); + expect(ownDsnResult.status).toBe(200); + expect(unlistedDsnResult.status).toBe(403); + }); + + it('defaults allowedDsns to the DSN of the active client', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValueOnce(new Response('ok')); + + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('returns 500 when allowedDsns is omitted and there is no client', async () => { + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('getTunnelPath', () => { + it('returns a short opaque path that does not leak the DSN', () => { + const path = getTunnelPath(TEST_DSN); + + expect(path).toMatch(/^\/[0-9a-z]{1,7}$/); + expect(path).not.toContain('sentry'); + }); + + // Browser and server SDKs of different versions must derive the same path, so the hash must never change. + it('derives a fixed path for a known DSN', () => { + expect(getTunnelPath(TEST_DSN)).toBe('/fdv680'); + }); + + it('is stable for the same DSN and differs between DSNs', () => { + expect(getTunnelPath(TEST_DSN)).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(TEST_DSN)).not.toBe(getTunnelPath('https://public@dsn.ingest.sentry.io/1338')); + }); + + it('ignores DSN formatting differences so browser and server derive the same path', () => { + expect(getTunnelPath('https://public:secret@dsn.ingest.sentry.io/1337')).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(makeDsn(TEST_DSN)!)).toBe(getTunnelPath(TEST_DSN)); + }); + + it('returns undefined for an invalid DSN', () => { + expect(getTunnelPath('not-a-dsn')).toBeUndefined(); + }); +}); + +describe('isTunnelRequest', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('matches a POST to the tunnel path of an allowed DSN', () => { + expect(isTunnelRequestFor({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: `${tunnelPath}/` }, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match other methods, other paths, or path prefixes', () => { + expect(isTunnelRequestFor({ method: 'GET', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: '/somewhere' }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: `${tunnelPath}x` }, { allowedDsns: [TEST_DSN] })).toBe(false); + }); + + it('does not match the tunnel path of a DSN that is not allowed', () => { + const otherDsn = 'https://other@example.com/9999'; - expect(result.status).toBe(413); - expect(await result.text()).toBe('Envelope too large'); - expect(fetchMock).not.toHaveBeenCalled(); + expect(isTunnelRequestFor({ method: 'POST', url: getTunnelPath(otherDsn)! }, { allowedDsns: [TEST_DSN] })).toBe( + false, + ); + }); + + it('uses the managed tunnel config of the active client when no options are passed', () => { + setTestClient(TEST_DSN); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); + + setTestClient(TEST_DSN, {}); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(true); + }); + + it('matches nothing without a client', () => { + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); + }); + + it('matches under a base path', () => { + expect( + isTunnelRequestFor( + { method: 'POST', url: `/docs${tunnelPath}` }, + { allowedDsns: [TEST_DSN], basePath: '/docs/' }, + ), + ).toBe(true); + expect( + isTunnelRequestFor( + { method: 'POST', url: `/docsx${tunnelPath}` }, + { allowedDsns: [TEST_DSN], basePath: '/docs' }, + ), + ).toBe(false); + }); + + it('matches nothing outside the base path', () => { + expect( + isTunnelRequestFor({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), + ).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor', basePath: '/docs' })).toBe( + false, + ); + }); +}); + +describe('isTunnelRequest request shapes', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('accepts a fetch Request with an absolute URL and query', () => { + const request = new Request(`https://example.com${tunnelPath}?o=1`, { method: 'POST' }); + + expect(isTunnelRequestFor(request, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('accepts a Node IncomingMessage-like object', () => { + const incomingMessage = { method: 'POST', url: `${tunnelPath}?o=1`, headers: { host: 'example.com' } }; + + expect(isTunnelRequestFor(incomingMessage, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match without a URL', () => { + expect(isTunnelRequestFor({ method: 'POST' }, { allowedDsns: [TEST_DSN] })).toBe(false); + }); +}); + +describe('getTunnelPaths', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('returns one path per allowed DSN and skips invalid ones', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect(resolveTunnelPaths({}, [TEST_DSN, otherDsn, 'not-a-dsn'])).toEqual([ + getTunnelPath(TEST_DSN), + getTunnelPath(otherDsn), + ]); + }); + + it('returns a fixed path instead of the DSN-derived ones', () => { + expect(resolveTunnelPaths({ path: '/monitor' }, [TEST_DSN])).toEqual(['/monitor']); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( + true, + ); + }); + + it('ignores a root fixed path, so the tunnel does not take over POSTs to the root', () => { + expect(resolveTunnelPaths({ path: '/' }, [TEST_DSN])).toEqual([getTunnelPath(TEST_DSN)]); + expect(isTunnelRequestFor({ method: 'POST', url: '/' }, { path: '/', allowedDsns: [TEST_DSN] })).toBe(false); + }); + + it('matches a fixed path that ends with a slash', () => { + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor/' })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); + }); + + it('serves the path of the active client DSN on top of allowedDsns, once', () => { + const browserDsn = 'https://browser@o2.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn, TEST_DSN] }); + + expect(getTunnelPaths()).toEqual([getTunnelPath(browserDsn), getTunnelPath(TEST_DSN)]); + }); + + it('uses the managed tunnel config of the active client', () => { + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN); + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN, {}); + expect(getTunnelPaths()).toEqual([getTunnelPath(TEST_DSN)]); + }); + + it('derives the paths once per client, so an invalid DSN in allowedDsns logs once', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setTestClient(TEST_DSN, { allowedDsns: ['also-not-a-dsn'] }); + + isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) }); + isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) }); + + expect(consoleErrorSpy).toHaveBeenCalledOnce(); + consoleErrorSpy.mockRestore(); + }); +}); + +describe('handleTunnelRequestIfMatched', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); + }); + + it('forwards a matching request', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + setTestClient(TEST_DSN, {}); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`); + + const result = await handleTunnelRequestIfMatched(request); + + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('forwards using the allowedDsns of the active client managed tunnel config', async () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn] }); + fetchMock.mockResolvedValueOnce(new Response('ok')); + const request = makeEnvelopeRequest({ dsn: browserDsn }, `http://localhost${getTunnelPath(browserDsn)}`); + + const result = await handleTunnelRequestIfMatched(request); + + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(browserDsn)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('leaves a fixed path to the app when there is no DSN to forward to', () => { + setTestClient(undefined, { path: '/monitor' }); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/monitor'); + + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + }); + + it('returns undefined without reading the body when the request does not match', () => { + setTestClient(TEST_DSN, {}); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); + + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('getTunnelIgnoreSpanFilter', () => { + function urlPathMatcher(filter: ReturnType): RegExp { + return (filter as { attributes: { 'url.path': RegExp } }).attributes['url.path']; + } + + it("matches the tunnel path of the client's DSN, with or without a trailing slash", () => { + const matcher = urlPathMatcher(getTunnelIgnoreSpanFilter({}, makeDsn(TEST_DSN))); + const tunnelPath = getTunnelPath(TEST_DSN)!; + + expect(matcher.test(tunnelPath)).toBe(true); + expect(matcher.test(`${tunnelPath}/`)).toBe(true); + expect(matcher.test(`${tunnelPath}x`)).toBe(false); + expect(matcher.test('/api/users')).toBe(false); + }); + + it('follows allowedDsns, a fixed path and the base path', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(otherDsn)!, + ), + ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(TEST_DSN)!, + ), + ).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor/' }, undefined)).test('/monitor')).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app/' }, makeDsn(TEST_DSN))).test( + `/app${getTunnelPath(TEST_DSN)}`, + ), + ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN))).test(getTunnelPath(TEST_DSN)!), + ).toBe(false); + }); + + it('returns nothing when there is no path to match', () => { + expect(getTunnelIgnoreSpanFilter({}, undefined)).toBeUndefined(); + }); +}); + +describe('server client with the managed tunnel enabled', () => { + it('ignores the server spans of tunnel requests and keeps user filters', () => { + const client = new ServerRuntimeClient( + getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {}, ignoreSpans: ['user-filter'] }), + ); + const ignoreSpans = client.getOptions().ignoreSpans!; + + expect(ignoreSpans[0]).toBe('user-filter'); + expect( + (ignoreSpans[1] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test(getTunnelPath(TEST_DSN)!), + ).toBe(true); + }); + + it('leaves ignoreSpans alone without the managed tunnel, and in non-server clients', () => { + expect( + new ServerRuntimeClient(getDefaultTestClientOptions({ dsn: TEST_DSN })).getOptions().ignoreSpans, + ).toBeUndefined(); + expect( + new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {} })).getOptions().ignoreSpans, + ).toBeUndefined(); + }); +}); + +describe('resolveServerTunnelOption', () => { + it('keeps a URL as the transport tunnel', () => { + expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); + }); + + it('enables the managed tunnel for `true` and options, and leaves the transport untunneled', () => { + expect(resolveServerTunnelOption(true)).toEqual({ tunnel: undefined, _managedTunnel: {} }); + expect(resolveServerTunnelOption({ allowedDsns: [TEST_DSN] })).toEqual({ + tunnel: undefined, + _managedTunnel: { allowedDsns: [TEST_DSN] }, }); + }); + + it('serves a relative path instead of using it as the transport tunnel', () => { + expect(resolveServerTunnelOption('/monitor')).toEqual({ tunnel: undefined, _managedTunnel: { path: '/monitor' } }); + }); + + it('leaves everything off when unset', () => { + expect(resolveServerTunnelOption(undefined)).toEqual({ tunnel: undefined }); + }); +}); + +describe('applyTunnelDefaults', () => { + it('fills in the defaults for `true` and options, and lets options the user set win', () => { + const serverTunnel: ServerTunnelOptions = { allowedDsns: [TEST_DSN] }; - it('returns 411 when the request has no Content-Length', async () => { - const body = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ dsn: TEST_DSN })}\n`)); - controller.close(); - }, - }); - // Node.js requires `duplex` for a stream body, but the DOM types do not have it yet. - const init: RequestInit & { duplex: 'half' } = { method: 'POST', body, duplex: 'half' }; + expect(applyTunnelDefaults(true, { basePath: '/app' })).toEqual({ basePath: '/app' }); + expect(applyTunnelDefaults(serverTunnel, { basePath: '/app' })).toEqual({ + basePath: '/app', + allowedDsns: [TEST_DSN], + }); + expect(applyTunnelDefaults({ basePath: '/custom' }, { basePath: '/app' })).toEqual({ basePath: '/custom' }); + }); - const result = await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', init), - allowedDsns: [TEST_DSN], - }); + it('treats a relative path as a managed tunnel with that path', () => { + expect(applyTunnelDefaults('/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); + }); - expect(result.status).toBe(411); - expect(await result.text()).toBe('Length required'); - expect(fetchMock).not.toHaveBeenCalled(); + it('does not add the base path again to a path that already starts with it', () => { + expect(applyTunnelDefaults('/app/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); + expect(applyTunnelDefaults({ path: '/app/monitor' }, { basePath: '/app' })).toEqual({ + basePath: '/app', + path: '/monitor', + }); + expect(applyTunnelDefaults('/application/monitor', { basePath: '/app' })).toEqual({ + basePath: '/app', + path: '/application/monitor', }); }); + + it('leaves a URL, an unset tunnel and empty defaults alone', () => { + expect(applyTunnelDefaults('https://example.com/tunnel', { basePath: '/app' })).toBe('https://example.com/tunnel'); + expect(applyTunnelDefaults(undefined, { basePath: '/app' })).toBeUndefined(); + expect(applyTunnelDefaults('/monitor', {})).toBe('/monitor'); + expect(applyTunnelDefaults(true, undefined)).toBe(true); + }); + + it('normalizes the default base path and ignores a root one', () => { + expect(applyTunnelDefaults(true, { basePath: 'app/' })).toEqual({ basePath: '/app' }); + expect(applyTunnelDefaults(true, { basePath: '/' })).toBe(true); + expect(applyTunnelDefaults(true, { basePath: undefined })).toBe(true); + }); }); + +describe('normalizeTunnelBasePath', () => { + it.each([ + ['/app', '/app'], + ['/app/', '/app'], + ['app', '/app'], + ['./app/', '/app'], + ['./', ''], + ['/', ''], + ['', ''], + [undefined, ''], + ])('normalizes %j to %j', (input, expected) => { + expect(normalizeTunnelBasePath(input)).toBe(expected); + }); +}); + +describe('setManagedTunnelDefaults', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); + }); + + it('serves the tunnel under the base path and drops its spans there', () => { + setTestClient(TEST_DSN, {}); + const client = getCurrentScope().getClient()!; + + setManagedTunnelDefaults({ basePath: '/app/' }); + + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app' }); + expect(isTunnelRequest({ method: 'POST', url: `/app${getTunnelPath(TEST_DSN)}` })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) })).toBe(false); + expect(client.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN)), + ); + }); + + it('does not add the base path again to a fixed path that already starts with it', () => { + setTestClient(TEST_DSN, { path: '/app/monitor' }); + const client = getCurrentScope().getClient()!; + + setManagedTunnelDefaults({ basePath: '/app' }); + + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app', path: '/monitor' }); + expect(isTunnelRequest({ method: 'POST', url: '/app/monitor' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/app/app/monitor' })).toBe(false); + }); + + it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { + setTestClient(TEST_DSN, { basePath: '/custom' }); + const client = getCurrentScope().getClient()!; + setManagedTunnelDefaults({ basePath: '/app' }); + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/custom' }); + _INTERNAL_resetManagedTunnelDefaults(); + + setTestClient(TEST_DSN, {}); + const rootClient = getCurrentScope().getClient()!; + setManagedTunnelDefaults({ basePath: '/' }); + expect(rootClient.getOptions()._managedTunnel).toEqual({}); + _INTERNAL_resetManagedTunnelDefaults(); + + setTestClient(TEST_DSN); + const offClient = getCurrentScope().getClient()!; + setManagedTunnelDefaults({ basePath: '/app' }); + expect(offClient.getOptions()._managedTunnel).toBeUndefined(); + }); + + it('enables the tunnel on clients created later, unless their `init` set one', () => { + setManagedTunnelDefaults({ tunnel: { path: '/monitor' }, basePath: '/app' }); + + setTestClient(TEST_DSN); + const laterClient = getCurrentScope().getClient()!; + applyManagedTunnelDefaults(laterClient); + expect(laterClient.getOptions()._managedTunnel).toEqual({ path: '/monitor', basePath: '/app' }); + expect(laterClient.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ path: '/monitor', basePath: '/app' }, makeDsn(TEST_DSN)), + ); + + setTestClient(TEST_DSN, { path: '/from-init' }); + const initClient = getCurrentScope().getClient()!; + applyManagedTunnelDefaults(initClient); + expect(initClient.getOptions()._managedTunnel).toEqual({ path: '/from-init', basePath: '/app' }); + }); +}); + +function setTestClient(dsn: string | undefined, managedTunnel?: ServerTunnelOptions): void { + setCurrentClient(new ServerRuntimeClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); +} + +/** `isTunnelRequest` for a server client with this managed tunnel and no DSN of its own. */ +function isTunnelRequestFor( + request: Parameters[0], + managedTunnel: ServerTunnelOptions, +): boolean { + setTestClient(undefined, managedTunnel); + + return isTunnelRequest(request); +} diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts index 0df557baa736..516543a2b5a9 100644 --- a/packages/nextjs/test/tunnel/index.test.ts +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -59,12 +59,14 @@ describe('POST', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( 'https://o1.ingest.sentry.io/api/1/envelope/?sentry_version=7&sentry_key=public', - { + expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/x-sentry-envelope' }, - body, - }, + }), ); + // The body streams through, so it is read back to compare it. + const [, init] = fetchSpy.mock.calls[0]!; + expect(new Uint8Array(await new Response((init as RequestInit).body).arrayBuffer())).toEqual(body); }); it('responds with 500 when the server SDK has no DSN', async () => { From 3ea9dc3751c675a153652c84419f1901a2d0a106 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 23 Oct 2026 21:25:23 +0200 Subject: [PATCH 039/126] chore(ci): remove PR review reminders (#25711) Remove automated PR review reminder comments. --------- Co-authored-by: GPT-6 --- .github/workflows/pr-review-reminder.yml | 37 --- scripts/pr-review-reminder.mjs | 321 ----------------------- 2 files changed, 358 deletions(-) delete mode 100644 .github/workflows/pr-review-reminder.yml delete mode 100644 scripts/pr-review-reminder.mjs diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml deleted file mode 100644 index e601f859d2c4..000000000000 --- a/.github/workflows/pr-review-reminder.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: 'PR: Review Reminder' - -on: - workflow_dispatch: - schedule: - # Run on weekdays at 10:00 AM UTC. No new reminders can fire on weekends because - # Saturday/Sunday are never counted as business days. - - cron: '0 10 * * 1-5' - -# pulls.* list + listRequestedReviewers + createComment on PRs → pull-requests: write -# repos.listCollaborators (outside) → Metadata read on the token (see GitHub App permission map) -# checkout → contents: read -permissions: - contents: read - pull-requests: write - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -jobs: - remind-reviewers: - # `schedule` has no `repository` on github.event; forks must be skipped only for workflow_dispatch. - if: github.event_name == 'schedule' || github.event.repository.fork != true - runs-on: namespace-profile-sentry-javascript-small - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Remind pending reviewers - uses: actions/github-script@v9 - with: - script: | - const { default: run } = await import( - `${process.env.GITHUB_WORKSPACE}/scripts/pr-review-reminder.mjs` - ); - await run({ github, context, core }); diff --git a/scripts/pr-review-reminder.mjs b/scripts/pr-review-reminder.mjs deleted file mode 100644 index e4886bde47ed..000000000000 --- a/scripts/pr-review-reminder.mjs +++ /dev/null @@ -1,321 +0,0 @@ -/** - * PR Review Reminder script. - * - * Posts reminder comments on open PRs whose requested reviewers have not - * responded within 2 business days. Re-nags every 2 business days thereafter - * until the review is submitted (or the request is removed). - * - * PRs are skipped (no reminder) when they are drafts, opened by a bot, already - * approved, or have an outstanding "changes requested" review (the PR is then - * waiting on the author, not the reviewers). - * - * @mentions are narrowed as follows: - * - Individual users: not [outside collaborators](https://docs.github.com/en/organizations/managing-outside-collaborators) - * on this repo (via `repos.listCollaborators` with `affiliation: outside` — repo-scoped, no extra token). - * - Team reviewers: only org teams in `SDK_TEAM_SLUGS` (by slug). - * - * Business days exclude weekends and a small set of recurring public holidays - * (same calendar date each year) for US, CA, and AT. - * - * Intended to be called from a GitHub Actions workflow via actions/github-script: - * - * const { default: run } = await import( - * `${process.env.GITHUB_WORKSPACE}/scripts/pr-review-reminder.mjs` - * ); - * await run({ github, context, core }); - */ - -// Team @mentions only for these slugs. Individuals are filtered using outside-collaborator list (see below). -const SDK_TEAM_SLUGS = new Set([ - 'team-javascript-sdks', - 'team-javascript-sdks-framework', - 'team-javascript-sdks-browser', - 'team-javascript-sdks-server', -]); - -// --------------------------------------------------------------------------- -// Outside collaborators (repo API — works with default GITHUB_TOKEN). -// Org members with access via teams or default permissions are not listed here. -// --------------------------------------------------------------------------- - -async function loadOutsideCollaboratorLogins(github, owner, repo, core) { - try { - const users = await github.paginate(github.rest.repos.listCollaborators, { - owner, - repo, - affiliation: 'outside', - per_page: 100, - }); - return new Set(users.map(u => u.login)); - } catch (e) { - const status = e.response?.status; - core.warning( - `Could not list outside collaborators for ${owner}/${repo} (${status ? `HTTP ${status}` : 'no status'}): ${e.message}. ` + - 'Skipping @mentions for individual reviewers (team reminders unchanged).', - ); - return null; - } -} - -// --------------------------------------------------------------------------- -// Recurring public holidays (month–day in UTC, same date every year). -// A calendar day counts as a holiday if it appears in any country list. -// --------------------------------------------------------------------------- - -const RECURRING_PUBLIC_HOLIDAYS_AT = [ - '01-01', - '01-06', - '05-01', - '08-15', - '10-26', - '11-01', - '12-08', - '12-24', - '12-25', - '12-26', - '12-31', -]; - -const RECURRING_PUBLIC_HOLIDAYS_CA = ['01-01', '07-01', '09-30', '11-11', '12-24', '12-25', '12-26', '12-31']; - -const RECURRING_PUBLIC_HOLIDAYS_US = ['01-01', '06-19', '07-04', '11-11', '12-24', '12-25', '12-26', '12-31']; - -const RECURRING_PUBLIC_HOLIDAY_MM_DD = new Set([ - ...RECURRING_PUBLIC_HOLIDAYS_AT, - ...RECURRING_PUBLIC_HOLIDAYS_CA, - ...RECURRING_PUBLIC_HOLIDAYS_US, -]); - -function monthDayUTC(date) { - const m = String(date.getUTCMonth() + 1).padStart(2, '0'); - const d = String(date.getUTCDate()).padStart(2, '0'); - return `${m}-${d}`; -} - -// --------------------------------------------------------------------------- -// Business-day counter. -// Counts fully-elapsed business days (Mon–Fri, not a public holiday) between -// requestedAt and now. "Fully elapsed" means the day has completely passed, -// so today is not included — giving the reviewer the rest of today to respond. -// -// Example: review requested Friday → elapsed complete days include Sat, Sun, -// Mon, Tue, … The first two business days are Mon and Tue, so the reminder -// fires on Wednesday morning. That gives the reviewer all of Monday and -// Tuesday to respond. -// --------------------------------------------------------------------------- - -function countElapsedBusinessDays(requestedAt, now) { - // Walk from the day after the request up to (but not including) today. - const start = new Date(requestedAt); - start.setUTCHours(0, 0, 0, 0); - start.setUTCDate(start.getUTCDate() + 1); - - const todayUTC = new Date(now); - todayUTC.setUTCHours(0, 0, 0, 0); - - let count = 0; - const cursor = new Date(start); - while (cursor < todayUTC) { - const dow = cursor.getUTCDay(); // 0 = Sun, 6 = Sat - if (dow !== 0 && dow !== 6) { - if (!RECURRING_PUBLIC_HOLIDAY_MM_DD.has(monthDayUTC(cursor))) { - count++; - } - } - cursor.setUTCDate(cursor.getUTCDate() + 1); - } - return count; -} - -// --------------------------------------------------------------------------- -// Reminder marker helpers -// --------------------------------------------------------------------------- - -// Returns a unique HTML comment marker for a reviewer key (login or "team:slug"). -// Used for precise per-reviewer deduplication in existing comments. -function reminderMarker(key) { - return ``; -} - -// --------------------------------------------------------------------------- -// Main entry point -// --------------------------------------------------------------------------- - -export default async function run({ github, context, core }) { - const { owner, repo } = context.repo; - const now = new Date(); - - core.info(`Using ${RECURRING_PUBLIC_HOLIDAY_MM_DD.size} recurring public holiday month–day values (US/CA/AT union)`); - - const outsideCollaboratorLogins = await loadOutsideCollaboratorLogins(github, owner, repo, core); - if (outsideCollaboratorLogins) { - core.info(`Excluding ${outsideCollaboratorLogins.size} outside collaborator login(s) from individual @mentions`); - } - - // --------------------------------------------------------------------------- - // Main loop - // --------------------------------------------------------------------------- - - // Fetch all open PRs - const prs = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: 'open', - per_page: 100, - }); - - core.info(`Found ${prs.length} open PRs`); - - for (const pr of prs) { - // Skip draft PRs and PRs opened by bots - if (pr.draft) continue; - if (pr.user?.type === 'Bot') continue; - - // Get currently requested reviewers (only those who haven't reviewed yet — - // GitHub automatically removes a reviewer from this list once they submit a review) - const { data: requested } = await github.rest.pulls.listRequestedReviewers({ - owner, - repo, - pull_number: pr.number, - }); - - const pendingReviewers = requested.users; // individual users - const pendingTeams = requested.teams; // team reviewers - if (pendingReviewers.length === 0 && pendingTeams.length === 0) continue; - - const reviews = await github.paginate(github.rest.pulls.listReviews, { - owner, - repo, - pull_number: pr.number, - per_page: 100, - }); - - // Skip if the PR already has at least one approval — no need to nudge remaining reviewers. - const hasApproval = reviews.some(r => r.state === 'APPROVED'); - if (hasApproval) continue; - - // Skip if any reviewer has an *outstanding* "changes requested" review — the ball is then in - // the author's court, not the reviewers', so nagging the reviewers is noise. - // - // A reviewer who is re-requested after requesting changes reappears in listRequestedReviewers - // (i.e. is back in `pendingReviewers`). Their earlier change-request is superseded by that new - // pending request, so it must NOT suppress reminders — otherwise we'd never nudge a reviewer - // the author has explicitly asked to take another look. We therefore ignore change-requests - // from currently-pending reviewers. (Dismissed reviews report state `DISMISSED`, so they no - // longer count either.) - const pendingReviewerLogins = new Set(pendingReviewers.map(u => u.login)); - const hasOutstandingChangesRequested = reviews.some( - r => r.state === 'CHANGES_REQUESTED' && !pendingReviewerLogins.has(r.user?.login), - ); - if (hasOutstandingChangesRequested) continue; - - // Fetch the PR timeline to determine when each review was (last) requested - const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { - owner, - repo, - issue_number: pr.number, - per_page: 100, - }); - - // Fetch existing comments so we can detect previous reminders - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number: pr.number, - per_page: 100, - }); - - const botComments = comments.filter(c => c.user?.login === 'github-actions[bot]'); - - // Returns the date of the most recent reminder comment that contains the given marker, - // or null if no such comment exists. - function latestReminderDate(key) { - const marker = reminderMarker(key); - const matches = botComments - .filter(c => c.body.includes(marker)) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - return matches.length > 0 ? new Date(matches[0].created_at) : null; - } - - // Returns true if a reminder is due for a reviewer/team: - // - The "anchor" is the later of: the review-request date, or the last - // reminder we already posted for this reviewer. This means the - // 2-business-day clock restarts after every reminder (re-nagging), and - // also resets when a new push re-requests the review. - // - A reminder fires when ≥ 2 full business days have elapsed since the anchor. - function needsReminder(requestedAt, key) { - const lastReminded = latestReminderDate(key); - const anchor = lastReminded && lastReminded > requestedAt ? lastReminded : requestedAt; - return countElapsedBusinessDays(anchor, now) >= 2; - } - - // Collect overdue individual reviewers - const toRemind = []; // { key, mention } - - for (const reviewer of pendingReviewers) { - const requestEvents = timeline - .filter(e => e.event === 'review_requested' && e.requested_reviewer?.login === reviewer.login) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - - if (requestEvents.length === 0) { - core.warning( - `PR #${pr.number}: pending reviewer @${reviewer.login} has no matching review_requested timeline event; skipping reminder for them.`, - ); - continue; - } - - const requestedAt = new Date(requestEvents[0].created_at); - if (!needsReminder(requestedAt, reviewer.login)) continue; - - if (outsideCollaboratorLogins === null) { - continue; - } - if (outsideCollaboratorLogins.has(reviewer.login)) { - continue; - } - - toRemind.push({ key: reviewer.login, mention: `@${reviewer.login}` }); - } - - // Collect overdue team reviewers - for (const team of pendingTeams) { - if (!SDK_TEAM_SLUGS.has(team.slug)) { - continue; - } - - const requestEvents = timeline - .filter(e => e.event === 'review_requested' && e.requested_team?.slug === team.slug) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - - if (requestEvents.length === 0) { - core.warning( - `PR #${pr.number}: pending team reviewer @${owner}/${team.slug} has no matching review_requested timeline event; skipping reminder for them.`, - ); - continue; - } - - const requestedAt = new Date(requestEvents[0].created_at); - const key = `team:${team.slug}`; - if (!needsReminder(requestedAt, key)) continue; - - toRemind.push({ key, mention: `@${owner}/${team.slug}` }); - } - - if (toRemind.length === 0) continue; - - // Build a single comment that includes per-reviewer markers (for precise dedup - // on subsequent runs) and @-mentions all overdue reviewers/teams. - const markers = toRemind.map(({ key }) => reminderMarker(key)).join('\n'); - const mentions = toRemind.map(({ mention }) => mention).join(', '); - const body = `${markers}\n👋 ${mentions} — Please review this PR when you get a chance!`; - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: pr.number, - body, - }); - - core.info(`Posted review reminder on PR #${pr.number} for: ${mentions}`); - } -} From bb32a5c838682504ce0e92c5a39d8fb132e344f5 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 23 Oct 2026 13:54:33 -0700 Subject: [PATCH 040/126] ref(core): Add shared `ServerRequestOptions` type across runtimes (#25579) Node, Deno, Bun and Cloudflare each have their own options for incoming HTTP requests, and the option sets drift. Bun.serve takes only `maxRequestBodySize`, and Cloudflare has no span options. When we add an option to one runtime, nothing reminds us to add it to the others. `ServerRequestOptions` in `@sentry/core/server` lists the options that every incoming request integration should support. Each runtime options type is checked against it with `CheckServerRequestOptions`. The check lists the options that the runtime does not support yet, with a comment that says why. It fails to compile when a shared option is neither declared nor listed, when a listed option is declared, or when a callback written for the shared type does not fit the runtime option. The runtime types do not extend the shared type. With `extends`, a new shared option would appear in every runtime type, supported or not, and nothing would fail to compile. Each runtime keeps its own declarations and docs. Node and Bun now type the request in `ignoreRequestBody` as `RequestOptions & HttpIncomingMessage`. The value was always an `IncomingMessage`. Callbacks typed with `RequestOptions` still compile. The `RequestOptions` part is deprecated, for removal in v12. A new BUGBOT rule flags an incoming request option that applies to more than one runtime but is not in the shared type. There is no runtime behavior change. Fixes JS-3968 Fixes #25570 Co-authored-by: Claude Opus 5.5 (1M context) --- .cursor/BUGBOT.md | 1 + .../bun/src/integrations/bunHttpServer.ts | 20 ++- packages/bun/src/integrations/bunserver.ts | 22 +++ .../cloudflare/src/integrations/httpServer.ts | 22 +++ .../http/server-request-options.ts | 128 ++++++++++++++++++ packages/core/src/server.ts | 5 + packages/deno/src/integrations/deno-serve.ts | 6 + packages/deno/src/integrations/http.ts | 15 +- .../http/httpServerIntegration.ts | 14 +- packages/node/src/integrations/http/index.ts | 19 +++ 10 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/integrations/http/server-request-options.ts diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 8212b1b8489e..c0db9f483eb2 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -78,6 +78,7 @@ Keep reviews high-signal. Prefer actionable, high-confidence findings over specu - What to do instead: Use an existing reference to the scope or client. For example, this is possible in most `Integration` hooks. - Flag hard-coded attribute names and span ops (e.g. `'sentry.origin'`, `'sentry.op'`, `'http.request.method'`, `'db.system.name'`) when `@sentry/conventions` exports a constant for them. Use the constant from `@sentry/conventions/attributes` (e.g. `SENTRY_ORIGIN`, `SENTRY_OP`) or `@sentry/conventions/op` instead. Do not flag values that have no constant in `@sentry/conventions`. - Flag unnecessary `span.setAttribute(s)` calls: If data is already available at span start, it must be set via the `attributes` option of `startSpan`, `startSpanManual`, `startInactiveSpan` or `startIdleSpan` calls. This ensures that as much context as possible is available when `tracesSampler` or `ignoreSpans` SDK options are applied. If a `span.setAttribute(s)` call happens at a later time than right after span start and the attribute value can only be computed at that time, do not flag it. +- Flag a new option on an integration for incoming HTTP requests that is not in `ServerRequestOptions` (`packages/core/src/integrations/http/server-request-options.ts`), when it applies to more than one runtime. These integrations are Node `httpIntegration`, Deno `denoHttpIntegration` and `denoServeIntegration`, Bun `bunHttpServerIntegration` and `bunServerIntegration`, and Cloudflare `httpServerIntegration`. Add the option to `ServerRequestOptions`, then support it in each runtime or list it as unsupported in that runtime's `CheckServerRequestOptions` check. Also flag a runtime option that matches a shared option in purpose but has a different name or meaning. ### Code quality diff --git a/packages/bun/src/integrations/bunHttpServer.ts b/packages/bun/src/integrations/bunHttpServer.ts index f21d47e93f67..bb423dc5773d 100644 --- a/packages/bun/src/integrations/bunHttpServer.ts +++ b/packages/bun/src/integrations/bunHttpServer.ts @@ -3,7 +3,12 @@ import * as http from 'node:http'; import * as https from 'node:https'; import type { IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import type { HttpIncomingMessage, HttpServerResponse } from '@sentry/core/server'; +import type { + AssertTrue, + CheckServerRequestOptions, + HttpIncomingMessage, + HttpServerResponse, +} from '@sentry/core/server'; import { getHttpServerSubscriptions, HTTP_ON_SERVER_REQUEST } from '@sentry/core/server'; const INTEGRATION_NAME = 'BunHttpServer' as const; @@ -37,8 +42,12 @@ interface BunHttpServerOptions { /** * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * + * The `request` parameter is the incoming `node:http` `IncomingMessage`. The `RequestOptions` part of the type is + * deprecated and will be removed in v12. Its fields that `IncomingMessage` does not have (such as `path` or + * `hostname`) are always `undefined`. */ - ignoreRequestBody?: (url: string, request: http.RequestOptions) => boolean; + ignoreRequestBody?: (url: string, request: http.RequestOptions & HttpIncomingMessage) => boolean; /** * Controls the maximum size of incoming HTTP request bodies attached to events. @@ -73,6 +82,13 @@ interface BunHttpServerOptions { onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions +>; + let hasPatched = false; const _bunHttpServerIntegration = ((options: BunHttpServerOptions = {}) => { diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 1bd340703aec..46ff0fc30e25 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import type { Integration, IntegrationFn, MaxRequestBodySize, SpanAttributes } from '@sentry/core'; import { captureBodyFromWinterCGRequest, @@ -20,6 +21,7 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; +import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; import { classifyResponseStreaming, getClientIPAddress } from '@sentry/core/server'; import type { Server, ServeOptions } from 'bun'; import { @@ -55,6 +57,26 @@ export type BunServerIntegrationOptions = { maxRequestBodySize?: MaxRequestBodySize; }; +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions< + BunServerIntegrationOptions, + Request, + Response, + // Not supported yet. + | 'spans' + | 'sessions' + | 'sessionFlushingDelayMS' + | 'ignoreRequestBody' + | 'ignoreIncomingRequests' + | 'ignoreStaticAssets' + | 'onSpanCreated' + | 'onSpanEnd' + > +>; + const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, diff --git a/packages/cloudflare/src/integrations/httpServer.ts b/packages/cloudflare/src/integrations/httpServer.ts index 97e960f916d2..7fa3cd7ce30b 100644 --- a/packages/cloudflare/src/integrations/httpServer.ts +++ b/packages/cloudflare/src/integrations/httpServer.ts @@ -1,5 +1,6 @@ import type { Client, IntegrationFn, MaxRequestBodySize } from '@sentry/core'; import { captureBodyFromWinterCGRequest, defineIntegration, getIsolationScope } from '@sentry/core'; +import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; const INTEGRATION_NAME = 'HttpServer' as const; @@ -40,6 +41,27 @@ export interface HttpServerIntegrationOptions { ignoreRequestBody?: (url: string, request: Request) => boolean; } +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions< + HttpServerIntegrationOptions, + Request, + Response, + // Not supported yet. + | 'spans' + | 'ignoreIncomingRequests' + | 'ignoreStaticAssets' + | 'onSpanCreated' + | 'onSpanEnd' + // Not supported. A worker has no long-lived process to run the timer + // that flushes session aggregates. + | 'sessions' + | 'sessionFlushingDelayMS' + > +>; + interface HttpServerIntegrationInstance { name: string; maxRequestBodySize: MaxRequestBodySize | undefined; diff --git a/packages/core/src/integrations/http/server-request-options.ts b/packages/core/src/integrations/http/server-request-options.ts new file mode 100644 index 000000000000..7125978ad603 --- /dev/null +++ b/packages/core/src/integrations/http/server-request-options.ts @@ -0,0 +1,128 @@ +import type { Span } from '../../types/span'; +import type { MaxRequestBodySize } from '../../utils/request'; + +/** + * Options that every integration for incoming HTTP requests should + * support, in each runtime. + * + * `TRequest` and `TResponse` are the request and response types that the + * runtime passes to callbacks: `HttpIncomingMessage` and + * `HttpServerResponse` for `node:http`, or the web `Request` and + * `Response` for `Deno.serve`, `Bun.serve` and Cloudflare. + * + * Runtime options types do not extend this type. Each one declares its + * own fields, with docs for that runtime, and uses + * {@link CheckServerRequestOptions} to check them against this type. + * Then a new field here fails to compile in each runtime, until the + * runtime supports it or lists it as unsupported. + */ +export interface ServerRequestOptions { + /** + * Whether to create spans for incoming requests. + * Defaults to the client's tracing configuration. + */ + spans?: boolean; + + /** + * Whether to create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming + * requests, to track the health and crash-free rate of releases. + * + * @default `true` + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions are flushed as a session aggregate. + * + * @default `60000` (60s) + */ + sessionFlushingDelayMS?: number; + + /** + * Controls the maximum size of incoming request bodies attached to events. + * + * @default 'medium' + */ + maxRequestBodySize?: MaxRequestBodySize; + + /** + * Do not capture the request body for incoming requests where the given callback returns `true`. + * + * `url` is the full URL of the request, including the query string. + */ + ignoreRequestBody?: (url: string, request: TRequest) => boolean; + + /** + * Do not create spans for incoming requests where the given callback returns `true`. + * + * `urlPath` is the path of the request, including the query string. + */ + ignoreIncomingRequests?: (urlPath: string, request: TRequest) => boolean; + + /** + * Whether to ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * A hook to change the span for an incoming request, after the span is created. + */ + onSpanCreated?: (span: Span, request: TRequest) => void; + + /** + * A hook to change the span for an incoming request one last time, before it ends. + */ + onSpanEnd?: (span: Span, request: TRequest, response: TResponse) => void; +} + +/** + * `true` when `TOptions` matches {@link ServerRequestOptions}, else a + * string that names the problem. Use it with a type that accepts only + * `true`, so a mismatch fails to compile: + * + * ```ts + * type _Check = AssertTrue>; + * ``` + * + * `TUnsupported` lists the fields that the runtime does not support. + * + * The check fails when: + * - `TOptions` does not declare a field, and `TUnsupported` does not + * list it. + * - `TUnsupported` lists a field that `TOptions` declares. + * - A callback written for the shared type does not fit the field in + * `TOptions`. + */ +export type CheckServerRequestOptions< + TOptions, + TRequest, + TResponse, + TUnsupported extends keyof ServerRequestOptions = never, +> = [MissingFields] extends [never] + ? [Extract] extends [never] + ? SupportedFields< + ServerRequestOptions, + TRequest, + TResponse, + TUnsupported + > extends SupportedFields + ? true + : 'A field type does not match ServerRequestOptions' + : `Listed as unsupported, but declared: ${Extract & string}` + : `Missing field: ${MissingFields & string}`; + +type MissingFields = Exclude< + keyof ServerRequestOptions, + TUnsupported | keyof TOptions +>; + +type SupportedFields = Pick< + T, + Extract, TUnsupported>> +>; + +/** Accepts only `true`. Use it with {@link CheckServerRequestOptions}. */ +export type AssertTrue = T; diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index d032d9c49a87..594318ca6e4e 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -54,6 +54,11 @@ export type { HttpServerResponse, HttpModuleExport, } from './integrations/http/types'; +export type { + AssertTrue, + CheckServerRequestOptions, + ServerRequestOptions, +} from './integrations/http/server-request-options'; export { createFetchIntegration } from './integrations/fetch'; export type { FetchIntegrationOptions } from './integrations/fetch'; export { classifyResponseStreaming } from './utils/responseStreaming'; diff --git a/packages/deno/src/integrations/deno-serve.ts b/packages/deno/src/integrations/deno-serve.ts index 9d20e9084848..8c8a4038e5b5 100644 --- a/packages/deno/src/integrations/deno-serve.ts +++ b/packages/deno/src/integrations/deno-serve.ts @@ -1,5 +1,6 @@ import type { Event, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; import { debug, defineIntegration } from '@sentry/core'; +import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; import { DEFAULT_IGNORE_STATUS_CODES, processHttpServerTransactionEvent } from '@sentry/core/server'; import type { RequestHandlerWrapperOptions } from '../wrap-deno-request-handler'; import { wrapDenoRequestHandler } from '../wrap-deno-request-handler'; @@ -99,6 +100,11 @@ export type DenoServeIntegrationOptions = { ignoreStatusCodes?: (number | [number, number])[]; }; +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue>; + export type ServeParams = // [(Request) => Response] | [Deno.ServeHandler] diff --git a/packages/deno/src/integrations/http.ts b/packages/deno/src/integrations/http.ts index 34dc1ff4e5f4..ea6a4fbae6c0 100644 --- a/packages/deno/src/integrations/http.ts +++ b/packages/deno/src/integrations/http.ts @@ -3,7 +3,13 @@ import { errorMonitor } from 'node:events'; import type { RequestOptions } from 'node:http'; import type { Event, Integration, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import type { HttpClientRequest, HttpIncomingMessage, HttpServerResponse } from '@sentry/core/server'; +import type { + AssertTrue, + CheckServerRequestOptions, + HttpClientRequest, + HttpIncomingMessage, + HttpServerResponse, +} from '@sentry/core/server'; import { DEFAULT_IGNORE_STATUS_CODES, getHttpClientSubscriptions, @@ -151,6 +157,13 @@ export interface DenoHttpIntegrationOptions { onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions +>; + const _denoHttpIntegration = ((options: DenoHttpIntegrationOptions = {}) => { const breadcrumbs = options.breadcrumbs ?? true; const tracePropagation = options.tracePropagation ?? true; diff --git a/packages/node/src/integrations/http/httpServerIntegration.ts b/packages/node/src/integrations/http/httpServerIntegration.ts index 6322cd0169bd..b3c0c939d971 100644 --- a/packages/node/src/integrations/http/httpServerIntegration.ts +++ b/packages/node/src/integrations/http/httpServerIntegration.ts @@ -45,9 +45,11 @@ export interface HttpServerIntegrationOptions { * This can be useful for long running requests where the body is not needed and we want to avoid capturing it. * * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the incoming request. - * @param request Contains the {@type RequestOptions} object used to make the incoming request. + * @param request The incoming request, a `node:http` `IncomingMessage`. Use `request.url`, `request.method`, + * `request.headers`, etc. The `RequestOptions` part of the type is deprecated and will be removed in v12. Its + * fields that `IncomingMessage` does not have (such as `path` or `hostname`) are always `undefined`. */ - ignoreRequestBody?: (url: string, request: RequestOptions) => boolean; + ignoreRequestBody?: (url: string, request: RequestOptions & HttpIncomingMessage) => boolean; /** * Controls the maximum size of incoming HTTP request bodies attached to events. @@ -84,13 +86,7 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => // core subscription helper. Explicitly opt out so the helper does not // double-create spans when the client has tracing enabled. spans: false as const, - // Cast: core uses HttpIncomingMessage; node consumers pass - // RequestOptions-typed callbacks. - // The two are structurally compatible for the fields the callback reads - // (url, method, headers). - ignoreRequestBody: options.ignoreRequestBody as - | ((url: string, request: HttpIncomingMessage) => boolean) - | undefined, + ignoreRequestBody: options.ignoreRequestBody, /** * Hook called by core's `instrumentServer` to wrap the upstream diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index f0cfc28cdc87..7431a6a34537 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -8,6 +8,12 @@ import { hasSpanStreamingEnabled, stripDataUrlContent, } from '@sentry/core'; +import type { + AssertTrue, + CheckServerRequestOptions, + HttpIncomingMessage, + HttpServerResponse, +} from '@sentry/core/server'; import { getRequestUrlFromClientRequest } from '@sentry/core/server'; import type { NodeClient } from '../../sdk/client'; import type { HttpServerIntegrationOptions } from './httpServerIntegration'; @@ -83,6 +89,19 @@ interface HttpOptions extends HttpServerIntegrationOptions, HttpServerSpansInteg outgoingRequestApplyCustomAttributes?: OutgoingHttpRequestInstrumentationOptions['outgoingRequestApplyCustomAttributes']; } +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions< + HttpOptions, + HttpIncomingMessage, + HttpServerResponse, + // Not supported yet. + 'onSpanEnd' + > +>; + /** * The http integration instruments Node's internal http and https modules. * It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span. From ad1bd2314104bda74092679ba82fbb5649c4e48e Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 23 Oct 2026 13:54:33 -0700 Subject: [PATCH 041/126] feat(bun): Add incoming request options and sessions to `bunServerIntegration` (#25580) `Bun.serve` took only `maxRequestBodySize`. A user could not skip body capture or spans for one route, and release health showed no request sessions or crash-free rate for `Bun.serve` apps. `denoServeIntegration` got these in #25493 and #25496. `bunServerIntegration` now accepts `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated`, `onSpanEnd`, `sessions` and `sessionFlushingDelayMS`. They use the same names and behavior as `denoServeIntegration`, and the callbacks receive the web `Request`. The shared `ServerRequestOptions` check for `Bun.serve` now lists no unsupported options. When the SDK skips the span, it still continues the incoming trace, attaches request data and the body, records the session and captures errors. For a streamed response, `onSpanEnd` and the session end when the stream finishes. A user callback that throws does not fail the request. If `ignoreRequestBody` throws, the SDK skips the body, so it cannot leak data that the filter was meant to hide. Three changes in behavior: - `ignoreStaticAssets` defaults to `true`, so `GET` requests for files such as `favicon.ico` or `*.js` no longer create spans. - `spans` defaults to the client's tracing configuration, so the integration no longer starts spans when tracing is disabled. - Errors thrown for `OPTIONS` and `HEAD` requests are now captured. These requests also record sessions, as on Node and Deno. Fixes #25571 Fixes JS-3969 Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 + packages/bun/src/integrations/bunserver.ts | 285 ++++++++--- .../bun/test/integrations/bunserver.test.ts | 477 +++++++++++++++++- 3 files changed, 706 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8c6381be03..e3daac526d90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,6 +195,14 @@ Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @ Work in this release was contributed by @4ktLuffy, @adityareddy-dev, @dasilvaluis, @faizansaiyed123, @jpdefo, @Jxxunnn, @mikamikasuki, @philipatkinson, @rubas, and @Shubham-Padkonde. Thank you for your contributions! +- **feat(bun): Add incoming request options and request sessions to `bunServerIntegration`** + + `bunServerIntegration` now accepts the same options as `denoServeIntegration`: `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated`, `onSpanEnd`, `sessions` and `sessionFlushingDelayMS`. The callbacks receive the web `Request`. + + `Bun.serve` now records a request session for each incoming request. Sessions are sent only when a `release` is set. To turn them off, set `bunServerIntegration({ sessions: false })`. + + `ignoreStaticAssets` defaults to `true`, so `Bun.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `bunServerIntegration({ ignoreStaticAssets: false })`. `Bun.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. Errors thrown for `OPTIONS` and `HEAD` requests are now captured. + ## 11.8.0 ### Important Changes diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 46ff0fc30e25..35015447cce9 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,13 +1,15 @@ /* eslint-disable max-lines */ -import type { Integration, IntegrationFn, MaxRequestBodySize, SpanAttributes } from '@sentry/core'; +import type { Client, Integration, IntegrationFn, MaxRequestBodySize, Span, SpanAttributes } from '@sentry/core'; import { captureBodyFromWinterCGRequest, captureException, continueTrace, + debug, defineIntegration, getClient, getUrlFragment, getUrlQuery, + hasSpansEnabled, hasSpanStreamingEnabled, httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, @@ -22,7 +24,12 @@ import { filterCollectedUrlQuery, } from '@sentry/core'; import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; -import { classifyResponseStreaming, getClientIPAddress } from '@sentry/core/server'; +import { + classifyResponseStreaming, + getClientIPAddress, + isStaticAssetRequest, + startRequestSession, +} from '@sentry/core/server'; import type { Server, ServeOptions } from 'bun'; import { CLIENT_ADDRESS, @@ -55,32 +62,86 @@ export type BunServerIntegrationOptions = { * @default 'medium' */ maxRequestBodySize?: MaxRequestBodySize; + + /** + * Do not capture the request body for incoming requests where the given callback returns `true`. + * + * `url` is the full URL of the request, including the query string. `request` is the incoming + * {@link Request}. The callback runs before the SDK reads the body. + */ + ignoreRequestBody?: (url: string, request: Request) => boolean; + + /** + * Do not capture server spans for incoming requests whose URL path makes the given callback return `true`. + * + * `urlPath` is the path of the request, including the query string. `request` is the incoming + * {@link Request}. Errors thrown by the handler are still captured. + */ + ignoreIncomingRequests?: (urlPath: string, request: Request) => boolean; + + /** + * Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating server spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Whether to create spans for incoming requests. + * Errors are still captured and request data is still attached when this is `false`. + * Defaults to the client's tracing configuration (`hasSpansEnabled`). + */ + spans?: boolean; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, before the request handler runs. + */ + onSpanCreated?: (span: Span, request: Request) => void; + + /** + * A hook that can be used to mutate the span one last time before it ends. + * For a streamed response, this runs when the stream finishes. + * It is not called when the request handler throws. + */ + onSpanEnd?: (span: Span, request: Request, response: Response) => void; + + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for + * incoming requests to track the health and crash-free rate of your releases in Sentry. + * + * For a streamed response, the session ends when the stream finishes. + * + * @default `true` + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions are flushed as a session aggregate. + * + * @default `60000` (60s) + */ + sessionFlushingDelayMS?: number; }; // Checks these options against the shared `ServerRequestOptions`. A new // shared field fails to compile here, until this integration supports // it or lists it as unsupported. -type _ServerRequestOptionsCheck = AssertTrue< - CheckServerRequestOptions< - BunServerIntegrationOptions, - Request, - Response, - // Not supported yet. - | 'spans' - | 'sessions' - | 'sessionFlushingDelayMS' - | 'ignoreRequestBody' - | 'ignoreIncomingRequests' - | 'ignoreStaticAssets' - | 'onSpanCreated' - | 'onSpanEnd' - > ->; +type _ServerRequestOptionsCheck = AssertTrue>; const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, maxRequestBodySize: options.maxRequestBodySize, + ignoreRequestBody: options.ignoreRequestBody, + ignoreIncomingRequests: options.ignoreIncomingRequests, + ignoreStaticAssets: options.ignoreStaticAssets ?? true, + spans: options.spans, + onSpanCreated: options.onSpanCreated, + onSpanEnd: options.onSpanEnd, + sessions: options.sessions ?? true, + sessionFlushingDelayMS: options.sessionFlushingDelayMS, setupOnce() { instrumentBunServe(); }, @@ -230,6 +291,59 @@ type RouteHandler = Extract< Function >; +function captureServeError(error: unknown): void { + captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } }); +} + +// A user callback that throws must not fail the request or leave the span open. +function callUserCallback(name: string, callback: () => T, fallback: T): T { + try { + return callback(); + } catch (e) { + debug.error(`Error in the Bun.serve ${name} callback.`, e); + return fallback; + } +} + +function shouldCreateSpan( + client: Client | undefined, + integration: BunServerIntegrationOptions | undefined, + request: Request, + urlPath: string, +): boolean { + if (!(integration?.spans ?? (client ? hasSpansEnabled(client.getOptions()) : false))) { + return false; + } + if (integration?.ignoreStaticAssets && request.method.toUpperCase() === 'GET' && isStaticAssetRequest(urlPath)) { + return false; + } + const ignoreIncomingRequests = integration?.ignoreIncomingRequests; + return !( + ignoreIncomingRequests && + callUserCallback('ignoreIncomingRequests', () => ignoreIncomingRequests(urlPath, request), false) + ); +} + +/** + * Calls `onDone` when the response is done: at once, or when a streamed body finishes. + * Returns the response to send. + */ +function onResponseDone(response: T, onDone: () => void, onStreamError: () => void): T { + if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { + const body = monitorStream(response.body, onDone, error => { + onStreamError(); + captureServeError(error); + }); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) as T; + } + onDone(); + return response; +} + function wrapRequestHandler( target: T, thisArg: unknown, @@ -239,8 +353,26 @@ function wrapRequestHandler( return withIsolationScope(async isolationScope => { const request = args[0]; const upperCaseMethod = request.method.toUpperCase(); + + const client = getClient(); + const integration = client?.getIntegrationByName(INTEGRATION_NAME); + const endSession = + client && integration?.sessions + ? startRequestSession(client, { + requestIsolationScope: isolationScope, + sessionFlushingDelayMS: integration.sessionFlushingDelayMS, + }) + : undefined; + if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { - return target.apply(thisArg, args); + try { + return await target.apply(thisArg, args); + } catch (e) { + captureServeError(e); + throw e; + } finally { + endSession?.(); + } } const parsedUrl = parseStringToURLObject(request.url); @@ -267,7 +399,6 @@ function wrapRequestHandler( routeName = route; } - const client = getClient(); const dataCollection = client?.getDataCollectionOptions(); let socketAddress: { address: string; port: number } | undefined; @@ -301,24 +432,52 @@ function wrapRequestHandler( ipAddress: socketAddress?.address, }); - if (client && dataCollection) { - const configuredBodySize = client.getIntegrationByName( - INTEGRATION_NAME, - )?.maxRequestBodySize; + if (dataCollection) { const effectiveBodySize = - configuredBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); - if (upperCaseMethod !== 'GET' && effectiveBodySize !== 'none') { + integration?.maxRequestBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); + const ignoreRequestBody = integration?.ignoreRequestBody; + if ( + upperCaseMethod !== 'GET' && + effectiveBodySize !== 'none' && + // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. + !( + ignoreRequestBody && + callUserCallback('ignoreRequestBody', () => ignoreRequestBody(request.url, request), true) + ) + ) { await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); } } + const urlPath = parsedUrl ? `${parsedUrl.pathname}${parsedUrl.search}` : '/'; + const createSpan = shouldCreateSpan(client, integration, request, urlPath); + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') ?? '', baggage: request.headers.get('baggage'), }, - () => - startSpanManual( + async () => { + if (!createSpan) { + let response: Response | undefined; + try { + response = (await target.apply(thisArg, args)) as Response | undefined; + } catch (e) { + captureServeError(e); + endSession?.(); + throw e; + } + if (response?.status) { + isolationScope.setContext('response', { status_code: response.status }); + } + return onResponseDone( + response, + () => endSession?.(), + () => undefined, + ); + } + + return startSpanManual( { attributes: { ...attributes, [SENTRY_OP]: HTTP_SERVER }, // With span streaming, span names have to be low cardinality, so we can't fall back to the URL path. @@ -328,47 +487,49 @@ function wrapRequestHandler( : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK, }, async (span, endSpan) => { + const { onSpanCreated, onSpanEnd } = integration ?? {}; + if (onSpanCreated) { + callUserCallback('onSpanCreated', () => onSpanCreated(span, request), undefined); + } + + let response: Response | undefined; try { - const response = (await target.apply(thisArg, args)) as Response | undefined; - if (response?.status) { - setHttpStatus(span, response.status); - - isolationScope.setContext('response', { - status_code: response.status, - }); - - if (dataCollection) { - span.setAttributes( - httpHeadersToSpanAttributes(response.headers.toJSON(), dataCollection, 'response'), - ); - } - } - if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { - const body = monitorStream(response.body, endSpan, error => { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } }); - }); - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - } - endSpan(); - return response; + response = (await target.apply(thisArg, args)) as Response | undefined; } catch (e) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(e, { - mechanism: { - type: 'auto.http.bun.serve', - handled: false, - }, - }); + captureServeError(e); endSpan(); + endSession?.(); throw e; } + + if (response?.status) { + setHttpStatus(span, response.status); + + isolationScope.setContext('response', { + status_code: response.status, + }); + + if (dataCollection) { + span.setAttributes(httpHeadersToSpanAttributes(response.headers.toJSON(), dataCollection, 'response')); + } + } + + const finalResponse = response; + return onResponseDone( + response, + () => { + if (onSpanEnd && finalResponse) { + callUserCallback('onSpanEnd', () => onSpanEnd(span, request, finalResponse), undefined); + } + endSpan(); + endSession?.(); + }, + () => span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }), + ); }, - ), + ); + }, ); }); } diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index bec2b892ea3a..5d846d3667d6 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,4 +1,4 @@ -import type { RequestEventData } from '@sentry/core'; +import type { AggregationCounts, Envelope, RequestEventData } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import type { BunOptions } from '../../src'; @@ -21,9 +21,11 @@ describe('Bun Serve Integration', () => { init({ dsn: 'https://username@domain/123', defaultIntegrations: false, - ...options, + // Spans follow the client's tracing configuration. + tracesSampleRate: 1, transport: () => SentryCore.createTransport({ recordDroppedEvent: () => undefined }, () => SentryCore.resolvedSyncPromise({})), + ...options, }); }; @@ -1195,4 +1197,475 @@ describe('Bun Serve Integration', () => { expect(normalizedRequest?.data).toBeUndefined(); }); }); + + describe('options', () => { + const captureBodySpy = spyOn(SentryCore, 'captureBodyFromWinterCGRequest'); + + beforeEach(() => { + captureBodySpy.mockClear(); + }); + + test('does not capture request bodies that ignoreRequestBody matches', async () => { + const ignoreRequestBody = (url: string, request: Request): boolean => + url.endsWith('/upload?id=1') && request.method === 'POST'; + setupClient({ integrations: [bunServerIntegration({ ignoreRequestBody })] }); + const server = Bun.serve({ port, fetch: async req => new Response(await req.text()) }); + try { + const ignored = await fetch(`http://localhost:${port}/upload?id=1`, { method: 'POST', body: 'secret' }); + expect(await ignored.text()).toBe('secret'); + expect(captureBodySpy).not.toHaveBeenCalled(); + + const captured = await fetch(`http://localhost:${port}/login`, { method: 'POST', body: 'hello' }); + expect(await captured.text()).toBe('hello'); + expect(captureBodySpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('does not capture the body and still responds when ignoreRequestBody throws', async () => { + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreRequestBody: () => { + throw new Error('filter failed'); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: async req => new Response(await req.text()) }); + try { + const response = await fetch(`http://localhost:${port}/login`, { method: 'POST', body: 'secret' }); + expect(await response.text()).toBe('secret'); + expect(captureBodySpy).not.toHaveBeenCalled(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }); + + test('does not create spans for requests that ignoreIncomingRequests matches', async () => { + const urlPaths: string[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreIncomingRequests: (urlPath, request) => { + urlPaths.push(urlPath); + return urlPath.startsWith('/health') && request instanceof Request; + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/health?probe=1`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(urlPaths).toEqual(['/health?probe=1', '/users']); + } finally { + await server.stop(true); + } + }); + + test('creates the span and still responds when ignoreIncomingRequests throws', async () => { + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreIncomingRequests: () => { + throw new Error('filter failed'); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }); + + test('does not create spans for static assets by default', async () => { + setupClient({ integrations: [bunServerIntegration()] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/favicon.ico`)).text()).toBe('ok'); + expect(await (await fetch(`http://localhost:${port}/assets/app.js`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + + // The filter only applies to GET. + await (await fetch(`http://localhost:${port}/favicon.ico`, { method: 'POST', body: 'x' })).text(); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('creates spans for static assets when ignoreStaticAssets is false', async () => { + setupClient({ integrations: [bunServerIntegration({ ignoreStaticAssets: false })] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/favicon.ico`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('does not create spans when spans is false, but still captures errors and request data', async () => { + setupClient({ integrations: [bunServerIntegration({ spans: false })] }); + let normalizedRequest: RequestEventData | undefined; + const error = new Error('handler failed'); + const server = Bun.serve({ + port, + fetch: () => { + normalizedRequest = SentryCore.getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest; + throw error; + }, + }); + try { + // `server.fetch` returns the handler error, which `bun:test` would report as unhandled from a real request. + await expect(server.fetch(new Request(`http://localhost:${port}/users`))).rejects.toBe(error); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(normalizedRequest?.url).toBe(`http://localhost:${port}/users`); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.bun.serve', handled: false }, + }); + } finally { + await server.stop(true); + } + }); + + test('does not create spans when tracing is disabled', async () => { + setupClient({ tracesSampleRate: undefined, integrations: [bunServerIntegration()] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(continueTraceSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('calls onSpanCreated and onSpanEnd', async () => { + const calls: string[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ + onSpanCreated: (span, request) => { + expect(span).toBe(mockSpan as unknown as SentryCore.Span); + calls.push(`created ${request.method} ${new URL(request.url).pathname}`); + }, + onSpanEnd: (span, request, response) => { + expect(span).toBe(mockSpan as unknown as SentryCore.Span); + calls.push(`end ${request.method} ${response.status}`); + }, + }), + ], + }); + const server = Bun.serve({ + port, + fetch: () => { + calls.push('handler'); + return new Response('ok', { status: 201 }); + }, + }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(calls).toEqual(['created GET /users', 'handler', 'end GET 201']); + } finally { + await server.stop(true); + } + }); + + test('calls onSpanEnd when a streamed response finishes', async () => { + const onSpanEndCalls: number[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ onSpanEnd: (_span, _request, response) => onSpanEndCalls.push(response.status) }), + ], + }); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + controller!.enqueue(new TextEncoder().encode('data: one\n\n')); + expect(onSpanEndCalls).toEqual([]); + + controller!.close(); + expect(await response.text()).toBe('data: one\n\n'); + expect(onSpanEndCalls).toEqual([200]); + } finally { + await server.stop(true); + } + }); + + test.each(['onSpanCreated', 'onSpanEnd'] as const)( + 'still responds and ends the span when %s throws', + async hook => { + setupClient({ + integrations: [ + bunServerIntegration({ + [hook]: () => { + throw new Error(`${hook} failed`); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(endSpanSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }, + ); + + test.each(['OPTIONS', 'HEAD'])('captures errors thrown for %s requests', async method => { + const error = new Error(`${method} failed`); + const server = Bun.serve({ + port, + fetch: () => { + throw error; + }, + }); + try { + await expect(server.fetch(new Request(`http://localhost:${port}/`, { method }))).rejects.toBe(error); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.bun.serve', handled: false }, + }); + } finally { + await server.stop(true); + } + }); + }); + + describe('sessions', () => { + type SessionCounts = Omit; + + function setupSessionClient(options?: BunOptions): { + client: SentryCore.Client; + sessions: SessionCounts[]; + firstSession: Promise; + } { + const sessions: SessionCounts[] = []; + let resolveFirstSession: () => void; + const firstSession = new Promise(resolve => (resolveFirstSession = resolve)); + setupClient({ + release: '1.0.0', + integrations: [bunServerIntegration()], + ...options, + transport: () => + SentryCore.createTransport({ recordDroppedEvent: () => undefined }, request => { + const envelope = SentryCore.parseEnvelope(request.body) as Envelope; + SentryCore.forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'sessions') { + for (const { started: _started, ...counts } of (item[1] as { aggregates: AggregationCounts[] }) + .aggregates) { + sessions.push(counts); + } + resolveFirstSession(); + } + }); + return SentryCore.resolvedSyncPromise({}); + }), + }); + return { client: SentryCore.getClient()!, sessions, firstSession }; + } + + test('records an exited session for a successful request', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + + test('records an errored session for a handled exception', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ + port, + fetch: () => { + SentryCore.captureException(new Error('handled')); + return new Response('ok'); + }, + }); + try { + await (await fetch(`http://localhost:${port}/handled`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 1, crashed: 0 }]); + }); + + test('records a crashed session when the handler throws', async () => { + const { client, sessions } = setupSessionClient(); + const error = new Error('unhandled'); + const server = Bun.serve({ + port, + fetch: () => { + throw error; + }, + }); + try { + await expect(server.fetch(new Request(`http://localhost:${port}/throws`))).rejects.toBe(error); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 0, crashed: 1 }]); + }); + + test.each([ + ['when tracing is enabled', {}], + ['when tracing is disabled', { tracesSampleRate: undefined }], + ] as const)('records a crashed session when a streamed response source fails %s', async (_label, options) => { + const { client, sessions } = setupSessionClient(options); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + const error = new Error('stream interrupted'); + const text = response.text(); + controller!.error(error); + await expect(text).rejects.toBe(error); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 0, crashed: 1 }]); + }); + + test('records sessions for OPTIONS and HEAD requests', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/cors`, { method: 'OPTIONS' })).text(); + await (await fetch(`http://localhost:${port}/cors`, { method: 'HEAD' })).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 2, errored: 0, crashed: 0 }]); + }); + + test('records sessions for requests without a span', async () => { + const { client, sessions } = setupSessionClient({ + integrations: [bunServerIntegration({ ignoreIncomingRequests: urlPath => urlPath === '/health' })], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/health`)).text(); + await (await fetch(`http://localhost:${port}/favicon.ico`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(sessions).toEqual([{ exited: 2, errored: 0, crashed: 0 }]); + }); + + test('does not record sessions when sessions is false', async () => { + const { client, sessions } = setupSessionClient({ integrations: [bunServerIntegration({ sessions: false })] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([]); + }); + + test('sends the session aggregate after sessionFlushingDelayMS', async () => { + const { sessions, firstSession } = setupSessionClient({ + integrations: [bunServerIntegration({ sessionFlushingDelayMS: 50 })], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + + // No flush here. The timeout only bounds a failure. With the default 60s delay, no session + // arrives within it. + let timeoutId: ReturnType | undefined; + const timedOut = new Promise<'timed out'>(resolve => { + timeoutId = setTimeout(() => resolve('timed out'), 2_000); + }); + const result = await Promise.race([firstSession.then(() => 'sent' as const), timedOut]); + clearTimeout(timeoutId); + + expect(result).toBe('sent'); + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + + test.each([ + ['when tracing is enabled', {}], + ['when tracing is disabled', { tracesSampleRate: undefined }], + ] as const)('records a streamed response session when the stream finishes %s', async (_label, options) => { + const { client, sessions } = setupSessionClient(options); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + controller!.enqueue(new TextEncoder().encode('chunk\n')); + await client.flush(); + expect(sessions).toEqual([]); + + controller!.close(); + expect(await response.text()).toBe('chunk\n'); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + }); }); From 5b2de994e49b2eee293f43ec27be1bcfa6017809 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 23 Oct 2026 13:54:33 -0700 Subject: [PATCH 042/126] feat(node): Add `onSpanEnd` to `httpIntegration` (#25581) `httpIntegration` takes `onSpanCreated` for incoming requests, but not `onSpanEnd`. A user could not change the server span based on the response, for example to rename it from data that is only known when the response finishes. Core's `node:http` server code already supports `onSpanEnd`, and `denoHttpIntegration` and `bunHttpServerIntegration` pass it through. `httpServerSpansIntegration` now accepts `onSpanEnd` and calls it as core does: when the response finishes, after the SDK sets the response attributes and the span status, before the span ends. `httpIntegration` passes it through. The shared `ServerRequestOptions` check for Node now lists no unsupported options. Fixes #25572 Fixes JS-3970 Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 + .../httpIntegration/instrument-options.mjs | 6 + .../instrument-throwing-callbacks.mjs | 3 + .../suites/tracing/httpIntegration/test.ts | 137 +++++++++++++++--- .../http/httpServerSpansIntegration.ts | 19 ++- packages/node/src/integrations/http/index.ts | 8 +- 6 files changed, 149 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3daac526d90..8fefee35d611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,6 +203,10 @@ Work in this release was contributed by @4ktLuffy, @adityareddy-dev, @dasilvalui `ignoreStaticAssets` defaults to `true`, so `Bun.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `bunServerIntegration({ ignoreStaticAssets: false })`. `Bun.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. Errors thrown for `OPTIONS` and `HEAD` requests are now captured. +- **feat(node): Add `onSpanEnd` to `httpIntegration`** + + `httpIntegration` and `httpServerSpansIntegration` now accept `onSpanEnd`, a hook that runs when the response for an incoming request finishes, before its span ends. Use it to change the span based on the response. Deno and Bun already have this option. + ## 11.8.0 ### Important Changes diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs index d283ccff7951..6a02d7d4a26c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs @@ -18,6 +18,12 @@ Sentry.init({ 'onSpanCreated.resMethod': res.req.method, }); }, + onSpanEnd: (span, req, res) => { + span.setAttributes({ + 'onSpanEnd.reqUrl': req.url, + 'onSpanEnd.resStatusCode': res.statusCode, + }); + }, }), ], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs index c2d7c4763781..6672b0dc99eb 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs @@ -15,6 +15,9 @@ Sentry.init({ onSpanCreated: () => { throw new Error('onSpanCreated error'); }, + onSpanEnd: () => { + throw new Error('onSpanEnd error'); + }, }), ], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 28ae77d23617..70618d73671d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -1,9 +1,62 @@ import { HTTP_CLIENT, HTTP_SERVER } from '@sentry/conventions/op'; import { createTestServer } from '@sentry-internal/test-utils'; -import { HTTP_RESPONSE_STATUS_CODE, SENTRY_OP, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { + CLIENT_ADDRESS, + CLIENT_PORT, + HTTP_REQUEST_METHOD, + HTTP_RESPONSE_STATUS_CODE, + HTTP_RESPONSE_STATUS_TEXT, + HTTP_ROUTE, + NETWORK_LOCAL_ADDRESS, + NETWORK_LOCAL_PORT, + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_PROTOCOL_NAME, + NETWORK_PROTOCOL_VERSION, + NETWORK_TRANSPORT, + SENTRY_ENVIRONMENT, + SENTRY_IS_LOCALHOST, + SENTRY_KIND, + SENTRY_OP, + SENTRY_ORIGIN, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_TRACE_LIFECYCLE, + SERVER_ADDRESS, + SERVER_PORT, + URL_FULL, + URL_PATH, + URL_SCHEME, + USER_AGENT_ORIGINAL, + USER_IP_ADDRESS, +} from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; -import { supports } from '../../../utils'; +import { EXPECTED_SDK_NAME, supports } from '../../../utils'; + +const LOOPBACK_ADDRESS = /^(::1|127\.0\.0\.1|::ffff:127\.0\.0\.1)$/; + +// Attributes from the context of the host (app, device, OS, culture, runtime, cloud). Which of them +// the SDK sends depends on the platform, so tests only check that they are well formed. +const HOST_CONTEXT_ATTRIBUTE = /^(app|device|os|culture|process\.runtime|cloud)\./; + +function splitHostContextAttributes(attributes: Record): { + hostContext: Record; + rest: Record; +} { + const hostContext: Record = {}; + const rest: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + (HOST_CONTEXT_ATTRIBUTE.test(key) ? hostContext : rest)[key] = value; + } + return { hostContext, rest }; +} function getCommonHttpRequestHeaders(): Record { return { @@ -22,30 +75,76 @@ describe('httpIntegration', () => { cleanupChildProcesses(); }); - describe('onSpanCreated option', () => { + describe('onSpanCreated and onSpanEnd options', () => { createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-options.mjs', (createRunner, test) => { - test('allows to configure onSpanCreated', async () => { + test('allows to configure onSpanCreated and onSpanEnd', async () => { const runner = createRunner() .expect({ - span: { - items: expect.arrayContaining([ + span: container => { + const segmentSpan = container.items.find(span => span.is_segment); + expect(segmentSpan).toEqual( expect.objectContaining({ - is_segment: true, span_id: expect.stringMatching(/[a-f\d]{16}/), trace_id: expect.stringMatching(/[a-f\d]{32}/), status: 'ok', - attributes: expect.objectContaining({ - [URL_FULL]: { type: 'string', value: expect.stringMatching(/\/test$/) }, - [HTTP_RESPONSE_STATUS_CODE]: { type: 'integer', value: 200 }, - [SENTRY_OP]: { type: 'string', value: HTTP_SERVER }, - onSpanCreated: { type: 'string', value: 'yes' }, - 'onSpanCreated.reqUrl': { type: 'string', value: expect.stringMatching(/\/test$/) }, - 'onSpanCreated.reqMethod': { type: 'string', value: 'GET' }, - 'onSpanCreated.resUrl': { type: 'string', value: expect.stringMatching(/\/test$/) }, - 'onSpanCreated.resMethod': { type: 'string', value: 'GET' }, - }), }), - ]), + ); + + // Every other attribute is listed, so the test fails when the span gets a new one. + const { hostContext, rest } = splitHostContextAttributes(segmentSpan!.attributes); + expect(rest).toEqual({ + onSpanCreated: { type: 'string', value: 'yes' }, + 'onSpanCreated.reqUrl': { type: 'string', value: '/test' }, + 'onSpanCreated.reqMethod': { type: 'string', value: 'GET' }, + 'onSpanCreated.resUrl': { type: 'string', value: '/test' }, + 'onSpanCreated.resMethod': { type: 'string', value: 'GET' }, + 'onSpanEnd.reqUrl': { type: 'string', value: '/test' }, + 'onSpanEnd.resStatusCode': { type: 'integer', value: 200 }, + [SENTRY_OP]: { type: 'string', value: HTTP_SERVER }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.http_server' }, + [SENTRY_KIND]: { type: 'string', value: 'server' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan!.span_id }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET /test' }, + [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'route' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['Http']) }, + [HTTP_REQUEST_METHOD]: { type: 'string', value: 'GET' }, + [HTTP_ROUTE]: { type: 'string', value: '/test' }, + [HTTP_RESPONSE_STATUS_CODE]: { type: 'integer', value: 200 }, + [HTTP_RESPONSE_STATUS_TEXT]: { type: 'string', value: 'OK' }, + [URL_FULL]: { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test$/) }, + [URL_PATH]: { type: 'string', value: '/test' }, + [URL_SCHEME]: { type: 'string', value: 'http' }, + [SERVER_ADDRESS]: { type: 'string', value: 'localhost' }, + [SERVER_PORT]: { type: 'integer', value: expect.any(Number) }, + [CLIENT_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [CLIENT_PORT]: { type: 'integer', value: expect.any(Number) }, + [USER_IP_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_LOCAL_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_LOCAL_PORT]: { type: 'integer', value: expect.any(Number) }, + [NETWORK_PEER_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_PEER_PORT]: { type: 'integer', value: expect.any(Number) }, + [NETWORK_PROTOCOL_NAME]: { type: 'string', value: 'http' }, + [NETWORK_PROTOCOL_VERSION]: { type: 'string', value: '1.1' }, + [NETWORK_TRANSPORT]: { type: 'string', value: 'tcp' }, + [USER_AGENT_ORIGINAL]: { type: 'string', value: 'node' }, + 'http.request.header.accept': { type: 'array', value: ['*/*'] }, + 'http.request.header.accept-encoding': { type: 'array', value: ['gzip, deflate'] }, + 'http.request.header.accept-language': { type: 'array', value: ['*'] }, + 'http.request.header.connection': { type: 'array', value: ['keep-alive'] }, + 'http.request.header.host': { type: 'array', value: [expect.stringMatching(/^localhost:\d+$/)] }, + 'http.request.header.sec-fetch-mode': { type: 'array', value: ['cors'] }, + 'http.request.header.user-agent': { type: 'array', value: ['node'] }, + }); + for (const attribute of Object.values(hostContext)) { + expect(attribute).toEqual({ type: expect.any(String), value: expect.anything() }); + } }, }) .start(); @@ -57,7 +156,7 @@ describe('httpIntegration', () => { describe('user callbacks that throw', () => { createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-throwing-callbacks.mjs', (createRunner, test) => { - test('still handles requests and sends the span when `onSpanCreated` and `ignoreIncomingRequests` throw', async () => { + test('still handles requests and sends the span when `onSpanCreated`, `onSpanEnd` and `ignoreIncomingRequests` throw', async () => { const runner = createRunner() .expect({ span: { diff --git a/packages/node/src/integrations/http/httpServerSpansIntegration.ts b/packages/node/src/integrations/http/httpServerSpansIntegration.ts index 5765629bdee9..db9c231a810f 100644 --- a/packages/node/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node/src/integrations/http/httpServerSpansIntegration.ts @@ -36,6 +36,7 @@ import { } from '@sentry/core/server'; import { debug, + safeCallback, getSpanStatusFromHttpCode, httpHeadersToSpanAttributes, getContentLengthFromHeaders, @@ -52,7 +53,6 @@ import { filterCollectedUrlQuery, hasSpanStreamingEnabled, HTTP_SPAN_NAME_FALLBACK, - safeCallback, } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { NodeClient } from '../../sdk/client'; @@ -105,6 +105,12 @@ export interface HttpServerSpansIntegrationOptions { * This is triggered after the span is created, but before it is recorded. */ onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; + + /** + * A hook that can be used to mutate the span one last time when the response is finished. + * This is triggered after the response status is set on the span, before the span ends. + */ + onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions = {}) => { @@ -113,7 +119,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions // oxlint-disable-next-line typescript/no-deprecated const ignoreStatusCodes = options.ignoreStatusCodes ?? DEFAULT_IGNORE_STATUS_CODES; - const { onSpanCreated } = options; + const { onSpanCreated, onSpanEnd } = options; return { name: INTEGRATION_NAME, @@ -214,6 +220,15 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions ); span.setAttributes(newAttributes); span.setStatus(status); + if (onSpanEnd) { + // A hook that throws must not skip `span.end()` or crash the process from the + // response's event listener. + safeCallback( + DEBUG_BUILD ? 'The `onSpanEnd` callback threw an error:' : '', + () => onSpanEnd(span, request, response), + () => undefined, + ); + } span.end(); } diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index 7431a6a34537..40c07a09ad40 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -93,13 +93,7 @@ interface HttpOptions extends HttpServerIntegrationOptions, HttpServerSpansInteg // shared field fails to compile here, until this integration supports // it or lists it as unsupported. type _ServerRequestOptionsCheck = AssertTrue< - CheckServerRequestOptions< - HttpOptions, - HttpIncomingMessage, - HttpServerResponse, - // Not supported yet. - 'onSpanEnd' - > + CheckServerRequestOptions >; /** From 2a5335757c24bb779cf6617fbb8005cd772d7967 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 23 Oct 2026 16:05:51 -0700 Subject: [PATCH 043/126] fix(cloudflare): Bind a default re-export with `var` in the Vite transform (#25663) The transform rewrote `export { default } from './handler'` to bind the wrapped handler with a `const`. A module in an import cycle that read the entry's default export during evaluation hit the `const` before its initialization, threw a `ReferenceError`, and stopped the Worker from starting. The binding is now a `var`, as `wrapDefaultSpecifier` and `wrapDefaultExport` already use. A cyclic read gets `undefined` and does not throw. It does not get the handler as the original live binding did, because the wrapper is not set until the entry's body runs. The JSDoc states this limit. Co-authored-by: Claude Opus 5.5 (1M context) --- packages/cloudflare/src/vite/transform.ts | 7 +++- .../cloudflare/test/vite/transform.test.ts | 33 +++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 273cfb16a766..e640de3a45d1 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -604,6 +604,11 @@ function wrapDefaultSpecifier(localName: string, state: TransformState): string /** * `export { default } from './handler'`. The handler may already be wrapped in its own module, * which this transform cannot see, so the emitted guard returns such a handler as-is. + * + * The original re-export is a live binding to the handler module's export, but the wrapper is not + * set until this module's body runs. A circular import that reads the default export before then + * gets `undefined` instead of the handler. The binding is a `var` so that the read does not throw, + * as a `const` would, and stop the Worker from starting. See `wrapDefaultSpecifier`. */ function wrapDefaultReexport( localName: string, @@ -616,7 +621,7 @@ function wrapDefaultReexport( localName === DEFAULT_IMPORT ? `import ${target} from ${sourceLiteral};` : `import { ${localName} as ${target} } from ${sourceLiteral};`, - `const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, ${state.optionsFn}, ${target});`, + `var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, ${state.optionsFn}, ${target});`, ); state.needsImport = true; state.autoWrapped.add(DEFAULT_EXPORT); diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 539b66c98961..76129b034923 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -241,7 +241,7 @@ describe('default export wrapping', () => { [ "import * as __SENTRY__ from '@sentry/cloudflare';", "import __SENTRY_REEXPORT_default__ from './handler';", - 'const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', + 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', ].join('\n'), ); @@ -254,12 +254,39 @@ describe('default export wrapping', () => { [ "import * as __SENTRY__ from '@sentry/cloudflare';", "import { handler as __SENTRY_REEXPORT_default__ } from './handler';", - 'const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', + 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', ].join('\n'), ); }); + it('does not throw when a circular import reads a default re-export during module evaluation', () => { + // `b` reads the entry's default export after `./handler.mjs` has run but before the entry has. + const entry = [ + "export { default } from './handler.mjs';", + "import { readDefault } from './b.mjs';", + 'console.log(readDefault());', + ].join('\n'); + const transformed = transform(entry, ctx)!.code.replace( + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const __SENTRY__ = { withSentry: (_options, handler) => handler, _INTERNAL_wrapUnlessInstrumented: (wrap, options, handler) => wrap(options, handler) };', + ); + + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-cycle-')); + try { + writeFileSync(join(dir, 'a.mjs'), transformed); + writeFileSync(join(dir, 'handler.mjs'), 'export default { fetch() {} };\n'); + writeFileSync( + join(dir, 'b.mjs'), + "import handler from './a.mjs';\nconst kind = typeof handler;\nexport function readDefault() { return kind; }\n", + ); + // Plain Node: Vitest's module runner does not evaluate import cycles like native ESM does. + expect(execFileSync(process.execPath, [join(dir, 'a.mjs')], { encoding: 'utf8' }).trim()).toBe('undefined'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('leaves sibling specifiers of a default re-export untouched', () => { const code = "export { default, helper } from './handler';"; @@ -267,7 +294,7 @@ describe('default export wrapping', () => { [ "import * as __SENTRY__ from '@sentry/cloudflare';", "import __SENTRY_REEXPORT_default__ from './handler';", - 'const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', + 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', "export { helper } from './handler';", ].join('\n'), From 21a9938c36976a230c56380b94c45e5ac789b33c Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 23 Oct 2026 16:18:52 -0700 Subject: [PATCH 044/126] feat(bun): Inject debug IDs and upload source maps in `sentryBunPlugin` (#25670) `sentryBunPlugin` only ran the orchestrion transform. To get readable stack traces, Bun users had to run `sentry sourcemap inject` and `upload` after the build and set the release themselves. `@sentry/bundler-plugins/bun` is a new adapter that takes the same options as the esbuild plugin. `@sentry/bun/plugin` now combines it with the orchestrion transform, the same way `@sentry/node/esbuild` does. Bun computes the debug ID of each output only after it adds the banner, and writes it as `//# debugId=` into the bundle and its source map. So the banner holds the debug ID snippet with a placeholder of the same length. When the build ends, the plugin replaces the placeholder with the ID that Bun wrote into the file. The source map stays valid, and chunks get a debug ID too. Then the plugin uploads the bundles through the shared upload code. The plugin injects debug IDs only when Bun writes source maps to `outdir`. In memory builds have no files to change. When Bun runs the plugin as a runtime plugin, it has no build config, and the plugin does nothing, as before. Fixes #25618 Fixes JS-3983 Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 + packages/bun/package.json | 1 + packages/bun/src/plugin.ts | 46 +++- packages/bun/test/plugin.test.ts | 86 +++++++ packages/bundler-plugins/package.json | 5 + .../bundler-plugins/rollup.npm.config.mjs | 1 + packages/bundler-plugins/src/bun/index.ts | 169 ++++++++++++++ packages/bundler-plugins/test/bun/bun.test.ts | 209 ++++++++++++++++++ pnpm-lock.yaml | 3 + 9 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 packages/bun/test/plugin.test.ts create mode 100644 packages/bundler-plugins/src/bun/index.ts create mode 100644 packages/bundler-plugins/test/bun/bun.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fefee35d611..2af7c74fcaa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,12 @@ Work in this release was contributed by @4ktLuffy, @adityareddy-dev, @dasilvalui `httpIntegration` and `httpServerSpansIntegration` now accept `onSpanEnd`, a hook that runs when the response for an incoming request finishes, before its span ends. Use it to change the span based on the response. Deno and Bun already have this option. +- **feat(bun): Inject debug IDs and upload source maps in `sentryBunPlugin` ([#25670](https://github.com/getsentry/sentry-javascript/pull/25670))** + + `sentryBunPlugin` from `@sentry/bun/plugin` now accepts the same options as `sentryEsbuildPlugin` (`org`, `project`, `authToken`, `release`, `sourcemaps`, ...). When `Bun.build` writes source maps to `outdir`, the plugin injects the debug ID that Bun writes into each bundle, injects the release, and uploads the source maps at the end of the build. You no longer need to run `sentry sourcemap inject` and `sentry sourcemap upload` after the build. + + Like the other bundler plugins, `sentryBunPlugin` now sends build telemetry and injects the release it detects (for example, from git). To turn off telemetry, set `telemetry: false`. + ## 11.8.0 ### Important Changes diff --git a/packages/bun/package.json b/packages/bun/package.json index 28f3d98b62b5..01efea07620e 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -43,6 +43,7 @@ "access": "public" }, "dependencies": { + "@sentry/bundler-plugins": "11.9.0", "@sentry/core": "11.9.0", "@sentry/conventions": "^0.29.0", "@sentry/node": "11.9.0", diff --git a/packages/bun/src/plugin.ts b/packages/bun/src/plugin.ts index 83c42f3126cd..0873a5ed454a 100644 --- a/packages/bun/src/plugin.ts +++ b/packages/bun/src/plugin.ts @@ -1,5 +1,5 @@ /** - * Sentry code-transform plugin for Bun's bundler (`bun build`). + * Sentry plugin for Bun's bundler (`bun build`). * * Usage: * @@ -7,7 +7,9 @@ * import { sentryBunPlugin } from '@sentry/bun/plugin'; * await Bun.build({ * entrypoints: ['./app.ts'], - * plugins: [sentryBunPlugin()], + * outdir: './dist', + * sourcemap: 'external', + * plugins: [sentryBunPlugin({ org: '…', project: '…', authToken: process.env.SENTRY_AUTH_TOKEN })], * }); * ``` * @@ -15,4 +17,42 @@ * * @module */ -export { sentryOrchestrionPlugin as sentryBunPlugin } from '@sentry/server-utils/orchestrion/bun'; +import { sentryBunPlugin as sentryBunBundlerPlugin } from '@sentry/bundler-plugins/bun'; +import type { SentryBunPluginOptions as SentryBunPluginOptionsBase } from '@sentry/bundler-plugins/bun'; +import type { InstrumentationConfig } from '@sentry/server-utils'; +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/bun'; + +export type SentryBunPluginOptions = SentryBunPluginOptionsBase & { + /** + * @ignore This is for internal use only when this plugin is consumed by a framework SDK + */ + instrumentations?: InstrumentationConfig[]; + + /** + * Automatic instrumentation of server-side dependencies at build time. + * + * Set to `false` to turn it off. + * + * @default true + */ + buildTimeInstrumentation?: boolean; +}; + +/** + * Bun plugin that combines the Sentry bundler plugin (source maps, release + * injection, …) with the code transformer (build-time `diagnostics_channel` + * instrumentation for server libraries). + */ +// oxlint-disable-next-line typescript/no-explicit-any +export function sentryBunPlugin(options?: SentryBunPluginOptions): any { + const bundlerPlugin = sentryBunBundlerPlugin(options); + const orchestrionPlugin: { setup: (build: unknown) => void } = sentryOrchestrionPlugin(options); + + return { + name: 'sentry-bun', + setup(build: Parameters[0]): void { + bundlerPlugin.setup(build); + orchestrionPlugin.setup(build); + }, + }; +} diff --git a/packages/bun/test/plugin.test.ts b/packages/bun/test/plugin.test.ts new file mode 100644 index 000000000000..140dd7a3eadc --- /dev/null +++ b/packages/bun/test/plugin.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { sentryBunPlugin } from '../src/plugin'; + +const DEBUG_ID_MARKER = /sentry-dbid-([0-9a-f-]{36})/; +const BUN_DEBUG_ID = /\/\/# debugId=([0-9A-F]{32})/; + +function readSourceMap(bundlePath: string, source: string): { debugId?: unknown } { + const inlineMap = source.match(/^\/\/# sourceMappingURL=data:application\/json;base64,(.*)$/m)?.[1]; + return JSON.parse( + inlineMap ? Buffer.from(inlineMap, 'base64').toString() : fs.readFileSync(`${bundlePath}.map`, 'utf8'), + ); +} + +function toUuid(bunDebugId: string): string { + const hex = bunDebugId.toLowerCase(); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +describe('sentryBunPlugin', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-bun-plugin-')); + fs.mkdirSync(path.join(tmpDir, 'src')); + fs.writeFileSync( + path.join(tmpDir, 'src', 'index.ts'), + 'const { value } = await import("./lazy");\nconsole.log(JSON.stringify({ value, ids: globalThis._sentryDebugIds }));\n', + ); + fs.writeFileSync(path.join(tmpDir, 'src', 'lazy.ts'), 'export const value = 42;\n'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test.each(['external', 'linked', 'inline'] as const)( + 'registers the debug ID that Bun wrote into each bundle and its source map (sourcemap: %s)', + async sourcemap => { + const outdir = path.join(tmpDir, 'dist'); + const result = await Bun.build({ + entrypoints: [path.join(tmpDir, 'src', 'index.ts')], + outdir, + target: 'bun', + sourcemap, + splitting: true, + plugins: [sentryBunPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } })], + }); + + expect(result.success).toBe(true); + + const bundles = result.outputs.filter(o => o.kind === 'entry-point' || o.kind === 'chunk'); + expect(bundles.some(bundle => bundle.kind === 'chunk')).toBe(true); + + const debugIds = bundles.map(bundle => { + const source = fs.readFileSync(bundle.path, 'utf8'); + const map = readSourceMap(bundle.path, source); + const bunDebugId = source.match(BUN_DEBUG_ID)?.[1]; + + expect(bunDebugId).toBeDefined(); + expect(map.debugId).toBe(bunDebugId); + expect(source.match(DEBUG_ID_MARKER)?.[1]).toBe(toUuid(bunDebugId!)); + return toUuid(bunDebugId!); + }); + + const run = Bun.spawnSync([process.execPath, path.join(outdir, 'index.js')]); + const { value, ids } = JSON.parse(run.stdout.toString()); + + expect(value).toBe(42); + expect(Object.values(ids).sort()).toEqual(debugIds.sort()); + }, + ); + + test('builds when Bun keeps the output in memory', async () => { + const result = await Bun.build({ + entrypoints: [path.join(tmpDir, 'src', 'index.ts')], + target: 'bun', + sourcemap: 'external', + plugins: [sentryBunPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } })], + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index 12bbe2ce482d..4e2a8a928025 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -39,6 +39,11 @@ "import": "./build/esm/esbuild/index.js", "require": "./build/cjs/esbuild/index.js" }, + "./bun": { + "types": "./build/types/bun/index.d.ts", + "import": "./build/esm/bun/index.js", + "require": "./build/cjs/bun/index.js" + }, "./core": { "types": "./build/types/core/index.d.ts", "import": "./build/esm/core/index.js", diff --git a/packages/bundler-plugins/rollup.npm.config.mjs b/packages/bundler-plugins/rollup.npm.config.mjs index 65f39a6a8d22..19e511012dc3 100644 --- a/packages/bundler-plugins/rollup.npm.config.mjs +++ b/packages/bundler-plugins/rollup.npm.config.mjs @@ -8,6 +8,7 @@ export default makeNPMConfigVariants( 'src/rollup/index.ts', 'src/vite/index.ts', 'src/esbuild/index.ts', + 'src/bun/index.ts', 'src/webpack/index.ts', 'src/webpack/component-annotation-transform.ts', 'src/babel-plugin/index.ts', diff --git a/packages/bundler-plugins/src/bun/index.ts b/packages/bundler-plugins/src/bun/index.ts new file mode 100644 index 000000000000..6f45fc0a1c01 --- /dev/null +++ b/packages/bundler-plugins/src/bun/index.ts @@ -0,0 +1,169 @@ +import type { Options } from '../core'; +import { + createSentryBuildPluginManager, + generateReleaseInjectorCode, + generateModuleMetadataInjectorCode, + getDebugIdSnippet, + createDebugIdUploadFunction, + CodeInjection, +} from '../core'; +import * as fs from 'node:fs'; + +interface BunBuildArtifact { + path: string; + kind: string; +} + +interface BunBuildConfig { + banner?: string; + sourcemap?: 'none' | 'linked' | 'inline' | 'external' | boolean; + outdir?: string; + define?: Record; +} + +interface BunPluginBuilder { + // Bun passes no `config` when the plugin runs as a runtime plugin (`Bun.plugin()`) + config?: BunBuildConfig; + onEnd: (callback: (result: { outputs: BunBuildArtifact[] }) => void | Promise) => void; +} + +interface BunPlugin { + name: string; + setup: (build: BunPluginBuilder) => void; +} + +const pluginName = 'sentry-bun-plugin'; + +// Bun computes the debug ID of an output after it adds the banner, so the +// banner holds this placeholder. It has the length of a debug ID, so the +// swap does not move any code that the source map points to. +const DEBUG_ID_PLACEHOLDER = 'sentry-bun-debug-id-placeholder-0000'; +// Bun writes `//# sourceMappingURL=` after this comment for linked and +// inline source maps, so match the last debug ID line, not the file end. +const BUN_DEBUG_ID_REGEX = /^\/\/# debugId=([0-9a-fA-F]{32})$(?![\s\S]*^\/\/# debugId=)/m; + +function bunDebugIdToUuid(bunDebugId: string): string { + const hex = bunDebugId.toLowerCase(); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export function sentryBunPlugin(userOptions: Options = {}): BunPlugin { + const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { + loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? `[${pluginName}]`, + buildTool: 'bun', + buildToolMajorVersion: process.versions['bun']?.split('.')[0], + }); + + const { + logger, + normalizedOptions: options, + bundleSizeOptimizationReplacementValues: replacementValues, + bundleMetadata, + createDependencyOnBuildArtifacts, + } = sentryBuildPluginManager; + + if (options.disable) { + return { name: 'sentry-bun-noop-plugin', setup: () => undefined }; + } + + const staticInjectionCode = new CodeInjection(); + + if (!options.release.inject) { + logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); + } else if (!options.release.name) { + logger.debug( + 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', + ); + } else { + staticInjectionCode.append( + generateReleaseInjectorCode({ + release: options.release.name, + injectBuildInformation: options._experiments.injectBuildInformation || false, + }), + ); + } + + if (Object.keys(bundleMetadata).length > 0) { + staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + } + + void sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal().catch(() => { + // Telemetry failures are acceptable + }); + + async function replaceDebugIdPlaceholder(bundlePath: string): Promise { + const source = await fs.promises.readFile(bundlePath, 'utf8'); + const bunDebugId = source.match(BUN_DEBUG_ID_REGEX)?.[1]; + if (!bunDebugId) { + logger.debug(`Bun wrote no debug ID into the bundle. File will not be source mapped: ${bundlePath}`); + return; + } + + await fs.promises.writeFile( + bundlePath, + source.split(DEBUG_ID_PLACEHOLDER).join(bunDebugIdToUuid(bunDebugId)), + 'utf8', + ); + } + + return { + name: pluginName, + setup(build: BunPluginBuilder): void { + const config = build.config; + if (!config) { + return; + } + + // Without `outdir`, Bun writes no files that the placeholder can be + // replaced in. + const injectDebugIds = + options.sourcemaps?.disable !== true && !!config.outdir && !!config.sourcemap && config.sourcemap !== 'none'; + + const injectionCode = new CodeInjection(); + injectionCode.append(staticInjectionCode); + if (injectDebugIds) { + injectionCode.append(getDebugIdSnippet(DEBUG_ID_PLACEHOLDER)); + } + + if (!injectionCode.isEmpty()) { + config.banner = config.banner ? `${config.banner}\n${injectionCode.code()}` : injectionCode.code(); + } + + if (Object.keys(replacementValues).length > 0) { + const replacementStringValues: Record = {}; + Object.entries(replacementValues).forEach(([key, value]) => { + replacementStringValues[key] = JSON.stringify(value); + }); + + config.define = { ...config.define, ...replacementStringValues }; + } + + const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); + const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); + + build.onEnd(async result => { + try { + await sentryBuildPluginManager.createRelease(); + + if (injectDebugIds) { + const bundlePaths = result.outputs + .filter(output => output.kind === 'entry-point' || output.kind === 'chunk') + .map(output => output.path); + + await Promise.all(bundlePaths.map(replaceDebugIdPlaceholder)); + + if (options.sourcemaps?.disable !== 'disable-upload') { + await upload(bundlePaths); + } + } + } finally { + freeGlobalDependencyOnBuildArtifacts(); + await sentryBuildPluginManager.deleteArtifacts(); + } + }); + }, + }; +} + +export default sentryBunPlugin; +export type { Options as SentryBunPluginOptions } from '../core'; diff --git a/packages/bundler-plugins/test/bun/bun.test.ts b/packages/bundler-plugins/test/bun/bun.test.ts new file mode 100644 index 000000000000..2ea1af33b4b6 --- /dev/null +++ b/packages/bundler-plugins/test/bun/bun.test.ts @@ -0,0 +1,209 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type * as DebugIdUpload from '../../src/core/debug-id-upload'; + +const { uploadSpy } = vi.hoisted(() => ({ uploadSpy: vi.fn() })); + +vi.mock('../../src/core/debug-id-upload', async importOriginal => ({ + ...(await importOriginal()), + createDebugIdUploadFunction: () => uploadSpy, +})); + +import { sentryBunPlugin } from '../../src/bun'; + +const DEBUG_ID_MARKER = /sentry-dbid-([0-9a-f-]{36})/; +const PLACEHOLDER = 'sentry-bun-debug-id-placeholder-0000'; +const BUN_DEBUG_ID = '0217B9855BFB77DF64756E2164756E21'; +const UUID = '0217b985-5bfb-77df-6475-6e2164756e21'; + +type Output = { path: string; kind: string }; +type OnEnd = (result: { success: boolean; outputs: Output[]; logs: unknown[] }) => Promise; +type Config = { banner?: string; sourcemap?: string | boolean; define?: Record; outdir?: string }; + +interface BunPlugin { + name: string; + setup: (build: { config?: Config; onEnd: (cb: OnEnd) => void }) => void; +} + +function setupPlugin(plugin: BunPlugin, config: Config | undefined) { + let onEnd: OnEnd | undefined; + plugin.setup({ + config, + onEnd: cb => { + onEnd = cb; + }, + }); + return { + onEnd, + end: (outputs: Output[]) => onEnd!({ success: true, outputs, logs: [] }), + }; +} + +describe('sentryBunPlugin', () => { + let outDir: string; + + beforeEach(() => { + uploadSpy.mockReset(); + outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-bun-plugin-')); + }); + + afterEach(() => { + fs.rmSync(outDir, { recursive: true, force: true }); + }); + + // Emulates what Bun writes for a bundle whose banner contains the snippet + function writeBundle(name: string, banner: string, debugId: string | null = BUN_DEBUG_ID): string { + const file = path.join(outDir, name); + const debugIdComment = debugId ? `\n//# debugId=${debugId}\n` : '\n'; + fs.writeFileSync(file, `// @bun\n${banner}\nconsole.log(1);\n${debugIdComment}`); + fs.writeFileSync(`${file}.map`, JSON.stringify({ version: 3, mappings: ';;AAAA', debugId })); + return file; + } + + it('returns a Bun plugin', () => { + expect(sentryBunPlugin({ telemetry: false })).toEqual({ name: 'sentry-bun-plugin', setup: expect.any(Function) }); + }); + + it('does nothing when Bun runs it as a runtime plugin, which has no build config', () => { + const { onEnd } = setupPlugin(sentryBunPlugin({ telemetry: false }) as BunPlugin, undefined); + expect(onEnd).toBeUndefined(); + }); + + it('adds the debug ID snippet to the banner when Bun emits source maps', () => { + const config: Config = { banner: '// existing', sourcemap: 'external', outdir: outDir }; + setupPlugin(sentryBunPlugin({ telemetry: false }) as BunPlugin, config); + + expect(config.banner).toMatch(/^\/\/ existing\n/); + expect(config.banner).toContain(PLACEHOLDER); + }); + + it.each([undefined, 'none', false])('does not add the debug ID snippet when `sourcemap` is %s', sourcemap => { + const config: Config = { sourcemap, outdir: outDir }; + setupPlugin(sentryBunPlugin({ telemetry: false }) as BunPlugin, config); + + expect(config.banner ?? '').not.toContain('sentry-dbid-'); + }); + + it('does not add the debug ID snippet when Bun keeps the output in memory', () => { + const config: Config = { sourcemap: 'external' }; + setupPlugin(sentryBunPlugin({ telemetry: false }) as BunPlugin, config); + + expect(config.banner ?? '').not.toContain('sentry-dbid-'); + }); + + it('injects the release into the banner', () => { + const config: Config = { outdir: outDir }; + setupPlugin(sentryBunPlugin({ telemetry: false, release: { name: '1.2.3' } }) as BunPlugin, config); + + expect(config.banner).toContain('SENTRY_RELEASE={id:"1.2.3"}'); + }); + + it('defines the bundle size optimization flags', () => { + const config: Config = { define: { FOO: '"bar"' }, outdir: outDir }; + setupPlugin( + sentryBunPlugin({ telemetry: false, bundleSizeOptimizations: { excludeDebugStatements: true } }) as BunPlugin, + config, + ); + + expect(config.define).toEqual({ FOO: '"bar"', __SENTRY_DEBUG__: 'false' }); + }); + + it('stamps each emitted bundle with the debug ID Bun wrote into it', async () => { + const config: Config = { sourcemap: 'external', outdir: outDir }; + const { end } = setupPlugin(sentryBunPlugin({ telemetry: false }) as BunPlugin, config); + + const entry = writeBundle('index.js', config.banner!); + const chunk = writeBundle('chunk-abc.js', config.banner!, 'DC1E64A3985F409564756E2164756E21'); + const mapBefore = fs.readFileSync(`${entry}.map`, 'utf8'); + + await end([ + { path: entry, kind: 'entry-point' }, + { path: chunk, kind: 'chunk' }, + { path: `${entry}.map`, kind: 'sourcemap' }, + { path: `${chunk}.map`, kind: 'sourcemap' }, + ]); + + const entrySource = fs.readFileSync(entry, 'utf8'); + expect(entrySource.match(DEBUG_ID_MARKER)?.[1]).toBe(UUID); + expect(entrySource).toContain(`//# debugId=${BUN_DEBUG_ID}`); + expect(entrySource.length).toBe( + config.banner!.length + `// @bun\n\nconsole.log(1);\n\n//# debugId=${BUN_DEBUG_ID}\n`.length, + ); + expect(fs.readFileSync(chunk, 'utf8').match(DEBUG_ID_MARKER)?.[1]).toBe('dc1e64a3-985f-4095-6475-6e2164756e21'); + expect(fs.readFileSync(`${entry}.map`, 'utf8')).toBe(mapBefore); + }); + + it('stamps a bundle that has a source map URL after the debug ID', async () => { + const config: Config = { sourcemap: 'linked', outdir: outDir }; + const { end } = setupPlugin(sentryBunPlugin({ telemetry: false }) as BunPlugin, config); + + const entry = path.join(outDir, 'index.js'); + fs.writeFileSync( + entry, + `// @bun\n${config.banner}\nconsole.log(1);\n\n//# debugId=${BUN_DEBUG_ID}\n//# sourceMappingURL=index.js.map\n`, + ); + + await end([{ path: entry, kind: 'entry-point' }]); + + expect(fs.readFileSync(entry, 'utf8').match(DEBUG_ID_MARKER)?.[1]).toBe(UUID); + }); + + it('leaves a bundle without a Bun debug ID unchanged', async () => { + const config: Config = { sourcemap: 'external', outdir: outDir }; + const { end } = setupPlugin(sentryBunPlugin({ telemetry: false }) as BunPlugin, config); + + const entry = writeBundle('index.js', config.banner!, null); + const before = fs.readFileSync(entry, 'utf8'); + + await end([{ path: entry, kind: 'entry-point' }]); + + expect(fs.readFileSync(entry, 'utf8')).toBe(before); + }); + + it('uploads the emitted bundles', async () => { + const config: Config = { sourcemap: 'external', outdir: outDir }; + const { end } = setupPlugin(sentryBunPlugin({ telemetry: false }) as BunPlugin, config); + + const entry = writeBundle('index.js', config.banner!); + const chunk = writeBundle('chunk-abc.js', config.banner!); + + await end([ + { path: entry, kind: 'entry-point' }, + { path: chunk, kind: 'chunk' }, + { path: `${entry}.map`, kind: 'sourcemap' }, + ]); + + expect(uploadSpy).toHaveBeenCalledWith([entry, chunk]); + }); + + it('stamps but does not upload with `sourcemaps.disable: "disable-upload"`', async () => { + const config: Config = { sourcemap: 'external', outdir: outDir }; + const { end } = setupPlugin( + sentryBunPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } }) as BunPlugin, + config, + ); + + const entry = writeBundle('index.js', config.banner!); + + await end([{ path: entry, kind: 'entry-point' }]); + + expect(fs.readFileSync(entry, 'utf8').match(DEBUG_ID_MARKER)?.[1]).toBe(UUID); + expect(uploadSpy).not.toHaveBeenCalled(); + }); + + it('does not inject or upload with `sourcemaps.disable: true`', async () => { + const config: Config = { sourcemap: 'external', outdir: outDir }; + const { end } = setupPlugin( + sentryBunPlugin({ telemetry: false, sourcemaps: { disable: true } }) as BunPlugin, + config, + ); + + expect(config.banner ?? '').not.toContain('sentry-dbid-'); + + await end([]); + + expect(uploadSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 617e70dc091a..01bc7f56e472 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1140,6 +1140,9 @@ importers: packages/bun: dependencies: + '@sentry/bundler-plugins': + specifier: 11.9.0 + version: link:../bundler-plugins '@sentry/conventions': specifier: ^0.29.0 version: 0.29.0 From eff54dd1727f29bb8cb98686cd98462756d4d42c Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 24 Oct 2026 21:17:40 +0200 Subject: [PATCH 045/126] chore(ci): remove browser flaky-test detector workflow (#25712) Remove the workflow that repeatedly runs changed browser integration tests to detect flakiness. Keep the flakiness runner available as a local package command. Co-authored-by: GPT-6 --- .github/workflows/flaky-test-detector.yml | 71 ----------------------- 1 file changed, 71 deletions(-) delete mode 100644 .github/workflows/flaky-test-detector.yml diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml deleted file mode 100644 index fbde86cf1a0f..000000000000 --- a/.github/workflows/flaky-test-detector.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: 'CI: Detect flaky tests' -on: - workflow_dispatch: - pull_request: - paths: - - 'dev-packages/browser-integration-tests/suites/**/test.ts' - branches-ignore: - - master - -env: - HEAD_COMMIT: ${{ github.event.inputs.commit || github.sha }} - -# 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: - flaky-detector: - runs-on: namespace-profile-sentry-javascript-large - timeout-minutes: 60 - name: 'Check tests for flakiness' - # Also skip if PR is from master -> develop - if: ${{ github.base_ref != 'master' && github.ref != 'refs/heads/master' }} - steps: - - name: Check out current branch - uses: actions/checkout@v7 - - name: Install pnpm - uses: ./.github/actions/install-pnpm - - name: Set up Node - uses: actions/setup-node@v7 - with: - node-version-file: 'package.json' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build packages - run: pnpm run build - - - name: Install Playwright - uses: ./.github/actions/install-playwright - with: - browsers: 'chromium' - cwd: dev-packages/browser-integration-tests - - - name: Determine changed tests - uses: dorny/paths-filter@v4.0.3 - id: changed - with: - list-files: json - filters: | - browser_integration: dev-packages/browser-integration-tests/suites/**/test.ts - - - name: Detect flaky tests - id: test - run: pnpm run test:detect-flaky - working-directory: dev-packages/browser-integration-tests - env: - CHANGED_TEST_PATHS: ${{ steps.changed.outputs.browser_integration_files }} - TEST_RUN_COUNT: 'AUTO' - - - name: Upload Playwright Traces - uses: actions/upload-artifact@v7 - if: failure() && steps.test.outcome == 'failure' - with: - name: playwright-test-results - path: dev-packages/browser-integration-tests/test-results - retention-days: 5 From a734d03c88098167dbaeab5664b40dc141fb9d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Sun, 25 Oct 2026 12:38:45 +0000 Subject: [PATCH 046/126] test(cloudflare): Check that Durable Object invocations end when the method returns (#25511) follow up to #25490 This adds an integration test that checks that the SDK does not keep Durable Object invocations open after the method returns. A tail worker in the same `wrangler dev` process records each DO invocation, and the test asserts that a DO `fetch`, a propagated RPC call and an `alarm` end within 250 ms. ## Why workerd keeps a DO invocation open, and billed, until its pending timers fire (#25489). The unit test in #25490 only checks the segment-end flush timer. This test fails for any SDK timer that is still pending when a DO method returns. Without #25490, the test fails with `fetch` at 507 ms, RPC at 502 ms and `alarm` at 500 ms. With it, they take 2 to 15 ms. ## Known issues - The test asserts a time limit, so a very slow CI machine can make it fail. The limit is more than 15 times the slowest local run. Co-authored-by: Claude Opus 5.5 --- .../invocation-duration/index.ts | 46 +++++++++++++++++++ .../invocation-duration/instrument.server.ts | 6 +++ .../durableobject/invocation-duration/tail.ts | 26 +++++++++++ .../durableobject/invocation-duration/test.ts | 46 +++++++++++++++++++ .../invocation-duration/vite.config.mts | 7 +++ .../invocation-duration/wrangler-tail.jsonc | 6 +++ .../invocation-duration/wrangler.jsonc | 13 ++++++ 7 files changed, 150 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/tail.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler-tail.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/index.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/index.ts new file mode 100644 index 000000000000..abc4a78578af --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/index.ts @@ -0,0 +1,46 @@ +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + TEST_DURABLE_OBJECT: DurableObjectNamespace; + TAIL: Fetcher; +} + +export class TestDurableObject extends DurableObject { + async fetch(): Promise { + return new Response('OK'); + } + + async ping(): Promise { + return 'pong'; + } + + async setAlarm(): Promise { + await this.ctx.storage.setAlarm(Date.now()); + } + + async alarm(): Promise {} +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/invocations') { + return env.TAIL.fetch(request); + } + + const stub = env.TEST_DURABLE_OBJECT.get(env.TEST_DURABLE_OBJECT.idFromName(url.pathname)); + + if (url.pathname === '/rpc') { + return new Response(await stub.ping()); + } + + if (url.pathname === '/alarm') { + await stub.setAlarm(); + return new Response('Alarm scheduled'); + } + + return stub.fetch(request); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/tail.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/tail.ts new file mode 100644 index 000000000000..865d977d6a5c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/tail.ts @@ -0,0 +1,26 @@ +interface Invocation { + trigger: string; + durationMs: number; +} + +const invocations: Invocation[] = []; + +export default { + async tail(events: TraceItem[]): Promise { + // `wallTime` is always 0 in local dev, so the duration ends when the tail event arrives. + const receivedAt = Date.now(); + + for (const { event, eventTimestamp, executionModel } of events) { + if (executionModel !== 'durableObject' || !event || eventTimestamp === null) { + continue; + } + + const trigger = 'rpcMethod' in event ? event.rpcMethod : 'scheduledTime' in event ? 'alarm' : 'fetch'; + invocations.push({ trigger, durationMs: receivedAt - eventTimestamp }); + } + }, + + async fetch(): Promise { + return Response.json(invocations); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/test.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/test.ts new file mode 100644 index 000000000000..11db17ae2348 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/test.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { createRunner } from '../../../runner'; + +interface Invocation { + trigger: string; + durationMs: number; +} + +// Regression test for https://github.com/getsentry/sentry-javascript/issues/25489 +// workerd keeps a Durable Object invocation open until its pending timers fire, so an SDK timer +// that is still pending when the method returns makes the invocation longer, and billed. +it('does not keep Durable Object invocations open after the method returns', async ({ signal }) => { + // The tail worker must run in the same `wrangler dev` process to receive the tail events. + const runner = createRunner(__dirname) + .withWranglerArgs('--config', join(__dirname, 'wrangler-tail.jsonc')) + .start(signal); + + await runner.makeRequest('get', '/fetch'); + await runner.makeRequest('get', '/rpc'); + await runner.makeRequest('get', '/alarm'); + + const { fetchInvocation, rpcInvocation, alarmInvocation } = await vi.waitFor( + async () => { + const invocations = (await runner.makeRequest('get', '/invocations')) ?? []; + const result = { + fetchInvocation: invocations.find(invocation => invocation.trigger === 'fetch'), + rpcInvocation: invocations.find(invocation => invocation.trigger === 'ping'), + alarmInvocation: invocations.find(invocation => invocation.trigger === 'alarm'), + }; + + expect(result.fetchInvocation).toBeDefined(); + expect(result.rpcInvocation).toBeDefined(); + expect(result.alarmInvocation).toBeDefined(); + + return result; + }, + { timeout: 10_000, interval: 100 }, + ); + + // An invocation takes a few ms locally. The limit leaves room for slow CI machines and still + // fails for a 500 ms timer. + expect(fetchInvocation?.durationMs).toBeLessThan(250); + expect(rpcInvocation?.durationMs).toBeLessThan(250); + expect(alarmInvocation?.durationMs).toBeLessThan(250); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler-tail.jsonc b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler-tail.jsonc new file mode 100644 index 000000000000..88d1d67db66f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler-tail.jsonc @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "durableobject-invocation-duration-tail", + "main": "tail.ts", + "compatibility_date": "2025-06-17", +} diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler.jsonc new file mode 100644 index 000000000000..f37ce093d9ce --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "durableobject-invocation-duration", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [{ "name": "TEST_DURABLE_OBJECT", "class_name": "TestDurableObject" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TestDurableObject"] }], + "services": [{ "binding": "TAIL", "service": "durableobject-invocation-duration-tail" }], + "tail_consumers": [{ "service": "durableobject-invocation-duration-tail" }], +} From 03e18aed2bd29490a944c6f5434906e8e8e8b0c9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:00:31 -0700 Subject: [PATCH 047/126] fix(cloudflare): Skip comments when locating the `default` keyword in the Vite transform (#25741) The transform found the end of the `export default` keywords with `lastIndexOf('default')` on the text before the expression. A comment in that span that contains the word `default`, such as `export default /* default handler */ { ... }`, moved the overwrite past the keyword and produced invalid syntax, so the Worker failed to start. The keyword is now found by matching `export`, then any whitespace or comments, then `default`, starting at the statement. Acorn only gives an `ExportDefaultDeclaration` when that sequence is there, so the match always succeeds. Cursor Bugbot found this on the v10 backport in #25732, which already has the same fix. Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Isaac Schlueter --- packages/cloudflare/src/vite/transform.ts | 22 ++++++++++++++- .../cloudflare/test/vite/transform.test.ts | 27 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index e640de3a45d1..43add52af3cd 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -396,7 +396,7 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state // MagicString positions are always relative to the original source. Only the `export default` // keywords are replaced, so parentheses around the expression stay. const source = state.ms.original; - const keywordsEnd = node.start + source.slice(node.start, decl.start).lastIndexOf('default') + 'default'.length; + const keywordsEnd = exportDefaultKeywordsEnd(source, node.start); const declaredName = decl.type === 'ClassDeclaration' || decl.type === 'FunctionDeclaration' ? (decl as ClassDeclarationNode).id?.name @@ -425,6 +425,26 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state state.autoWrapped.add(DEFAULT_EXPORT); } +/** + * Offset just past the `default` keyword of the `export default` statement at `start`. Comments can + * sit between `export`, `default` and the expression, and may themselves contain the word `default`. + */ +function exportDefaultKeywordsEnd(source: string, start: number): number { + let index = start + 'export'.length; + while (index < source.length && !source.startsWith('default', index)) { + if (source.startsWith('//', index)) { + const lineEnd = source.indexOf('\n', index); + index = lineEnd === -1 ? source.length : lineEnd; + } else if (source.startsWith('/*', index)) { + const commentEnd = source.indexOf('*/', index + 2); + index = commentEnd === -1 ? source.length : commentEnd + 2; + } else { + index++; + } + } + return index + 'default'.length; +} + function isAnonymousFunctionOrClass(node: BaseNode): boolean { if (node.type === 'ArrowFunctionExpression') return true; const isFunctionOrClass = [ diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 76129b034923..bc99d7133f5b 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -362,6 +362,33 @@ describe('default export wrapping', () => { ); }); + it('wraps a default export with a comment that mentions `default`', () => { + const code = 'export /* the default */ default /* default handler */ { fetch() {} };'; + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'var __SENTRY_DEFAULT_EXPORT__ = /* default handler */ { fetch() {} };', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), __SENTRY_DEFAULT_EXPORT__);', + ].join('\n'), + ); + }); + + it('wraps a default export with a line comment that mentions `default`', () => { + const code = ['export // the default', 'default // default handler', '{ fetch() {} };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'var __SENTRY_DEFAULT_EXPORT__ = // default handler', + '{ fetch() {} };', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), __SENTRY_DEFAULT_EXPORT__);', + ].join('\n'), + ); + }); + it('keeps the module binding of a named default class', () => { const code = ['export default class Handler { fetch() {} }', 'Handler.ready = true;'].join('\n'); From 7dfffba816981d8a7965c91f4cc818127d456a0a Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 26 Oct 2026 11:17:04 -0700 Subject: [PATCH 048/126] feat(cloudflare): Add incoming request span options to `httpServerIntegration` (#25582) Cloudflare's `httpServerIntegration` took only the body capture options. A user could not skip spans for one route, such as a health check, or change the request span from the request or the response. Node, Deno and Bun all allow this. `httpServerIntegration` now accepts `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`. They use the same names and behavior as `denoServeIntegration` and `bunServerIntegration`, and the callbacks receive the web `Request`. The shared `ServerRequestOptions` check for Cloudflare now lists only the session options, which a worker cannot support because it has no long-lived process to run the flush timer. When the SDK skips the span, it still continues the incoming trace, attaches request data and the body, and captures errors. It also still waits for a streamed response to finish before it flushes. For a streamed response, `onSpanEnd` runs when the stream finishes. It does not run when the handler throws. A callback that throws does not fail the request. `ignoreRequestBody` also no longer fails the request when it throws. The SDK skips the body instead, so it cannot leak data that the filter was meant to hide. Deno and Bun already do this. Two defaults change behavior: - `ignoreStaticAssets` defaults to `true`, so `GET` requests for files such as `favicon.ico` or `*.js` no longer create spans. - `spans` defaults to the client's tracing configuration, so the SDK no longer starts request spans when tracing is disabled. Fixes #25573 Fixes JS-3971 Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 + .../integrations/http-server-spans/index.ts | 41 +++ .../http-server-spans/instrument.server.ts | 18 ++ .../integrations/http-server-spans/test.ts | 71 +++++ .../http-server-spans/vite.config.mts | 7 + .../http-server-spans/wrangler.jsonc | 9 + .../public-api/metrics/server-address/test.ts | 1 - .../cloudflare/src/integrations/httpServer.ts | 133 ++++++++- .../src/wrapRequestHandlerWithInit.ts | 200 ++++++++------ packages/cloudflare/test/request.test.ts | 253 ++++++++++++++++++ 10 files changed, 642 insertions(+), 99 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af7c74fcaa7..a63d5b321595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @kyrylolvov and @seongwon030. Thank you for your contributions! +### Important Changes + +- **feat(cloudflare): Add incoming request span options to `httpServerIntegration`** + + Cloudflare's `httpServerIntegration` now accepts `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`, with the same meaning as in the Node, Deno and Bun integrations. The callbacks receive the web `Request`. + + `ignoreStaticAssets` defaults to `true`, so workers no longer create spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `httpServerIntegration({ ignoreStaticAssets: false })`. Request spans also follow the client's tracing configuration by default, so the SDK does not start them when tracing is disabled. If `ignoreRequestBody` throws, the request no longer fails, and the SDK skips the body. + ## 11.9.0 ### Important Changes diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts new file mode 100644 index 000000000000..ce625f75cd49 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts @@ -0,0 +1,41 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +// A plain worker. The `@sentry/cloudflare/vite` plugin wraps it with `withSentry` at build time, +// with the options from `instrument.server.ts`. +export default { + async fetch(request: Request): Promise { + const { pathname } = new URL(request.url); + + if (pathname === '/health') { + Sentry.captureMessage('Health check'); + return new Response('ok'); + } + + if (pathname === '/favicon.ico') { + Sentry.captureMessage('Static asset'); + return new Response('icon'); + } + + if (pathname === '/health/stream') { + // No span for this request. The message is captured after the handler returns, while the + // body streams, so it is only sent if the SDK waits for the stream to finish. + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('a')); + await new Promise(resolve => setTimeout(resolve, 50)); + Sentry.captureMessage('Streamed after handler return'); + controller.enqueue(encoder.encode('b')); + controller.close(); + }, + }); + return new Response(body, { headers: { 'content-type': 'text/event-stream' } }); + } + + return new Response('ok', { status: 201 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts new file mode 100644 index 000000000000..95122b1cba8a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts @@ -0,0 +1,18 @@ +import { defineCloudflareOptions, httpServerIntegration } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + traceLifecycle: 'static', + integrations: [ + httpServerIntegration({ + ignoreIncomingRequests: urlPath => urlPath.startsWith('/health'), + onSpanCreated: (span, request) => { + span.setAttribute('test.on_span_created', new URL(request.url).pathname); + }, + onSpanEnd: (span, _request, response) => { + span.setAttribute('test.on_span_end', response.status); + }, + }), + ], +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts new file mode 100644 index 000000000000..35946ea9ac45 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts @@ -0,0 +1,71 @@ +import type { Envelope, Event } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +function itemOf(envelope: Envelope): { type: string; event: Event } { + const [header, event] = envelope[1][0] as [{ type: string }, Event]; + return { type: header.type, event }; +} + +function messageEvent(message: string): (envelope: Envelope) => void { + return envelope => { + const { type, event } = itemOf(envelope); + expect(type).toBe('event'); + expect(event.message).toBe(message); + }; +} + +// The `onSpanCreated` and `onSpanEnd` hooks of the worker set these attributes. +function apiTransaction(envelope: Envelope): void { + const { type, event } = itemOf(envelope); + expect(type).toBe('transaction'); + expect(event.transaction).toBe('GET /api'); + expect(event.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'test.on_span_created': '/api', + 'test.on_span_end': 201, + }), + ); +} + +// Each test makes a request without a span, then a request with one. With `failOnUnexpected`, a +// transaction for the first request fails the test, as it arrives before the second one. + +it('does not create a span for a request that ignoreIncomingRequests matches', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Health check')) + .expect(apiTransaction) + .start(signal); + + await runner.makeRequest('get', '/health'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); + +it('does not create a span for a static asset request by default', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Static asset')) + .expect(apiTransaction) + .start(signal); + + await runner.makeRequest('get', '/favicon.ico'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); + +it('sends events from a streamed response without a span after the stream finishes', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Streamed after handler return')) + .expect(apiTransaction) + .start(signal); + + expect(await runner.makeRequest('get', '/health/stream')).toBe('ab'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc new file mode 100644 index 000000000000..790abe9762d8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-integrations-http-server-spans", + // `main` points at the source entry. The Sentry Vite plugin builds from it, and the runner serves + // the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts index 013bf552d772..12cf754e08b6 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -12,7 +12,6 @@ it('should add server.address attribute to metrics when serverName is set', asyn name: 'test.counter', type: 'counter', value: 1, - span_id: expect.any(String), timestamp: expect.any(Number), trace_id: expect.any(String), attributes: { diff --git a/packages/cloudflare/src/integrations/httpServer.ts b/packages/cloudflare/src/integrations/httpServer.ts index 7fa3cd7ce30b..1975f168819e 100644 --- a/packages/cloudflare/src/integrations/httpServer.ts +++ b/packages/cloudflare/src/integrations/httpServer.ts @@ -1,6 +1,15 @@ -import type { Client, IntegrationFn, MaxRequestBodySize } from '@sentry/core'; -import { captureBodyFromWinterCGRequest, defineIntegration, getIsolationScope } from '@sentry/core'; +import type { Client, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; +import { + captureBodyFromWinterCGRequest, + defineIntegration, + getIsolationScope, + hasSpansEnabled, + parseStringToURLObject, + safeCallback, +} from '@sentry/core'; import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; +import { isStaticAssetRequest } from '@sentry/core/server'; +import { DEBUG_BUILD } from '../debug-build'; const INTEGRATION_NAME = 'HttpServer' as const; @@ -39,6 +48,42 @@ export interface HttpServerIntegrationOptions { * ``` */ ignoreRequestBody?: (url: string, request: Request) => boolean; + + /** + * Do not create spans for incoming requests whose URL path makes the given callback return `true`. + * + * `urlPath` is the path of the request, including the query string. `request` is the incoming + * {@link Request}. Errors thrown by the handler are still captured. + */ + ignoreIncomingRequests?: (urlPath: string, request: Request) => boolean; + + /** + * Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Whether to create spans for incoming requests. + * Errors are still captured and request data is still attached when this is `false`. + * Defaults to the client's tracing configuration (`hasSpansEnabled`). + */ + spans?: boolean; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, before the request handler runs. + */ + onSpanCreated?: (span: Span, request: Request) => void; + + /** + * A hook that can be used to mutate the span one last time before it ends. + * For a streamed response, this runs when the stream finishes. + * It is not called when the request handler throws. + */ + onSpanEnd?: (span: Span, request: Request, response: Response) => void; } // Checks these options against the shared `ServerRequestOptions`. A new @@ -49,23 +94,15 @@ type _ServerRequestOptionsCheck = AssertTrue< HttpServerIntegrationOptions, Request, Response, - // Not supported yet. - | 'spans' - | 'ignoreIncomingRequests' - | 'ignoreStaticAssets' - | 'onSpanCreated' - | 'onSpanEnd' // Not supported. A worker has no long-lived process to run the timer // that flushes session aggregates. - | 'sessions' - | 'sessionFlushingDelayMS' + 'sessions' | 'sessionFlushingDelayMS' > >; -interface HttpServerIntegrationInstance { +interface HttpServerIntegrationInstance extends HttpServerIntegrationOptions { name: string; maxRequestBodySize: MaxRequestBodySize | undefined; - ignoreRequestBody?: (url: string, request: Request) => boolean; } const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): HttpServerIntegrationInstance => { @@ -73,6 +110,11 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): Ht name: INTEGRATION_NAME, maxRequestBodySize: options.maxRequestBodySize, ignoreRequestBody: options.ignoreRequestBody, + ignoreIncomingRequests: options.ignoreIncomingRequests, + ignoreStaticAssets: options.ignoreStaticAssets ?? true, + spans: options.spans, + onSpanCreated: options.onSpanCreated, + onSpanEnd: options.onSpanEnd, }; }) satisfies IntegrationFn; @@ -122,10 +164,75 @@ export async function captureIncomingRequestBody(client: Client, request: Reques return; } - if (integration.ignoreRequestBody?.(request.url, request)) { + const ignoreRequestBody = integration.ignoreRequestBody; + if ( + ignoreRequestBody && + // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. + safeCallback( + DEBUG_BUILD ? 'Error in `ignoreRequestBody`, skipping the body:' : '', + () => ignoreRequestBody(request.url, request), + () => true, + ) + ) { return; } const isolationScope = getIsolationScope(); await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); } + +/** + * Whether to create a span for the incoming request, based on the HttpServer integration config. + * Called internally by `wrapRequestHandler`. + */ +export function shouldCreateIncomingRequestSpan(client: Client | undefined, request: Request): boolean { + if (!client) { + return false; + } + + const integration = client.getIntegrationByName(INTEGRATION_NAME); + if (!(integration?.spans ?? hasSpansEnabled(client.getOptions()))) { + return false; + } + + const urlObject = parseStringToURLObject(request.url); + const urlPath = urlObject ? `${urlObject.pathname}${urlObject.search}` : '/'; + if (integration?.ignoreStaticAssets && request.method === 'GET' && isStaticAssetRequest(urlPath)) { + return false; + } + + const ignoreIncomingRequests = integration?.ignoreIncomingRequests; + return !( + ignoreIncomingRequests && + safeCallback( + DEBUG_BUILD ? 'Error in `ignoreIncomingRequests`, creating the span:' : '', + () => ignoreIncomingRequests(urlPath, request), + () => false, + ) + ); +} + +/** + * The `onSpanCreated` and `onSpanEnd` hooks of the HttpServer integration, guarded so that a hook + * that throws does not fail the request. Called internally by `wrapRequestHandler`. + */ +export function getIncomingRequestSpanHooks(client: Client | undefined): { + onSpanCreated: (span: Span, request: Request) => void; + onSpanEnd: (span: Span, request: Request, response: Response) => void; +} { + const integration = client?.getIntegrationByName(INTEGRATION_NAME); + return { + onSpanCreated: (span, request) => + safeCallback( + DEBUG_BUILD ? 'Error in `onSpanCreated`:' : '', + () => integration?.onSpanCreated?.(span, request), + () => undefined, + ), + onSpanEnd: (span, request, response) => + safeCallback( + DEBUG_BUILD ? 'Error in `onSpanEnd`:' : '', + () => integration?.onSpanEnd?.(span, request, response), + () => undefined, + ), + }; +} diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 71444c9a87e3..e852ad1cf661 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -6,6 +6,7 @@ import { SENTRY_SEGMENT_NAME_SOURCE, } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; +import type { Span } from '@sentry/core'; import { captureException, continueTrace, @@ -21,7 +22,11 @@ import { } from '@sentry/core'; import { classifyResponseStreaming } from '@sentry/core/server'; import { DEBUG_BUILD } from './debug-build'; -import { captureIncomingRequestBody } from './integrations/httpServer'; +import { + captureIncomingRequestBody, + getIncomingRequestSpanHooks, + shouldCreateIncomingRequestSpan, +} from './integrations/httpServer'; import { flushDeferredChannelEvents } from './orchestrion-deferred-channels'; import type { CloudflareClient, CloudflareOptions } from './client'; import type { ExecutionContextCompat } from './executionContext'; @@ -155,98 +160,123 @@ export function wrapRequestHandlerWithInit( await captureIncomingRequestBody(client, request); } + const { onSpanCreated, onSpanEnd } = getIncomingRequestSpanHooks(client); + + // Runs the handler. Ends the span (if any) and flushes when the response is done. + const respond = async (span: Span | undefined): Promise => { + let res: Response; + + try { + res = await handler(); + if (span) { + setHttpStatus(span, res.status); + } + + // After the handler runs, the span name might have been updated by nested instrumentation + // (e.g., Remix parameterizing routes). The span should already have the correct name + // from that instrumentation, so we don't need to do anything here. + } catch (e) { + span?.end(); + if (captureErrors) { + captureException(e, { mechanism: { handled: false, type: errorMechanismType } }); + } + waitUntil?.(flushAndDispose(client)); + throw e; + } + + let ended = false; + const endSpan = (): void => { + if (ended) return; + + ended = true; + if (span) { + onSpanEnd(span, request, res); + span.end(); + } + }; + + const userClassification = safeCallback( + DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', + () => options.isStreamingResponse?.(res, request), + () => undefined, + ); + // An async callback returns a promise, which would otherwise count as `true`. + const isStreaming = + typeof userClassification === 'boolean' ? userClassification : classifyResponseStreaming(res).isStreaming; + + if (isStreaming && res.body) { + try { + let transformerUsed = false; + + const endSpanOnce = (): void => { + if (ended) return; + + endSpan(); + waitUntil?.(flushAndDispose(client)); + }; + + // Workers with a compatibility date before 2022-11-30 and without the + // `transformstream_enable_standard_constructor` flag (for example under older mini-oxygen + // releases) ignore the transformer, so `flush` and `cancel` would never end the span. Only a + // used transformer runs `start`. + const transform = new TransformStream({ + start() { + transformerUsed = true; + }, + flush() { + // Source stream completed normally. + endSpanOnce(); + }, + cancel() { + // Client disconnected (or downstream cancelled). The `cancel` + // is being called while the response is still considered + // active, so this is a safe place to end the span. + endSpanOnce(); + }, + }); + + if (transformerUsed) { + // Passing the original response as the init keeps `encodeBody: 'manual'`, which a + // Response does not expose as a property. Without it, workerd compresses a + // pre-compressed body a second time. + return new Response(res.body.pipeThrough(transform), res); + } + } catch { + // Falls back to ending the span at handler return. + } + } + + // Non-streaming response - end span immediately and return original + endSpan(); + + // Don't dispose for protocol upgrades (101 Switching Protocols) - the connection stays alive. + // This includes WebSocket upgrades where webSocketMessage/webSocketClose handlers + // will still be called and may need the client to capture errors. + if (res.status === 101) { + waitUntil?.(client?.flush(2000)); + } else { + waitUntil?.(flushAndDispose(client)); + } + return res; + }; + + const createSpan = shouldCreateIncomingRequestSpan(client, request); + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { + if (!createSpan) { + return respond(undefined); + } + // Note: This span will not have a duration unless I/O happens in the handler. This is // because of how the cloudflare workers runtime works. // See: https://developers.cloudflare.com/workers/runtime-apis/performance/ // Use startSpanManual to control when span ends (needed for streaming responses) - return startSpanManual({ name, attributes }, async span => { - let res: Response; - - try { - res = await handler(); - setHttpStatus(span, res.status); - - // After the handler runs, the span name might have been updated by nested instrumentation - // (e.g., Remix parameterizing routes). The span should already have the correct name - // from that instrumentation, so we don't need to do anything here. - } catch (e) { - span.end(); - if (captureErrors) { - captureException(e, { mechanism: { handled: false, type: errorMechanismType } }); - } - waitUntil?.(flushAndDispose(client)); - throw e; - } - - const userClassification = safeCallback( - DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', - () => options.isStreamingResponse?.(res, request), - () => undefined, - ); - // An async callback returns a promise, which would otherwise count as `true`. - const isStreaming = - typeof userClassification === 'boolean' ? userClassification : classifyResponseStreaming(res).isStreaming; - - if (isStreaming && res.body) { - try { - let ended = false; - let transformerUsed = false; - - const endSpanOnce = (): void => { - if (ended) return; - - ended = true; - span.end(); - waitUntil?.(flushAndDispose(client)); - }; - - // Workers with a compatibility date before 2022-11-30 and without the - // `transformstream_enable_standard_constructor` flag (for example under older mini-oxygen - // releases) ignore the transformer, so `flush` and `cancel` would never end the span. Only a - // used transformer runs `start`. - const transform = new TransformStream({ - start() { - transformerUsed = true; - }, - flush() { - // Source stream completed normally. - endSpanOnce(); - }, - cancel() { - // Client disconnected (or downstream cancelled). The `cancel` - // is being called while the response is still considered - // active, so this is a safe place to end the span. - endSpanOnce(); - }, - }); - - if (transformerUsed) { - // Passing the original response as the init keeps `encodeBody: 'manual'`, which a - // Response does not expose as a property. Without it, workerd compresses a - // pre-compressed body a second time. - return new Response(res.body.pipeThrough(transform), res); - } - } catch { - // Falls back to ending the span at handler return. - } - } - - // Non-streaming response - end span immediately and return original - span.end(); - - // Don't dispose for protocol upgrades (101 Switching Protocols) - the connection stays alive. - // This includes WebSocket upgrades where webSocketMessage/webSocketClose handlers - // will still be called and may need the client to capture errors. - if (res.status === 101) { - waitUntil?.(client?.flush(2000)); - } else { - waitUntil?.(flushAndDispose(client)); - } - return res; + return startSpanManual({ name, attributes }, span => { + onSpanCreated(span, request); + return respond(span); }); }, ); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a8c9a3f735f0..972180c62a8c 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -356,6 +356,45 @@ describe('withSentry', () => { expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toEqual(JSON.stringify({ key: 'value' })); }); + test.each([ + ['matches', () => true], + [ + 'throws', + () => { + throw new Error('filter failed'); + }, + ], + ])('does not capture the body when ignoreRequestBody %s', async (_label, ignoreRequestBody) => { + let sentryEvent: Event = {}; + + const response = await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + integrations: [httpServerIntegration({ ignoreRequestBody })], + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password: 'secret' }), + }), + context: createMockExecutionContext(), + }, + () => { + SentryCore.captureMessage('request body'); + return new Response('test'); + }, + ); + + expect(await response.text()).toBe('test'); + expect(sentryEvent.message).toBe('request body'); + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); + test('does not capture cookies when dataCollection.cookies is disabled', async () => { let sentryEvent: Event = {}; @@ -649,6 +688,220 @@ describe('withSentry', () => { }); }); }); + + describe('httpServerIntegration span options', () => { + // Whether a span is active in the handler. + async function hasActiveSpan( + options: Partial, + url = 'https://example.com/users?id=1', + init?: RequestInit, + ): Promise { + let active = false; + const response = await wrapRequestHandler( + { + options: { ...MOCK_OPTIONS, tracesSampleRate: 1, ...options }, + request: new Request(url, init), + context: createMockExecutionContext(), + }, + () => { + active = !!SentryCore.getActiveSpan(); + return new Response('ok'); + }, + ); + expect(await response.text()).toBe('ok'); + return active; + } + + test('creates a span by default', async () => { + expect(await hasActiveSpan({})).toBe(true); + }); + + test('does not create spans for requests that ignoreIncomingRequests matches', async () => { + const calls: [string, string][] = []; + const integrations = [ + httpServerIntegration({ + ignoreIncomingRequests: (urlPath, request) => { + calls.push([urlPath, request.method]); + return urlPath.startsWith('/health'); + }, + }), + ]; + + expect(await hasActiveSpan({ integrations }, 'https://example.com/health?probe=1')).toBe(false); + expect(await hasActiveSpan({ integrations }, 'https://example.com/users')).toBe(true); + expect(calls).toEqual([ + ['/health?probe=1', 'GET'], + ['/users', 'GET'], + ]); + }); + + test('creates the span when ignoreIncomingRequests throws', async () => { + const integrations = [ + httpServerIntegration({ + ignoreIncomingRequests: () => { + throw new Error('filter failed'); + }, + }), + ]; + + expect(await hasActiveSpan({ integrations })).toBe(true); + }); + + test('does not create spans for GET requests to static assets by default', async () => { + expect(await hasActiveSpan({}, 'https://example.com/favicon.ico')).toBe(false); + expect(await hasActiveSpan({}, 'https://example.com/assets/app.js')).toBe(false); + expect(await hasActiveSpan({}, 'https://example.com/favicon.ico', { method: 'POST', body: 'x' })).toBe(true); + }); + + test('creates spans for static assets when ignoreStaticAssets is false', async () => { + const integrations = [httpServerIntegration({ ignoreStaticAssets: false })]; + + expect(await hasActiveSpan({ integrations }, 'https://example.com/favicon.ico')).toBe(true); + }); + + test('does not create spans when tracing is disabled', async () => { + expect(await hasActiveSpan({ tracesSampleRate: undefined })).toBe(false); + }); + + test('does not create spans when spans is false, but still captures errors and continues the trace', async () => { + const events: Event[] = []; + const request = new Request('https://example.com/users'); + request.headers.set('sentry-trace', '12312012123120121231201212312012-1121201211212012-1'); + const error = new Error('handler failed'); + + await expect( + wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + tracesSampleRate: 1, + integrations: [httpServerIntegration({ spans: false })], + beforeSend(event) { + events.push(event); + return null; + }, + }, + request, + context: createMockExecutionContext(), + }, + () => { + expect(SentryCore.getActiveSpan()).toBeUndefined(); + throw error; + }, + ), + ).rejects.toBe(error); + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.http.cloudflare' }); + expect(events[0]?.contexts?.trace?.trace_id).toBe('12312012123120121231201212312012'); + }); + + test('calls onSpanCreated and onSpanEnd', async () => { + const calls: string[] = []; + let transaction: Event | undefined; + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + tracesSampleRate: 1, + integrations: [ + httpServerIntegration({ + onSpanCreated: (span, request) => { + calls.push(`created ${request.method} ${new URL(request.url).pathname}`); + span.setAttribute('onSpanCreated', 'yes'); + }, + onSpanEnd: (span, request, response) => { + calls.push(`end ${request.method} ${response.status}`); + span.setAttribute('onSpanEnd', response.status); + }, + }), + ], + beforeSendTransaction(event) { + transaction = event; + return null; + }, + }, + request: new Request('https://example.com/users'), + context: createMockExecutionContext(), + }, + () => { + calls.push('handler'); + return new Response('ok', { status: 201 }); + }, + ).then(response => response.text()); + + await vi.waitFor(() => expect(transaction).toBeDefined()); + expect(calls).toEqual(['created GET /users', 'handler', 'end GET 201']); + expect(transaction?.contexts?.trace?.data).toEqual( + expect.objectContaining({ onSpanCreated: 'yes', onSpanEnd: 201 }), + ); + }); + + test('calls onSpanEnd when a streamed response finishes', async () => { + const onSpanEnd = vi.fn(); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const response = await wrapRequestHandler( + { + options: { ...MOCK_OPTIONS, tracesSampleRate: 1, integrations: [httpServerIntegration({ onSpanEnd })] }, + request: new Request('https://example.com/events'), + context: createMockExecutionContext(), + }, + () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + ); + + controller!.enqueue(new TextEncoder().encode('data: one\n\n')); + expect(onSpanEnd).not.toHaveBeenCalled(); + + controller!.close(); + expect(await response.text()).toBe('data: one\n\n'); + expect(onSpanEnd).toHaveBeenCalledTimes(1); + expect(onSpanEnd).toHaveBeenCalledWith(expect.anything(), expect.any(Request), expect.any(Response)); + }); + + test.each(['onSpanCreated', 'onSpanEnd'] as const)('still responds when %s throws', async hook => { + const integrations = [ + httpServerIntegration({ + [hook]: () => { + throw new Error(`${hook} failed`); + }, + }), + ]; + + expect(await hasActiveSpan({ integrations })).toBe(true); + }); + + test('flushes after a streamed response finishes when no span is created', async () => { + const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const response = await wrapRequestHandler( + { + options: { ...MOCK_OPTIONS, tracesSampleRate: undefined }, + request: new Request('https://example.com/events'), + context, + }, + () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + ); + expect(waitUntilSpy).not.toHaveBeenCalled(); + + controller!.close(); + await response.text(); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + }); + }); }); function createMockExecutionContext(): ExecutionContext { From bd2b38741f2778ab547fe961db19732ad445a4db Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 26 Oct 2026 11:17:05 -0700 Subject: [PATCH 049/126] feat(node, bun, deno, cloudflare): Align portable exports across server SDKs (#25583) The server SDKs export different sets of helpers from `@sentry/core` and `@sentry/server-utils`, although the helpers work on each runtime. A user who moves between runtimes, or follows docs written for another one, finds a helper missing. Two of the gaps also hide default integrations: Node and Bun add `awsIntegration` to their defaults, and Deno adds `fastifyIntegration`, but they did not export them. A user who sets `defaultIntegrations` could not add them back. - Node and Bun now export `moduleMetadataIntegration`, `instrumentPostgresJsSql`, `instrumentCreateReactAgent`, `awsIntegration` and the `FlueOptions` type. `@sentry/aws-serverless` and `@sentry/google-cloud-serverless` re-export the new Node values, as the consistent exports check requires. - Deno and Cloudflare now export `parameterize` and `instrumentStateGraphCompile`, so all four runtimes export the same LangGraph helpers. - Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types. - Deno now exports `fastifyIntegration`. Cloudflare's channel integrations (express, pg and so on) are separate work, because they need a warning when the vite plugin is missing. Fixes #25574 Fixes JS-3972 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 9 +++++++ .../suites/public-api/parameterize/index.ts | 15 +++++++++++ .../parameterize/instrument.server.ts | 5 ++++ .../suites/public-api/parameterize/test.ts | 15 +++++++++++ .../public-api/parameterize/vite.config.mts | 7 ++++++ .../public-api/parameterize/wrangler.jsonc | 9 +++++++ .../suites/parameterize/test.ts | 25 +++++++++++++++++++ packages/astro/src/index.server.ts | 4 +++ packages/aws-serverless/src/index.ts | 3 +++ packages/bun/src/index.ts | 6 +++++ packages/cloudflare/src/index.ts | 4 +++ packages/deno/src/index.ts | 3 +++ packages/deno/test/exports.test.ts | 4 +++ packages/google-cloud-serverless/src/index.ts | 4 +++ packages/node/src/index.ts | 4 +++ 15 files changed, 117 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc create mode 100644 dev-packages/deno-integration-tests/suites/parameterize/test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a63d5b321595..adcb500dde2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @ `ignoreStaticAssets` defaults to `true`, so workers no longer create spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `httpServerIntegration({ ignoreStaticAssets: false })`. Request spans also follow the client's tracing configuration by default, so the SDK does not start them when tracing is disabled. If `ignoreRequestBody` throws, the request no longer fails, and the SDK skips the body. +- **feat(node, bun, deno, cloudflare): Align portable exports across server SDKs** + + Node, Bun, Deno and Cloudflare now export the same helpers from `@sentry/core` and `@sentry/server-utils` where they work on that runtime: + + - Node and Bun now export `moduleMetadataIntegration`, `instrumentPostgresJsSql`, `instrumentCreateReactAgent`, `awsIntegration` and the `FlueOptions` type. `awsIntegration` was already in their default integrations. + - Deno and Cloudflare now export `parameterize` and `instrumentStateGraphCompile`. + - Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types. + - Deno now exports `fastifyIntegration`, which was already in its default integrations. + ## 11.9.0 ### Important Changes diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts new file mode 100644 index 000000000000..55fb7b4466b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +// A plain worker. The `@sentry/cloudflare/vite` plugin wraps it with `withSentry` at build time, +// with the options from `instrument.server.ts`. +export default { + async fetch(): Promise { + const userId = 42; + Sentry.captureMessage(Sentry.parameterize`User ${userId} logged in`); + return new Response('ok'); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts new file mode 100644 index 000000000000..9de8aec879bb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts @@ -0,0 +1,5 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts new file mode 100644 index 000000000000..b8dc67c3e462 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts @@ -0,0 +1,15 @@ +import type { Event } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('parameterize from the public entry sends a message template and its values', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.logentry).toEqual({ message: 'User %s logged in', params: [42] }); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc new file mode 100644 index 000000000000..cbfb247dd034 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-public-api-parameterize", + // `main` points at the source entry. The Sentry Vite plugin builds from it, and the runner serves + // the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/deno-integration-tests/suites/parameterize/test.ts b/dev-packages/deno-integration-tests/suites/parameterize/test.ts new file mode 100644 index 000000000000..16272b3db672 --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/parameterize/test.ts @@ -0,0 +1,25 @@ +// + +import * as Sentry from '@sentry/deno'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { errorSink, resetGlobals, withTimeout } from '../../src/index.ts'; + +Deno.test('parameterize from the public entry sends a message template and its values', async () => { + resetGlobals(); + const sink = errorSink(); + + Sentry.init({ + dsn: 'https://username@domain/123', + beforeSend: sink.beforeSend, + }); + + const userId = 42; + Sentry.captureMessage(Sentry.parameterize`User ${userId} logged in`); + + const event = await withTimeout( + sink.waitFor(e => e.logentry?.message === 'User %s logged in'), + 5_000, + 'parameterized message event', + ); + assertEquals(event.logentry, { message: 'User %s logged in', params: [42] }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 80158e311e49..86fd2127c724 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -13,6 +13,7 @@ export { addIntegration, amqplibIntegration, anthropicAIIntegration, + awsIntegration, // eslint-disable-next-line typescript/no-deprecated anrIntegration, googleGenAIIntegration, @@ -102,6 +103,7 @@ export { langChainIntegration, langGraphIntegration, createFlueInstrumentation, + instrumentPostgresJsSql, mastraIntegration, piDurableIntegration, mcpServerIntegration, @@ -177,6 +179,8 @@ export { instrumentLangChainEmbeddings, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + moduleMetadataIntegration, zodErrorsIntegration, profiler, logger, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index fd07e76a3fba..994aabb838ce 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -2,6 +2,7 @@ export { addEventProcessor, addBreadcrumb, addIntegration, + moduleMetadataIntegration, captureException, captureEvent, captureMessage, @@ -71,6 +72,7 @@ export { mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, + instrumentPostgresJsSql, modulesIntegration, nodeRuntimeMetricsIntegration, type NodeRuntimeMetricsOptions, @@ -159,6 +161,7 @@ export { instrumentGoogleGenAIClient, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4e91068d21f8..f7c8a286287b 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -10,6 +10,8 @@ export type { Exception, Session, SeverityLevel, + Log, + LogSeverityLevel, Span, StackFrame, Stacktrace, @@ -25,6 +27,7 @@ export { addEventProcessor, addBreadcrumb, addIntegration, + moduleMetadataIntegration, captureException, captureEvent, captureMessage, @@ -93,6 +96,7 @@ export { mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, + instrumentPostgresJsSql, modulesIntegration, contextLinesIntegration, nodeContextIntegration, @@ -176,6 +180,8 @@ export { instrumentGoogleGenAIClient, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + awsIntegration, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 88ce8deaac01..8be218013d43 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -11,6 +11,8 @@ export type { FeatureFlagsIntegration, Session, SeverityLevel, + Log, + LogSeverityLevel, Span, StackFrame, Stacktrace, @@ -30,6 +32,7 @@ export { captureException, captureEvent, captureMessage, + parameterize, captureFeedback, close, createTransport, @@ -139,6 +142,7 @@ export { createLangChainCallbackHandler, instrumentLangChainEmbeddings, instrumentStateGraph, + instrumentStateGraphCompile, instrumentCreateReactAgent, vercelAIIntegration, honoIntegration, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index f55fe14de14f..59b68d31034c 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -30,6 +30,7 @@ export { captureException, captureEvent, captureMessage, + parameterize, captureFeedback, close, createTransport, @@ -122,6 +123,7 @@ export { createLangChainCallbackHandler, instrumentLangChainEmbeddings, instrumentStateGraph, + instrumentStateGraphCompile, instrumentCreateReactAgent, } from '@sentry/server-utils'; export type { FlueOptions } from '@sentry/server-utils'; @@ -156,6 +158,7 @@ export { genericPoolIntegration, googleGenAIIntegration, graphqlIntegration, + fastifyIntegration, hapiIntegration, honoIntegration, honoMiddleware, diff --git a/packages/deno/test/exports.test.ts b/packages/deno/test/exports.test.ts index c1468653c566..76b4cce1cd18 100644 --- a/packages/deno/test/exports.test.ts +++ b/packages/deno/test/exports.test.ts @@ -12,6 +12,7 @@ const EXPECTED_FUNCTION_EXPORTS = [ 'createConsolaReporter', 'growthbookIntegration', 'moduleMetadataIntegration', + 'parameterize', // @sentry/core/server 'trpcMiddleware', // @sentry/server-utils @@ -24,7 +25,10 @@ const EXPECTED_FUNCTION_EXPORTS = [ 'createLangChainCallbackHandler', 'instrumentLangChainEmbeddings', 'instrumentStateGraph', + 'instrumentStateGraphCompile', 'instrumentCreateReactAgent', + // In the default integrations, so users who set `defaultIntegrations` can add it back + 'fastifyIntegration', // ./sdk 'initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance', diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index b90e609bbfc1..2c4cb5726769 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -2,6 +2,7 @@ export { addEventProcessor, addBreadcrumb, addIntegration, + moduleMetadataIntegration, captureException, captureEvent, captureMessage, @@ -71,6 +72,7 @@ export { mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, + instrumentPostgresJsSql, modulesIntegration, nodeRuntimeMetricsIntegration, type NodeRuntimeMetricsOptions, @@ -156,6 +158,8 @@ export { instrumentGoogleGenAIClient, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + awsIntegration, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 130194f72cf5..7cc711e110f0 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -6,6 +6,7 @@ export { expressErrorHandler, setupExpressErrorHandler } from '@sentry/server-ut export { amqplibIntegration, anthropicAIIntegration, + awsIntegration, dataloaderIntegration, expressIntegration, type ExpressIntegrationOptions, @@ -57,6 +58,8 @@ export { instrumentLangChainEmbeddings, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + instrumentPostgresJsSql, fastifyIntegration, // oxlint-disable-next-line typescript/no-deprecated setupFastifyErrorHandler, @@ -148,6 +151,7 @@ export { captureSession, endSession, addIntegration, + moduleMetadataIntegration, startSpan, startSpanManual, startInactiveSpan, From ad71ef6173280b0558855be61bb3e039da71f377 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 26 Oct 2026 11:17:05 -0700 Subject: [PATCH 050/126] feat(cloudflare): Let users configure the channel integrations (#25584) When the `@sentry/cloudflare/vite` plugin bundles an instrumented package, such as `pg` or `openai`, the SDK adds the matching channel integration. It calls each factory with no arguments, so a Cloudflare user always got the default options. They could not, for example, turn off prompt and response capture with `openAIIntegration({ recordInputs: false })`, as Node, Bun and Deno users can. `@sentry/cloudflare` now exports the channel integrations that Node exports. These are the integrations that the vite plugin can register, so their code already runs on workerd. A user instance passed in `integrations` replaces the registered default with the same name, at `init()` and also when the module loads after `init()`. Both packages set `"sideEffects": false`, so a worker bundles only the integrations it imports. The `withSentry` bundle does not change in size. These integrations get data only when the vite plugin injected the channels. Without the plugin, a configured integration records nothing. On Cloudflare, `invokeOrchestrionInstrumentation` now logs a debug warning, once for each integration, when the plugin did not run. Fixes #25575 Fixes JS-3973 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 13 ++++++ .../openai-options/index.ts | 33 +++++++++++++++ .../openai-options/instrument.server.ts | 12 ++++++ .../openai-options/test.ts | 25 +++++++++++ .../openai-options/vite.config.mts | 7 ++++ .../openai-options/wrangler.jsonc | 6 +++ packages/cloudflare/src/index.ts | 42 +++++++++++++++++++ packages/cloudflare/test/sdk.test.ts | 31 +++++++++++++- .../src/orchestrion/instrumentation.ts | 22 +++++++++- .../test/orchestrion/instrumentation.test.ts | 41 +++++++++++++++++- 10 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc diff --git a/CHANGELOG.md b/CHANGELOG.md index adcb500dde2c..5af7d1823ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,19 @@ Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @ - Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types. - Deno now exports `fastifyIntegration`, which was already in its default integrations. +- **feat(cloudflare): Let users configure the channel integrations** + + When the `@sentry/cloudflare/vite` plugin bundles an instrumented package (for example `pg` or `openai`), the SDK adds the matching integration with its default options. `@sentry/cloudflare` now exports these integrations, so you can set their options, for example in `instrument.server.ts`: + + ```ts + export default Sentry.defineCloudflareOptions(env => ({ + dsn: env.SENTRY_DSN, + integrations: [Sentry.openAIIntegration({ recordInputs: false, recordOutputs: false })], + })); + ``` + + These integrations need the vite plugin. Without it, they record nothing, and with `debug: true` the SDK logs a warning. + ## 11.9.0 ### Important Changes diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts new file mode 100644 index 000000000000..42d2b68be39d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts @@ -0,0 +1,33 @@ +import OpenAI from 'openai'; + +// Return a canned response so the `openai` SDK runs on workerd without hitting the network. +const mockFetch: typeof fetch = async () => + new Response( + JSON.stringify({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: 'gpt-3.5-turbo', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello from OpenAI!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + +export default { + async fetch(_request, _env, _ctx) { + // Not wrapped with `instrumentOpenAiClient`: the vite plugin instruments the `openai` package. + const client = new OpenAI({ apiKey: 'mock-api-key', fetch: mockFetch }); + await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }); + return new Response('ok'); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/instrument.server.ts new file mode 100644 index 000000000000..5bde10c05ba7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/instrument.server.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.defineCloudflareOptions((env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + // `dataCollection` records inputs by default. The integration options must win over it. + integrations: [Sentry.openAIIntegration({ recordInputs: false })], +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts new file mode 100644 index 000000000000..8aab877f22f8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts @@ -0,0 +1,25 @@ +import { GEN_AI_INPUT_MESSAGES, GEN_AI_RESPONSE_TEXT, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { GEN_AI_CHAT } from '@sentry/conventions/op'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('applies the options of an openAIIntegration the user configured', async ({ signal }) => { + const runner = createRunner(__dirname).ignore('event').start(signal); + + // The segment span and the gen_ai child. The segment can arrive before the child's envelope. + const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 2); + + await runner.makeRequest('get', '/'); + + const genAiSpans = (await spansPromise).filter(span => !span.is_segment); + expect(genAiSpans).toHaveLength(1); + const attributes = genAiSpans[0]?.attributes; + + // The vite plugin instrumented `openai`, so the channel integration created the span. + expect(attributes?.[SENTRY_OP]).toEqual({ type: 'string', value: GEN_AI_CHAT }); + expect(attributes?.[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ai.openai' }); + // `recordInputs: false` from the configured integration wins over `dataCollection`, which records + // inputs by default. Outputs keep the default. + expect(attributes?.[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(attributes?.[GEN_AI_RESPONSE_TEXT]).toEqual({ type: 'string', value: '["Hello from OpenAI!"]' }); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc new file mode 100644 index 000000000000..4c475b2fe276 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "openai-options-worker", + "compatibility_date": "2026-04-26", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 8be218013d43..e1fb6e60cee3 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -152,6 +152,48 @@ export { getInstrumentedModuleNames, } from '@sentry/server-utils'; +// The orchestrion channel integrations. When the `@sentry/cloudflare/vite` +// plugin bundles an instrumented package, the SDK adds the matching +// integration with its default options. Pass one in `integrations` to set +// options. Without the plugin, these integrations record nothing. +export { + amqplibIntegration, + anthropicAIIntegration, + awsIntegration, + dataloaderIntegration, + expressIntegration, + firebaseIntegration, + genericPoolIntegration, + googleGenAIIntegration, + graphqlIntegration, + groqIntegration, + hapiIntegration, + kafkaIntegration, + knexIntegration, + koaIntegration, + langChainIntegration, + langGraphIntegration, + lruMemoizerIntegration, + mastraIntegration, + mcpServerIntegration, + mistralAIIntegration, + mongoIntegration, + mongooseIntegration, + mysql2Integration, + mysqlIntegration, + neonIntegration, + nitroIntegration, + openAIIntegration, + piDurableIntegration, + postgresIntegration, + postgresJsIntegration, + redisIntegration, + SentryMastraExporter, + tediousIntegration, + togetherAIIntegration, + typesafeIntegration, +} from '@sentry/server-utils'; + export { instrumentWorkflowWithSentry } from './workflows'; export { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index dcbbb7089da4..d1be4d2d4599 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -6,7 +6,7 @@ import type { CloudflareOptions } from '../src/client'; import { CloudflareClient } from '../src/client'; import { getDefaultIntegrations, init } from '../src/sdk'; import { resetSdk } from './testUtils'; -import { spanStreamingIntegration } from '../src/'; +import { mysqlIntegration as exportedMysqlIntegration, spanStreamingIntegration } from '../src/'; describe('init', () => { beforeEach(() => { @@ -443,4 +443,33 @@ describe('getDefaultIntegrations', () => { expect(client?.getIntegrationByName('Mysql')).toBeDefined(); }); + + test('keeps a channel integration the user configured instead of the registered default', async () => { + resetSdk(); + const { mysqlIntegration } = await import('@sentry/server-utils'); + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['mysql']), + integrations: new Map([['mysql', mysqlIntegration]]), + }; + const userInstance = exportedMysqlIntegration(); + + const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [userInstance] }); + + expect(client?.getIntegrationByName('Mysql')).toBe(userInstance); + }); + + test('keeps a channel integration the user configured when its module loads after init', async () => { + resetSdk(); + const { mysqlIntegration } = await import('@sentry/server-utils'); + const userInstance = exportedMysqlIntegration(); + const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [userInstance] }); + + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['mysql']), + integrations: new Map([['mysql', mysqlIntegration]]), + }; + client?.emit('orchestrion.module-injected', 'mysql'); + + expect(client?.getIntegrationByName('Mysql')).toBe(userInstance); + }); }); diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts index ded8aa30ac68..0dbddefc4da7 100644 --- a/packages/server-utils/src/orchestrion/instrumentation.ts +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -1,6 +1,6 @@ import * as diagnosticsChannel from '../utils/diagnosticsChannel'; import type { Client } from '@sentry/core'; -import { addNonEnumerableProperty, debug, waitForTracingChannelBinding } from '@sentry/core'; +import { addNonEnumerableProperty, debug, GLOBAL_OBJ, waitForTracingChannelBinding } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { getOrchestrionInjectedModules } from './detect'; @@ -13,10 +13,13 @@ const INSTRUMENTED = Symbol.for('SentryOrchestrionInstrumented'); // oxlint-disable-next-line typescript/no-explicit-any type InstrumentationFn = ((...args: any[]) => void) & { [INSTRUMENTED]?: boolean }; -const globalAny = globalThis as { Bun?: unknown; Deno?: unknown }; +const globalAny = globalThis as { Bun?: unknown; Deno?: unknown; navigator?: { userAgent?: string } }; const isBun = typeof globalAny.Bun !== 'undefined'; const isDeno = typeof globalAny.Deno !== 'undefined'; +// Labels already warned about, so a client per request does not repeat the warning. +const warnedMissingCloudflarePlugin = new Set(); + /** * Run an integration's channel-subscription callback once one of its modules is * orchestrion-injected, and never more than once. @@ -113,6 +116,21 @@ export function invokeOrchestrionInstrumentation { diff --git a/packages/server-utils/test/orchestrion/instrumentation.test.ts b/packages/server-utils/test/orchestrion/instrumentation.test.ts index fce7ffc3bf26..07c61fd5bb7a 100644 --- a/packages/server-utils/test/orchestrion/instrumentation.test.ts +++ b/packages/server-utils/test/orchestrion/instrumentation.test.ts @@ -1,5 +1,5 @@ import type { AsyncContextStrategy } from '@sentry/core'; -import { GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core'; +import { debug, GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { invokeOrchestrionInstrumentation } from '../../src/orchestrion/instrumentation'; @@ -190,4 +190,43 @@ describe('invokeOrchestrionInstrumentation', () => { expect(callback).not.toHaveBeenCalled(); expect(client.listenerCount()).toBe(1); }); + + describe('on Cloudflare', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('warns once when the vite plugin did not run', () => { + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []); + invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('`@sentry/cloudflare/vite` plugin did not run')); + }); + + it('does not warn when the vite plugin ran, even before any module loaded', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: new Set() }; + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['pg'], vi.fn(), []); + + expect(warn).not.toHaveBeenCalled(); + }); + }); + + it('does not warn about the Cloudflare plugin on other runtimes', () => { + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['redis'], vi.fn(), []); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); }); From 908fa35076b13043c58f05d302702101cb5d9650 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 26 Oct 2026 16:27:15 -0700 Subject: [PATCH 051/126] fix(cloudflare): Do not add back channel integrations the user removed (#25585) The `@sentry/cloudflare/vite` plugin registers an integration factory for each instrumented package when the package first runs. A package that runs after `init()`, for example a driver loaded with a dynamic `import()`, emits `orchestrion.module-injected`, and the SDK then added its integration without checking the user's options. So an `integrations` function that filtered the integration out, `defaultIntegrations: false`, or a custom `defaultIntegrations` list did not keep it out. For the AI integrations, that can turn prompt and response capture back on, and whether it happened depended on module load order. The handler now applies the rules that `init()` applies to the defaults: - If `defaultIntegrations` is set, the SDK defaults are off, so the handler adds nothing. - If `integrations` is a function, the handler passes the late integration through it and installs the result with the same name, if there is one. This calls the function again, once for each late module. If the function throws, the handler skips the integration, so the module load that emitted the event still works. - If `integrations` is an array, nothing changes. An array cannot remove a default, and `addIntegration` already skips a name the user configured. The Nuxt and SvelteKit Cloudflare SDKs always pass a `defaultIntegrations` list, which they build from the Cloudflare defaults. Under the first rule they lost every late integration, so the `nuxt-4-cloudflare` and `sveltekit-cloudflare-pages` E2E tests got no `mysql` spans. These SDKs now set the internal option `_extendsDefaultIntegrations` when they build the list, so the handler still adds late integrations for them. A list the user passes still turns them off. We kept this option over moving the handler into a default integration, because a user who builds a list from `getDefaultIntegrations()` and filters it would then still get the filtered integration back. Fixes #25576 Fixes JS-3974 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- packages/cloudflare/src/baseSdk.ts | 37 ++++++- packages/cloudflare/src/client.ts | 10 ++ packages/cloudflare/test/sdk.test.ts | 102 ++++++++++++++++++ .../plugins/sentry-cloudflare.server.ts | 6 +- .../plugins/sentry-cloudflare.server.test.ts | 56 ++++++++++ packages/sveltekit/src/worker/cloudflare.ts | 1 + .../sveltekit/test/worker/cloudflare.test.ts | 25 ++++- 7 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 0375e7f94601..af1d2d681f66 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { _INTERNAL_isClientClosed, debug, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { _INTERNAL_isClientClosed, debug, getCurrentScope, safeCallback, setCurrentClient } from '@sentry/core'; import { consoleIntegration, conversationIdIntegration, @@ -107,6 +107,9 @@ export function initWithDefaultIntegrations( return cached; } + // `false` or a custom list turns off the SDK defaults, late-loaded ones + // included, unless an SDK built the list from the defaults. + const usesSdkDefaults = options.defaultIntegrations === undefined || !!options._extendsDefaultIntegrations; if (options.defaultIntegrations === undefined) { options.defaultIntegrations = getDefaultIntegrationsImpl(options); } @@ -153,12 +156,36 @@ export function initWithDefaultIntegrations( // lazily required on first use) stores its subscriber factory on the global // marker too late for the default-integrations snapshot above. Its injected // snippet emits this event right after storing the factory, so install the - // integration on the live client here. `addIntegration` dedupes by - // integration name, so already-installed integrations are no-ops. + // integration on the live client here, with the same rules as the defaults. + // `addIntegration` dedupes by integration name, so an integration the user + // configured stays in place. + const userIntegrations = options.integrations; client.on('orchestrion.module-injected', moduleName => { const factory = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get(moduleName); - if (factory) { - client.addIntegration(factory()); + if (!usesSdkDefaults || !factory) { + return; + } + + const integration = factory(); + if (typeof userIntegrations !== 'function') { + client.addIntegration(integration); + return; + } + + // Pass the late default through the user's `integrations` function, so a + // filter that removed it at init removes it here too. This calls the + // function again, once for each late module. If the function throws, skip + // the integration, so the module load that emitted the event still works. + const kept = safeCallback( + DEBUG_BUILD ? 'Error in the `integrations` function, skipping a late-loaded integration:' : '', + () => + getIntegrationsToSetup({ defaultIntegrations: [integration], integrations: userIntegrations }).find( + ({ name }) => name === integration.name, + ), + () => undefined, + ); + if (kept) { + client.addIntegration(kept); } }); diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 051ffd8627d2..ee2171cd987c 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -351,6 +351,16 @@ interface BaseCloudflareOptions { */ enableDedupe?: boolean; + /** + * @ignore Used internally by SDKs that build `defaultIntegrations` from the Cloudflare defaults. + * @hidden Used internally by SDKs that build `defaultIntegrations` from the Cloudflare defaults. + * + * Marks the `defaultIntegrations` list as an extension of the SDK defaults, so the SDK also adds + * the integrations of instrumented modules that load after `init()`. + * @default false + */ + _extendsDefaultIntegrations?: boolean; + /** * The Cloudflare SDK is not OpenTelemetry native. By default (`false`) it does not set up a tracer * provider; spans are emitted via the SDK's own instrumentation and scopes are isolated with diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index d1be4d2d4599..b30630a92843 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -458,6 +458,108 @@ describe('getDefaultIntegrations', () => { expect(client?.getIntegrationByName('Mysql')).toBe(userInstance); }); + describe('when a channel integration module loads after init', () => { + const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + + async function loadMysqlLate(client: CloudflareClient | undefined): Promise { + const { mysqlIntegration } = await import('@sentry/server-utils'); + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['mysql']), + integrations: new Map([['mysql', mysqlIntegration]]), + }; + client?.emit('orchestrion.module-injected', 'mysql'); + } + + test('does not add it when the integrations function filtered it out', async () => { + resetSdk(); + const client = init({ + dsn: DSN, + integrations: defaults => defaults.filter(integration => integration.name !== 'Mysql'), + }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + + test('adds it when the integrations function keeps it', async () => { + resetSdk(); + const client = init({ dsn: DSN, integrations: defaults => defaults }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeDefined(); + }); + + test('keeps the instance the integrations function configured', async () => { + resetSdk(); + const client = init({ + dsn: DSN, + integrations: defaults => [...defaults, exportedMysqlIntegration()], + }); + const configured = client?.getIntegrationByName('Mysql'); + expect(configured).toBeDefined(); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBe(configured); + }); + + test('does not add it and does not throw when the integrations function throws', async () => { + resetSdk(); + let calls = 0; + const client = init({ + dsn: DSN, + integrations: defaults => { + calls++; + if (calls > 1) { + throw new Error('integrations failed'); + } + return defaults; + }, + }); + + await expect(loadMysqlLate(client)).resolves.toBeUndefined(); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + + test('adds it when a custom defaultIntegrations list extends the SDK defaults', async () => { + resetSdk(); + const client = init({ dsn: DSN, defaultIntegrations: [], _extendsDefaultIntegrations: true }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeDefined(); + }); + + test('does not add it when the integrations function filtered it out of a list that extends the SDK defaults', async () => { + resetSdk(); + const client = init({ + dsn: DSN, + defaultIntegrations: [], + _extendsDefaultIntegrations: true, + integrations: defaults => defaults.filter(integration => integration.name !== 'Mysql'), + }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + + test.each([ + ['false', false as const], + ['a custom list', [] as Integration[]], + ])('does not add it when defaultIntegrations is %s', async (_label, defaultIntegrations) => { + resetSdk(); + const client = init({ dsn: DSN, defaultIntegrations }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + }); + test('keeps a channel integration the user configured when its module loads after init', async () => { resetSdk(); const { mysqlIntegration } = await import('@sentry/server-utils'); diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index cc2fcb1c3315..1893de6a57b1 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -71,7 +71,11 @@ export const sentryCloudflareNitroPlugin = const requestHandlerOptions = { // `wrapRequestHandler` only sets up the integrations that work without `nodejs_compat`. // Nitro's Cloudflare presets require the flag anyway, so opt into the full set. - options: { defaultIntegrations: getDefaultIntegrations(cloudflareOptions), ...cloudflareOptions }, + options: { + defaultIntegrations: getDefaultIntegrations(cloudflareOptions), + _extendsDefaultIntegrations: cloudflareOptions.defaultIntegrations === undefined, + ...cloudflareOptions, + }, request, context: getCloudflareProperties(event).context, }; diff --git a/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts b/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts new file mode 100644 index 000000000000..33de134f47cc --- /dev/null +++ b/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts @@ -0,0 +1,56 @@ +import type { CloudflareOptions } from '@sentry/cloudflare'; +import { wrapRequestHandler } from '@sentry/cloudflare/request'; +import type { NitroApp } from 'nitropack'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryCloudflareNitroPlugin } from '../../../src/runtime/plugins/sentry-cloudflare.server'; + +vi.mock('@sentry/cloudflare/request', () => ({ + wrapRequestHandler: vi.fn((_options: unknown, callback: () => unknown) => callback()), +})); + +const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +async function handleRequest(options: CloudflareOptions): Promise { + const nitroApp = { localFetch: vi.fn(), hooks: { hook: vi.fn() } } as unknown as NitroApp; + sentryCloudflareNitroPlugin(options)(nitroApp); + + const event = { + protocol: 'https:', + host: 'example.com', + method: 'GET', + headers: {}, + context: { cf: {}, cloudflare: { context: {} } }, + }; + await nitroApp.localFetch('/api/db-mysql', event as unknown as RequestInit); +} + +describe('sentryCloudflareNitroPlugin', () => { + beforeEach(() => { + vi.mocked(wrapRequestHandler).mockClear(); + }); + + it('passes a defaultIntegrations list that extends the SDK defaults', async () => { + await handleRequest({ dsn: DSN }); + + expect(wrapRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + defaultIntegrations: expect.any(Array), + _extendsDefaultIntegrations: true, + }), + }), + expect.any(Function), + ); + }); + + it('does not mark a defaultIntegrations list the user passed as extending the SDK defaults', async () => { + await handleRequest({ dsn: DSN, defaultIntegrations: [] }); + + expect(wrapRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ defaultIntegrations: [], _extendsDefaultIntegrations: false }), + }), + expect.any(Function), + ); + }); +}); diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 651cc6af6f37..f76450d64712 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -23,6 +23,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { rewriteFramesIntegration(), svelteKitSpansIntegration(), ], + _extendsDefaultIntegrations: options.defaultIntegrations === undefined, // SvelteKit emits its own OpenTelemetry spans (Kit tracing), so, like the Node SvelteKit SDK, it // defaults to registering the tracer provider instead of inheriting Cloudflare's no-provider default. // Only the `init`-backed wrapper from the main entry point honors this; `@sentry/cloudflare/request` diff --git a/packages/sveltekit/test/worker/cloudflare.test.ts b/packages/sveltekit/test/worker/cloudflare.test.ts index dea694b292ec..946854d0b102 100644 --- a/packages/sveltekit/test/worker/cloudflare.test.ts +++ b/packages/sveltekit/test/worker/cloudflare.test.ts @@ -63,7 +63,11 @@ describe('initCloudflareSentryHandle', () => { { // SvelteKit emits its own OpenTelemetry spans, so it opts into the tracer provider rather than // inheriting Cloudflare's no-provider default. - options: expect.objectContaining({ dsn: options.dsn, enableOpenTelemetrySetup: true }), + options: expect.objectContaining({ + dsn: options.dsn, + enableOpenTelemetrySetup: true, + _extendsDefaultIntegrations: true, + }), request, context, captureErrors: false, @@ -74,6 +78,25 @@ describe('initCloudflareSentryHandle', () => { expect(resolve).toHaveBeenCalledTimes(1); }); + it('does not mark a defaultIntegrations list the user passed as extending the SDK defaults', async () => { + const { options, event, resolve } = getHandlerInput(); + + // @ts-expect-error - resolving an empty object is enough for this test + vi.mocked(wrapRequestHandler).mockImplementationOnce((_, cb) => cb()); + + const handle = initCloudflareSentryHandle({ ...options, defaultIntegrations: [] }); + + // @ts-expect-error - only passing a partial event object + await handle({ event, resolve }); + + expect(wrapRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ defaultIntegrations: [], _extendsDefaultIntegrations: false }), + }), + expect.any(Function), + ); + }); + it('adds flag to skip request isolation in subsequent sentry handler', async () => { const { options, event, resolve } = getHandlerInput(); const locals = {}; From 2af46858b2e899505d294d6531965f5829bde7f4 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 26 Oct 2026 16:27:16 -0700 Subject: [PATCH 052/126] ref(deno): Use a shared request handler wrapper for `Deno.serve` (#25586) Deno.serve, Bun.serve and Cloudflare each wrap a web `Request` handler with their own copy of the same steps: request data and body, the `http.server` span, streamed responses, errors and sessions. The copies drift, so a fix in one runtime does not reach the others. This adds `wrapServerRequestHandler` to `@sentry/server-utils` and moves `Deno.serve` onto it. Bun.serve and Cloudflare follow in separate PRs, so each runtime's changes can be reviewed alone. The wrapper has only what Deno needs today, and the next PRs extend it. It is exported from the shared entry, so the Node-free `@sentry/server-utils/no-diagnostic-channels` entry can use it too. The rules follow core's `node:http` server code. `OPTIONS` and `HEAD` requests get no span and no body capture, but still get request data, a session and error capture. The static asset filter applies only to `GET`. For streamed responses, the wrapper uses the stream monitor from the Bun SDK, which reads only when the client pulls and reports errors from the stream source. It replaces Deno's own copy, which read ahead of the client and ignored source errors. The existing Deno tests pass with no change. Visible changes for Deno: - An error from the source of a streamed response is now captured, and the span gets an error status. - The SDK no longer reads a streamed response ahead of the client. - Errors in `OPTIONS` and `HEAD` requests now carry the request data. - `client.address` is no longer set to the path of a Unix socket. Part of #25577 Part of JS-3975 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 + packages/deno/src/integrations/deno-serve.ts | 42 ++- packages/deno/src/utils/streaming.ts | 72 ----- .../deno/src/wrap-deno-request-handler.ts | 229 ++------------- packages/deno/test/deno-serve.test.ts | 40 +++ packages/deno/test/streaming.test.ts | 127 -------- packages/server-utils/src/exports.ts | 2 + .../server-utils/src/utils/monitorStream.ts | 46 +++ .../src/utils/wrapServerRequestHandler.ts | 270 ++++++++++++++++++ .../test/utils/monitorStream.test.ts | 120 ++++++++ .../utils/wrapServerRequestHandler.test.ts | 169 +++++++++++ 11 files changed, 704 insertions(+), 421 deletions(-) delete mode 100644 packages/deno/src/utils/streaming.ts delete mode 100644 packages/deno/test/streaming.test.ts create mode 100644 packages/server-utils/src/utils/monitorStream.ts create mode 100644 packages/server-utils/src/utils/wrapServerRequestHandler.ts create mode 100644 packages/server-utils/test/utils/monitorStream.test.ts create mode 100644 packages/server-utils/test/utils/wrapServerRequestHandler.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af7d1823ccd..f9f91e55cc08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,14 @@ Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @ These integrations need the vite plugin. Without it, they record nothing, and with `debug: true` the SDK logs a warning. +- **ref(deno): Use the shared request handler wrapper for `Deno.serve`** + + `Deno.serve` now uses a request handler wrapper that `Bun.serve` and Cloudflare will also use. Three small changes for Deno users: + + - When the source of a streamed response fails, the SDK now captures the error and marks the span as failed. + - The SDK no longer reads a streamed response ahead of the client. + - Errors in `OPTIONS` and `HEAD` requests now carry the request data. + ## 11.9.0 ### Important Changes diff --git a/packages/deno/src/integrations/deno-serve.ts b/packages/deno/src/integrations/deno-serve.ts index 8c8a4038e5b5..425040e4607e 100644 --- a/packages/deno/src/integrations/deno-serve.ts +++ b/packages/deno/src/integrations/deno-serve.ts @@ -1,6 +1,6 @@ -import type { Event, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; +import type { Client, Event, Integration, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; import { debug, defineIntegration } from '@sentry/core'; -import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; +import type { AssertTrue, CheckServerRequestOptions, ServerRequestOptions } from '@sentry/core/server'; import { DEFAULT_IGNORE_STATUS_CODES, processHttpServerTransactionEvent } from '@sentry/core/server'; import type { RequestHandlerWrapperOptions } from '../wrap-deno-request-handler'; import { wrapDenoRequestHandler } from '../wrap-deno-request-handler'; @@ -140,6 +140,7 @@ const applyHandlerWrap = request, info, serveOptions, + getRequestOptions: getDenoServeRequestOptions, } as RequestHandlerWrapperOptions, () => handler(request, info as Deno.ServeHandlerInfo), ); @@ -159,21 +160,38 @@ const instrumentedDenoServe = (serve: typeof Deno.serve): typeof Deno.serve => }, }); -const _denoServeIntegration = ((options: DenoServeIntegrationOptions = {}) => { +// Every shared option is a required key, so a new one fails to compile below until the integration +// passes it on. +type DenoServeRequestOptions = { + [K in keyof Required>]: ServerRequestOptions[K]; +}; + +interface DenoServeIntegration extends Integration { + requestOptions: DenoServeRequestOptions; +} + +/** The incoming request options of the client's `denoServeIntegration`, if it has one. */ +function getDenoServeRequestOptions(client: Client): ServerRequestOptions | undefined { + return client.getIntegrationByName(INTEGRATION_NAME)?.requestOptions; +} + +const _denoServeIntegration = ((options: DenoServeIntegrationOptions = {}): DenoServeIntegration => { // oxlint-disable-next-line typescript/no-deprecated const ignoreStatusCodes = options.ignoreStatusCodes ?? DEFAULT_IGNORE_STATUS_CODES; return { name: INTEGRATION_NAME, - maxRequestBodySize: options.maxRequestBodySize, - ignoreRequestBody: options.ignoreRequestBody, - ignoreIncomingRequests: options.ignoreIncomingRequests, - ignoreStaticAssets: options.ignoreStaticAssets ?? true, - spans: options.spans, - onSpanCreated: options.onSpanCreated, - onSpanEnd: options.onSpanEnd, - sessions: options.sessions ?? true, - sessionFlushingDelayMS: options.sessionFlushingDelayMS, + requestOptions: { + maxRequestBodySize: options.maxRequestBodySize, + ignoreRequestBody: options.ignoreRequestBody, + ignoreIncomingRequests: options.ignoreIncomingRequests, + ignoreStaticAssets: options.ignoreStaticAssets ?? true, + spans: options.spans, + onSpanCreated: options.onSpanCreated, + onSpanEnd: options.onSpanEnd, + sessions: options.sessions ?? true, + sessionFlushingDelayMS: options.sessionFlushingDelayMS, + }, processEvent(event: Event): Event | null { // Gated on this integration's own span origin so it does not filter `node:http` // transactions, which `denoHttpIntegration` owns via its own `ignoreStatusCodes`. diff --git a/packages/deno/src/utils/streaming.ts b/packages/deno/src/utils/streaming.ts deleted file mode 100644 index 0b82c16e4a47..000000000000 --- a/packages/deno/src/utils/streaming.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Span } from '@sentry/core'; -import { classifyResponseStreaming } from '@sentry/core/server'; - -/** - * Tee a stream, and end the provided span (if any) when the stream ends. - * Returns the other side of the tee, which can be used to send the - * response to a client. - * - * `onEnd` runs right before the span ends. - */ -export async function streamResponse(span: Span | undefined, res: Response, onEnd?: () => void): Promise { - const classification = classifyResponseStreaming(res); - const endSpan = (): void => { - onEnd?.(); - span?.end(); - }; - - // not streaming, just end the span and return the response - if (!classification.isStreaming || !res.body) { - endSpan(); - return res; - } - - // Streaming response detected - monitor consumption to keep span alive - try { - return new Response(monitorStream(res.body, endSpan), { - status: res.status, - statusText: res.statusText, - headers: res.headers, - }); - } catch { - // tee() failed - handle without streaming - endSpan(); - return res; - } -} - -/** - * zero-copy monitoring of stream progress. - */ -function monitorStream( - stream: ReadableStream>, - onDone: () => void, -): ReadableStream> { - const reader = stream.getReader(); - reader.closed.then( - () => onDone(), - () => onDone(), - ); - return new ReadableStream({ - async start(controller) { - let result: ReadableStreamReadResult>; - do { - result = await reader.read(); - if (result.value) { - try { - controller.enqueue(result.value); - } catch (er) { - controller.error(er); - reader.releaseLock(); - return; - } - } - } while (!result.done); - controller.close(); - reader.releaseLock(); - }, - cancel(reason) { - return reader.cancel(reason); - }, - }); -} diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index ed8d6ae2b77e..4d24405d58e4 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -1,85 +1,14 @@ -import { - CLIENT_ADDRESS, - CLIENT_PORT, - NETWORK_PROTOCOL_NAME, - SENTRY_OP, - SENTRY_SEGMENT_NAME_SOURCE, -} from '@sentry/conventions/attributes'; -import { HTTP_SERVER } from '@sentry/conventions/op'; -import type { Client, Integration } from '@sentry/core'; -import { - captureBodyFromWinterCGRequest, - captureException, - continueTrace, - debug, - getClient, - getHttpSpanDetailsFromUrlObject, - hasSpansEnabled, - hasSpanStreamingEnabled, - httpHeadersToSpanAttributes, - HTTP_SPAN_NAME_FALLBACK, - parseStringToURLObject, - setHttpStatus, - startSpanManual, - winterCGHeadersToDict, - winterCGRequestToRequestData, - withIsolationScope, -} from '@sentry/core'; -import { getClientIPAddress, isStaticAssetRequest, startRequestSession } from '@sentry/core/server'; -import type { DenoServeIntegrationOptions } from './integrations/deno-serve'; -import { streamResponse } from './utils/streaming'; +import type { Client } from '@sentry/core'; +import { debug, getClient, withIsolationScope } from '@sentry/core'; +import type { ServerRequestOptions } from '@sentry/core/server'; +import { wrapServerRequestHandler } from '@sentry/server-utils'; export type RequestHandlerWrapperOptions = { request: Request; info: Deno.ServeHandlerInfo; serveOptions?: Deno.ServeOptions; -}; - -function captureServeError(e: unknown): void { - captureException(e, { - mechanism: { - handled: false, - type: 'auto.http.deno', - data: { function: 'serve' }, - }, - }); -} - -// A user callback that throws must not fail the request or leave the span open. -function callUserCallback(name: string, callback: () => T, fallback: T): T { - try { - return callback(); - } catch (e) { - debug.error(`Error in the Deno.serve ${name} callback.`, e); - return fallback; - } -} - -function shouldCreateSpan( - client: Client, - integration: DenoServeIntegrationOptions | undefined, - request: Request, - urlPath: string, -): boolean { - if (!(integration?.spans ?? hasSpansEnabled(client.getOptions()))) { - return false; - } - if (integration?.ignoreStaticAssets && request.method === 'GET' && isStaticAssetRequest(urlPath)) { - return false; - } - const ignoreIncomingRequests = integration?.ignoreIncomingRequests; - return !( - ignoreIncomingRequests && - callUserCallback('ignoreIncomingRequests', () => ignoreIncomingRequests(urlPath, request), false) - ); -} - -const assignIfSet = , K extends keyof T>( - obj: T, - key: K, - value: T[K] | undefined | null, -): void => { - if (value !== undefined && value !== null) obj[key] = value; + /** Reads the incoming request options from the client's `denoServeIntegration`. */ + getRequestOptions: (client: Client) => ServerRequestOptions | undefined; }; export const wrapDenoRequestHandler = ( @@ -87,7 +16,7 @@ export const wrapDenoRequestHandler = ( handler: () => Promise | Response, ): Response | Promise => { return withIsolationScope(async isolationScope => { - const { request, info } = wrapperOptions; + const { request, info, getRequestOptions } = wrapperOptions; const client = getClient(); if (!client) { @@ -100,140 +29,20 @@ export const wrapDenoRequestHandler = ( } isolationScope.setClient(client); - const integration = client.getIntegrationByName('DenoServe'); - const endSession = integration?.sessions - ? startRequestSession(client, { - requestIsolationScope: isolationScope, - sessionFlushingDelayMS: integration.sessionFlushingDelayMS, - }) - : undefined; - - if (request.method === 'OPTIONS' || request.method === 'HEAD') { - try { - return await handler(); - } catch (e) { - captureServeError(e); - throw e; - } finally { - endSession?.(); - } - } - - const urlObject = parseStringToURLObject(request.url); - const [rawName, attributes] = getHttpSpanDetailsFromUrlObject( - urlObject, - 'server', - 'auto.http.deno', - request, - undefined, - client, - ); - // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. - // A `route` source means the name already is (e.g. the `/` path), so it is kept as-is. - const name = - attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !hasSpanStreamingEnabled(client) - ? rawName - : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK; - - const contentLength = request.headers.get('content-length'); - assignIfSet(attributes, 'http.request.body.size', contentLength && parseInt(contentLength, 10)); - assignIfSet(attributes, 'user_agent.original', request.headers.get('user-agent')); - - const headers = winterCGHeadersToDict(request.headers); - const socketIp = (info?.remoteAddr as Deno.NetAddr)?.hostname; - const dataCollection = client.getDataCollectionOptions(); - if (dataCollection.userInfo) { - // `client.address` is the originating client, so a forwarding header wins over the socket, which - // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` - // stays unset then. - const forwardedAddress = getClientIPAddress(headers); - assignIfSet( - attributes, - CLIENT_ADDRESS, - forwardedAddress || socketIp || (info?.remoteAddr as Deno.UnixAddr)?.path, - ); - if (!forwardedAddress) { - assignIfSet(attributes, CLIENT_PORT, (info?.remoteAddr as Deno.NetAddr)?.port); - } - } - - // describes the OSI application-layer protocol (http), not the scheme (might be https) - attributes[NETWORK_PROTOCOL_NAME] = 'http'; - - Object.assign(attributes, httpHeadersToSpanAttributes(headers, dataCollection)); - attributes[SENTRY_OP] = HTTP_SERVER; - isolationScope.setSDKProcessingMetadata({ - normalizedRequest: winterCGRequestToRequestData(request), - ipAddress: socketIp, - }); - - const effectiveBodySize = - integration?.maxRequestBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); - const ignoreRequestBody = integration?.ignoreRequestBody; - if ( - request.method !== 'GET' && - effectiveBodySize !== 'none' && - // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. - !(ignoreRequestBody && callUserCallback('ignoreRequestBody', () => ignoreRequestBody(request.url, request), true)) - ) { - await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); - } - - const urlPath = urlObject ? `${urlObject.pathname}${urlObject.search}` : '/'; - const createSpan = shouldCreateSpan(client, integration, request, urlPath); - - return continueTrace( + return wrapServerRequestHandler( { - sentryTrace: request.headers.get('sentry-trace') || '', - baggage: request.headers.get('baggage'), - }, - async () => { - if (!createSpan) { - let res: Response; - try { - res = await handler(); - isolationScope.setContext('response', { - status_code: res.status, - }); - } catch (e) { - captureServeError(e); - endSession?.(); - throw e; - } - return endSession ? streamResponse(undefined, res, endSession) : res; - } - - return startSpanManual({ name, attributes }, async span => { - const { onSpanCreated, onSpanEnd } = integration ?? {}; - if (onSpanCreated) { - callUserCallback('onSpanCreated', () => onSpanCreated(span, request), undefined); - } - let res: Response; - - try { - res = await handler(); - setHttpStatus(span, res.status); - isolationScope.setContext('response', { - status_code: res.status, - }); - span.setAttributes( - httpHeadersToSpanAttributes(Object.fromEntries(res.headers), dataCollection, 'response'), - ); - } catch (e) { - span.end(); - captureServeError(e); - endSession?.(); - throw e; - } - - return streamResponse(span, res, () => { - if (onSpanEnd) { - callUserCallback('onSpanEnd', () => onSpanEnd(span, request, res), undefined); - } - endSession?.(); - }); - }); + request, + client, + isolationScope, + options: getRequestOptions(client), + origin: 'auto.http.deno', + mechanism: { handled: false, type: 'auto.http.deno', data: { function: 'serve' } }, + getSocketAddress: () => { + const remoteAddr = info?.remoteAddr as Deno.NetAddr | undefined; + return remoteAddr?.hostname ? { address: remoteAddr.hostname, port: remoteAddr.port } : undefined; + }, }, + handler, ); }); }; diff --git a/packages/deno/test/deno-serve.test.ts b/packages/deno/test/deno-serve.test.ts index 90719660ea43..e7749f3c39fb 100644 --- a/packages/deno/test/deno-serve.test.ts +++ b/packages/deno/test/deno-serve.test.ts @@ -866,6 +866,46 @@ Deno.test('Deno.serve should handle streaming responses', async () => { assertEquals(transactionEvents[0]?.contexts?.trace?.op, 'http.server'); }); +Deno.test('Deno.serve should capture an error from a streamed response source', async () => { + resetGlobals(); + const errorEvents: ErrorEvent[] = []; + const transactionEvents: TransactionEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('chunk1\n')); + setTimeout(() => controller.error(new Error('stream interrupted')), 10); + }, + }); + return new Response(stream, { headers: { 'content-type': 'text/event-stream' } }); + }); + + const response = await fetch(`${server.url}/stream`); + await response.text().catch(() => undefined); + await server.close(); + await client.flush(); + + assertEquals(errorEvents.length, 1); + assertEquals(errorEvents[0]?.exception?.values?.[0]?.value, 'stream interrupted'); + assertEquals(errorEvents[0]?.exception?.values?.[0]?.mechanism?.type, 'auto.http.deno'); + assertEquals(transactionEvents[0]?.contexts?.trace?.status, 'internal_error'); +}); + Deno.test('Deno.serve should work when manually capturing exceptions within handler', async () => { resetGlobals(); const errorEvents: ErrorEvent[] = []; diff --git a/packages/deno/test/streaming.test.ts b/packages/deno/test/streaming.test.ts deleted file mode 100644 index 05951459e786..000000000000 --- a/packages/deno/test/streaming.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// - -import { assertEquals, assertStrictEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; - -import { streamResponse } from '../src/utils/streaming.ts'; - -Deno.test('cancels the source when the response reader is cancelled', async () => { - let sourceCancelReason: unknown; - - const source = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('first event')); - }, - cancel(reason) { - sourceCancelReason = reason; - }, - }); - - const span = { end() {} } as Parameters[0]; - const response = await streamResponse( - span, - new Response(source, { headers: { 'content-type': 'text/event-stream' } }), - ); - const reader = response.body!.getReader(); - const reason = new Error('client disconnected'); - - await reader.read(); - await reader.cancel(reason); - - assertStrictEquals(sourceCancelReason, reason); -}); - -Deno.test('reader.closed.then(f, f) suppresses rejection when releaseLock is called on an open stream', async () => { - // Reproduces the bug from GitHub issue #20177: - // In monitorStream, reader.releaseLock() is called while the source stream - // is still open (e.g. the error path when controller.enqueue() throws). - // Per WHATWG Streams spec, this rejects reader.closed with a TypeError. - // Using .then(onDone, onDone) handles both cases; .finally() would propagate - // the rejection as unhandled. - - let onDoneCalled = false; - - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('data')); - // intentionally not closing — stream stays open - }, - }); - - const reader = stream.getReader(); - - // This is the exact pattern from monitorStream (line 84 in streaming.ts). - // With .finally(() => onDone()), this would propagate the rejection. - reader.closed.then( - () => { - onDoneCalled = true; - }, - () => { - onDoneCalled = true; - }, - ); - - await reader.read(); - - // This is what monitorStream does on the error path (line 98) when - // controller.enqueue() throws — releaseLock while the source is still open. - reader.releaseLock(); - - let unhandledRejection: PromiseRejectionEvent | undefined; - const handler = (e: PromiseRejectionEvent): void => { - e.preventDefault(); - unhandledRejection = e; - }; - globalThis.addEventListener('unhandledrejection', handler); - - try { - await new Promise(resolve => setTimeout(resolve, 50)); - - assertEquals(onDoneCalled, true, 'onDone should have been called via the rejection handler'); - assertEquals(unhandledRejection, undefined, 'should not have caused an unhandled promise rejection'); - } finally { - globalThis.removeEventListener('unhandledrejection', handler); - } -}); - -Deno.test('calls onEnd before ending the span for a non-streamed response', async () => { - const calls: string[] = []; - const span = { end: () => calls.push('end') } as unknown as Parameters[0]; - - await streamResponse(span, new Response('{}', { headers: { 'content-type': 'application/json' } }), () => - calls.push('onEnd'), - ); - - assertEquals(calls, ['onEnd', 'end']); -}); - -Deno.test('calls onEnd before ending the span when a streamed response finishes', async () => { - const calls: string[] = []; - let resolveEnded: () => void; - const ended = new Promise(resolve => (resolveEnded = resolve)); - const span = { - end: () => { - calls.push('end'); - resolveEnded(); - }, - } as unknown as Parameters[0]; - - let sourceController: ReadableStreamDefaultController | undefined; - const source = new ReadableStream({ - start(controller) { - sourceController = controller; - controller.enqueue(new TextEncoder().encode('chunk')); - }, - }); - const response = await streamResponse( - span, - new Response(source, { headers: { 'content-type': 'text/event-stream' } }), - () => calls.push('onEnd'), - ); - - await new Promise(resolve => setTimeout(resolve, 0)); - assertEquals(calls, []); - sourceController!.close(); - assertEquals(await response.text(), 'chunk'); - await ended; - assertEquals(calls, ['onEnd', 'end']); -}); diff --git a/packages/server-utils/src/exports.ts b/packages/server-utils/src/exports.ts index 15381f95dc9b..799ed7c69d18 100644 --- a/packages/server-utils/src/exports.ts +++ b/packages/server-utils/src/exports.ts @@ -1,5 +1,7 @@ // Shared exports not using diagnostics channels export { setHttpServerSpanRouteAttribute } from './utils/setHttpServerSpanRouteAttribute'; +export { wrapServerRequestHandler } from './utils/wrapServerRequestHandler'; +export type { ServerRequestHandlerConfig } from './utils/wrapServerRequestHandler'; export { setAsyncLocalStorageAsyncContextStrategy } from './async-context'; export { openTelemetryIntegration, getOtlpTracesEndpoint } from './opentelemetry'; export * from './ai'; diff --git a/packages/server-utils/src/utils/monitorStream.ts b/packages/server-utils/src/utils/monitorStream.ts new file mode 100644 index 000000000000..6b4651f990d1 --- /dev/null +++ b/packages/server-utils/src/utils/monitorStream.ts @@ -0,0 +1,46 @@ +import { isError } from '@sentry/core'; + +/** Monitor the source without reading ahead or treating cancellation as a source failure. */ +export function monitorStream( + stream: ReadableStream, + onDone: () => void, + onError: (error: unknown) => void, +): ReadableStream { + const reader = stream.getReader(); + // A cancelled reader closes normally; only a source failure should be captured as an error. + // A source that stops on `request.signal` errors with an `AbortError` when the client + // disconnects, which is a cancellation too. + void reader.closed.then(onDone, error => { + if (!(isError(error) && error.name === 'AbortError')) { + onError(error); + } + onDone(); + }); + + return new ReadableStream( + { + async pull(controller) { + try { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + reader.releaseLock(); + } else { + controller.enqueue(value); + } + } catch (error) { + controller.error(error); + reader.releaseLock(); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + reader.releaseLock(); + } + }, + }, + { highWaterMark: 0 }, + ); +} diff --git a/packages/server-utils/src/utils/wrapServerRequestHandler.ts b/packages/server-utils/src/utils/wrapServerRequestHandler.ts new file mode 100644 index 000000000000..9702af119181 --- /dev/null +++ b/packages/server-utils/src/utils/wrapServerRequestHandler.ts @@ -0,0 +1,270 @@ +import { + CLIENT_ADDRESS, + CLIENT_PORT, + HTTP_REQUEST_BODY_SIZE, + NETWORK_PROTOCOL_NAME, + SENTRY_OP, + SENTRY_SEGMENT_NAME_SOURCE, + USER_AGENT_ORIGINAL, +} from '@sentry/conventions/attributes'; +import { HTTP_SERVER } from '@sentry/conventions/op'; +import type { Client, Mechanism, Scope, Span } from '@sentry/core'; +import { + captureBodyFromWinterCGRequest, + captureException, + continueTrace, + getHttpSpanDetailsFromUrlObject, + hasSpansEnabled, + hasSpanStreamingEnabled, + httpHeadersToSpanAttributes, + HTTP_SPAN_NAME_FALLBACK, + parseStringToURLObject, + safeCallback, + setHttpStatus, + SPAN_STATUS_ERROR, + startSpanManual, + winterCGHeadersToDict, + winterCGRequestToRequestData, +} from '@sentry/core'; +import type { ServerRequestOptions } from '@sentry/core/server'; +import { + classifyResponseStreaming, + getClientIPAddress, + isStaticAssetRequest, + startRequestSession, +} from '@sentry/core/server'; +import { DEBUG_BUILD } from '../debug-build'; +import { monitorStream } from './monitorStream'; + +export interface ServerRequestHandlerConfig { + request: Request; + client: Client; + /** The isolation scope of this request. */ + isolationScope: Scope; + /** The options of the runtime's integration for incoming requests. */ + options: ServerRequestOptions | undefined; + /** Origin of the span, e.g. `auto.http.deno`. */ + origin: string; + /** Mechanism of errors that the handler or the response stream throws. */ + mechanism: Mechanism; + /** The address and port of the socket, if the runtime knows them. */ + getSocketAddress?: () => { address: string; port?: number } | undefined; +} + +/** + * Wraps a handler of a web `Request`, as used by `Deno.serve`, `Bun.serve` and Cloudflare Workers. + * + * It records the request session, attaches the request data and body, continues the incoming + * trace, creates the `http.server` span, and captures errors that the handler throws. The rules + * follow core's `node:http` server code: + * + * - `OPTIONS` and `HEAD` requests get no span and no body capture. + * - The static asset filter applies only to `GET`. + * - The span and the session of a streamed response end when the stream finishes. + */ +export async function wrapServerRequestHandler( + config: ServerRequestHandlerConfig, + handler: () => Response | Promise, +): Promise { + const { request, client, isolationScope, options, mechanism } = config; + const method = request.method.toUpperCase(); + + const endSession = options?.sessions + ? startRequestSession(client, { + requestIsolationScope: isolationScope, + sessionFlushingDelayMS: options.sessionFlushingDelayMS, + }) + : undefined; + + const socketAddress = config.getSocketAddress?.(); + isolationScope.setSDKProcessingMetadata({ + normalizedRequest: winterCGRequestToRequestData(request), + ipAddress: socketAddress?.address, + }); + + if (method === 'OPTIONS' || method === 'HEAD') { + try { + return await handler(); + } catch (e) { + captureException(e, { mechanism }); + throw e; + } finally { + endSession?.(); + } + } + + const dataCollection = client.getDataCollectionOptions(); + const effectiveBodySize = + options?.maxRequestBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); + const ignoreRequestBody = options?.ignoreRequestBody; + if ( + method !== 'GET' && + effectiveBodySize !== 'none' && + // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. + !( + ignoreRequestBody && + safeCallback( + DEBUG_BUILD ? 'Error in `ignoreRequestBody`, skipping the body:' : '', + () => ignoreRequestBody(request.url, request), + () => true, + ) + ) + ) { + await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); + } + + const urlObject = parseStringToURLObject(request.url); + const urlPath = urlObject ? `${urlObject.pathname}${urlObject.search}` : '/'; + const createSpan = shouldCreateSpan(client, options, request, urlPath); + + return continueTrace( + { + sentryTrace: request.headers.get('sentry-trace') || '', + baggage: request.headers.get('baggage'), + }, + async () => { + if (!createSpan) { + let response: Response; + try { + response = await handler(); + } catch (e) { + captureException(e, { mechanism }); + endSession?.(); + throw e; + } + isolationScope.setContext('response', { status_code: response.status }); + return onResponseDone(response, mechanism, () => endSession?.()); + } + + const [rawName, attributes] = getHttpSpanDetailsFromUrlObject( + urlObject, + 'server', + config.origin, + request, + undefined, + client, + ); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + // A `route` source means the name already is (e.g. the `/` path), so it is kept as-is. + const name = + attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !hasSpanStreamingEnabled(client) + ? rawName + : method || HTTP_SPAN_NAME_FALLBACK; + + const contentLength = request.headers.get('content-length'); + if (contentLength) { + attributes[HTTP_REQUEST_BODY_SIZE] = parseInt(contentLength, 10); + } + const userAgent = request.headers.get('user-agent'); + if (userAgent) { + attributes[USER_AGENT_ORIGINAL] = userAgent; + } + + const headers = winterCGHeadersToDict(request.headers); + if (dataCollection.userInfo) { + // `client.address` is the originating client, so a forwarding header wins over the socket, which + // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` + // stays unset then. + const forwardedAddress = getClientIPAddress(headers); + if (forwardedAddress) { + attributes[CLIENT_ADDRESS] = forwardedAddress; + } else if (socketAddress) { + attributes[CLIENT_ADDRESS] = socketAddress.address; + if (socketAddress.port !== undefined) { + attributes[CLIENT_PORT] = socketAddress.port; + } + } + } + + // describes the OSI application-layer protocol (http), not the scheme (might be https) + attributes[NETWORK_PROTOCOL_NAME] = 'http'; + Object.assign(attributes, httpHeadersToSpanAttributes(headers, dataCollection)); + attributes[SENTRY_OP] = HTTP_SERVER; + + return startSpanManual({ name, attributes }, async span => { + const { onSpanCreated, onSpanEnd } = options ?? {}; + if (onSpanCreated) { + safeCallback( + DEBUG_BUILD ? 'Error in `onSpanCreated`:' : '', + () => onSpanCreated(span, request), + () => undefined, + ); + } + + let response: Response; + try { + response = await handler(); + } catch (e) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + span.end(); + captureException(e, { mechanism }); + endSession?.(); + throw e; + } + + setHttpStatus(span, response.status); + isolationScope.setContext('response', { status_code: response.status }); + span.setAttributes( + httpHeadersToSpanAttributes(winterCGHeadersToDict(response.headers), dataCollection, 'response'), + ); + + return onResponseDone( + response, + mechanism, + () => { + if (onSpanEnd) { + safeCallback( + DEBUG_BUILD ? 'Error in `onSpanEnd`:' : '', + () => onSpanEnd(span, request, response), + () => undefined, + ); + } + span.end(); + endSession?.(); + }, + span, + ); + }); + }, + ); +} + +function shouldCreateSpan( + client: Client, + options: ServerRequestOptions | undefined, + request: Request, + urlPath: string, +): boolean { + if (!(options?.spans ?? hasSpansEnabled(client.getOptions()))) { + return false; + } + if (options?.ignoreStaticAssets && request.method.toUpperCase() === 'GET' && isStaticAssetRequest(urlPath)) { + return false; + } + const ignoreIncomingRequests = options?.ignoreIncomingRequests; + return !( + ignoreIncomingRequests && + safeCallback( + DEBUG_BUILD ? 'Error in `ignoreIncomingRequests`, creating the span:' : '', + () => ignoreIncomingRequests(urlPath, request), + () => false, + ) + ); +} + +/** + * Calls `onDone` when the response is done: at once, or when a streamed body finishes. + * Returns the response to send. + */ +function onResponseDone(response: Response, mechanism: Mechanism, onDone: () => void, span?: Span): Response { + if (response.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { + const body = monitorStream(response.body, onDone, error => { + span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { mechanism }); + }); + // Passing the original response as the init keeps its status, status text and headers. + return new Response(body, response); + } + onDone(); + return response; +} diff --git a/packages/server-utils/test/utils/monitorStream.test.ts b/packages/server-utils/test/utils/monitorStream.test.ts new file mode 100644 index 000000000000..fee33b3cec75 --- /dev/null +++ b/packages/server-utils/test/utils/monitorStream.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { monitorStream } from '../../src/utils/monitorStream'; + +const encode = (text: string): Uint8Array => new TextEncoder().encode(text); + +// Runs every pending promise job, for checks that something did not happen. +const flushPromiseJobs = (): Promise => new Promise(resolve => setImmediate(resolve)); + +describe('monitorStream', () => { + it('passes the chunks through and calls onDone when the source closes', async () => { + const onDone = vi.fn(); + const onError = vi.fn(); + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encode('a')); + controller.enqueue(encode('b')); + controller.close(); + }, + }); + + const text = await new Response(monitorStream(source, onDone, onError)).text(); + + expect(text).toBe('ab'); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it('reads from the source only when the consumer pulls', async () => { + let pulls = 0; + const source = new ReadableStream( + { + pull(controller) { + pulls++; + controller.enqueue(encode(String(pulls))); + }, + }, + { highWaterMark: 0 }, + ); + + const reader = monitorStream(source, vi.fn(), vi.fn()).getReader(); + await flushPromiseJobs(); + expect(pulls).toBe(0); + + await reader.read(); + await flushPromiseJobs(); + expect(pulls).toBe(1); + await reader.cancel(); + }); + + it('cancels the source when the consumer cancels, without reporting an error', async () => { + let sourceCancelReason: unknown; + const onDone = vi.fn(); + const onError = vi.fn(); + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encode('first event')); + }, + cancel(reason) { + sourceCancelReason = reason; + }, + }); + const reader = monitorStream(source, onDone, onError).getReader(); + const reason = new Error('client disconnected'); + + await reader.read(); + await reader.cancel(reason); + await vi.waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + + expect(sourceCancelReason).toBe(reason); + expect(onError).not.toHaveBeenCalled(); + }); + + it('reports a source error and calls onDone, without an unhandled rejection', async () => { + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + const onDone = vi.fn(); + const onError = vi.fn(); + const error = new Error('source failed'); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + try { + const response = new Response(monitorStream(source, onDone, onError)); + const text = response.text(); + controller!.error(error); + + await expect(text).rejects.toBe(error); + await vi.waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + await flushPromiseJobs(); + + expect(onError).toHaveBeenCalledWith(error); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + } + }); + + it('does not report an abort of the source, such as a client disconnect, as an error', async () => { + const onDone = vi.fn(); + const onError = vi.fn(); + const abort = new DOMException('The request has been cancelled.', 'AbortError'); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const text = new Response(monitorStream(source, onDone, onError)).text(); + controller!.error(abort); + + await expect(text).rejects.toBe(abort); + await vi.waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts b/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts new file mode 100644 index 000000000000..217e92b42e30 --- /dev/null +++ b/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts @@ -0,0 +1,169 @@ +import type { Event } from '@sentry/core'; +import { getIsolationScope, setCurrentClient, withIsolationScope } from '@sentry/core'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../../src/async-context'; +import type { ServerRequestHandlerConfig } from '../../src/utils/wrapServerRequestHandler'; +import { wrapServerRequestHandler } from '../../src/utils/wrapServerRequestHandler'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +const MECHANISM = { handled: false, type: 'auto.http.test' }; + +function setup(options: ServerRequestHandlerConfig['options'] = {}): { + events: Event[]; + transactions: Event[]; + serve: (request: Request, handler: () => Response | Promise) => Promise; +} { + const events: Event[] = []; + const transactions: Event[] = []; + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + beforeSend: event => { + events.push(event); + return null; + }, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }), + ); + setCurrentClient(client); + client.init(); + + const serve = (request: Request, handler: () => Response | Promise): Promise => + withIsolationScope(isolationScope => + wrapServerRequestHandler( + { request, client, isolationScope, options, origin: 'auto.http.test', mechanism: MECHANISM }, + handler, + ), + ); + return { events, transactions, serve }; +} + +describe('wrapServerRequestHandler', () => { + beforeAll(() => { + setAsyncLocalStorageAsyncContextStrategy(); + }); + + it.each(['OPTIONS', 'HEAD'])( + 'attaches request data and captures errors for %s requests, without a span', + async method => { + const { events, transactions, serve } = setup(); + const error = new Error(`${method} failed`); + + await expect( + serve(new Request('https://example.com/cors', { method }), () => { + expect(getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.method).toBe(method); + throw error; + }), + ).rejects.toBe(error); + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual(MECHANISM); + expect(transactions).toHaveLength(0); + }, + ); + + it('skips the body when ignoreRequestBody throws', async () => { + const { serve } = setup({ + ignoreRequestBody: () => { + throw new Error('filter failed'); + }, + }); + let data: unknown = 'not read'; + + const response = await serve(new Request('https://example.com/login', { method: 'POST', body: 'secret' }), () => { + data = getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.data; + return new Response('ok'); + }); + + expect(await response.text()).toBe('ok'); + expect(data).toBeUndefined(); + }); + + it('calls onSpanEnd and ends the span when a streamed response finishes', async () => { + const calls: string[] = []; + const { transactions, serve } = setup({ + onSpanEnd: (span, _request, response) => { + calls.push('onSpanEnd'); + span.setAttribute('onSpanEnd.status', response.status); + }, + }); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const response = await serve( + new Request('https://example.com/events'), + () => new Response(source, { status: 201, headers: { 'content-type': 'text/event-stream' } }), + ); + expect(response.status).toBe(201); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + controller!.enqueue(new TextEncoder().encode('data: one\n\n')); + expect(calls).toEqual([]); + + controller!.close(); + expect(await response.text()).toBe('data: one\n\n'); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + expect(calls).toEqual(['onSpanEnd']); + expect(transactions[0]?.contexts?.trace?.data?.['onSpanEnd.status']).toBe(201); + }); + + // Serves a streamed response whose source fails after the handler returns. + async function serveFailingStream( + serve: ReturnType['serve'], + error: unknown = new Error('stream interrupted'), + ): Promise { + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const response = await serve( + new Request('https://example.com/events'), + () => new Response(source, { headers: { 'content-type': 'application/x-ndjson' } }), + ); + const text = response.text(); + controller!.error(error); + await expect(text).rejects.toBe(error); + } + + it('captures an error from a streamed response source and marks the span as failed', async () => { + const { events, transactions, serve } = setup({ spans: true }); + + await serveFailingStream(serve); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual(MECHANISM); + expect(transactions[0]?.contexts?.trace?.status).toBe('internal_error'); + }); + + it('captures an error from a streamed response source without a span', async () => { + const { events, transactions, serve } = setup({ spans: false }); + + await serveFailingStream(serve); + await vi.waitFor(() => expect(events).toHaveLength(1)); + + expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual(MECHANISM); + expect(transactions).toHaveLength(0); + }); + + it('does not capture or fail the span when the client disconnects from a streamed response', async () => { + const { events, transactions, serve } = setup({ spans: true }); + + await serveFailingStream(serve, new DOMException('The request has been cancelled.', 'AbortError')); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + expect(events).toHaveLength(0); + expect(transactions[0]?.contexts?.trace?.status).toBe('ok'); + }); +}); From c125a5da2e6b7d916a5880f65876cddfee96c56d Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 26 Oct 2026 16:27:16 -0700 Subject: [PATCH 053/126] ref(bun): Use the shared request handler wrapper for `Bun.serve` (#25587) Moves `Bun.serve` onto `wrapServerRequestHandler`, which `Deno.serve` uses since the previous PR. Bun keeps only what is unique to it: the patches for `fetch`, `routes` and `server.reload`, the route template, the route parameters, and the socket address from `server.requestIP()`. Its copy of the stream monitor moves out, because the wrapper uses the same code from `@sentry/server-utils`. The wrapper gets two optional fields for this. `routeName` passes the matched route to core's span helper, which sets `http.route`, the `route` source and the span name. `attributes` adds span attributes that only the runtime knows, here `url.path.parameter.*`. Bun built its own span attributes, which did not follow the Sentry conventions for `http.server` spans. The conventions say that server instrumentation sets the matched route in `http.route`, and that `url.template` is for `http.client` spans and client-side routing. The host goes in `server.address`, as on Node, Deno and Cloudflare. So Bun spans change: - `url.template` becomes `http.route`. Routes without parameters now also get `http.route` and the `route` source. - `url.domain` becomes `server.address`. - A request to `/` gets the `route` source and the name `GET /`, as on the other runtimes, because the path is already low cardinality. - Spans also get `user_agent.original` and `http.request.body.size`. - Errors in `OPTIONS` and `HEAD` requests now carry the request data. The wrapper now also keeps every `Set-Cookie` header of a response. `Headers.forEach` lists each one on its own, so a plain dict kept only the last one. This fixes Deno too, and Cloudflare once it moves. Part of #25577 Part of JS-3975 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 14 +- packages/bun/src/integrations/bunserver.ts | 369 +++--------------- packages/bun/src/utils/streaming.ts | 40 -- .../bun/test/integrations/bunserver.test.ts | 82 +++- .../src/utils/wrapServerRequestHandler.ts | 60 ++- .../utils/wrapServerRequestHandler.test.ts | 62 ++- 6 files changed, 237 insertions(+), 390 deletions(-) delete mode 100644 packages/bun/src/utils/streaming.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f91e55cc08..f21d4eb250df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,13 +36,21 @@ Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @ These integrations need the vite plugin. Without it, they record nothing, and with `debug: true` the SDK logs a warning. -- **ref(deno): Use the shared request handler wrapper for `Deno.serve`** +- **ref(deno, bun): Use a shared request handler wrapper for `Deno.serve` and `Bun.serve`** - `Deno.serve` now uses a request handler wrapper that `Bun.serve` and Cloudflare will also use. Three small changes for Deno users: + `Deno.serve` and `Bun.serve` now use the same request handler wrapper, which Cloudflare will also use. On both runtimes, errors in `OPTIONS` and `HEAD` requests now carry the request data, and the SDK keeps every `Set-Cookie` header of a response instead of only the last one. + + For Deno users: - When the source of a streamed response fails, the SDK now captures the error and marks the span as failed. - The SDK no longer reads a streamed response ahead of the client. - - Errors in `OPTIONS` and `HEAD` requests now carry the request data. + + `Bun.serve` spans now follow the Sentry conventions for `http.server` spans, as on Node, Deno and Cloudflare: + + - `url.template` is now `http.route`. Routes without parameters also get `http.route` and the `route` source. + - `url.domain` is now `server.address`. + - A request to `/` gets the `route` source and the name `GET /`. + - Spans also get `user_agent.original` and `http.request.body.size`. ## 11.9.0 diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 35015447cce9..887f9582466f 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,54 +1,9 @@ -/* eslint-disable max-lines */ import type { Client, Integration, IntegrationFn, MaxRequestBodySize, Span, SpanAttributes } from '@sentry/core'; -import { - captureBodyFromWinterCGRequest, - captureException, - continueTrace, - debug, - defineIntegration, - getClient, - getUrlFragment, - getUrlQuery, - hasSpansEnabled, - hasSpanStreamingEnabled, - httpHeadersToSpanAttributes, - HTTP_SPAN_NAME_FALLBACK, - isURLObjectRelative, - parseStringToURLObject, - setHttpStatus, - SPAN_STATUS_ERROR, - startSpanManual, - winterCGRequestToRequestData, - withIsolationScope, - filterCollectedUrl, - filterCollectedUrlQuery, -} from '@sentry/core'; -import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; -import { - classifyResponseStreaming, - getClientIPAddress, - isStaticAssetRequest, - startRequestSession, -} from '@sentry/core/server'; +import { debug, defineIntegration, getClient, withIsolationScope } from '@sentry/core'; +import type { AssertTrue, CheckServerRequestOptions, ServerRequestOptions } from '@sentry/core/server'; +import { URL_PATH_PARAMETER_KEY_BASE } from '@sentry/conventions/attributes'; +import { wrapServerRequestHandler } from '@sentry/server-utils'; import type { Server, ServeOptions } from 'bun'; -import { - CLIENT_ADDRESS, - CLIENT_PORT, - HTTP_REQUEST_METHOD, - NETWORK_PROTOCOL_NAME, - SENTRY_OP, - SENTRY_SEGMENT_NAME_SOURCE, - URL_DOMAIN, - URL_FRAGMENT, - URL_FULL, - URL_PATH, - URL_PORT, - URL_QUERY, - URL_SCHEME, - SENTRY_ORIGIN, -} from '@sentry/conventions/attributes'; -import { HTTP_SERVER } from '@sentry/conventions/op'; -import { monitorStream } from '../utils/streaming'; const INTEGRATION_NAME = 'BunServer' as const; @@ -130,18 +85,35 @@ export type BunServerIntegrationOptions = { // it or lists it as unsupported. type _ServerRequestOptionsCheck = AssertTrue>; -const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}) => { +// Every shared option is a required key, so a new one fails to compile below until the integration +// passes it on. +type BunServerRequestOptions = { + [K in keyof Required>]: ServerRequestOptions[K]; +}; + +interface BunServerIntegration extends Integration { + requestOptions: BunServerRequestOptions; +} + +/** The incoming request options of the client's `bunServerIntegration`, if it has one. */ +function getBunServerRequestOptions(client: Client): ServerRequestOptions | undefined { + return client.getIntegrationByName(INTEGRATION_NAME)?.requestOptions; +} + +const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}): BunServerIntegration => { return { name: INTEGRATION_NAME, - maxRequestBodySize: options.maxRequestBodySize, - ignoreRequestBody: options.ignoreRequestBody, - ignoreIncomingRequests: options.ignoreIncomingRequests, - ignoreStaticAssets: options.ignoreStaticAssets ?? true, - spans: options.spans, - onSpanCreated: options.onSpanCreated, - onSpanEnd: options.onSpanEnd, - sessions: options.sessions ?? true, - sessionFlushingDelayMS: options.sessionFlushingDelayMS, + requestOptions: { + maxRequestBodySize: options.maxRequestBodySize, + ignoreRequestBody: options.ignoreRequestBody, + ignoreIncomingRequests: options.ignoreIncomingRequests, + ignoreStaticAssets: options.ignoreStaticAssets ?? true, + spans: options.spans, + onSpanCreated: options.onSpanCreated, + onSpanEnd: options.onSpanEnd, + sessions: options.sessions ?? true, + sessionFlushingDelayMS: options.sessionFlushingDelayMS, + }, setupOnce() { instrumentBunServe(); }, @@ -291,59 +263,6 @@ type RouteHandler = Extract< Function >; -function captureServeError(error: unknown): void { - captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } }); -} - -// A user callback that throws must not fail the request or leave the span open. -function callUserCallback(name: string, callback: () => T, fallback: T): T { - try { - return callback(); - } catch (e) { - debug.error(`Error in the Bun.serve ${name} callback.`, e); - return fallback; - } -} - -function shouldCreateSpan( - client: Client | undefined, - integration: BunServerIntegrationOptions | undefined, - request: Request, - urlPath: string, -): boolean { - if (!(integration?.spans ?? (client ? hasSpansEnabled(client.getOptions()) : false))) { - return false; - } - if (integration?.ignoreStaticAssets && request.method.toUpperCase() === 'GET' && isStaticAssetRequest(urlPath)) { - return false; - } - const ignoreIncomingRequests = integration?.ignoreIncomingRequests; - return !( - ignoreIncomingRequests && - callUserCallback('ignoreIncomingRequests', () => ignoreIncomingRequests(urlPath, request), false) - ); -} - -/** - * Calls `onDone` when the response is done: at once, or when a streamed body finishes. - * Returns the response to send. - */ -function onResponseDone(response: T, onDone: () => void, onStreamError: () => void): T { - if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { - const body = monitorStream(response.body, onDone, error => { - onStreamError(); - captureServeError(error); - }); - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }) as T; - } - onDone(); - return response; -} - function wrapRequestHandler( target: T, thisArg: unknown, @@ -352,186 +271,37 @@ function wrapRequestHandler( ): Promise>> { return withIsolationScope(async isolationScope => { const request = args[0]; - const upperCaseMethod = request.method.toUpperCase(); + const runHandler = (): Promise => + Promise.resolve(target.apply(thisArg, args) as Response | undefined); const client = getClient(); - const integration = client?.getIntegrationByName(INTEGRATION_NAME); - const endSession = - client && integration?.sessions - ? startRequestSession(client, { - requestIsolationScope: isolationScope, - sessionFlushingDelayMS: integration.sessionFlushingDelayMS, - }) - : undefined; - - if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { - try { - return await target.apply(thisArg, args); - } catch (e) { - captureServeError(e); - throw e; - } finally { - endSession?.(); - } - } - - const parsedUrl = parseStringToURLObject(request.url); - const attributes = getSpanAttributesFromParsedUrl(parsedUrl, request); - - let routeName = parsedUrl?.pathname || '/'; - if (request.params) { - Object.keys(request.params).forEach(key => { - attributes[`url.path.parameter.${key}`] = (request.params as Record)[key]; - }); - - // If a route has parameters, it's a parameterized route - if (route) { - attributes[SENTRY_SEGMENT_NAME_SOURCE] = 'route'; - attributes['url.template'] = route; - routeName = route; - } + if (!client) { + debug.warn('Cannot instrument Bun.serve request. No client defined.'); + return runHandler(); } - // Handle wildcard routes - if (route?.endsWith('/*')) { - attributes[SENTRY_SEGMENT_NAME_SOURCE] = 'route'; - attributes['url.template'] = route; - routeName = route; - } - - const dataCollection = client?.getDataCollectionOptions(); - - let socketAddress: { address: string; port: number } | undefined; - if (dataCollection) { - const headers = request.headers.toJSON(); - // Bun passes the `Server` as the second argument to both `fetch` and route handlers, except - // when the handler runs through `server.fetch()`. - socketAddress = getRequestIP(args[1], request); - - if (dataCollection.userInfo) { - // `client.address` is the originating client, so a forwarding header wins over the socket, which - // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` - // stays unset then. - const forwardedAddress = getClientIPAddress(headers); - if (forwardedAddress) { - attributes[CLIENT_ADDRESS] = forwardedAddress; - } else if (socketAddress) { - attributes[CLIENT_ADDRESS] = socketAddress.address; - attributes[CLIENT_PORT] = socketAddress.port; - } - } - - Object.assign(attributes, httpHeadersToSpanAttributes(headers, dataCollection)); + const attributes: SpanAttributes = {}; + for (const [key, value] of Object.entries(request.params ?? {})) { + attributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = String(value); } - // describes the OSI application-layer protocol (http), not the scheme (might be https) - attributes[NETWORK_PROTOCOL_NAME] = 'http'; - - isolationScope.setSDKProcessingMetadata({ - normalizedRequest: winterCGRequestToRequestData(request), - ipAddress: socketAddress?.address, - }); - - if (dataCollection) { - const effectiveBodySize = - integration?.maxRequestBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); - const ignoreRequestBody = integration?.ignoreRequestBody; - if ( - upperCaseMethod !== 'GET' && - effectiveBodySize !== 'none' && - // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. - !( - ignoreRequestBody && - callUserCallback('ignoreRequestBody', () => ignoreRequestBody(request.url, request), true) - ) - ) { - await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); - } - } - - const urlPath = parsedUrl ? `${parsedUrl.pathname}${parsedUrl.search}` : '/'; - const createSpan = shouldCreateSpan(client, integration, request, urlPath); - - return continueTrace( + return wrapServerRequestHandler( { - sentryTrace: request.headers.get('sentry-trace') ?? '', - baggage: request.headers.get('baggage'), - }, - async () => { - if (!createSpan) { - let response: Response | undefined; - try { - response = (await target.apply(thisArg, args)) as Response | undefined; - } catch (e) { - captureServeError(e); - endSession?.(); - throw e; - } - if (response?.status) { - isolationScope.setContext('response', { status_code: response.status }); - } - return onResponseDone( - response, - () => endSession?.(), - () => undefined, - ); - } - - return startSpanManual( - { - attributes: { ...attributes, [SENTRY_OP]: HTTP_SERVER }, - // With span streaming, span names have to be low cardinality, so we can't fall back to the URL path. - name: - attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !client || !hasSpanStreamingEnabled(client) - ? `${request.method} ${routeName}` - : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK, - }, - async (span, endSpan) => { - const { onSpanCreated, onSpanEnd } = integration ?? {}; - if (onSpanCreated) { - callUserCallback('onSpanCreated', () => onSpanCreated(span, request), undefined); - } - - let response: Response | undefined; - try { - response = (await target.apply(thisArg, args)) as Response | undefined; - } catch (e) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureServeError(e); - endSpan(); - endSession?.(); - throw e; - } - - if (response?.status) { - setHttpStatus(span, response.status); - - isolationScope.setContext('response', { - status_code: response.status, - }); - - if (dataCollection) { - span.setAttributes(httpHeadersToSpanAttributes(response.headers.toJSON(), dataCollection, 'response')); - } - } - - const finalResponse = response; - return onResponseDone( - response, - () => { - if (onSpanEnd && finalResponse) { - callUserCallback('onSpanEnd', () => onSpanEnd(span, request, finalResponse), undefined); - } - endSpan(); - endSession?.(); - }, - () => span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }), - ); - }, - ); + request, + client, + isolationScope, + options: getBunServerRequestOptions(client), + origin: 'auto.http.bun.serve', + mechanism: { type: 'auto.http.bun.serve', handled: false }, + // Bun passes the `Server` as the second argument to both `fetch` and route handlers, except + // when the handler runs through `server.fetch()`. + getSocketAddress: () => getRequestIP(args[1], request), + routeName: route, + attributes, }, + runHandler, ); - }); + }) as Promise>>; } function getRequestIP( @@ -548,36 +318,3 @@ function getRequestIP( return undefined; } } - -function getSpanAttributesFromParsedUrl( - parsedUrl: ReturnType, - request: Request, -): SpanAttributes { - const attributes: SpanAttributes = { - [SENTRY_ORIGIN]: 'auto.http.bun.serve', - [HTTP_REQUEST_METHOD]: request.method || 'GET', - [SENTRY_SEGMENT_NAME_SOURCE]: 'url', - }; - - if (parsedUrl) { - attributes[URL_QUERY] = filterCollectedUrlQuery(getUrlQuery(parsedUrl.search)); - attributes[URL_FRAGMENT] = getUrlFragment(parsedUrl.hash); - if (parsedUrl.pathname) { - attributes[URL_PATH] = parsedUrl.pathname; - } - if (!isURLObjectRelative(parsedUrl)) { - attributes[URL_FULL] = filterCollectedUrl(parsedUrl.href); - if (parsedUrl.port) { - attributes[URL_PORT] = parsedUrl.port; - } - if (parsedUrl.protocol) { - attributes[URL_SCHEME] = parsedUrl.protocol; - } - if (parsedUrl.hostname) { - attributes[URL_DOMAIN] = parsedUrl.hostname; - } - } - } - - return attributes; -} diff --git a/packages/bun/src/utils/streaming.ts b/packages/bun/src/utils/streaming.ts deleted file mode 100644 index d19466a6a035..000000000000 --- a/packages/bun/src/utils/streaming.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** Monitor the source without reading ahead or treating cancellation as a source failure. */ -export function monitorStream( - stream: ReadableStream, - onDone: () => void, - onError: (error: unknown) => void, -): ReadableStream { - const reader = stream.getReader(); - // A cancelled reader closes normally; only a source failure should be captured as an error. - void reader.closed.then(onDone, error => { - onError(error); - onDone(); - }); - - return new ReadableStream( - { - async pull(controller) { - try { - const { done, value } = await reader.read(); - if (done) { - controller.close(); - reader.releaseLock(); - } else { - controller.enqueue(value); - } - } catch (error) { - controller.error(error); - reader.releaseLock(); - } - }, - async cancel(reason) { - try { - await reader.cancel(reason); - } finally { - reader.releaseLock(); - } - }, - }, - { highWaterMark: 0 }, - ); -} diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 5d846d3667d6..0eb88f86fd00 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,3 +1,9 @@ +import { + HTTP_ROUTE, + SENTRY_SEGMENT_NAME_SOURCE, + SERVER_ADDRESS, + URL_PATH_PARAMETER_KEY_BASE, +} from '@sentry/conventions/attributes'; import type { AggregationCounts, Envelope, RequestEventData } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; @@ -99,6 +105,37 @@ describe('Bun Serve Integration', () => { }, ); + test('lets the fetch handler upgrade a request to a WebSocket', async () => { + const server = Bun.serve({ + port, + fetch(request, server) { + if (server.upgrade(request)) { + return undefined; + } + return new Response('no upgrade', { status: 400 }); + }, + websocket: { + message(ws, message) { + ws.send(`echo: ${message}`); + }, + }, + }); + try { + const ws = new WebSocket(`ws://localhost:${port}/socket`); + const reply = await new Promise((resolve, reject) => { + ws.onopen = () => ws.send('hello'); + ws.onmessage = event => resolve(String(event.data)); + ws.onerror = () => reject(new Error('WebSocket failed')); + }); + ws.close(); + + expect(reply).toBe('echo: hello'); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }); + test.each(['client disconnected', new Error('client disconnected')])( 'ends the span without capturing a cancelled stream: %s', async reason => { @@ -228,7 +265,7 @@ describe('Bun Serve Integration', () => { 'url.full': `http://localhost:${port}/users?id=123`, 'url.port': port.toString(), 'url.scheme': 'http:', - 'url.domain': 'localhost', + [SERVER_ADDRESS]: 'localhost', 'http.request.header.accept': ['*/*'], 'http.request.header.accept-encoding': ['gzip, deflate, br, zstd'], 'http.request.header.connection': ['keep-alive'], @@ -266,12 +303,12 @@ describe('Bun Serve Integration', () => { 'sentry.op': 'http.server', 'sentry.origin': 'auto.http.bun.serve', 'http.request.method': 'POST', - 'sentry.segment.name.source': 'url', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', 'url.path': '/', 'url.full': `http://localhost:${port}/`, 'url.port': port.toString(), 'url.scheme': 'http:', - 'url.domain': 'localhost', + [SERVER_ADDRESS]: 'localhost', 'http.request.header.accept': ['*/*'], 'http.request.header.accept-encoding': ['gzip, deflate, br, zstd'], 'http.request.header.connection': ['keep-alive'], @@ -279,7 +316,7 @@ describe('Bun Serve Integration', () => { 'http.request.header.host': [expect.any(String)], 'http.request.header.user-agent': [expect.stringContaining('Bun')], }), - name: 'POST', + name: 'POST /', }, expect.any(Function), ); @@ -390,7 +427,7 @@ describe('Bun Serve Integration', () => { 'url.full': `http://localhost:${port}/api/test`, 'url.port': port.toString(), 'url.scheme': 'http:', - 'url.domain': 'localhost', + [SERVER_ADDRESS]: 'localhost', // HTTP headers as span attributes following OpenTelemetry semantic conventions 'http.request.header.user-agent': ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'], 'http.request.header.content-type': ['application/json'], @@ -463,13 +500,13 @@ describe('Bun Serve Integration', () => { 'sentry.origin': 'auto.http.bun.serve', 'http.request.method': 'GET', 'sentry.segment.name.source': 'route', - 'url.template': '/users/:id', - 'url.path.parameter.id': '123', + [HTTP_ROUTE]: '/users/:id', + [`${URL_PATH_PARAMETER_KEY_BASE}.id`]: '123', 'url.path': '/users/123', 'url.full': `http://localhost:${port}/users/123`, 'url.port': port.toString(), 'url.scheme': 'http:', - 'url.domain': 'localhost', + [SERVER_ADDRESS]: 'localhost', }), name: 'GET /users/:id', }), @@ -479,6 +516,31 @@ describe('Bun Serve Integration', () => { await server.stop(); }); + test('sets http.route for a route without parameters', async () => { + const server = Bun.serve({ + routes: { + '/api/health': () => new Response('ok'), + }, + port, + }); + + const response = await fetch(`http://localhost:${port}/api/health`); + expect(await response.text()).toBe('ok'); + + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [HTTP_ROUTE]: '/api/health', + }), + name: 'GET /api/health', + }), + expect.any(Function), + ); + + await server.stop(); + }); + test('handles wildcard routes correctly', async () => { const server = Bun.serve({ routes: { @@ -502,12 +564,12 @@ describe('Bun Serve Integration', () => { 'sentry.origin': 'auto.http.bun.serve', 'http.request.method': 'GET', 'sentry.segment.name.source': 'route', - 'url.template': '/api/*', + [HTTP_ROUTE]: '/api/*', 'url.path': '/api/users/123', 'url.full': `http://localhost:${port}/api/users/123`, 'url.port': port.toString(), 'url.scheme': 'http:', - 'url.domain': 'localhost', + [SERVER_ADDRESS]: 'localhost', }), name: 'GET /api/*', }), diff --git a/packages/server-utils/src/utils/wrapServerRequestHandler.ts b/packages/server-utils/src/utils/wrapServerRequestHandler.ts index 9702af119181..447764ddf5e5 100644 --- a/packages/server-utils/src/utils/wrapServerRequestHandler.ts +++ b/packages/server-utils/src/utils/wrapServerRequestHandler.ts @@ -8,7 +8,7 @@ import { USER_AGENT_ORIGINAL, } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; -import type { Client, Mechanism, Scope, Span } from '@sentry/core'; +import type { Client, Mechanism, Scope, Span, SpanAttributes } from '@sentry/core'; import { captureBodyFromWinterCGRequest, captureException, @@ -49,6 +49,10 @@ export interface ServerRequestHandlerConfig { mechanism: Mechanism; /** The address and port of the socket, if the runtime knows them. */ getSocketAddress?: () => { address: string; port?: number } | undefined; + /** The matched route of the server framework, e.g. `/users/:id`. Sets `http.route` and the span name. */ + routeName?: string; + /** Span attributes that only the runtime knows, e.g. the route parameters. */ + attributes?: SpanAttributes; } /** @@ -61,11 +65,13 @@ export interface ServerRequestHandlerConfig { * - `OPTIONS` and `HEAD` requests get no span and no body capture. * - The static asset filter applies only to `GET`. * - The span and the session of a streamed response end when the stream finishes. + * + * The handler can return no response, as Bun's `fetch` does after a WebSocket upgrade. */ -export async function wrapServerRequestHandler( +export async function wrapServerRequestHandler( config: ServerRequestHandlerConfig, - handler: () => Response | Promise, -): Promise { + handler: () => R | Promise, +): Promise { const { request, client, isolationScope, options, mechanism } = config; const method = request.method.toUpperCase(); @@ -124,7 +130,7 @@ export async function wrapServerRequestHandler( }, async () => { if (!createSpan) { - let response: Response; + let response: R; try { response = await handler(); } catch (e) { @@ -132,7 +138,9 @@ export async function wrapServerRequestHandler( endSession?.(); throw e; } - isolationScope.setContext('response', { status_code: response.status }); + if (response) { + isolationScope.setContext('response', { status_code: response.status }); + } return onResponseDone(response, mechanism, () => endSession?.()); } @@ -141,7 +149,7 @@ export async function wrapServerRequestHandler( 'server', config.origin, request, - undefined, + config.routeName, client, ); // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. @@ -179,6 +187,7 @@ export async function wrapServerRequestHandler( // describes the OSI application-layer protocol (http), not the scheme (might be https) attributes[NETWORK_PROTOCOL_NAME] = 'http'; Object.assign(attributes, httpHeadersToSpanAttributes(headers, dataCollection)); + Object.assign(attributes, config.attributes); attributes[SENTRY_OP] = HTTP_SERVER; return startSpanManual({ name, attributes }, async span => { @@ -191,7 +200,7 @@ export async function wrapServerRequestHandler( ); } - let response: Response; + let response: R; try { response = await handler(); } catch (e) { @@ -202,17 +211,19 @@ export async function wrapServerRequestHandler( throw e; } - setHttpStatus(span, response.status); - isolationScope.setContext('response', { status_code: response.status }); - span.setAttributes( - httpHeadersToSpanAttributes(winterCGHeadersToDict(response.headers), dataCollection, 'response'), - ); + if (response) { + setHttpStatus(span, response.status); + isolationScope.setContext('response', { status_code: response.status }); + span.setAttributes( + httpHeadersToSpanAttributes(responseHeadersToDict(response.headers), dataCollection, 'response'), + ); + } return onResponseDone( response, mechanism, () => { - if (onSpanEnd) { + if (onSpanEnd && response) { safeCallback( DEBUG_BUILD ? 'Error in `onSpanEnd`:' : '', () => onSpanEnd(span, request, response), @@ -256,15 +267,30 @@ function shouldCreateSpan( * Calls `onDone` when the response is done: at once, or when a streamed body finishes. * Returns the response to send. */ -function onResponseDone(response: Response, mechanism: Mechanism, onDone: () => void, span?: Span): Response { - if (response.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { +function onResponseDone( + response: R, + mechanism: Mechanism, + onDone: () => void, + span?: Span, +): R { + if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { const body = monitorStream(response.body, onDone, error => { span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); captureException(error, { mechanism }); }); // Passing the original response as the init keeps its status, status text and headers. - return new Response(body, response); + return new Response(body, response) as R; } onDone(); return response; } + +// `Headers.forEach` lists each `Set-Cookie` header on its own, so a plain dict keeps only the last one. +function responseHeadersToDict(headers: Headers): Record { + const dict: Record = winterCGHeadersToDict(headers); + const setCookie = typeof headers.getSetCookie === 'function' ? headers.getSetCookie() : []; + if (setCookie.length) { + dict['set-cookie'] = setCookie; + } + return dict; +} diff --git a/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts b/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts index 217e92b42e30..ff8cf8703fd1 100644 --- a/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts +++ b/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts @@ -1,3 +1,4 @@ +import { HTTP_ROUTE, SENTRY_SEGMENT_NAME_SOURCE, URL_PATH_PARAMETER_KEY_BASE } from '@sentry/conventions/attributes'; import type { Event } from '@sentry/core'; import { getIsolationScope, setCurrentClient, withIsolationScope } from '@sentry/core'; import { beforeAll, describe, expect, it, vi } from 'vitest'; @@ -8,10 +9,13 @@ import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; const MECHANISM = { handled: false, type: 'auto.http.test' }; -function setup(options: ServerRequestHandlerConfig['options'] = {}): { +function setup( + options: ServerRequestHandlerConfig['options'] = {}, + extra: Partial = {}, +): { events: Event[]; transactions: Event[]; - serve: (request: Request, handler: () => Response | Promise) => Promise; + serve: (request: Request, handler: () => R | Promise) => Promise; } { const events: Event[] = []; const transactions: Event[] = []; @@ -32,10 +36,13 @@ function setup(options: ServerRequestHandlerConfig['options'] = {}): { setCurrentClient(client); client.init(); - const serve = (request: Request, handler: () => Response | Promise): Promise => + const serve = ( + request: Request, + handler: () => R | Promise, + ): Promise => withIsolationScope(isolationScope => wrapServerRequestHandler( - { request, client, isolationScope, options, origin: 'auto.http.test', mechanism: MECHANISM }, + { request, client, isolationScope, options, origin: 'auto.http.test', mechanism: MECHANISM, ...extra }, handler, ), ); @@ -157,6 +164,19 @@ describe('wrapServerRequestHandler', () => { expect(transactions).toHaveLength(0); }); + it('ends the span when the handler returns no response, as after a WebSocket upgrade', async () => { + const onSpanEnd = vi.fn(); + const { events, transactions, serve } = setup({ spans: true, onSpanEnd }); + + const response = await serve(new Request('https://example.com/socket'), () => undefined); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + expect(response).toBeUndefined(); + expect(events).toHaveLength(0); + expect(transactions[0]?.contexts?.response).toBeUndefined(); + expect(onSpanEnd).not.toHaveBeenCalled(); + }); + it('does not capture or fail the span when the client disconnects from a streamed response', async () => { const { events, transactions, serve } = setup({ spans: true }); @@ -166,4 +186,38 @@ describe('wrapServerRequestHandler', () => { expect(events).toHaveLength(0); expect(transactions[0]?.contexts?.trace?.status).toBe('ok'); }); + + it('names the span after the route and adds the runtime attributes', async () => { + const { transactions, serve } = setup( + {}, + { routeName: '/users/:id', attributes: { [`${URL_PATH_PARAMETER_KEY_BASE}.id`]: '42' } }, + ); + + const response = await serve(new Request('https://example.com/users/42'), () => new Response('ok')); + expect(await response.text()).toBe('ok'); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + expect(transactions[0]?.transaction).toBe('GET /users/:id'); + expect(transactions[0]?.contexts?.trace?.data).toEqual( + expect.objectContaining({ + [HTTP_ROUTE]: '/users/:id', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [`${URL_PATH_PARAMETER_KEY_BASE}.id`]: '42', + }), + ); + }); + + it('keeps every Set-Cookie header of the response', async () => { + const { transactions, serve } = setup(); + const headers = new Headers(); + headers.append('set-cookie', 'first=1'); + headers.append('set-cookie', 'second=2'); + + const response = await serve(new Request('https://example.com/login'), () => new Response('ok', { headers })); + expect(await response.text()).toBe('ok'); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + const setCookie = transactions[0]?.contexts?.trace?.data?.['http.response.header.set-cookie'] as string[]; + expect(setCookie.map(cookie => cookie.split('=')[0])).toEqual(['first', 'second']); + }); }); From ccab113ea30d77a1168567d2bdc1b24113747fc6 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 27 Oct 2026 09:09:46 +0100 Subject: [PATCH 054/126] feat: Make span origins spec compliant (#25602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several span origins didn't follow the [trace origin spec](https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/) (`...`, where `category` is a span op category). Most put the library in the category slot, which makes it impossible to group origins by category the way ops are grouped. v11 is already renaming origins (#22722, #23627), so this is the window to fix them. | Before | After | |---|---| | `auto.amqplib.{publisher,consumer}` | `auto.queue.amqplib.{publisher,consumer}` | | `auto.kafkajs.{producer,consumer}` | `auto.queue.kafkajs.{producer,consumer}` | | `auto.vercelai.channel` | `auto.ai.vercelai` (matches the other `auto.ai.` origins) | | `auto.firebase.firestore` | `auto.db.firebase.firestore` | | `auto.firebase.functions` | `auto.function.firebase` | | `auto.aws.aws_sdk` | `auto.{rpc,db,queue,ai}.aws_sdk` (category follows the span op: DynamoDB `db`, SQS/SNS `queue`, Bedrock `ai`, otherwise `rpc`) | | `auto.aws_lambda` | `auto.function.aws_serverless` (matches the existing `auto.function.aws_serverless.*` mechanism types) | | `auto.mcp.notification` | `auto.function.mcp_server.notification` (sits next to `auto.function.mcp_server`) | | `auto.db.cloudflare.durable_object.sql` | `auto.db.cloudflare.durable_object_sql` (was five parts) | | `auto.http.browser.{lcp,cls,inp}` | `auto.ui.browser.{lcp,cls,inp}` (web vital spans, op `ui.webvital.*`) | The Vercel AI exception mechanism type moves along with its origin. AGENTS.md and BUGBOT.md now spell out the category and four-part rules so new origins are checked against them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .cursor/BUGBOT.md | 3 ++ AGENTS.md | 6 ++++ .../tracing/metrics/web-vitals-cls/test.ts | 2 +- .../metrics/web-vitals-inp-late/test.ts | 2 +- .../metrics/web-vitals-inp-navigate/test.ts | 4 +-- .../web-vitals-inp-parametrized-late/test.ts | 2 +- .../web-vitals-inp-parametrized/test.ts | 2 +- .../tracing/metrics/web-vitals-inp/test.ts | 2 +- .../tracing/metrics/web-vitals-lcp/test.ts | 2 +- .../suites/tracing/durableobject-sql/test.ts | 4 +-- .../diagnostics-channel/vercelai-6/test.ts | 4 +-- .../suites/orchestrion-amqplib/test.ts | 2 +- .../suites/orchestrion-aws/test.ts | 2 +- .../suites/orchestrion-firebase/test.ts | 2 +- .../suites/orchestrion-kafkajs/test.ts | 2 +- .../suites/orchestrion-vercel-ai/test.ts | 2 +- .../aws-serverless-layer/tests/layer.test.ts | 16 ++++----- .../aws-serverless/tests/npm.test.ts | 2 +- .../cloudflare-mastra/tests/mastra.test.ts | 2 +- .../cloudflare-think/tests/errors.test.ts | 2 +- .../cloudflare-think/tests/think.test.ts | 2 +- .../tests/index.test.ts | 4 +-- .../node-eve/tests/eve.test.ts | 6 ++-- .../node-firebase/tests/functions.test.ts | 6 ++-- .../node-firebase/tests/spans.test.ts | 2 +- .../node-mastra/tests/mastra.test.ts | 2 +- .../tests/transactions.test.ts | 2 +- .../react-17/tests/spans.test.ts | 2 +- .../react-router-6/tests/spans.test.ts | 2 +- .../tests/spans.test.ts | 2 +- .../react-router-7-spa/tests/spans.test.ts | 2 +- .../react-router-8-spa/tests/spans.test.ts | 2 +- .../tests/routing-instrumentation.test.ts | 2 +- .../tests/routing-instrumentation.test.ts | 2 +- .../aws-serverless/aws-integration/test.ts | 28 ++++++++------- .../suites/aws-serverless/bedrock/test.ts | 4 +-- .../suites/tracing/amqplib/test.ts | 20 +++++------ .../suites/tracing/kafkajs/test.ts | 4 +-- .../suites/tracing/vercelai/v4-static/test.ts | 2 +- .../suites/tracing/vercelai/v5/test.ts | 6 ++-- .../suites/tracing/vercelai/v6_v7/test.ts | 34 +++++++++---------- .../aws-serverless/src/requestSpanOptions.ts | 2 +- .../browser-utils/src/web-vitals/spans.ts | 6 ++-- .../test/web-vitals/spans.test.ts | 32 ++++++++--------- .../instrumentations/instrumentSqlStorage.ts | 2 +- .../instrumentDurableObjectStorage.test.ts | 2 +- .../test/instrumentSqlStorage.test.ts | 8 ++--- .../src/integrations/mcp-server/attributes.ts | 2 +- .../mcp-server/capturePolicy.test.ts | 2 +- .../mcp-server/semanticConventions.test.ts | 12 +++---- .../transportInstrumentation.test.ts | 2 +- .../server-utils/src/integrations/amqplib.ts | 4 +-- .../src/integrations/aws-sdk/constants.ts | 4 +-- .../src/integrations/aws-sdk/index.ts | 18 +++++++--- .../src/integrations/firebase/firestore.ts | 2 +- .../src/integrations/firebase/functions.ts | 2 +- .../src/integrations/kafkajs/spans.ts | 4 +-- .../vercel-ai/vercel-ai-dc-subscriber.ts | 4 +-- .../test/orchestrion/firebase.test.ts | 6 ++-- 59 files changed, 167 insertions(+), 148 deletions(-) diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index c0db9f483eb2..7091a161f177 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -49,6 +49,9 @@ Keep reviews high-signal. Prefer actionable, high-confidence findings over specu - When calling any `startSpan` API (`startInactiveSpan`, `startSpanManual`, etc), always ensure that the following span attributes are set: - `SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN` (`'sentry.origin'`) with a proper span origin - a proper origin must only contain [a-z], [A-Z], [0-9], `_` and `.` characters. + - a proper origin has at most four parts, `...`, and must not skip a part. `type` is `auto` or `manual`. + - `category` must be a span op category (e.g. `http`, `db`, `queue`, `function`, `ai`, `ui`, `rpc`), matching the span's op. Flag origins that put a library or vendor in the category slot (e.g. `auto.kafkajs.producer` instead of `auto.queue.kafkajs.producer`). + - the origin identifies the integration, not the tooling behind it. Flag leftover implementation markers like `otel` or `orchestrion` used as a part. - flag any non-conforming origin values as invalid and link to the trace origin specification (https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/) - `SEMANTIC_ATTRIBUTE_SENTRY_OP` (`'sentry.op'`) with a proper span op - Span ops should be lower case only, and use snake_case. The `.` character is used to delimit op parts. diff --git a/AGENTS.md b/AGENTS.md index 1539af03e28e..71f47f9164af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,12 @@ Uses **Git Flow** (see `docs/gitflow.md`). - `Sentry.init()` follows one rule for repeated calls: the first call wins. See `docs/repeated-init.md` before you add or change an `init()`. +- Span origins must follow the [trace origin spec](https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/): + `...`, at most four parts, no skipped + parts, only `[a-zA-Z0-9_]` within a part. `category` is the span op category + (`auto.queue.kafkajs.producer`, not `auto.kafkajs.producer`). Don't encode tooling like + `otel` or `orchestrion` in the origin. + ## Linting & Formatting - This project uses **Oxlint** and **Oxfmt** — NOT ESLint or Prettier diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts index a42e7a6542e4..3a402ae3c2d2 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/test.ts @@ -43,7 +43,7 @@ sentryTest('captures CLS as a streamed span with source attributes', async ({ ge const pageloadSpan = await pageloadSpanPromise; expect(clsSpan.attributes[SENTRY_OP]).toEqual({ type: 'string', value: 'ui.webvital.cls' }); - expect(clsSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.http.browser.cls' }); + expect(clsSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ui.browser.cls' }); expect(clsSpan.attributes[SENTRY_EXCLUSIVE_TIME]).toEqual({ type: 'integer', value: 0 }); expect(clsSpan.attributes[USER_AGENT_ORIGINAL]?.value).toEqual(expect.stringContaining('Chrome')); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts index 63f6f5ca17a6..3847ee0df51a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts @@ -101,7 +101,7 @@ sentryTest( status: 'ok', attributes: { [SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' }, - [SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' }, + [SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' }, [SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' }, [UI_COMPONENT_NAME]: { value: 'NormalButton', type: 'string' }, [BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > NormalButton', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts index 8cdafe4870c0..edacd3444d55 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts @@ -101,7 +101,7 @@ sentryTest( status: 'ok', attributes: { [SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' }, - [SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' }, + [SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' }, [SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' }, [UI_COMPONENT_NAME]: { value: 'NavigationLink', type: 'string' }, [BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > nav#navigation > NavigationLink', type: 'string' }, @@ -192,7 +192,7 @@ sentryTest( status: 'ok', attributes: { [SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' }, - [SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' }, + [SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' }, [SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' }, [BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > nav#navigation > NavigationLink', type: 'string' }, [SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts index 81d264959eb5..d06da7a58912 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts @@ -102,7 +102,7 @@ sentryTest( status: 'ok', attributes: { [SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' }, - [SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' }, + [SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' }, [SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' }, [UI_COMPONENT_NAME]: { value: 'NormalButton', type: 'string' }, [BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > NormalButton', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts index cda0412f8d4e..269c543c4dec 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts @@ -102,7 +102,7 @@ sentryTest( status: 'ok', attributes: { [SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' }, - [SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' }, + [SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' }, [SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' }, [UI_COMPONENT_NAME]: { value: 'NormalButton', type: 'string' }, [BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > NormalButton', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts index 3510b5bb1d9b..ac0f4ea88aa4 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts @@ -43,7 +43,7 @@ sentryTest('captures INP click as a streamed span', async ({ getLocalTestUrl, pa const pageloadSpan = await pageloadSpanPromise; expect(inpSpan.attributes[SENTRY_OP]).toEqual({ type: 'string', value: UI_INTERACTION_CLICK }); - expect(inpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.http.browser.inp' }); + expect(inpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ui.browser.inp' }); expect(inpSpan.attributes[USER_AGENT_ORIGINAL]?.value).toEqual(expect.stringContaining('Chrome')); // Check the INP span carries the transaction/segment name it belongs to diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts index 263b31d96660..5753e13389c0 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts @@ -52,7 +52,7 @@ sentryTest('captures LCP as a streamed span with element attributes', async ({ g const pageloadSpan = await pageloadSpanPromise; expect(lcpSpan.attributes[SENTRY_OP]).toEqual({ type: 'string', value: 'ui.webvital.lcp' }); - expect(lcpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.http.browser.lcp' }); + expect(lcpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ui.browser.lcp' }); expect(lcpSpan.attributes[SENTRY_EXCLUSIVE_TIME]).toEqual({ type: 'integer', value: 0 }); expect(lcpSpan.attributes[USER_AGENT_ORIGINAL]?.value).toEqual(expect.stringContaining('Chrome')); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts index 1f8e514601a8..7898bb3feebe 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts @@ -23,7 +23,7 @@ it('instruments SQL exec operations on Durable Object storage', async ({ signal expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/exec' }); const sqlSpans = spans.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.db.cloudflare.durable_object.sql', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.db.cloudflare.durable_object_sql', ); expect(sqlSpans).toHaveLength(3); @@ -33,7 +33,7 @@ it('instruments SQL exec operations on Durable Object storage', async ({ signal name: 'CREATE TABLE users', attributes: expect.objectContaining({ [SENTRY_OP]: { type: 'string', value: 'db.query' }, - [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.durable_object.sql' }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.durable_object_sql' }, 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, 'db.operation.name': { type: 'string', value: 'exec' }, 'db.query.text': { diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts index 3e886958978f..2eac8c531796 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts @@ -27,7 +27,7 @@ it('captures Vercel AI v6 spans via @sentry/cloudflare vercelAIIntegration', asy status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'gen_ai.invoke_agent' }, - 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, + 'sentry.origin': { type: 'string', value: 'auto.ai.vercelai' }, [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'invoke_agent' }, [GEN_AI_USAGE_INPUT_TOKENS]: { type: 'integer', value: 10 }, [GEN_AI_USAGE_OUTPUT_TOKENS]: { type: 'integer', value: 20 }, @@ -40,7 +40,7 @@ it('captures Vercel AI v6 spans via @sentry/cloudflare vercelAIIntegration', asy status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'gen_ai.generate_content' }, - 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, + 'sentry.origin': { type: 'string', value: 'auto.ai.vercelai' }, [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'generate_content' }, }), }), diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts index 9c1031ed9ee3..4514b59d377c 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts @@ -65,5 +65,5 @@ Deno.test('amqplib instrumentation: orchestrion:amqplib:publish channel produces assertEquals(publishSpan.name, 'send my-exchange'); assertEquals(publishSpan.attributes['messaging.destination.name']?.value, 'my-exchange'); assertEquals(publishSpan.attributes['messaging.system']?.value, 'rabbitmq'); - assertEquals(publishSpan.attributes['sentry.origin']?.value, 'auto.amqplib.publisher'); + assertEquals(publishSpan.attributes['sentry.origin']?.value, 'auto.queue.amqplib.publisher'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts index 1beb142f3313..b040db2da39d 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts @@ -88,5 +88,5 @@ Deno.test('aws-sdk instrumentation: orchestrion @smithy/smithy-client:send chann assertEquals(awsSpan.attributes['rpc.service']?.value, 'CloudWatch'); assertEquals(awsSpan.attributes['rpc.method']?.value, 'DescribeAlarms'); assertEquals(awsSpan.attributes['cloud.region']?.value, 'us-east-1'); - assertEquals(awsSpan.attributes['sentry.origin']?.value, 'auto.aws.aws_sdk'); + assertEquals(awsSpan.attributes['sentry.origin']?.value, 'auto.rpc.aws_sdk'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts index 4f6440b8a677..cb131997dca9 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts @@ -70,5 +70,5 @@ Deno.test('firebase instrumentation: orchestrion @firebase/firestore:add-doc cha assertEquals(fsSpan.attributes['db.namespace']?.value, '[DEFAULT]'); assertEquals(fsSpan.attributes['db.system.name']?.value, 'firebase.firestore'); assertEquals(fsSpan.attributes['firebase.firestore.options.projectId']?.value, 'demo-project'); - assertEquals(fsSpan.attributes['sentry.origin']?.value, 'auto.firebase.firestore'); + assertEquals(fsSpan.attributes['sentry.origin']?.value, 'auto.db.firebase.firestore'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts index 0265468722a3..694426b326f0 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts @@ -57,5 +57,5 @@ Deno.test('kafkajs instrumentation: orchestrion:kafkajs:send_batch channel produ assertEquals(kafkaSpan.name, 'send my-topic'); assertEquals(kafkaSpan.attributes['messaging.system']?.value, 'kafka'); assertEquals(kafkaSpan.attributes['messaging.destination.name']?.value, 'my-topic'); - assertEquals(kafkaSpan.attributes['sentry.origin']?.value, 'auto.kafkajs.producer'); + assertEquals(kafkaSpan.attributes['sentry.origin']?.value, 'auto.queue.kafkajs.producer'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts index fdd20fd3239d..fbc0658a6b51 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts @@ -66,5 +66,5 @@ Deno.test('vercel-ai instrumentation: orchestrion:ai:generateText channel produc assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'gpt-4o'); assertEquals(aiSpan.attributes['vercel.ai.operationId']?.value, 'ai.generateText'); assertEquals(aiSpan.attributes['gen_ai.usage.total_tokens']?.value, 15); - assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.vercelai.channel'); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.ai.vercelai'); }); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts index 8e47ac4255a4..f3ae0d58ceb0 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts @@ -37,7 +37,7 @@ test.describe('Lambda layer', () => { data: { 'sentry.sample_rate': 1, 'sentry.segment.name.source': 'component', - 'sentry.origin': 'auto.aws_lambda', + 'sentry.origin': 'auto.function.aws_serverless', 'sentry.op': 'function.aws', 'cloud.account.id': '012345678912', 'cloud.platform': 'aws_lambda', @@ -49,7 +49,7 @@ test.describe('Lambda layer', () => { 'sentry.kind': 'server', }, op: 'function.aws', - origin: 'auto.aws_lambda', + origin: 'auto.function.aws_serverless', span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -108,7 +108,7 @@ test.describe('Lambda layer', () => { data: { 'sentry.sample_rate': 1, 'sentry.segment.name.source': 'component', - 'sentry.origin': 'auto.aws_lambda', + 'sentry.origin': 'auto.function.aws_serverless', 'sentry.op': 'function.aws', 'cloud.account.id': '012345678912', 'cloud.platform': 'aws_lambda', @@ -120,7 +120,7 @@ test.describe('Lambda layer', () => { 'sentry.kind': 'server', }, op: 'function.aws', - origin: 'auto.aws_lambda', + origin: 'auto.function.aws_serverless', span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -234,7 +234,7 @@ test.describe('Lambda layer', () => { data: { 'sentry.sample_rate': 1, 'sentry.segment.name.source': 'component', - 'sentry.origin': 'auto.aws_lambda', + 'sentry.origin': 'auto.function.aws_serverless', 'sentry.op': 'function.aws', 'cloud.account.id': '012345678912', 'cloud.platform': 'aws_lambda', @@ -246,7 +246,7 @@ test.describe('Lambda layer', () => { 'sentry.kind': 'server', }, op: 'function.aws', - origin: 'auto.aws_lambda', + origin: 'auto.function.aws_serverless', span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -283,11 +283,11 @@ test.describe('Lambda layer', () => { expect(transactionEvent.contexts?.trace).toEqual( expect.objectContaining({ op: 'function.aws', - origin: 'auto.aws_lambda', + origin: 'auto.function.aws_serverless', status: 'ok', data: expect.objectContaining({ 'sentry.op': 'function.aws', - 'sentry.origin': 'auto.aws_lambda', + 'sentry.origin': 'auto.function.aws_serverless', 'sentry.kind': 'server', 'cloud.resource_id': 'arn:aws:lambda:us-east-1:012345678912:function:LayerCallback', 'faas.name': 'LayerCallback', diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts index e9f687cdf2be..35a262a25e64 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts @@ -16,7 +16,7 @@ function assertLambdaTrace(spans: SerializedStreamedSpan[], functionName: string expect(segmentSpan?.attributes).toMatchObject({ 'sentry.op': { value: 'function.aws', type: 'string' }, - 'sentry.origin': { value: 'auto.aws_lambda', type: 'string' }, + 'sentry.origin': { value: 'auto.function.aws_serverless', type: 'string' }, 'sentry.kind': { value: 'server', type: 'string' }, 'sentry.segment.name.source': { value: 'component', type: 'string' }, 'cloud.account.id': { value: '012345678912', type: 'string' }, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mastra/tests/mastra.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mastra/tests/mastra.test.ts index d07faa5b2931..a7a291a596c3 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mastra/tests/mastra.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mastra/tests/mastra.test.ts @@ -54,7 +54,7 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs ).toEqual([]); expect( traceSpans - .filter(span => String(attrValue(span, 'sentry.origin') ?? '').startsWith('auto.vercelai')) + .filter(span => String(attrValue(span, 'sentry.origin') ?? '').startsWith('auto.ai.vercelai')) .map(describeSpan), ).toEqual([]); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts index 76f65c74e01b..d373d9e00cb0 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts @@ -33,7 +33,7 @@ test('captures an error thrown inside a Think tool and marks its span errored', const exception = error.exception?.values?.[0]; expect(exception?.type).toBe('Error'); expect(exception?.value).toBe('Think tool failed on purpose'); - expect(exception?.mechanism?.type).toBe('auto.vercelai.channel'); + expect(exception?.mechanism?.type).toBe('auto.ai.vercelai'); const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'); expect(getSpanOp(toolSpan!)).toBe('gen_ai.execute_tool'); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts index 1368b59298a7..6190178bb72a 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts @@ -36,7 +36,7 @@ test('captures the invoke_agent / generate_content / execute_tool hierarchy for const generateContent = spans.find(span => getSpanOp(span) === 'gen_ai.generate_content'); const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); - expect(attr(invokeAgent, 'sentry.origin')).toBe('auto.vercelai.channel'); + expect(attr(invokeAgent, 'sentry.origin')).toBe('auto.ai.vercelai'); expect(attr(invokeAgent, 'gen_ai.operation.name')).toBe('invoke_agent'); // Think passes `this.constructor.name` to the AI SDK as the `functionId`, so this only holds while // the build-time wrapper leaves the user's class name alone (#24700). diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/tests/index.test.ts index 1645f34851a7..3b72d063622b 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/tests/index.test.ts @@ -30,7 +30,7 @@ test('captures Vercel AI v7 spans with nodejs_compat using tracing channels', as 'gen_ai.usage.output_tokens': { value: 20, type: 'integer' }, 'gen_ai.usage.total_tokens': { value: 30, type: 'integer' }, 'sentry.op': { value: 'gen_ai.invoke_agent', type: 'string' }, - 'sentry.origin': { value: 'auto.vercelai.channel', type: 'string' }, + 'sentry.origin': { value: 'auto.ai.vercelai', type: 'string' }, }), }); @@ -49,7 +49,7 @@ test('captures Vercel AI v7 spans with nodejs_compat using tracing channels', as 'gen_ai.usage.output_tokens': { value: 20, type: 'integer' }, 'gen_ai.usage.total_tokens': { value: 30, type: 'integer' }, 'sentry.op': { value: 'gen_ai.generate_content', type: 'string' }, - 'sentry.origin': { value: 'auto.vercelai.channel', type: 'string' }, + 'sentry.origin': { value: 'auto.ai.vercelai', type: 'string' }, }), }); }); diff --git a/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts b/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts index de45eacd39d2..60998bf9ff26 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts @@ -53,7 +53,7 @@ test('captures Vercel AI agent spans (invoke_agent, generate_content, execute_to const generateContent = traceSpans.find(span => getSpanOp(span) === 'gen_ai.generate_content'); const executeTool = traceSpans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); - expect(invokeAgent?.attributes?.['sentry.origin']?.value).toBe('auto.vercelai.channel'); + expect(invokeAgent?.attributes?.['sentry.origin']?.value).toBe('auto.ai.vercelai'); expect(invokeAgent?.attributes?.['gen_ai.operation.name']?.value).toBe('invoke_agent'); expect(invokeAgent?.attributes?.['gen_ai.request.model']?.value).toBe('openai/gpt-4o-mini'); expect(invokeAgent?.attributes?.['gen_ai.provider.name']?.value).toBe('openrouter'); @@ -134,7 +134,7 @@ test('captures errors thrown inside an eve tool', async ({ baseURL }) => { type: 'Error', value: expect.stringContaining('Intentional eve tool failure'), mechanism: { - type: 'auto.vercelai.channel', + type: 'auto.ai.vercelai', handled: false, }, }, @@ -165,7 +165,7 @@ test('captures a gen_ai.decide span for a Jev call inside an eve tool', async ({ expect(decide?.name).toBe('decide typesafe-ai/jev'); expect(decide?.status).toBe('ok'); - expect(decide?.attributes?.[SENTRY_ORIGIN]?.value).toBe('auto.vercelai.channel'); + expect(decide?.attributes?.[SENTRY_ORIGIN]?.value).toBe('auto.ai.vercelai'); expect(decide?.attributes?.[GEN_AI_OPERATION_NAME]?.value).toBe('decide'); expect(decide?.attributes?.[GEN_AI_REQUEST_MODEL]?.value).toBe('typesafe-ai/jev'); expect(decide?.attributes?.[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(275); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts index 7accea39f1b1..46091b06fb29 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts @@ -31,7 +31,7 @@ test('should create one segment for an HTTP function', async () => { 'faas.trigger': { value: 'http.request', type: 'string' }, 'gcp.function.context.type': { value: 'firebase.function.http.request', type: 'string' }, 'sentry.kind': { value: 'server', type: 'string' }, - 'sentry.origin': { value: 'auto.firebase.functions', type: 'string' }, + 'sentry.origin': { value: 'auto.function.firebase', type: 'string' }, 'sentry.sample_rate': { value: expect.any(Number), type: 'integer' }, 'sentry.segment.name.source': { value: 'component', type: 'string' }, }), @@ -60,7 +60,7 @@ test('should send failed span when the function fails', async () => { expect.objectContaining({ type: 'Error', value: 'There is an error!', - mechanism: { type: 'auto.firebase.functions', handled: false }, + mechanism: { type: 'auto.function.firebase', handled: false }, }), ]); }); @@ -96,7 +96,7 @@ test('should create a document and trigger onDocumentCreated and another with au 'gcp.function.context.type': { value: `firebase.function.${trigger}`, type: 'string' }, 'sentry.kind': { value: 'server', type: 'string' }, 'sentry.op': { value: 'function.gcp', type: 'string' }, - 'sentry.origin': { value: 'auto.firebase.functions', type: 'string' }, + 'sentry.origin': { value: 'auto.function.firebase', type: 'string' }, 'sentry.sample_rate': { value: expect.any(Number), type: 'integer' }, 'sentry.segment.name.source': { value: 'component', type: 'string' }, }), diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/node-firebase/tests/spans.test.ts index dd4b11aa7586..cd9717ccdea0 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tests/spans.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; // The orchestrion spans are Sentry-native, so they carry no span-kind attribute (`sentry.kind`). -const origin = 'auto.firebase.firestore'; +const origin = 'auto.db.firebase.firestore'; function firestoreSpan(operation: string): unknown { return expect.objectContaining({ diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts index 7927a8f9531a..c12f9e9ed3e7 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts @@ -67,7 +67,7 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs ).toEqual([]); expect( traceSpans - .filter(span => String(attrValue(span, 'sentry.origin') ?? '').startsWith('auto.vercelai')) + .filter(span => String(attrValue(span, 'sentry.origin') ?? '').startsWith('auto.ai.vercelai')) .map(describeSpan), ).toEqual([]); diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts index 835998af23b3..c57a52e6b891 100644 --- a/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts @@ -105,7 +105,7 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.attributes).toEqual( expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.inp', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'sentry.transaction': { value: '/', type: 'string' }, diff --git a/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts index 6eba5f11e4a2..d5fdf3e9342d 100644 --- a/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts @@ -88,7 +88,7 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.attributes).toEqual( expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.inp', type: 'string' }, 'browser.web_vital.inp.target': { value: 'body > div#root > input#exception-button[type="button"]', type: 'string', diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts index ecd63899dfee..a47722acba77 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts @@ -88,7 +88,7 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.attributes).toEqual( expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.inp', type: 'string' }, 'browser.web_vital.inp.target': { value: 'body > div#root > input#exception-button[type="button"]', type: 'string', diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts index 3d0e91e50b60..2c09be78eb0f 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts @@ -70,6 +70,6 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.span_id).toMatch(/[a-f0-9]{16}/); expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp); expect(inpSpan.attributes['sentry.op']?.value).toBe('ui.interaction.click'); - expect(inpSpan.attributes['sentry.origin']?.value).toBe('auto.http.browser.inp'); + expect(inpSpan.attributes['sentry.origin']?.value).toBe('auto.ui.browser.inp'); expect(inpSpan.attributes['sentry.exclusive_time']?.value).toEqual(expect.any(Number)); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts index 65ae46c0e1d4..b177a028ded0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts @@ -88,7 +88,7 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.attributes).toEqual( expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.inp', type: 'string' }, 'browser.web_vital.inp.target': { value: 'body > div#root > input#exception-button[type="button"]', type: 'string', diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts index 6c9ad5efc502..a6bf22054ff5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts @@ -88,7 +88,7 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan.attributes).toEqual( expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.inp', type: 'string' }, 'browser.web_vital.inp.target': { value: 'body > div#root > input#exception-button[type="button"]', type: 'string', diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts index 2e29eed35616..aea4a266822c 100644 --- a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts @@ -175,7 +175,7 @@ test('sends a pageload span with web vital attributes and a standalone LCP span' expect(lcpSpan.attributes).toMatchObject({ 'sentry.op': { value: 'ui.webvital.lcp', type: 'string' }, - 'sentry.origin': { value: 'auto.http.browser.lcp', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.browser.lcp', type: 'string' }, 'sentry.pageload.span_id': { value: pageloadSpan.span_id, type: 'string' }, 'browser.web_vital.lcp.value': webVitalNumber, }); diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts index 0f243a9ea0c0..2141b22d9330 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts @@ -67,7 +67,7 @@ test('sends a pageload span for the root route with web vital attributes and a s expect(lcpSpan.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'ui.webvital.lcp' }, - 'sentry.origin': { type: 'string', value: 'auto.http.browser.lcp' }, + 'sentry.origin': { type: 'string', value: 'auto.ui.browser.lcp' }, 'sentry.pageload.span_id': { type: 'string', value: pageloadSpan.span_id }, 'browser.web_vital.lcp.value': webVitalNumber, }); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts index 951a0e437e38..f91142fc4e0b 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts @@ -5,7 +5,9 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn // The suite runs twice on CI: once with the OTel `Aws` integration (default) and once with the // orchestrion diagnostics-channel integration auto-injected (`INJECT_ORCHESTRION`). Both emit the // same spans; only the origin differs. -const ORIGIN = 'auto.aws.aws_sdk'; +const RPC_ORIGIN = 'auto.rpc.aws_sdk'; +const DB_ORIGIN = 'auto.db.aws_sdk'; +const QUEUE_ORIGIN = 'auto.queue.aws_sdk'; // The aws-sdk instrumentation creates spans by patching the underlying smithy middleware stack. The // patch target differs between aws-sdk versions, so we run the exact same assertions against both: @@ -52,7 +54,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'S3.PutObject', status: 'ok', attributes: expect.objectContaining({ - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'sentry.op': { value: 'rpc', type: 'string' }, 'rpc.system': { value: 'aws-api', type: 'string' }, 'rpc.method': { value: 'PutObject', type: 'string' }, @@ -71,7 +73,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'GetObject', type: 'string' }, 'rpc.service': { value: 'S3', type: 'string' }, 'aws.s3.bucket': { value: 'ot-demo-test', type: 'string' }, @@ -89,7 +91,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): status: 'error', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'GetObject', type: 'string' }, 'rpc.service': { value: 'S3', type: 'string' }, }), @@ -102,7 +104,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'DynamoDB.PutItem', attributes: expect.objectContaining({ 'sentry.op': { value: 'db', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: DB_ORIGIN, type: 'string' }, 'rpc.method': { value: 'PutItem', type: 'string' }, 'rpc.service': { value: 'DynamoDB', type: 'string' }, 'db.system.name': { value: 'dynamodb', type: 'string' }, @@ -117,7 +119,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'DynamoDB.Query', attributes: expect.objectContaining({ 'sentry.op': { value: 'db', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: DB_ORIGIN, type: 'string' }, 'rpc.method': { value: 'Query', type: 'string' }, 'db.operation.name': { value: 'Query', type: 'string' }, 'aws.dynamodb.count': { value: 1, type: 'integer' }, @@ -129,7 +131,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): expectSpan('SQS SendMessage', { name: 'send my-queue', attributes: expect.objectContaining({ - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: QUEUE_ORIGIN, type: 'string' }, 'sentry.op': { value: 'queue.publish', type: 'string' }, 'rpc.method': { value: 'SendMessage', type: 'string' }, 'rpc.service': { value: 'SQS', type: 'string' }, @@ -145,7 +147,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): expectSpan('SQS ReceiveMessage', { name: 'receive my-queue', attributes: expect.objectContaining({ - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: QUEUE_ORIGIN, type: 'string' }, 'sentry.op': { value: 'queue.receive', type: 'string' }, 'rpc.method': { value: 'ReceiveMessage', type: 'string' }, 'messaging.system': { value: 'aws_sqs', type: 'string' }, @@ -159,7 +161,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): expectSpan('SNS Publish', { name: 'send my-topic', attributes: expect.objectContaining({ - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: QUEUE_ORIGIN, type: 'string' }, 'rpc.method': { value: 'Publish', type: 'string' }, 'rpc.service': { value: 'SNS', type: 'string' }, 'messaging.system': { value: 'aws.sns', type: 'string' }, @@ -190,7 +192,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'my-function Invoke', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'Invoke', type: 'string' }, 'rpc.service': { value: 'Lambda', type: 'string' }, 'faas.invoked_name': { value: 'my-function', type: 'string' }, @@ -205,7 +207,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'PutRecord', type: 'string' }, 'rpc.service': { value: 'Kinesis', type: 'string' }, 'aws.kinesis.stream.name': { value: 'my-stream', type: 'string' }, @@ -217,7 +219,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'SecretsManager.GetSecretValue', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'GetSecretValue', type: 'string' }, 'rpc.service': { value: 'SecretsManager', type: 'string' }, 'aws.secretsmanager.secret.arn': { @@ -232,7 +234,7 @@ function assertAwsServiceSpans(spans: SerializedStreamedSpanContainer['items']): name: 'SFN.StartExecution', attributes: expect.objectContaining({ 'sentry.op': { value: 'rpc', type: 'string' }, - 'sentry.origin': { value: ORIGIN, type: 'string' }, + 'sentry.origin': { value: RPC_ORIGIN, type: 'string' }, 'rpc.method': { value: 'StartExecution', type: 'string' }, 'rpc.service': { value: 'SFN', type: 'string' }, 'aws.step_functions.state_machine.arn': { diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts index a7bc374499b4..0a86d5232552 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts @@ -24,7 +24,7 @@ function assertBedrockSpans(container: SerializedStreamedSpanContainer): void { // Converse (non-streaming) const converseSpan = container.items.find(span => span.name === `chat ${MODEL_ID}`); expect(converseSpan!.status).toBe('ok'); - expect(converseSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.aws.aws_sdk'); + expect(converseSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.aws_sdk'); expect(converseSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat'); expect(converseSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('aws.bedrock'); expect(converseSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat'); @@ -39,7 +39,7 @@ function assertBedrockSpans(container: SerializedStreamedSpanContainer): void { // InvokeModel (non-streaming, anthropic.claude request/response body) const invokeModelSpan = container.items.find(span => span.name === `generate_content ${MODEL_ID}`); expect(invokeModelSpan!.status).toBe('ok'); - expect(invokeModelSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.aws.aws_sdk'); + expect(invokeModelSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.aws_sdk'); expect(invokeModelSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content'); expect(invokeModelSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('aws.bedrock'); expect(invokeModelSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content'); diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts index cd86d01459ec..5dd002898bff 100644 --- a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts @@ -36,7 +36,7 @@ const expectedProducerSpan = (routingKey: string) => [URL_FULL]: { type: 'string', value: 'amqp://sentry:***@localhost:5672/' }, [SENTRY_KIND]: { type: 'string', value: 'producer' }, [SENTRY_OP]: { type: 'string', value: QUEUE_PUBLISH }, - [SENTRY_ORIGIN]: { type: 'string', value: 'auto.amqplib.publisher' }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.queue.amqplib.publisher' }, }), status: 'ok', }); @@ -51,7 +51,7 @@ const EXPECTED_MESSAGE_SPAN_CONSUMER = expect.objectContaining({ [MESSAGING_OPERATION_TYPE]: { type: 'string', value: 'process' }, [SENTRY_KIND]: { type: 'string', value: 'consumer' }, [SENTRY_OP]: { type: 'string', value: QUEUE_PROCESS }, - [SENTRY_ORIGIN]: { type: 'string', value: 'auto.amqplib.consumer' }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.queue.amqplib.consumer' }, }), status: 'ok', }); @@ -77,7 +77,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ span: container => { const producer = container.items.find(span => span.is_segment && span.name === 'root span'); const consumer = container.items.find( - t => t.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.consumer', + t => t.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.consumer', ); expect(producer).toBeDefined(); @@ -87,7 +87,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ expect(consumer!.name).toBe('process queue1'); const producerSpan = container.items.find( - s => s.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.publisher', + s => s.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.publisher', ); expect(producerSpan?.name).toBe('send queue1'); expect(producerSpan).toMatchObject(expectedProducerSpan('queue1')); @@ -112,7 +112,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ .expect({ span: container => { const consumer = container.items.find( - t => t.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.consumer', + t => t.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.consumer', ); expect(consumer).toBeDefined(); @@ -124,7 +124,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ [MESSAGING_SYSTEM]: { type: 'string', value: 'rabbitmq' }, [SENTRY_KIND]: { type: 'string', value: 'consumer' }, [SENTRY_OP]: { type: 'string', value: QUEUE_PROCESS }, - [SENTRY_ORIGIN]: { type: 'string', value: 'auto.amqplib.consumer' }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.queue.amqplib.consumer' }, }), }), ); @@ -152,7 +152,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ expect(container.items.find(span => span.is_segment)?.name).toBe('root span'); const producerSpans = container.items.filter( - s => s.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.publisher', + s => s.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.publisher', ); // The confirm channel internally calls the base publish; the instrumentation must not @@ -179,7 +179,7 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ .expect({ span: container => { const consumer = container.items.find( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.consumer', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.consumer', ); expect(consumer).toBeDefined(); @@ -205,12 +205,12 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ .expect({ span: container => { const producerSpan = container.items.find( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.publisher', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.publisher', ); expect(producerSpan?.name).toBe('send callback-queue'); const consumerSpan = container.items.find( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.consumer', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.queue.amqplib.consumer', ); expect(consumerSpan?.name).toBe('process callback-queue'); expect(consumerSpan?.status).toBe('ok'); diff --git a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts index 8593a1f124ff..cd814ff541d7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts @@ -2,8 +2,8 @@ import type { TransactionEvent } from '@sentry/core'; import { afterAll, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; -const producerOrigin = 'auto.kafkajs.producer'; -const consumerOrigin = 'auto.kafkajs.consumer'; +const producerOrigin = 'auto.queue.kafkajs.producer'; +const consumerOrigin = 'auto.queue.kafkajs.consumer'; describeWithDockerCompose('kafkajs', { workingDirectory: [__dirname] }, () => { afterAll(() => { diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/test.ts index 99604cb8a599..01d9aa1450e3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/test.ts @@ -25,7 +25,7 @@ import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; import { getStringAttributeValue } from '../../../../utils'; -const expectedOrigin = 'auto.vercelai.channel'; +const expectedOrigin = 'auto.ai.vercelai'; const toolCallArgs = '{\\"location\\":\\"San Francisco\\"}'; describe('Vercel AI integration (v4)', () => { diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts index b608a1f717fc..7c82eb3f9a91 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts @@ -39,7 +39,7 @@ describe('Vercel AI integration (v5)', () => { const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(7); const firstInvokeAgentSpan = spans.find( @@ -131,7 +131,7 @@ describe('Vercel AI integration (v5)', () => { const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(7); const firstInvokeAgentSpan = spans.find( @@ -247,7 +247,7 @@ describe('Vercel AI integration (v5)', () => { .completed(); const segment = receivedSpans.find(span => span.is_segment && span.name === 'main'); - const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel'); + const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai'); expect(spans).toHaveLength(3); const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent'); expect(invokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent'); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts index 077d96e0d9bb..ea89bf21ddaf 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts @@ -40,7 +40,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe cleanupChildProcesses(); }); - const expectedOrigin = 'auto.vercelai.channel'; + const expectedOrigin = 'auto.ai.vercelai'; // We only run this in ESM and CJS to verify full support // Other suites we only run in ESM to simplify the test setup @@ -56,7 +56,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(7); const firstInvokeAgentSpan = spans.find( @@ -309,7 +309,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(7); const firstInvokeAgentSpan = spans.find( @@ -539,7 +539,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe .completed(); const segment = receivedSpans.find(span => span.is_segment && span.name === 'main'); - const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel'); + const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai'); expect(spans).toHaveLength(3); const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent')!; expect(invokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin }); @@ -611,7 +611,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(7); const invokeAgentSpans = spans.filter( @@ -651,7 +651,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); expect(spans).toHaveLength(4); const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent weather_agent')!; @@ -707,7 +707,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgents = spans.filter(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent'); const generateContents = spans.filter( @@ -756,7 +756,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgents = spans.filter(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent'); const generateContents = spans.filter( @@ -802,7 +802,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!; expect(invokeAgent.attributes['vercel.ai.operationId']?.value).toBe('ai.streamText'); @@ -857,7 +857,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!; expect(invokeAgent.status).toBe('ok'); @@ -921,7 +921,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!; expect(invokeAgent.status).toBe('ok'); @@ -965,7 +965,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); // The model throws, so the operation rejects. The spans must still be *finished* (and // therefore present in the transaction) with an error status — not left open. @@ -1001,7 +1001,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const generateContent = spans.find( span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content', @@ -1108,7 +1108,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const generateContent = spans.find( span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content', @@ -1151,7 +1151,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const embedSpan = spans.find( @@ -1203,7 +1203,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe const segment = container.items.find(span => span.is_segment && span.name === 'main'); expect(segment).toBeDefined(); const spans = container.items.filter( - span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel', + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', ); const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent'); @@ -1293,7 +1293,7 @@ describe('Vercel AI integration experimental_decide', () => { const decideSpan = container.items.find(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE)!; expect(decideSpan.name).toBe('decide typesafe-ai/jev'); expect(decideSpan.status).toBe('ok'); - expect(decideSpan.attributes[SENTRY_ORIGIN]?.value).toBe('auto.vercelai.channel'); + expect(decideSpan.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.vercelai'); expect(decideSpan.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('decide'); expect(decideSpan.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('gateway'); expect(decideSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('typesafe-ai/jev'); diff --git a/packages/aws-serverless/src/requestSpanOptions.ts b/packages/aws-serverless/src/requestSpanOptions.ts index 82c10b13852a..aa4e6a354f2f 100644 --- a/packages/aws-serverless/src/requestSpanOptions.ts +++ b/packages/aws-serverless/src/requestSpanOptions.ts @@ -63,7 +63,7 @@ export function getRequestSpanOptions(event: unknown, context: Context, requestI : context.functionName, attributes: { [SENTRY_OP]: FUNCTION_AWS, - [SENTRY_ORIGIN]: 'auto.aws_lambda', + [SENTRY_ORIGIN]: 'auto.function.aws_serverless', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SENTRY_KIND]: 'server', [FAAS_INVOCATION_ID]: context.awsRequestId, diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index 1660a44cf273..3361254ffc1d 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -228,7 +228,7 @@ export function _sendLcpSpan( _emitWebVitalSpan({ name, op: UI_WEBVITAL_LCP, - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: lcpValue, attributes, @@ -329,7 +329,7 @@ export function _sendClsSpan( _emitWebVitalSpan({ name, op: UI_WEBVITAL_CLS, - origin: 'auto.http.browser.cls', + origin: 'auto.ui.browser.cls', metricName: 'cls', value: clsValue, attributes, @@ -457,7 +457,7 @@ export function _sendInpSpan( _emitWebVitalSpan({ name, op: INTERACTION_TYPE_TO_SPAN_OP[interactionType], - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: inpValue, attributes, diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index 92c86637cbf2..ebbb00ab9331 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -99,7 +99,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test Vital', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 100, startTime: 1.5, @@ -108,7 +108,7 @@ describe('_emitWebVitalSpan', () => { expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith({ name: 'Test Vital', attributes: { - 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.origin': 'auto.ui.browser.lcp', 'sentry.op': 'ui.webvital.lcp', 'sentry.exclusive_time': 0, 'browser.web_vital.lcp.value': 100, @@ -135,7 +135,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test Vital', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 100, startTime: 1.5, @@ -156,7 +156,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.interaction.click', - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: 100, startTime: 1.5, @@ -176,7 +176,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.interaction.click', - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: 100, startTime: 1.5, @@ -201,7 +201,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.interaction.click', - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: 100, startTime: 1.5, @@ -223,7 +223,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.interaction.click', - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: 100, startTime: 1.5, @@ -244,7 +244,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 50, parentSpan: mockPageloadSpan as any, @@ -270,7 +270,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.interaction.click', - origin: 'auto.http.browser.inp', + origin: 'auto.ui.browser.inp', metricName: 'inp', value: 50, parentSpan: mockNonPageloadSpan as any, @@ -290,7 +290,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 50, attributes: { 'custom.attr': 'value' }, @@ -313,7 +313,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 50, startTime: 1.0, @@ -333,7 +333,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 50, startTime: 1.0, @@ -351,7 +351,7 @@ describe('_emitWebVitalSpan', () => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', - origin: 'auto.http.browser.lcp', + origin: 'auto.ui.browser.lcp', metricName: 'lcp', value: 50, startTime: 1.0, @@ -419,7 +419,7 @@ describe('_sendLcpSpan', () => { expect.objectContaining({ name: '', attributes: expect.objectContaining({ - 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.origin': 'auto.ui.browser.lcp', 'sentry.op': 'ui.webvital.lcp', 'sentry.exclusive_time': 0, 'sentry.pageload.span_id': 'pageload-123', @@ -567,7 +567,7 @@ describe('_sendClsSpan', () => { expect.objectContaining({ name: '
', attributes: expect.objectContaining({ - 'sentry.origin': 'auto.http.browser.cls', + 'sentry.origin': 'auto.ui.browser.cls', 'sentry.op': 'ui.webvital.cls', 'sentry.pageload.span_id': 'pageload-789', 'browser.web_vital.cls.source.1': '
', @@ -676,7 +676,7 @@ describe('_sendInpSpan', () => { name: ' diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/package.json b/dev-packages/e2e-tests/test-applications/remix-v3/package.json index 557ccc9f0d72..7d52c7d0d8a1 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-v3/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", - "remix": "3.0.0" + "remix": "3.1.0" }, "devDependencies": { "@playwright/test": "1.64.1", diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts index 393a3e8e3195..5823184ba0c0 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts @@ -68,6 +68,37 @@ test('sends a navigation span for a link the runtime intercepts', async ({ page expect(span.attributes?.['sentry.segment.name.source']?.value).toBe('route'); }); +// The span is named by the navigation's own fetch, not the earlier prefetch. +for (const mode of ['intent', 'viewport'] as const) { + test(`names a navigation to a link prefetched on ${mode} by the route`, async ({ page }) => { + const prefetchPromise = page.waitForRequest( + request => request.url().endsWith(`/users/${mode}`) && request.headers()['x-remix-frame'] === 'true', + ); + + await page.goto('/'); + const link = page.locator(`#to-user-prefetch-${mode}`); + if (mode === 'intent') { + await link.hover(); + } + await (await prefetchPromise).response(); + + const spanPromise = waitForStreamedSpan( + APP_NAME, + span => + getSpanOp(span) === 'navigation' && + span.attributes?.['sentry.origin']?.value === 'auto.navigation.remix_v3' && + span.attributes?.['url.path']?.value === `/users/${mode}`, + ); + + await link.click(); + await expect(page.locator('#user')).toBeVisible(); + + const span = await spanPromise; + expect(span.name).toBe('/users/:id'); + expect(span.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + }); +} + test('names a navigation to a route it loaded before right at the start', async ({ page }) => { await page.goto('/'); await page.locator('#to-user').click(); From c6ac34c3f1c3d2fb0d448dcb9bfb94a12c88d60b Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 28 Oct 2026 16:09:16 +0100 Subject: [PATCH 124/126] test(cloudflare): Bump agents and MCP SDK in cloudflare-mcp-agent e2e app (#25830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Why:** `agents` pins `@modelcontextprotocol/sdk` to an exact version, so bumping the SDK alone (#25782) installs two copies and breaks the typecheck: `McpAgent.server` expects the `McpServer` from `agents`' copy. This PR bumps `agents` to 0.31.0 together with the SDK version it pins (1.32.1). It also adds `@modelcontextprotocol/server`: `agents/mcp` imports it statically (for an `instanceof` guard against SDK v2 servers) even though it is listed as an optional peer, so the Worker bundle fails without it. Closes #25782 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../test-applications/cloudflare-mcp-agent/package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json index 15331dcb5861..0549f5cae5b7 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json @@ -14,9 +14,10 @@ "test:dev": "TEST_ENV=development playwright test" }, "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0", + "@modelcontextprotocol/sdk": "1.32.1", + "@modelcontextprotocol/server": "2.3.1", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", - "agents": "0.11.9", + "agents": "0.31.0", "zod": "^4.3.6" }, "devDependencies": { From 90a71e7629d12d413655a49c1f64593f075e51eb Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 28 Oct 2026 16:27:18 +0100 Subject: [PATCH 125/126] feat(server-utils): Instrument together-ai 1.x (#25823) together-ai 1.0.0 moved `create` onto new `BaseCompletions`/`BaseEmbeddings` classes, so our `<1` config gives no spans on 1.x, and widening the range alone wouldn't match anything. This adds a separate 1.x entry that targets the base classes. Everything else we rely on is unchanged. The gen-ai-libraries E2E app stays on 0.54.0 for now. Bumping it needs a base URL change because 1.x adds `/v1` to request paths. --------- Co-authored-by: Claude Opus 5.5 --- .../node-suites/excludes.ts | 1 + .../tracing/together-ai/v1/instrument.mjs | 10 ++ .../tracing/together-ai/v1/scenario.mjs | 115 ++++++++++++++++++ .../suites/tracing/together-ai/v1/test.ts | 65 ++++++++++ .../orchestrion/config/openai-compatible.ts | 10 +- .../src/orchestrion/config/together-ai.ts | 12 +- 6 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/together-ai/v1/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/together-ai/v1/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/together-ai/v1/test.ts diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index c2b32342e3f0..c62a8b2059ec 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -124,6 +124,7 @@ export const NO_AUTO_INSTRUMENTATION = [ 'suites/tracing/redis/test.ts', 'suites/tracing/tedious/test.ts', 'suites/tracing/together-ai/test.ts', + 'suites/tracing/together-ai/v1/test.ts', 'suites/tracing/typesafe/test.ts', 'suites/tracing/vercelai/**', ]; diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/instrument.mjs new file mode 100644 index 000000000000..cc192fb89834 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/scenario.mjs new file mode 100644 index 000000000000..c52720e3de60 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/scenario.mjs @@ -0,0 +1,115 @@ +import Together from 'together-ai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/chat/completions', (req, res) => { + const { model, stream } = req.body; + + // error-model returns 404 (not retried by the SDK) so the span records an error + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + const chunks = [ + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { role: 'assistant', content: 'Hello from Together streaming!' } }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 12, completion_tokens: 18, total_tokens: 30 }, + }, + ]; + for (const chunk of chunks) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.end('data: [DONE]\n\n'); + return; + } + + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [ + { index: 0, message: { role: 'assistant', content: 'Hello from Together mock!' }, finish_reason: 'stop' }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }); + }); + + app.post('/v1/embeddings', (req, res) => { + res.send({ + id: 'embd-mock123', + object: 'list', + model: req.body.model, + data: [{ object: 'embedding', index: 0, embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 8, total_tokens: 8 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Together({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}`, + }); + + await client.chat.completions.create({ + model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }); + + try { + await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // expected + } + + const stream = await client.chat.completions.create({ + model: 'meta-llama/Llama-3.1-8B-Instruct-Turbo', + messages: [{ role: 'user', content: 'Tell me about streaming' }], + stream: true, + }); + for await (const chunk of stream) { + void chunk; + } + + await client.embeddings.create({ + model: 'togethercomputer/m2-bert-80M-8k-retrieval', + input: 'Embedding test!', + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/test.ts new file mode 100644 index 000000000000..c48e54e7b561 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/v1/test.ts @@ -0,0 +1,65 @@ +import { + GEN_AI_OPERATION_NAME, + GEN_AI_PROVIDER_NAME, + GEN_AI_RESPONSE_ID, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { GEN_AI_CHAT, GEN_AI_EMBEDDINGS } from '@sentry/conventions/op'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +const ORIGIN = 'auto.ai.together_ai'; + +// together-ai 1.x defines `create` on `BaseCompletions`/`BaseEmbeddings` (`Completions`/`Embeddings` are now +// empty subclasses), which needs its own orchestrion config entry. Attribute extraction is version-independent +// and covered by the suite pinned to 0.x; this one guards that each instrumented method still gets a span. +describe('Together integration (1.x)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('auto-instruments chat completions and embeddings on together-ai 1.x', async () => { + const runner = createRunner().ignore('event'); + const spansPromise = runner.collectStreamedSpansUntilSegment('main'); + + await runner.start().completed(); + + const spans = (await spansPromise).filter(span => span.attributes[SENTRY_ORIGIN]?.value === ORIGIN); + expect(spans.map(span => span.name).sort()).toEqual([ + 'chat error-model', + 'chat meta-llama/Llama-3.1-8B-Instruct-Turbo', + 'chat meta-llama/Llama-3.3-70B-Instruct-Turbo', + 'embeddings togethercomputer/m2-bert-80M-8k-retrieval', + ]); + for (const span of spans) { + expect(span.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('together_ai'); + } + + const chatSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan!.status).toBe('ok'); + expect(chatSpan!.attributes[SENTRY_OP]?.value).toBe(GEN_AI_CHAT); + expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(25); + + const errorSpan = spans.find(span => span.name === 'chat error-model'); + expect(errorSpan!.status).toBe('error'); + + const streamSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123'); + expect(streamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30); + + const embeddingsSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123'); + expect(embeddingsSpan!.attributes[SENTRY_OP]?.value).toBe(GEN_AI_EMBEDDINGS); + expect(embeddingsSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('embeddings'); + }); + }, + { additionalDependencies: { 'together-ai': '1.0.0' } }, + ); +}); diff --git a/packages/server-utils/src/orchestrion/config/openai-compatible.ts b/packages/server-utils/src/orchestrion/config/openai-compatible.ts index 7decaedb3feb..d19dc8d2ee45 100644 --- a/packages/server-utils/src/orchestrion/config/openai-compatible.ts +++ b/packages/server-utils/src/orchestrion/config/openai-compatible.ts @@ -7,21 +7,25 @@ import type { InstrumentationConfig } from '../apmTypes'; * a thenable `APIPromise`. Sync tracing preserves its lazy body parsing; Auto would trigger it via `.then()`. * A streaming call resolves to the same OpenAI-style async-iterable `Stream` the openai integration consumes. These * SDKs ship dual CJS/ESM and the matcher compares `filePath` exactly, hence one entry per built file. + * `classNames` covers SDKs that moved `create` onto a base class while keeping this layout. * * Because the wire format is OpenAI-compatible, the span building, streaming and response parsing are all * reused from `ai/openai`; only the module name, channels and `gen_ai.provider.name` differ per provider. */ -export function openAiCompatibleConfig(module: { name: string; versionRange: string }): InstrumentationConfig[] { +export function openAiCompatibleConfig( + module: { name: string; versionRange: string }, + classNames = { chat: 'Completions', embeddings: 'Embeddings' }, +): InstrumentationConfig[] { return [ ...['resources/chat/completions.js', 'resources/chat/completions.mjs'].map(filePath => ({ channelName: 'chat', module: { ...module, filePath }, - functionQuery: { className: 'Completions', methodName: 'create', kind: 'Sync' as const }, + functionQuery: { className: classNames.chat, methodName: 'create', kind: 'Sync' as const }, })), ...['resources/embeddings.js', 'resources/embeddings.mjs'].map(filePath => ({ channelName: 'embeddings', module: { ...module, filePath }, - functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Sync' as const }, + functionQuery: { className: classNames.embeddings, methodName: 'create', kind: 'Sync' as const }, })), ]; } diff --git a/packages/server-utils/src/orchestrion/config/together-ai.ts b/packages/server-utils/src/orchestrion/config/together-ai.ts index 82a7f4ed006e..6ad5eccf0a3a 100644 --- a/packages/server-utils/src/orchestrion/config/together-ai.ts +++ b/packages/server-utils/src/orchestrion/config/together-ai.ts @@ -4,10 +4,14 @@ import { getModuleNames } from './module-names'; import { openAiCompatibleConfig } from './openai-compatible'; // `together-ai` is a Stainless-generated, OpenAI-compatible SDK — see `openAiCompatibleConfig`. -export const togetherAiConfig = openAiCompatibleConfig({ - name: 'together-ai', - versionRange: '>=0.6.0 <1', -}) satisfies InstrumentationConfig[]; +export const togetherAiConfig = [ + ...openAiCompatibleConfig({ name: 'together-ai', versionRange: '>=0.6.0 <1' }), + // 1.x defines `create` on `BaseCompletions`/`BaseEmbeddings`; `Completions`/`Embeddings` are empty subclasses. + ...openAiCompatibleConfig( + { name: 'together-ai', versionRange: '>=1.0.0 <2' }, + { chat: 'BaseCompletions', embeddings: 'BaseEmbeddings' }, + ), +] satisfies InstrumentationConfig[]; export const togetherAiModuleNames = getModuleNames(togetherAiConfig); From 612654cfd9ac729c8d6a5662fe420287d9eb34ed Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 28 Oct 2026 16:16:46 +0100 Subject: [PATCH 126/126] meta(changelog): Update changelog for 11.10.0 --- CHANGELOG.md | 205 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 172 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9bd3d02ec53..1a0d0150cd82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,32 +4,67 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @kyrylolvov, @seongwon030, @larcane97, @pcbeingused333, @psh4607, @4ktLuffy, and @lfantone. Thank you for your contributions! +## 11.10.0 ### Important Changes -- **feat(nuxt): Start the server SDK on the Nuxt Vite server (experimental) ([#25524](https://github.com/getsentry/sentry-javascript/pull/25524))** +- **feat: Add a managed tunnel that the SDK derives from the DSN ([#25199](https://github.com/getsentry/sentry-javascript/pull/25199), [#25200](https://github.com/getsentry/sentry-javascript/pull/25200), [#25783](https://github.com/getsentry/sentry-javascript/pull/25783))** - Sentry now supports Node.js production builds using Nuxt 4.6's experimental Vite server (`server: { builder: 'vite' }`). Your `sentry.server.config` loads automatically, enabling request tracing and automatic database spans for supported libraries. + Set `tunnel: true` in your browser `Sentry.init` call to send events through your own server. This helps when ad blockers block requests to Sentry. The browser derives the tunnel path from the DSN, so older and newer builds of your client and server always agree on the path. - Development mode, Cloudflare, automatic capture of server rendering errors, and connecting server and browser traces via HTML meta tags are not yet supported. This experimental support may change in minor releases. + To serve the tunnel, add `serveTunnelIntegration()` to your server SDK. Astro, Nuxt, Nitro, React Router, Remix 3, SolidStart, SvelteKit and TanStack Start then answer tunnel requests automatically: -- **feat(cloudflare): Add incoming request span options to `httpServerIntegration`** + ```ts + // browser + Sentry.init({ dsn, tunnel: true }); - Cloudflare's `httpServerIntegration` now accepts `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`, with the same meaning as in the Node, Deno and Bun integrations. The callbacks receive the web `Request`. + // server + Sentry.init({ dsn, integrations: [Sentry.serveTunnelIntegration()] }); + ``` - `ignoreStaticAssets` defaults to `true`, so workers no longer create spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `httpServerIntegration({ ignoreStaticAssets: false })`. Request spans also follow the client's tracing configuration by default, so the SDK does not start them when tracing is disabled. If `ignoreRequestBody` throws, the request no longer fails, and the SDK skips the body. + If the browser uses a different DSN than the server, set `serveTunnelIntegration({ allowedDsns: [browserDsn] })`. For other setups, the server SDKs export `handleTunnelRequestIfMatched`, `handleTunnelRequest`, `isTunnelRequest` and `getTunnelPath`: -- **feat(node, bun, deno, cloudflare): Align portable exports across server SDKs** + ```ts + app.use((req, res, next) => Sentry.handleTunnelRequestIfMatched(req, res) ?? next()); + ``` - Node, Bun, Deno and Cloudflare now export the same helpers from `@sentry/core` and `@sentry/server-utils` where they work on that runtime: + The `request` option of `handleTunnelRequest` is deprecated and will be removed in v12. - - Node and Bun now export `moduleMetadataIntegration`, `instrumentPostgresJsSql`, `instrumentCreateReactAgent`, `awsIntegration` and the `FlueOptions` type. `awsIntegration` was already in their default integrations. - - Deno and Cloudflare now export `parameterize` and `instrumentStateGraphCompile`. - - Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types. - - Deno now exports `fastifyIntegration`, which was already in its default integrations. +- **feat: Make span origins spec compliant ([#25602](https://github.com/getsentry/sentry-javascript/pull/25602))** + + Some span origins changed their names to follow the [trace origin spec](https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/): + - `auto.amqplib.*` is now `auto.queue.amqplib.*`, and `auto.kafkajs.*` is now `auto.queue.kafkajs.*`. + - `auto.vercelai.channel` is now `auto.ai.vercelai`. + - `auto.firebase.firestore` is now `auto.db.firebase.firestore`, and `auto.firebase.functions` is now `auto.function.firebase`. + - `auto.aws.aws_sdk` is now `auto.{rpc,db,queue,ai}.aws_sdk`, with the category from the span op. + - `auto.aws_lambda` is now `auto.function.aws_serverless`. + - `auto.mcp.notification` is now `auto.function.mcp_server.notification`. + - `auto.db.cloudflare.durable_object.sql` is now `auto.db.cloudflare.durable_object_sql`. + - `auto.http.browser.{lcp,cls,inp}` is now `auto.ui.browser.{lcp,cls,inp}`. -- **feat(cloudflare): Let users configure the channel integrations** + If you have `ignoreSpans` rules, queries or alerts on the old origins, change them to use the new origins. + +- **feat(bun): Add incoming request options and request sessions to `bunServerIntegration` ([#25580](https://github.com/getsentry/sentry-javascript/pull/25580))** + + `bunServerIntegration` now accepts the same options as `denoServeIntegration`: `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated`, `onSpanEnd`, `sessions` and `sessionFlushingDelayMS`. The callbacks receive the web `Request`. + + `Bun.serve` now records a request session for each incoming request. Sessions are sent only when a `release` is set. To turn them off, set `bunServerIntegration({ sessions: false })`. + + `ignoreStaticAssets` defaults to `true`, so `Bun.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `bunServerIntegration({ ignoreStaticAssets: false })`. `Bun.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. Errors thrown for `OPTIONS` and `HEAD` requests are now captured. + +- **feat(bun): Inject debug IDs and upload source maps in `sentryBunPlugin` ([#25670](https://github.com/getsentry/sentry-javascript/pull/25670))** + + `sentryBunPlugin` from `@sentry/bun/plugin` now accepts the same options as `sentryEsbuildPlugin` (`org`, `project`, `authToken`, `release`, `sourcemaps`, ...). When `Bun.build` writes source maps to `outdir`, the plugin injects the debug ID that Bun writes into each bundle, injects the release, and uploads the source maps at the end of the build. You no longer need to run `sentry sourcemap inject` and `sentry sourcemap upload` after the build. + + Like the other bundler plugins, `sentryBunPlugin` now sends build telemetry and injects the release it detects (for example, from git). To turn off telemetry, set `telemetry: false`. + +- **feat(cloudflare): Add incoming request span options to `httpServerIntegration` ([#25582](https://github.com/getsentry/sentry-javascript/pull/25582))** + + Cloudflare's `httpServerIntegration` now accepts `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`, with the same meaning as in the Node, Deno and Bun integrations. The callbacks receive the web `Request`. + + `ignoreStaticAssets` defaults to `true`, so workers no longer create spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `httpServerIntegration({ ignoreStaticAssets: false })`. Request spans also follow the client's tracing configuration by default, so the SDK does not start them when tracing is disabled. If `ignoreRequestBody` throws, the request no longer fails, and the SDK skips the body. + +- **feat(cloudflare): Let users configure the channel integrations ([#25584](https://github.com/getsentry/sentry-javascript/pull/25584))** When the `@sentry/cloudflare/vite` plugin bundles an instrumented package (for example `pg` or `openai`), the SDK adds the matching integration with its default options. `@sentry/cloudflare` now exports these integrations, so you can set their options, for example in `instrument.server.ts`: @@ -42,7 +77,26 @@ Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @ These integrations need the vite plugin. Without it, they record nothing, and with `debug: true` the SDK logs a warning. -- **ref(deno, bun): Use a shared request handler wrapper for `Deno.serve` and `Bun.serve`** +- **feat(node): Add `onSpanEnd` to `httpIntegration` ([#25581](https://github.com/getsentry/sentry-javascript/pull/25581))** + + `httpIntegration` and `httpServerSpansIntegration` now accept `onSpanEnd`, a hook that runs when the response for an incoming request finishes, before its span ends. Use it to change the span based on the response. Deno and Bun already have this option. + +- **feat(node, bun, deno, cloudflare): Align portable exports across server SDKs ([#25583](https://github.com/getsentry/sentry-javascript/pull/25583))** + + Node, Bun, Deno and Cloudflare now export the same helpers from `@sentry/core` and `@sentry/server-utils` where they work on that runtime: + + - Node and Bun now export `moduleMetadataIntegration`, `instrumentPostgresJsSql`, `instrumentCreateReactAgent`, `awsIntegration` and the `FlueOptions` type. `awsIntegration` was already in their default integrations. + - Deno and Cloudflare now export `parameterize` and `instrumentStateGraphCompile`. + - Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types. + - Deno now exports `fastifyIntegration`, which was already in its default integrations. + +- **feat(nuxt): Start the server SDK on the Nuxt Vite server (experimental) ([#25524](https://github.com/getsentry/sentry-javascript/pull/25524))** + + Sentry now supports Node.js production builds using Nuxt 4.6's experimental Vite server (`server: { builder: 'vite' }`). Your `sentry.server.config` loads automatically, enabling request tracing and automatic database spans for supported libraries. + + Development mode, Cloudflare, automatic capture of server rendering errors, and connecting server and browser traces via HTML meta tags are not yet supported. This experimental support may change in minor releases. + +- **ref(deno, bun): Use a shared request handler wrapper for `Deno.serve` and `Bun.serve` ([#25586](https://github.com/getsentry/sentry-javascript/pull/25586), [#25587](https://github.com/getsentry/sentry-javascript/pull/25587))** `Deno.serve` and `Bun.serve` now use the same request handler wrapper, which Cloudflare will also use. On both runtimes, errors in `OPTIONS` and `HEAD` requests now carry the request data, and the SDK keeps every `Set-Cookie` header of a response instead of only the last one. @@ -58,6 +112,109 @@ Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @ - A request to `/` gets the `route` source and the name `GET /`. - Spans also get `user_agent.original` and `http.request.body.size`. +### Other Changes + +- chore(deps): Bump rrweb to 2.45.0 ([#25794](https://github.com/getsentry/sentry-javascript/pull/25794)) +- feat: Export the Node bundler plugins from SDKs that wrap @sentry/node ([#25536](https://github.com/getsentry/sentry-javascript/pull/25536)) +- feat(cloudflare): instrument Workers KV bindings ([#25488](https://github.com/getsentry/sentry-javascript/pull/25488)) +- feat(nextjs): Warn when Next.js production builds miss the instrumentation file ([#25603](https://github.com/getsentry/sentry-javascript/pull/25603)) +- feat(react-router): Support renderToReadableStream in createSentryHandleRequest ([#25254](https://github.com/getsentry/sentry-javascript/pull/25254)) +- feat(server-utils): Emit `execute_tool` spans for Anthropic's `beta.sessions.events.toolRunner()` ([#25554](https://github.com/getsentry/sentry-javascript/pull/25554)) +- feat(server-utils): Record Workers AI Clairic calls as gen_ai.extract spans ([#25624](https://github.com/getsentry/sentry-javascript/pull/25624)) +- feat(server-utils): Instrument together-ai 1.x ([#25823](https://github.com/getsentry/sentry-javascript/pull/25823)) +- fix(browser): Preserve empty stacks in continuous profiles ([#25764](https://github.com/getsentry/sentry-javascript/pull/25764)) +- fix(browser): resolve request URLs against location.href in tracing ([#25784](https://github.com/getsentry/sentry-javascript/pull/25784)) +- fix(build): Make CJS re-exports visible to Node ESM imports ([#25599](https://github.com/getsentry/sentry-javascript/pull/25599)) +- fix(bun): Add systemErrorIntegration to default integrations ([#25681](https://github.com/getsentry/sentry-javascript/pull/25681)) +- fix(cloudflare): Bind a default re-export with `var` in the Vite transform ([#25663](https://github.com/getsentry/sentry-javascript/pull/25663)) +- fix(cloudflare): Do not add back channel integrations the user removed ([#25585](https://github.com/getsentry/sentry-javascript/pull/25585)) +- fix(cloudflare): Skip comments when locating the `default` keyword in the Vite transform ([#25741](https://github.com/getsentry/sentry-javascript/pull/25741)) +- fix(core): allow project arrays in framework build options ([#25534](https://github.com/getsentry/sentry-javascript/pull/25534)) +- fix(core): Drop sampled-out errors before they take a promise buffer slot ([#25751](https://github.com/getsentry/sentry-javascript/pull/25751)) +- fix(core): End node:http client spans when the response is destroyed ([#25503](https://github.com/getsentry/sentry-javascript/pull/25503)) +- fix(core): Preserve AI proxy property references ([#22474](https://github.com/getsentry/sentry-javascript/pull/22474)) +- fix(core): Resolve relative URLs when enhancing fetch error messages ([#25555](https://github.com/getsentry/sentry-javascript/pull/25555)) +- fix(feedback): Wait for frames without the form before capturing the screenshot ([#25658](https://github.com/getsentry/sentry-javascript/pull/25658)) +- fix(hono): Make the Hono middleware types compile on Hono 5 ([#25452](https://github.com/getsentry/sentry-javascript/pull/25452)) +- fix(node): Flush buffered streamed spans on process exit ([#25539](https://github.com/getsentry/sentry-javascript/pull/25539)) +- fix(node): Read module versions from the isolated layouts of pnpm and Bun ([#25526](https://github.com/getsentry/sentry-javascript/pull/25526)) +- fix(node): Set the eve conversation id on model.started for eve 0.84+ ([#25809](https://github.com/getsentry/sentry-javascript/pull/25809)) +- fix(node): Skip spans for outgoing requests to Sentry via `undici` fetch and `node:http` ([#25441](https://github.com/getsentry/sentry-javascript/pull/25441)) +- fix(node): Use the request method for Hapi routes with multiple methods ([#25527](https://github.com/getsentry/sentry-javascript/pull/25527)) +- fix(nuxt,nitro): Accept Nitro 3 betas without narrowing peer support ([#25815](https://github.com/getsentry/sentry-javascript/pull/25815)) +- fix(opentelemetry): Convert OpenTelemetry performance.now() timestamps ([#25790](https://github.com/getsentry/sentry-javascript/pull/25790)) +- fix(react): Avoid double slashes in manifest route names ([#25770](https://github.com/getsentry/sentry-javascript/pull/25770)) +- fix(react): Parameterize React Router transactions with encoded params ([#25639](https://github.com/getsentry/sentry-javascript/pull/25639)) +- fix(remix): Drop Remix 3 release candidates from the peer range ([#25808](https://github.com/getsentry/sentry-javascript/pull/25808)) +- fix(replay): Apply attribute masking to breadcrumb messages ([#25180](https://github.com/getsentry/sentry-javascript/pull/25180)) +- fix(server-utils): Cap the runs the LangChain callback handler keeps open ([#25595](https://github.com/getsentry/sentry-javascript/pull/25595)) +- fix(server-utils): Keep OpenAI streamed tool calls that have no index ([#25622](https://github.com/getsentry/sentry-javascript/pull/25622)) +- fix(server-utils): Keep OpenRouter evaluation confidence and legend on Vercel AI spans ([#25644](https://github.com/getsentry/sentry-javascript/pull/25644)) +- fix(server-utils): Nest Flue runs on Node under the request that dispatched them ([#25549](https://github.com/getsentry/sentry-javascript/pull/25549)) +- fix(server-utils): Record AI SDK tool spans for tools run outside its tool loop ([#25820](https://github.com/getsentry/sentry-javascript/pull/25820)) +- fix(server-utils): Use cumulative input and cache token counts from streamed message_delta ([#25623](https://github.com/getsentry/sentry-javascript/pull/25623)) +- fix(server-utils): Use the stored pi-durable session id as conversation id ([#25792](https://github.com/getsentry/sentry-javascript/pull/25792)) +- fix(tanstackstart-react): Unminify server errors in Nitro builds ([#25360](https://github.com/getsentry/sentry-javascript/pull/25360)) + +
+ Internal Changes + +- chore: Add catch-all rule for shared packages in CODEOWNERS ([#25789](https://github.com/getsentry/sentry-javascript/pull/25789)) +- chore: Pin pnpm 10 in Volta and remove .npmrc ([#25641](https://github.com/getsentry/sentry-javascript/pull/25641)) +- chore: Replace Volta with pnpm-managed node versions ([#25689](https://github.com/getsentry/sentry-javascript/pull/25689)) +- chore: Update PR template and prompt agent to use it ([#25763](https://github.com/getsentry/sentry-javascript/pull/25763)) +- chore: Use the isolated pnpm node_modules layout ([#25368](https://github.com/getsentry/sentry-javascript/pull/25368)) +- chore(ci): remove browser flaky-test detector workflow ([#25712](https://github.com/getsentry/sentry-javascript/pull/25712)) +- chore(ci): remove PR review reminders ([#25711](https://github.com/getsentry/sentry-javascript/pull/25711)) +- chore(deps-dev): Bump @remix-run/route-pattern from 0.24.1 to 1.0.0 in the remix group across 1 directory ([#25652](https://github.com/getsentry/sentry-javascript/pull/25652)) +- chore(deps-dev): Bump es-check from 7.2.1 to 9.8.1 ([#25656](https://github.com/getsentry/sentry-javascript/pull/25656)) +- chore(deps-dev): Bump hono from 4.13.9 to 4.13.11 in /packages/hono ([#25716](https://github.com/getsentry/sentry-javascript/pull/25716)) +- chore(deps-dev): Bump next from 15.5.27 to 15.5.28 in /packages/nextjs ([#25661](https://github.com/getsentry/sentry-javascript/pull/25661)) +- chore(deps-dev): Bump zod from 3.25.76 to 4.6.7 ([#25655](https://github.com/getsentry/sentry-javascript/pull/25655)) +- chore(deps): Bump next from 16.3.8 to 16.4.1 in /dev-packages/e2e-tests/test-applications/vinext-cf-workers ([#25672](https://github.com/getsentry/sentry-javascript/pull/25672)) +- chore(deps): Bump zod from 4.5.4 to 4.6.6 in /dev-packages/e2e-tests/test-applications/node-eve ([#25722](https://github.com/getsentry/sentry-javascript/pull/25722)) +- chore(deps): Bump zod from 4.5.4 to 4.6.6 in /dev-packages/e2e-tests/test-applications/node-mastra ([#25721](https://github.com/getsentry/sentry-javascript/pull/25721)) +- ci: Always report optional E2E results and update the report on re-runs ([#25651](https://github.com/getsentry/sentry-javascript/pull/25651)) +- ci: Fail the optional E2E report job when it cannot update the PR comment ([#25819](https://github.com/getsentry/sentry-javascript/pull/25819)) +- ci: Prepare required workflows for develop merge queue ([#25642](https://github.com/getsentry/sentry-javascript/pull/25642)) +- ci: Remove temporary pull_request trigger from optional E2E workflow ([#25817](https://github.com/getsentry/sentry-javascript/pull/25817)) +- ci: Show an unknown status for optional E2E jobs without a final result ([#25660](https://github.com/getsentry/sentry-javascript/pull/25660)) +- ci: Upload the weekly flaky test report as an artifact ([#25814](https://github.com/getsentry/sentry-javascript/pull/25814)) +- ci(e2e): rightsize namespace runner jobs +- ci(size-limit): Measure bundles when increases are accepted ([#25788](https://github.com/getsentry/sentry-javascript/pull/25788)) +- docs: Update contribution guidelines ([#25714](https://github.com/getsentry/sentry-javascript/pull/25714)) +- docs: Use dark-mode-aware Sentry logo in READMEs ([#25775](https://github.com/getsentry/sentry-javascript/pull/25775)) +- feat(deps): Bump fastify from 5.12.5 to 5.12.7 ([#25654](https://github.com/getsentry/sentry-javascript/pull/25654)) +- docs(changelog): Clarify React Router framework mode minimum in 11.0.0 ([#25771](https://github.com/getsentry/sentry-javascript/pull/25771)) +- ref(core): Add shared `ServerRequestOptions` type across runtimes ([#25579](https://github.com/getsentry/sentry-javascript/pull/25579)) +- ref(hono): Share the middleware classification ([#25453](https://github.com/getsentry/sentry-javascript/pull/25453)) +- ref(server-utils): Use `GEN_AI_EXTRACT` op from `@sentry/conventions` ([#25734](https://github.com/getsentry/sentry-javascript/pull/25734)) +- Remove permissions from settings.json ([#25766](https://github.com/getsentry/sentry-javascript/pull/25766)) +- test: Check that runtimes drop the server spans of managed tunnel requests ([#25414](https://github.com/getsentry/sentry-javascript/pull/25414)) +- test: Fix unit test timeouts from cold dynamic imports ([#25811](https://github.com/getsentry/sentry-javascript/pull/25811)) +- test: Wire the managed tunnel by hand in Node, Cloudflare and Next.js ([#25785](https://github.com/getsentry/sentry-javascript/pull/25785)) +- test(browser): Fix flaky long task, pageload cancellation and CLS timing assertions ([#25518](https://github.com/getsentry/sentry-javascript/pull/25518)) +- test(cloudflare): Add Workers AI E2E test for Clairic ([#25625](https://github.com/getsentry/sentry-javascript/pull/25625)) +- test(cloudflare): Bump agents and MCP SDK in cloudflare-mcp-agent e2e app ([#25830](https://github.com/getsentry/sentry-javascript/pull/25830)) +- test(cloudflare): Check that Durable Object invocations end when the method returns ([#25511](https://github.com/getsentry/sentry-javascript/pull/25511)) +- test(cloudflare): Fix flaky D1 query span naming test ([#25810](https://github.com/getsentry/sentry-javascript/pull/25810)) +- test(e2e): Add node-flue-send-to-sentry test app ([#25550](https://github.com/getsentry/sentry-javascript/pull/25550)) +- test(e2e): Assert source map upload and stripping with a shared helper ([#25636](https://github.com/getsentry/sentry-javascript/pull/25636)) +- test(e2e): Fix optional Anthropic and OpenAI decisions apps ([#25795](https://github.com/getsentry/sentry-javascript/pull/25795)) +- test(e2e): Pin @mastra/deployer in node-mastra to match @mastra/core ([#25824](https://github.com/getsentry/sentry-javascript/pull/25824)) +- test(e2e): Pull Supabase images from GHCR ([#25718](https://github.com/getsentry/sentry-javascript/pull/25718)) +- test(nextjs): Raise @opennextjs/cloudflare floor for nextjs-16 ([#25682](https://github.com/getsentry/sentry-javascript/pull/25682)) +- test(nextjs): Unpin nextjs-16-cf-workers latest variant back to next@latest ([#25124](https://github.com/getsentry/sentry-javascript/pull/25124)) +- test(node): Retry stalled npm installs in integration test setup ([#25685](https://github.com/getsentry/sentry-javascript/pull/25685)) +- test(react-router): Use a separate URL per loader error test in instrumentation API e2e test ([#25607](https://github.com/getsentry/sentry-javascript/pull/25607)) +- test(remix): Cover prefetched navigations in the Remix 3 E2E app ([#25829](https://github.com/getsentry/sentry-javascript/pull/25829)) +- test(replay): Fix flaky EventBufferCompressionWorker size limit test ([#25519](https://github.com/getsentry/sentry-javascript/pull/25519)) +- test(tanstackstart-react): Add source map E2E app ([#25363](https://github.com/getsentry/sentry-javascript/pull/25363)) + +
+ +Work in this release was contributed by @4ktLuffy, @faizansaiyed123, @itisrmk, @kunal649, @kyrylolvov, @larcane97, @lfantone, @pcbeingused333, @psh4607, and @seongwon030. Thank you for your contributions! + ## 11.9.0 ### Important Changes @@ -247,24 +404,6 @@ Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @ Work in this release was contributed by @4ktLuffy, @adityareddy-dev, @dasilvaluis, @faizansaiyed123, @jpdefo, @Jxxunnn, @mikamikasuki, @philipatkinson, @rubas, and @Shubham-Padkonde. Thank you for your contributions! -- **feat(bun): Add incoming request options and request sessions to `bunServerIntegration`** - - `bunServerIntegration` now accepts the same options as `denoServeIntegration`: `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated`, `onSpanEnd`, `sessions` and `sessionFlushingDelayMS`. The callbacks receive the web `Request`. - - `Bun.serve` now records a request session for each incoming request. Sessions are sent only when a `release` is set. To turn them off, set `bunServerIntegration({ sessions: false })`. - - `ignoreStaticAssets` defaults to `true`, so `Bun.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `bunServerIntegration({ ignoreStaticAssets: false })`. `Bun.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. Errors thrown for `OPTIONS` and `HEAD` requests are now captured. - -- **feat(node): Add `onSpanEnd` to `httpIntegration`** - - `httpIntegration` and `httpServerSpansIntegration` now accept `onSpanEnd`, a hook that runs when the response for an incoming request finishes, before its span ends. Use it to change the span based on the response. Deno and Bun already have this option. - -- **feat(bun): Inject debug IDs and upload source maps in `sentryBunPlugin` ([#25670](https://github.com/getsentry/sentry-javascript/pull/25670))** - - `sentryBunPlugin` from `@sentry/bun/plugin` now accepts the same options as `sentryEsbuildPlugin` (`org`, `project`, `authToken`, `release`, `sourcemaps`, ...). When `Bun.build` writes source maps to `outdir`, the plugin injects the debug ID that Bun writes into each bundle, injects the release, and uploads the source maps at the end of the build. You no longer need to run `sentry sourcemap inject` and `sentry sourcemap upload` after the build. - - Like the other bundler plugins, `sentryBunPlugin` now sends build telemetry and injects the release it detects (for example, from git). To turn off telemetry, set `telemetry: false`. - ## 11.8.0 ### Important Changes