From 0bd937b70f6e5b52d4dee170a296841c40efcdb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 29 Apr 2026 14:05:46 +0200 Subject: [PATCH 01/84] chore(ci): Use pull-requests: write for PR review reminder workflow (#20597) The schedule-triggered workflow was failing with 403 when trying to create comments on PRs. GitHub requires `pull-requests: write` (not `issues: write`) to create comments on pull requests via the Issues API when the workflow runs on schedule. ref: https://github.com/getsentry/sentry-javascript/actions/workflows/pr-review-reminder.yml Co-authored-by: Claude Opus 4.5 --- .github/workflows/pr-review-reminder.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml index 3eda72221948..8674dc760dde 100644 --- a/.github/workflows/pr-review-reminder.yml +++ b/.github/workflows/pr-review-reminder.yml @@ -7,14 +7,12 @@ on: # Saturday/Sunday are never counted as business days. - cron: '0 10 * * 1-5' -# pulls.* list + listRequestedReviewers → pull-requests: read -# issues timeline + comments + createComment → issues: write +# 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 - issues: write - pull-requests: read + pull-requests: write concurrency: group: ${{ github.workflow }} From 8fb1dc77ac114f326ab2aed0a81f4797f7a87aa3 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 29 Apr 2026 14:27:13 +0200 Subject: [PATCH 02/84] feat(browser): Always emit `http.client` streamed spans (#20420) - When span streaming is enabled, always emit http.client spans even without an active parent span (like pageload/navigation) - Previously these spans were dropped with a no_parent_span client outcome in all cases - Applies to browser fetch instrumentation, browser XHR instrumentation and the OTel sampler - Non-streaming behavior unchanged: spans without a parent are still dropped and recorded as `no_parent_span` client outcomes closes #17932 --- .../test.ts | 22 +++------ .../scenario-fetch.mjs | 4 ++ .../scenario.mjs | 2 - .../test.ts | 24 +++++----- packages/browser/src/tracing/request.ts | 8 ++-- packages/core/src/fetch.ts | 9 ++-- .../core/src/tracing/spans/captureSpan.ts | 47 ++++++++++++++++--- .../lib/tracing/spans/captureSpan.test.ts | 4 +- packages/opentelemetry/src/sampler.ts | 9 ++-- packages/opentelemetry/test/sampler.test.ts | 18 +++++++ 10 files changed, 101 insertions(+), 46 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario-fetch.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts index 609df6f551a3..05f70c5a649f 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts @@ -1,6 +1,6 @@ import { expect } from '@playwright/test'; -import type { ClientReport } from '@sentry/core'; import { sentryTest } from '../../../utils/fixtures'; +import { getSpanOp, waitForStreamedSpan } from '../../../utils/spanUtils'; import { envelopeRequestParser, hidePage, @@ -9,7 +9,7 @@ import { } from '../../../utils/helpers'; sentryTest( - 'records no_parent_span client report for fetch requests without an active span', + 'sends http.client span for fetch requests without an active span when span streaming is enabled', async ({ getLocalTestUrl, page }) => { sentryTest.skip(shouldSkipTracingTest()); @@ -23,22 +23,14 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); - const clientReportPromise = waitForClientReportRequest(page, report => { - return report.discarded_events.some(e => e.reason === 'no_parent_span'); - }); + const spanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'http.client'); await page.goto(url); - await hidePage(page); - - const clientReport = envelopeRequestParser(await clientReportPromise); + const span = await spanPromise; - expect(clientReport.discarded_events).toEqual([ - { - category: 'span', - quantity: 1, - reason: 'no_parent_span', - }, - ]); + expect(span.name).toMatch(/^GET /); + expect(span.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser' }); + expect(span.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); }, ); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario-fetch.mjs b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario-fetch.mjs new file mode 100644 index 000000000000..6a1cc2c77ba3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario-fetch.mjs @@ -0,0 +1,4 @@ +import * as Sentry from '@sentry/node'; +fetch('http://localhost:9999/external').catch(async () => { + await Sentry.flush(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs deleted file mode 100644 index 18afc6db5113..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs +++ /dev/null @@ -1,2 +0,0 @@ -import http from 'http'; -http.get('http://localhost:9999/external', () => {}).on('error', () => {}); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts index 2b987f92d755..0b0ff7cac854 100644 --- a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts @@ -1,24 +1,24 @@ import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; -describe('no_parent_span client report (streaming)', () => { +describe('no_parent_span with streaming enabled', () => { afterAll(() => { cleanupChildProcesses(); }); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('records no_parent_span outcome for http.client span without a local parent', async () => { + createEsmAndCjsTests(__dirname, 'scenario-fetch.mjs', 'instrument.mjs', (createRunner, test) => { + test('sends http.client span without a local parent when span streaming is enabled', async () => { const runner = createRunner() - .unignore('client_report') .expect({ - client_report: report => { - expect(report.discarded_events).toEqual([ - { - category: 'span', - quantity: 1, - reason: 'no_parent_span', - }, - ]); + span: span => { + const httpClientSpan = span.items.find(item => + item.attributes?.['sentry.op'] + ? item.attributes['sentry.op'].type === 'string' && item.attributes['sentry.op'].value === 'http.client' + : false, + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan?.name).toMatch(/^GET .*\/external$/); }, }) .start(); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index b393f0585b5b..9cbf45563f0b 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -406,9 +406,11 @@ function xhrCallback( const client = getClient(); const hasParent = !!getActiveSpan(); + // With span streaming, we always emit http.client spans, even without a parent span + const shouldEmitSpan = hasParent || (!!client && hasSpanStreamingEnabled(client)); const span = - shouldCreateSpanResult && hasParent + shouldCreateSpanResult && shouldEmitSpan ? startInactiveSpan({ name: `${method} ${urlForSpanName}`, attributes: { @@ -425,7 +427,7 @@ function xhrCallback( }) : new SentryNonRecordingSpan(); - if (shouldCreateSpanResult && !hasParent) { + if (shouldCreateSpanResult && !shouldEmitSpan) { client?.recordDroppedEvent('no_parent_span', 'span'); } @@ -438,7 +440,7 @@ function xhrCallback( // If performance is disabled (TWP) or there's no active root span (pageload/navigation/interaction), // we do not want to use the span as base for the trace headers, // which means that the headers will be generated from the scope and the sampling decision is deferred - hasSpansEnabled() && hasParent ? span : undefined, + hasSpansEnabled() && shouldEmitSpan ? span : undefined, propagateTraceparent, ); } diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index c65f147613dc..a64a98255fa9 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -2,6 +2,7 @@ import { getClient } from './currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes'; import { setHttpStatus, SPAN_STATUS_ERROR, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; +import { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled'; import type { FetchBreadcrumbHint } from './types-hoist/breadcrumb'; import type { HandlerDataFetch } from './types-hoist/instrument'; import type { ResponseHookInfo } from './types-hoist/request'; @@ -110,13 +111,15 @@ export function instrumentFetchRequest( const client = getClient(); const hasParent = !!getActiveSpan(); + // With span streaming, we always emit http.client spans, even without a parent span + const shouldEmitSpan = hasParent || (!!client && hasSpanStreamingEnabled(client)); const span = - shouldCreateSpanResult && hasParent + shouldCreateSpanResult && shouldEmitSpan ? startInactiveSpan(getSpanStartOptions(url, method, spanOrigin)) : new SentryNonRecordingSpan(); - if (shouldCreateSpanResult && !hasParent) { + if (shouldCreateSpanResult && !shouldEmitSpan) { client?.recordDroppedEvent('no_parent_span', 'span'); } @@ -136,7 +139,7 @@ export function instrumentFetchRequest( // If performance is disabled (TWP) or there's no active root span (pageload/navigation/interaction), // we do not want to use the span as base for the trace headers, // which means that the headers will be generated from the scope and the sampling decision is deferred - hasSpansEnabled() && hasParent ? span : undefined, + hasSpansEnabled() && shouldEmitSpan ? span : undefined, propagateTraceparent, ); if (headers) { diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index e41a9cfdf484..c06d4ce43560 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -18,6 +18,7 @@ import { } from '../../semanticAttributes'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; import { getCombinedScopeData } from '../../utils/scopeData'; +import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '../../utils/url'; import { INTERNAL_getSegmentSpan, showSpanDropWarning, @@ -241,21 +242,55 @@ function inferHttpSpanData( return; } - // Only overwrite the span name when we have an explicit http.route — it's more specific than - // what OTel instrumentation sets as the span name. For all other cases (url.full, http.target), - // the OTel-set name is already good enough and we'd risk producing a worse name (e.g. full URL). const httpRoute = attributes['http.route']; if (typeof httpRoute === 'string') { spanJSON.name = `${httpMethod} ${httpRoute}`; safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route' }); } else { - // Fallback: set source to 'url' for HTTP spans without a route. - // The spec requires sentry.span.source on segment spans, and the non-streamed exporter - // always sets this — so we need to ensure it's present for streamed spans too. + // Infer span name from URL attributes, matching the non-streamed exporter's behavior. + // Only overwrite the name for OTel spans (known spanKind) + if (spanKind === SPAN_KIND_CLIENT || spanKind === SPAN_KIND_SERVER) { + const urlPath = getUrlPath(attributes, spanKind); + if (urlPath) { + spanJSON.name = `${httpMethod} ${urlPath}`; + } + } safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }); } } +/** + * Extract a URL path from span attributes for use in the span name. + * Mirrors the logic in the non-streamed exporter's `getSanitizedUrl`. + */ +function getUrlPath( + attributes: RawAttributes>, + spanKind: number | undefined, +): string | undefined { + const httpUrl = attributes['http.url'] || attributes['url.full']; + const httpTarget = attributes['http.target']; + + const parsedUrl = typeof httpUrl === 'string' ? parseUrl(httpUrl) : undefined; + const sanitizedUrl = parsedUrl ? getSanitizedUrlString(parsedUrl) : undefined; + + // For server spans, prefer the relative target path + if (spanKind === SPAN_KIND_SERVER && typeof httpTarget === 'string') { + return stripUrlQueryAndFragment(httpTarget); + } + + // For client spans (and others), use the full sanitized URL + if (sanitizedUrl) { + return sanitizedUrl; + } + + // Fall back to target if no full URL is available + if (typeof httpTarget === 'string') { + return stripUrlQueryAndFragment(httpTarget); + } + + return undefined; +} + function inferDbSpanData(spanJSON: StreamedSpanJSON, attributes: RawAttributes>): void { safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 56b039d56b67..186f7f23a536 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -530,10 +530,10 @@ describe('inferSpanDataFromOtelAttributes', () => { expect(spanJSON.attributes?.['sentry.source']).toBe('route'); }); - it('does not overwrite name when no http.route but sets source to url', () => { + it('infers name from url.full when no http.route and sets source to url', () => { const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'url.full': 'http://example.com/api' }); inferSpanDataFromOtelAttributes(spanJSON, 2); - expect(spanJSON.name).toBe('GET'); + expect(spanJSON.name).toBe('GET http://example.com/api'); expect(spanJSON.attributes?.['sentry.source']).toBe('url'); }); diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 235ff3247f5d..05dc0758458b 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -75,10 +75,13 @@ export class SentrySampler implements Sampler { const maybeSpanHttpMethod = spanAttributes[SEMATTRS_HTTP_METHOD] || spanAttributes[ATTR_HTTP_REQUEST_METHOD]; // If we have a http.client span that has no local parent, we never want to sample it - // but we want to leave downstream sampling decisions up to the server + // but we want to leave downstream sampling decisions up to the server. + // Exception: when span streaming is enabled, we always emit these spans. if (spanKind === SpanKind.CLIENT && maybeSpanHttpMethod && (!parentSpan || parentContext?.isRemote)) { - this._client.recordDroppedEvent('no_parent_span', 'span'); - return wrapSamplingDecision({ decision: undefined, context, spanAttributes }); + if (!this._isSpanStreaming) { + this._client.recordDroppedEvent('no_parent_span', 'span'); + return wrapSamplingDecision({ decision: undefined, context, spanAttributes }); + } } const parentSampled = parentSpan ? getParentSampled(parentSpan, traceId, spanName) : undefined; diff --git a/packages/opentelemetry/test/sampler.test.ts b/packages/opentelemetry/test/sampler.test.ts index 22fa724fa161..55c3cff8ac32 100644 --- a/packages/opentelemetry/test/sampler.test.ts +++ b/packages/opentelemetry/test/sampler.test.ts @@ -348,5 +348,23 @@ describe('SentrySampler', () => { expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'span'); }); + + it('always emits streamed http.client spans without a local parent', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, traceLifecycle: 'stream' })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET http://example.com/api'; + const spanKind = SpanKind.CLIENT; + const spanAttributes = { + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + }; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); + expect(spyOnDroppedEvent).not.toHaveBeenCalled(); + }); }); }); From 1bc267d070a8c0640b01e9d3655ae9dba35fcd9c Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 15:35:14 +0200 Subject: [PATCH 03/84] chore(browser): Remove debug config from tests (#20600) This lead to hard to read logs, as this enables the debugger which leaks into other tests as well etc. --- packages/browser/test/profiling/UIProfiler.test.ts | 2 -- packages/browser/test/profiling/integration.test.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index b64ee35fc50e..456c5c222b22 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -590,7 +590,6 @@ describe('Browser Profiling v2 trace lifecycle', () => { Sentry.init({ ...getBaseOptionsForTraceLifecycle(send), - debug: true, }); Sentry.uiProfiler.startProfiler(); @@ -691,7 +690,6 @@ describe('Browser Profiling v2 manual lifecycle', () => { Sentry.init({ ...getBaseOptionsForManualLifecycle(send), - debug: true, }); Sentry.uiProfiler.startProfiler(); diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index f9d97230701c..a08db412ccec 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -69,7 +69,6 @@ describe('BrowserProfilingIntegration', () => { }); it("warns when profileLifecycle is 'trace' but tracing is disabled", async () => { - debug.enable(); const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); // @ts-expect-error mock constructor From 2aa76b0dd605b3cd9e3ceea3f791ee9f81c4ced3 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 15:35:55 +0200 Subject: [PATCH 04/84] test(browser): Fix flaky loader test (#20596) Ironically, this flaked in a PR fixing another flake. Closes https://github.com/getsentry/sentry-javascript/issues/20564 --- .../noOnLoad/sdkLoadedInMeanwhile/subject.js | 4 +++- .../loader/noOnLoad/sdkLoadedInMeanwhile/test.ts | 16 +++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/subject.js b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/subject.js index 46296b3b8c05..a446728e6995 100644 --- a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/subject.js +++ b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/subject.js @@ -1,6 +1,8 @@ setTimeout(() => { const cdnScript = document.createElement('script'); - cdnScript.src = '/cdn.bundle.js'; + // Distinct URL from the loader's `/cdn.bundle.js` so Chromium cannot satisfy this via memory-cache + // (would skip `page.route` and make CDN load counts flaky). + cdnScript.src = `/cdn.bundle.js?sentryInjected=1`; cdnScript.addEventListener('load', () => { Sentry.init({ diff --git a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts index 132281668fda..d758ec5e7901 100644 --- a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts +++ b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts @@ -30,8 +30,11 @@ sentryTest('it does not download the SDK if the SDK was loaded in the meanwhile' const tmpDir = await getLocalTestUrl({ testDir: __dirname, skipRouteHandler: true, skipDsnRouteHandler: true }); await page.route(`${TEST_HOST}/*.*`, route => { - const file = route.request().url().split('/').pop(); + const pathname = new URL(route.request().url()).pathname; + const file = pathname.split('/').pop() || ''; + // Loader + subject both fetch the CDN bundle. Chromium may not hit `page.route` twice for the same URL + // (memory cache); subject.js uses a cache-busted URL so we reliably observe two network loads. if (file === 'cdn.bundle.js') { cdnLoadedCount++; } @@ -47,10 +50,8 @@ sentryTest('it does not download the SDK if the SDK was loaded in the meanwhile' const eventData = envelopeRequestParser(req); - await waitForFunction(() => cdnLoadedCount === 2); - // Still loaded the CDN bundle twice - expect(cdnLoadedCount).toBe(2); + await expect.poll(() => cdnLoadedCount, { timeout: 15_000 }).toBe(2); // But only sent to Sentry once expect(sentryEventCount).toBe(1); @@ -62,10 +63,3 @@ sentryTest('it does not download the SDK if the SDK was loaded in the meanwhile' expect(eventData.exception?.values?.length).toBe(1); expect(eventData.exception?.values?.[0]?.value).toBe('window.doSomethingWrong is not a function'); }); - -async function waitForFunction(cb: () => boolean, timeout = 2000, increment = 100) { - while (timeout > 0 && !cb()) { - await new Promise(resolve => setTimeout(resolve, increment)); - await waitForFunction(cb, timeout - increment, increment); - } -} From fc4b3fa03b0e96c0ab80832d04399eeb4e24e89e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 29 Apr 2026 15:58:39 +0200 Subject: [PATCH 05/84] ref(tests): Rename streamed http.client span test folders (#20602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to [#20420](https://github.com/getsentry/sentry-javascript/pull/20420) — the `no-parent-span-client-report-streamed` test folders were originally testing client report outcomes, but that PR changed them to verify that http.client spans are emitted when streaming is enabled. Renames the folders to `http-client-span-streamed` to reflect what the tests actually assert, removes unused imports left over from the old test, and updates the node test's describe block name. --- .../init.js | 0 .../subject.js | 0 .../test.ts | 7 +------ .../instrument.mjs | 0 .../scenario-fetch.mjs | 0 .../test.ts | 2 +- 6 files changed, 2 insertions(+), 7 deletions(-) rename dev-packages/browser-integration-tests/suites/tracing/{no-parent-span-client-report-streamed => http-client-span-streamed}/init.js (100%) rename dev-packages/browser-integration-tests/suites/tracing/{no-parent-span-client-report-streamed => http-client-span-streamed}/subject.js (100%) rename dev-packages/browser-integration-tests/suites/tracing/{no-parent-span-client-report-streamed => http-client-span-streamed}/test.ts (88%) rename dev-packages/node-integration-tests/suites/tracing/{no-parent-span-client-report-streamed => http-client-span-streamed}/instrument.mjs (100%) rename dev-packages/node-integration-tests/suites/tracing/{no-parent-span-client-report-streamed => http-client-span-streamed}/scenario-fetch.mjs (100%) rename dev-packages/node-integration-tests/suites/tracing/{no-parent-span-client-report-streamed => http-client-span-streamed}/test.ts (94%) diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/init.js rename to dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/init.js diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/subject.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/subject.js rename to dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/subject.js diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts similarity index 88% rename from dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts rename to dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts index 05f70c5a649f..79290f65f3cf 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts @@ -1,12 +1,7 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../utils/spanUtils'; -import { - envelopeRequestParser, - hidePage, - shouldSkipTracingTest, - waitForClientReportRequest, -} from '../../../utils/helpers'; sentryTest( 'sends http.client span for fetch requests without an active span when span streaming is enabled', diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/instrument.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/instrument.mjs rename to dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/instrument.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario-fetch.mjs b/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/scenario-fetch.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario-fetch.mjs rename to dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/scenario-fetch.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/test.ts similarity index 94% rename from dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts rename to dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/test.ts index 0b0ff7cac854..58eb063ed345 100644 --- a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/test.ts @@ -1,7 +1,7 @@ import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; -describe('no_parent_span with streaming enabled', () => { +describe('http.client span with streaming enabled', () => { afterAll(() => { cleanupChildProcesses(); }); From d58f82a2ebd1bcaa5bc7ea7ca0b10625004fa511 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:36:59 +0200 Subject: [PATCH 06/84] feat(deps): bump hono from 4.12.12 to 4.12.14 (#20340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [hono](https://github.com/honojs/hono) from 4.12.12 to 4.12.14.
Release notes

Sourced from hono's releases.

v4.12.14

Security fixes

This release includes fixes for the following security issues:

Improper handling of JSX attribute names in hono/jsx SSR

Affects: hono/jsx. Fixes missing validation of JSX attribute names during server-side rendering, which could allow malformed attribute keys to corrupt the generated HTML output and inject unintended attributes or elements. GHSA-458j-xx4x-4375

Other changes

  • fix(aws-lambda): handle invalid header names in request processing (#4883) fa2c74fe

v4.12.13

What's Changed

New Contributors

Full Changelog: https://github.com/honojs/hono/compare/v4.12.12...v4.12.13

Commits
  • cf2d2b7 4.12.14
  • 66daa2e Merge commit from fork
  • fa2c74f fix(aws-lambda): handle invalid header names in request processing (#4883)
  • 3779927 4.12.13
  • faa6c46 feat(cache): add onCacheNotAvailable option (#4876)
  • f23e97b feat(trailing-slash): add skip option (#4862)
  • 1aa32fb fix(types): infer response type from last handler in app.on 9- and 10-handler...
  • See full diff in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/bun-integration-tests/package.json | 2 +- dev-packages/cloudflare-integration-tests/package.json | 2 +- dev-packages/node-integration-tests/package.json | 2 +- yarn.lock | 9 +++++---- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index 2a9eeaf6cc63..de6829ee6274 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -15,7 +15,7 @@ "dependencies": { "@sentry/bun": "10.51.0", "@sentry/hono": "10.51.0", - "hono": "^4.12.12" + "hono": "^4.12.14" }, "devDependencies": { "@sentry-internal/test-utils": "10.51.0", diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 7f51d30a3454..a2b92c4e7d67 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -16,7 +16,7 @@ "@langchain/langgraph": "^1.0.1", "@sentry/cloudflare": "10.51.0", "@sentry/hono": "10.51.0", - "hono": "^4.12.12" + "hono": "^4.12.14" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250922.0", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index c079c807fa3e..d8c0f93282a7 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -58,7 +58,7 @@ "generic-pool": "^3.9.0", "graphql": "^16.11.0", "graphql-tag": "^2.12.6", - "hono": "^4.12.12", + "hono": "^4.12.14", "http-terminator": "^3.2.0", "ioredis": "^5.4.1", "kafkajs": "2.2.4", diff --git a/yarn.lock b/yarn.lock index 40d531ea0275..072b85ad3012 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18913,10 +18913,10 @@ homedir-polyfill@^1.0.1: dependencies: parse-passwd "^1.0.0" -hono@^4.12.12: - version "4.12.12" - resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.12.tgz#1f14b0ffb47c386ff50d457d66e706d9c9a7f09c" - integrity sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q== +hono@^4.12.14: + version "4.12.14" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.14.tgz#4777c9512b7c84138e4f09e61e3d2fa305eb1414" + integrity sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w== hookable@^5.5.3: version "5.5.3" @@ -28553,6 +28553,7 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" + uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From ae8ff88b85ac1a438d5e5e6dd726b670d5423ea2 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 30 Apr 2026 10:09:07 +0200 Subject: [PATCH 07/84] feat(browser): Migrate spotlight event processor to `ignoreSpans` (#20595) Added some integration tests too, might be an overkill but they are quite simple. closes https://github.com/getsentry/sentry-javascript/issues/20363 --- .../init.js | 18 ++++++ .../subject.js | 12 ++++ .../template.html | 12 ++++ .../test.ts | 58 +++++++++++++++++++ .../spotlight-interaction-filter/init.js | 17 ++++++ .../spotlight-interaction-filter/subject.js | 12 ++++ .../template.html | 12 ++++ .../spotlight-interaction-filter/test.ts | 57 ++++++++++++++++++ .../utils/helpers.ts | 8 +++ .../browser/src/integrations/spotlight.ts | 25 +++----- .../test/integrations/spotlight.test.ts | 51 ++++++++++++++++ 11 files changed, 264 insertions(+), 18 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/test.ts create mode 100644 packages/browser/test/integrations/spotlight.test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/init.js new file mode 100644 index 000000000000..cf9618aeaf23 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + enableLongTask: false, + _experiments: { + enableInteractions: true, + }, + }), + Sentry.spanStreamingIntegration(), + Sentry.spotlightBrowserIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/subject.js new file mode 100644 index 000000000000..cae57f7a9167 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/subject.js @@ -0,0 +1,12 @@ +// Block the main thread for 70ms so the PerformanceObserver registers +// a click event entry, which triggers `ui.interaction.click` child spans. +const simulateSlowClick = e => { + const startTime = Date.now(); + while (Date.now() - startTime < 70) { + // + } + e.target.classList.add('clicked'); +}; + +document.querySelector('[data-test-id=spotlight-button]').addEventListener('click', simulateSlowClick); +document.querySelector('[data-test-id=regular-button]').addEventListener('click', simulateSlowClick); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/template.html new file mode 100644 index 000000000000..9348e00e7db7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/template.html @@ -0,0 +1,12 @@ + + + + + + +
+ +
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/test.ts new file mode 100644 index 000000000000..e9c27f682272 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/test.ts @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipCdnBundleTest, shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, observeStreamedSpan, waitForStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'filters ui.interaction.click spans for spotlight elements via ignoreSpans in streaming mode', + async ({ getLocalTestUrl, page }) => { + // spotlightBrowserIntegration is not available in CDN bundles + if (shouldSkipTracingTest() || shouldSkipCdnBundleTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Set up an observer that fails if a spotlight interaction span is ever sent + let sawSpotlightInteractionSpan = false; + await observeStreamedSpan(page, span => { + if (getSpanOp(span) === 'ui.interaction.click' && span.name?.includes('#sentry-spotlight')) { + sawSpotlightInteractionSpan = true; + return true; + } + return false; + }); + + await page.goto(url); + + // Wait for pageload to finish before clicking + await waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + // Click on the spotlight element — its ui.interaction.click child should be filtered + await page.locator('[data-test-id=spotlight-button]').click(); + await page.locator('.clicked[data-test-id=spotlight-button]').isVisible(); + + // Wait for the spotlight click's segment span to arrive + await waitForStreamedSpans(page, spans => + spans.some(span => span.is_segment && getSpanOp(span) === 'ui.action.click'), + ); + + // Click on the regular button — its ui.interaction.click child should be kept + const regularInteractionSpansPromise = waitForStreamedSpans(page, spans => + spans.some(span => getSpanOp(span) === 'ui.interaction.click' && !span.name?.includes('#sentry-spotlight')), + ); + + await page.locator('[data-test-id=regular-button]').click(); + await page.locator('.clicked[data-test-id=regular-button]').isVisible(); + + const regularSpans = await regularInteractionSpansPromise; + const regularInteractionSpan = regularSpans.find( + span => getSpanOp(span) === 'ui.interaction.click' && !span.name?.includes('#sentry-spotlight'), + ); + expect(regularInteractionSpan).toBeDefined(); + expect(regularInteractionSpan!.name).toContain('button'); + + // Verify no spotlight interaction span was ever sent + expect(sawSpotlightInteractionSpan).toBe(false); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/init.js new file mode 100644 index 000000000000..1125cb73618b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/init.js @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + enableLongTask: false, + _experiments: { + enableInteractions: true, + }, + }), + Sentry.spotlightBrowserIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/subject.js new file mode 100644 index 000000000000..cae57f7a9167 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/subject.js @@ -0,0 +1,12 @@ +// Block the main thread for 70ms so the PerformanceObserver registers +// a click event entry, which triggers `ui.interaction.click` child spans. +const simulateSlowClick = e => { + const startTime = Date.now(); + while (Date.now() - startTime < 70) { + // + } + e.target.classList.add('clicked'); +}; + +document.querySelector('[data-test-id=spotlight-button]').addEventListener('click', simulateSlowClick); +document.querySelector('[data-test-id=regular-button]').addEventListener('click', simulateSlowClick); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/template.html new file mode 100644 index 000000000000..9348e00e7db7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/template.html @@ -0,0 +1,12 @@ + + + + + + +
+ +
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/test.ts new file mode 100644 index 000000000000..d0480062c10a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/test.ts @@ -0,0 +1,57 @@ +import { expect } from '@playwright/test'; +import type { TransactionEvent } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + envelopeRequestParser, + shouldSkipCdnBundleTest, + shouldSkipTracingTest, + waitForTransactionRequest, +} from '../../../../utils/helpers'; + +sentryTest( + 'filters ui.interaction.click spans for spotlight elements via ignoreSpans', + async ({ getLocalTestUrl, page }) => { + // spotlightBrowserIntegration is not available in CDN bundles + if (shouldSkipTracingTest() || shouldSkipCdnBundleTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + // Wait for the pageload transaction to complete + await waitForTransactionRequest(page); + + // Click on the spotlight element — interaction span should be filtered + const spotlightTxnPromise = waitForTransactionRequest(page, txn => txn.contexts?.trace?.op === 'ui.action.click'); + await page.locator('[data-test-id=spotlight-button]').click(); + await page.locator('.clicked[data-test-id=spotlight-button]').isVisible(); + const spotlightTransaction = envelopeRequestParser(await spotlightTxnPromise); + + expect(spotlightTransaction.contexts?.trace?.op).toBe('ui.action.click'); + + const spotlightInteractionSpans = spotlightTransaction.spans?.filter(span => span.op === 'ui.interaction.click'); + expect(spotlightInteractionSpans).toHaveLength(0); + + // Let the first idle span fully settle before clicking again + await page.waitForTimeout(1000); + + // Click on the regular button — wait specifically for a transaction that contains + // a ui.interaction.click child span, since the PerformanceObserver may deliver + // the event entry asynchronously + const regularTxnPromise = waitForTransactionRequest( + page, + txn => + txn.contexts?.trace?.op === 'ui.action.click' && + (txn.spans?.some(span => span.op === 'ui.interaction.click') ?? false), + ); + await page.locator('[data-test-id=regular-button]').click(); + await page.locator('.clicked[data-test-id=regular-button]').isVisible(); + const regularTransaction = envelopeRequestParser(await regularTxnPromise); + + const regularInteractionSpans = regularTransaction.spans?.filter(span => span.op === 'ui.interaction.click'); + expect(regularInteractionSpans?.length).toBeGreaterThanOrEqual(1); + expect(regularInteractionSpans![0]!.description).toContain('button'); + expect(regularInteractionSpans![0]!.description).not.toContain('#sentry-spotlight'); + }, +); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index ff0398d6b209..91e5339ff550 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -421,6 +421,14 @@ export function shouldSkipFeedbackTest(): boolean { * @returns `true` if we should skip the feature flags test */ export function shouldSkipFeatureFlagsTest(): boolean { + return shouldSkipCdnBundleTest(); +} + +/** + * Returns true if we're running in a CDN bundle environment (not ESM/CJS). + * Use this to skip tests for integrations that are only available via npm, not CDN bundles. + */ +export function shouldSkipCdnBundleTest(): boolean { const bundle = process.env.PW_BUNDLE; return bundle != null && !bundle.includes('esm') && !bundle.includes('cjs'); } diff --git a/packages/browser/src/integrations/spotlight.ts b/packages/browser/src/integrations/spotlight.ts index bea72e029a97..4c04b16ed63b 100644 --- a/packages/browser/src/integrations/spotlight.ts +++ b/packages/browser/src/integrations/spotlight.ts @@ -1,4 +1,4 @@ -import type { Client, Envelope, Event, IntegrationFn } from '@sentry/core'; +import type { Client, Envelope, IntegrationFn } from '@sentry/core'; import { debug, defineIntegration, serializeEnvelope } from '@sentry/core'; import { getNativeImplementation } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; @@ -14,6 +14,8 @@ export type SpotlightConnectionOptions = { export const INTEGRATION_NAME = 'SpotlightBrowser'; +export const SPOTLIGHT_IGNORE_SPANS = [{ op: 'ui.interaction.click', name: '#sentry-spotlight' }]; + const _spotlightIntegration = ((options: Partial = {}) => { const sidecarUrl = options.sidecarUrl || 'http://localhost:8969/stream'; @@ -22,10 +24,10 @@ const _spotlightIntegration = ((options: Partial = { setup: () => { DEBUG_BUILD && debug.log('Using Sidecar URL', sidecarUrl); }, - // We don't want to send interaction transactions/root spans created from - // clicks within Spotlight to Sentry. Neither do we want them to be sent to - // spotlight. - processEvent: event => (isSpotlightInteraction(event) ? null : event), + beforeSetup(client: Client) { + const opts = client.getOptions(); + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...SPOTLIGHT_IGNORE_SPANS]; + }, afterAllSetup: (client: Client) => { setupSidecarForwarding(client, sidecarUrl); }, @@ -73,16 +75,3 @@ function setupSidecarForwarding(client: Client, sidecarUrl: string): void { * Learn more about spotlight at https://spotlightjs.com */ export const spotlightBrowserIntegration = defineIntegration(_spotlightIntegration); - -/** - * Flags if the event is a transaction created from an interaction with the spotlight UI. - */ -export function isSpotlightInteraction(event: Event): boolean { - return Boolean( - event.type === 'transaction' && - event.spans && - event.contexts?.trace && - event.contexts.trace.op === 'ui.action.click' && - event.spans.some(({ description }) => description?.includes('#sentry-spotlight')), - ); -} diff --git a/packages/browser/test/integrations/spotlight.test.ts b/packages/browser/test/integrations/spotlight.test.ts new file mode 100644 index 000000000000..c49a971e3c28 --- /dev/null +++ b/packages/browser/test/integrations/spotlight.test.ts @@ -0,0 +1,51 @@ +import type { Client, ClientOptions } from '@sentry/core'; +import { shouldIgnoreSpan } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { SPOTLIGHT_IGNORE_SPANS, spotlightBrowserIntegration } from '../../src/integrations/spotlight'; + +function makeMockClient(initial: Partial = {}): Client { + const options = { ...initial } as ClientOptions; + return { getOptions: () => options } as Client; +} + +function setupIntegrationAndGetIgnoreSpans(initial: Partial = {}) { + const integration = spotlightBrowserIntegration(); + const client = makeMockClient(initial); + integration.beforeSetup!(client); + return client.getOptions().ignoreSpans!; +} + +describe('spotlightBrowserIntegration', () => { + it('appends spotlight interaction filters to ignoreSpans', () => { + expect(setupIntegrationAndGetIgnoreSpans()).toEqual(SPOTLIGHT_IGNORE_SPANS); + }); + + it('preserves user-provided ignoreSpans entries', () => { + expect(setupIntegrationAndGetIgnoreSpans({ ignoreSpans: [/keep-me/] })).toEqual([ + /keep-me/, + ...SPOTLIGHT_IGNORE_SPANS, + ]); + }); + + describe('drops spotlight interaction spans', () => { + it.each([ + ['click on spotlight overlay', 'body > div#sentry-spotlight > div.overlay'], + ['click on spotlight button', 'body > div > div#sentry-spotlight > button.close'], + ['click on nested spotlight element', 'html > body > aside#sentry-spotlight'], + ])('%s', (_label, name) => { + const ignoreSpans = setupIntegrationAndGetIgnoreSpans(); + expect(shouldIgnoreSpan({ description: name, op: 'ui.interaction.click' }, ignoreSpans)).toBe(true); + }); + }); + + describe('keeps non-spotlight interaction spans', () => { + it.each([ + ['regular click', 'body > div.main > button.submit', 'ui.interaction.click'], + ['regular ui action', '/dashboard', 'ui.action.click'], + ['non-interaction span', 'GET /api/data', 'http.client'], + ])('%s', (_label, name, op) => { + const ignoreSpans = setupIntegrationAndGetIgnoreSpans(); + expect(shouldIgnoreSpan({ description: name, op }, ignoreSpans)).toBe(false); + }); + }); +}); From f1af9e127d00b81ae35ebdf8c36f6a039f8e293e Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 30 Apr 2026 12:33:47 +0200 Subject: [PATCH 08/84] test(browser): Fix browserTracingIntegration unit test (#20604) After seeing this flake, I looked into this and found this to be fundamentally flawed. The reason being, that while we used fake timers, it turns out that the pageload span logic does not use `Date.now()` as start date, but instead it uses the browser origin. So depending on how/when the test ran, the timing was actually off and we did not really test what we wanted to. Also, we lacked a unit test for the redirect case here. I added that as well and ensures that this properly tests what we want. Fixes https://github.com/getsentry/sentry-javascript/issues/20391 --- .../tracing/browserTracingIntegration.test.ts | 128 +++++++++++------- 1 file changed, 79 insertions(+), 49 deletions(-) diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 58294ea31fa2..83f00a09092a 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -17,6 +17,8 @@ import { spanToJSON, startInactiveSpan, TRACING_DEFAULTS, + browserPerformanceTimeOrigin, + getSpanDescendants, } from '@sentry/core'; import { JSDOM } from 'jsdom'; import { TextDecoder, TextEncoder } from 'util'; @@ -58,6 +60,8 @@ const originalGlobalHistory = WINDOW.history; describe('browserTracingIntegration', () => { beforeEach(() => { vi.useFakeTimers(); + // Ensure start time aligns with cached origin time, which is used as pageload start time + vi.setSystemTime(browserPerformanceTimeOrigin()!); getCurrentScope().clear(); getIsolationScope().clear(); getCurrentScope().setClient(undefined); @@ -228,11 +232,11 @@ describe('browserTracingIntegration', () => { setCurrentClient(client); client.init(); - const span = getActiveSpan(); + const span = getActiveSpan()!; expect(span).toBeDefined(); - expect(spanIsSampled(span!)).toBe(true); - expect(span!.isRecording()).toBe(true); - expect(spanToJSON(span!)).toEqual({ + expect(spanIsSampled(span)).toBe(true); + expect(span.isRecording()).toBe(true); + expect(spanToJSON(span)).toEqual({ description: '/', op: 'pageload', origin: 'auto.pageload.browser', @@ -254,13 +258,13 @@ describe('browserTracingIntegration', () => { vi.advanceTimersByTime(1600); WINDOW.history.pushState({}, '', '/test'); - expect(span!.isRecording()).toBe(false); + expect(span.isRecording()).toBe(false); - const span2 = getActiveSpan(); + const span2 = getActiveSpan()!; expect(span2).toBeDefined(); - expect(spanIsSampled(span2!)).toBe(true); - expect(span2!.isRecording()).toBe(true); - expect(spanToJSON(span2!)).toEqual({ + expect(spanIsSampled(span2)).toBe(true); + expect(span2.isRecording()).toBe(true); + expect(spanToJSON(span2)).toEqual({ description: '/test', op: 'navigation', origin: 'auto.navigation.browser', @@ -290,15 +294,16 @@ describe('browserTracingIntegration', () => { const dom2 = new JSDOM(undefined, { url: 'https://example.com/test2' }); Object.defineProperty(global, 'location', { value: dom2.window.document.location, writable: true }); + vi.advanceTimersByTime(1600); WINDOW.history.pushState({}, '', '/test2'); - expect(span2!.isRecording()).toBe(false); + expect(span2.isRecording()).toBe(false); - const span3 = getActiveSpan(); + const span3 = getActiveSpan()!; expect(span3).toBeDefined(); - expect(spanIsSampled(span3!)).toBe(true); - expect(span3!.isRecording()).toBe(true); - expect(spanToJSON(span3!)).toEqual({ + expect(spanIsSampled(span3)).toBe(true); + expect(span3.isRecording()).toBe(true); + expect(spanToJSON(span3)).toEqual({ description: '/test2', op: 'navigation', origin: 'auto.navigation.browser', @@ -325,6 +330,66 @@ describe('browserTracingIntegration', () => { }); }); + it('starts redirect when URL changes after < 1.5s', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration()], + }), + ); + setCurrentClient(client); + client.init(); + + const span = getActiveSpan()!; + expect(span).toBeDefined(); + expect(spanIsSampled(span)).toBe(true); + expect(span.isRecording()).toBe(true); + expect(spanToJSON(span)).toEqual({ + description: '/', + op: 'pageload', + origin: 'auto.pageload.browser', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // this is what is used to get the span name - JSDOM does not update this on it's own! + const dom = new JSDOM(undefined, { url: 'https://example.com/test' }); + Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true }); + + vi.advanceTimersByTime(100); + WINDOW.history.pushState({}, '', '/test'); + + expect(span.isRecording()).toBe(true); + + const span2 = getActiveSpan()!; + expect(span2).toBeDefined(); + + // span is still active now + expect(getActiveSpan()).toBe(span); + + // span has connected redirect span + expect(getSpanDescendants(span).map(span => spanToJSON(span))).toContainEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation.redirect', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + description: '/test', + op: 'navigation.redirect', + origin: 'auto.navigation.browser', + parent_span_id: span.spanContext().spanId, + }), + ); + }); + describe('startBrowserTracingPageLoadSpan', () => { it('works without integration setup', () => { const client = new BrowserClient( @@ -1301,7 +1366,6 @@ describe('browserTracingIntegration', () => { describe('idleTimeout', () => { it('is created by default', () => { - vi.useFakeTimers(); const client = new BrowserClient( getDefaultBrowserClientOptions({ tracesSampleRate: 1, @@ -1336,8 +1400,6 @@ describe('browserTracingIntegration', () => { }); it('can be a custom value', () => { - vi.useFakeTimers(); - const client = new BrowserClient( getDefaultBrowserClientOptions({ tracesSampleRate: 1, @@ -1440,36 +1502,4 @@ describe('browserTracingIntegration', () => { expect(spanJson2.links).toBeUndefined(); }); }); - - // TODO(lforst): I cannot manage to get this test to pass. - /* - it('heartbeatInterval can be a custom value', () => { - vi.useFakeTimers(); - - const interval = 200; - - const client = new BrowserClient( - getDefaultBrowserClientOptions({ - tracesSampleRate: 1, - integrations: [browserTracingIntegration({ heartbeatInterval: interval })], - }), - ); - - setCurrentClient(client); - client.init(); - - const mockFinish = vi.fn(); - // eslint-disable-next-line deprecation/deprecation - const transaction = getActiveTransaction() as IdleTransaction; - transaction.sendAutoFinishSignal(); - transaction.end = mockFinish; - - const span = startInactiveSpan({ name: 'child-span' }); // activities = 1 - span!.end(); // activities = 0 - - expect(mockFinish).toHaveBeenCalledTimes(0); - vi.advanceTimersByTime(interval * 3); - expect(mockFinish).toHaveBeenCalledTimes(1); - }); - */ }); From 866958eeaaf846f427f9e5b118ae1e83cf7bfb1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 30 Apr 2026 12:36:33 +0200 Subject: [PATCH 09/84] test(cloudflare): Add e2e test for MCPAgent with DurableObject instrumentation (#20601) closes #17598 closes [JS-927](https://linear.app/getsentry/issue/JS-927/add-tests-to-ensure-that-cloudflare-mcpagent-changes-work-with-durable) This test ensures that the Sentry SDK properly instruments MCPAgent (which extends DurableObject) from the Cloudflare agents package. It verifies that MCP tool call spans are correctly created and linked. --------- Co-authored-by: Claude Opus 4.5 --- .../cloudflare-mcp-agent/.gitignore | 1 + .../cloudflare-mcp-agent/package.json | 32 +++++++ .../cloudflare-mcp-agent/playwright.config.ts | 15 +++ .../cloudflare-mcp-agent/src/env.d.ts | 4 + .../cloudflare-mcp-agent/src/index.ts | 78 +++++++++++++++ .../start-event-proxy.mjs | 6 ++ .../cloudflare-mcp-agent/tests/index.test.ts | 96 +++++++++++++++++++ .../cloudflare-mcp-agent/tsconfig.json | 21 ++++ .../cloudflare-mcp-agent/wrangler.jsonc | 21 ++++ 9 files changed, 274 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/wrangler.jsonc diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/.gitignore new file mode 100644 index 000000000000..e71378008bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/.gitignore @@ -0,0 +1 @@ +.wrangler 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 new file mode 100644 index 000000000000..190582ec8d71 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json @@ -0,0 +1,32 @@ +{ + "name": "cloudflare-mcp-agent", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\"", + "build": "wrangler deploy --dry-run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "agents": "0.11.9", + "zod": "^4.3.6" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260426.0", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^6.0.3", + "wrangler": "^4.86.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/playwright.config.ts new file mode 100644 index 000000000000..5f22d56bb19c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/playwright.config.ts @@ -0,0 +1,15 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38788; + +const config = getPlaywrightConfig({ + startCommand: `pnpm dev --port ${APP_PORT}`, + port: APP_PORT, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/env.d.ts new file mode 100644 index 000000000000..a936f6586952 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/env.d.ts @@ -0,0 +1,4 @@ +interface Env { + E2E_TEST_DSN: string; + MCP_AGENT: DurableObjectNamespace; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts new file mode 100644 index 000000000000..964ab22cce55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts @@ -0,0 +1,78 @@ +import * as Sentry from '@sentry/cloudflare'; +import { McpAgent } from 'agents/mcp'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as z from 'zod'; + +class MyMCPAgentBase extends McpAgent> { + #mcpServer = new McpServer({ + name: 'cloudflare-mcp-agent', + version: '1.0.0', + }); + + get server() { + return Sentry.wrapMcpServerWithSentry(this.#mcpServer); + } + + async init(): Promise { + this.#mcpServer.registerTool( + 'my-tool', + { + title: 'My Tool', + description: 'My Tool Description', + inputSchema: { + message: z.string(), + }, + }, + async ({ message }) => { + const span = Sentry.getActiveSpan(); + + await new Promise(resolve => setTimeout(resolve, 500)); + + if (span) { + span.setAttribute('mcp.tool.name', 'my-tool'); + span.setAttribute('mcp.tool.extra', 'from-mcpagent'); + span.setAttribute('mcp.tool.input', JSON.stringify({ message })); + } + + return { + content: [ + { + type: 'text' as const, + text: `Tool my-tool: ${message}`, + }, + ], + }; + }, + ); + } +} + +export const MyMCPAgent = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, + debug: true, + transportOptions: { + bufferSize: 1000, + }, + }), + MyMCPAgentBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, + debug: true, + transportOptions: { + bufferSize: 1000, + }, + }), + MyMCPAgent.serve('/mcp', { binding: 'MCP_AGENT' }), +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/start-event-proxy.mjs new file mode 100644 index 000000000000..946988f3fdc7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-mcp-agent', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts new file mode 100644 index 000000000000..cde74a76aa27 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from '@playwright/test'; +import { waitForRequest } from '@sentry-internal/test-utils'; + +test('sends spans for MCP tool calls via MCPAgent (DurableObject)', async ({ baseURL }) => { + const mcpToolWaiter = waitForRequest('cloudflare-mcp-agent', event => { + const transaction = event.envelope[1][0][1]; + return ( + typeof transaction !== 'string' && + 'transaction' in transaction && + transaction.transaction === 'tools/call my-tool' + ); + }); + + // Step 1: Initialize the MCP session + const initResponse = await fetch(`${baseURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { + name: 'test-client', + version: '1.0.0', + }, + }, + }), + }); + + expect(initResponse.status).toBe(200); + const sessionId = initResponse.headers.get('Mcp-Session-Id'); + expect(sessionId).toBeTruthy(); + + // Step 2: Send initialized notification + await fetch(`${baseURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Session-Id': sessionId!, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }), + }); + + // Step 3: Call the tool with the session ID + const response = await fetch(`${baseURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Session-Id': sessionId!, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'my-tool', + arguments: { + message: 'hello from MCPAgent test', + }, + }, + }), + }); + + expect(response.status).toBe(200); + + const mcpData = await mcpToolWaiter; + const mcpEvent = mcpData.envelope[1][0][1]; + + expect(mcpEvent.contexts?.trace?.trace_id).toBe(mcpData.envelope[0].trace.trace_id); + expect(mcpEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + parent_span_id: expect.any(String), + span_id: expect.any(String), + op: 'mcp.server', + origin: 'auto.function.mcp_server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.op': 'mcp.server', + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'my-tool', + 'mcp.tool.extra': 'from-mcpagent', + 'mcp.tool.input': '{"message":"hello from MCPAgent test"}', + }), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tsconfig.json new file mode 100644 index 000000000000..2e9384f1b328 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "jsx": "react-jsx", + "module": "es2022", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "exclude": ["test"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/wrangler.jsonc new file mode 100644 index 000000000000..a29277811225 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/wrangler.jsonc @@ -0,0 +1,21 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-mcp-agent", + "main": "src/index.ts", + "compatibility_date": "2025-03-21", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { + "name": "MCP_AGENT", + "class_name": "MyMCPAgent", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["MyMCPAgent"], + }, + ], +} From fe75826aaf0f19c1fa5dacf7455dbf8f8e22ca08 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:52:55 +0200 Subject: [PATCH 10/84] chore(skill): Improve test skill to include nested playwright tests (#20610) chore(skill): Improve test skill to include nested playwright tests --- .agents/skills/write-tests/SKILL.md | 104 +++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 9 deletions(-) diff --git a/.agents/skills/write-tests/SKILL.md b/.agents/skills/write-tests/SKILL.md index 94fa4dee89df..924388b13215 100644 --- a/.agents/skills/write-tests/SKILL.md +++ b/.agents/skills/write-tests/SKILL.md @@ -22,7 +22,17 @@ Follow these steps in order before writing any test code. 1. **Decide the framework.** Testing a function's return value, side effects, or module interactions → Vitest (lives under `packages//test/`). Testing that a real HTTP request to a running app produces the correct Sentry envelope → Playwright (lives under - `dev-packages/e2e-tests/test-applications//tests/`). + `dev-packages/e2e-tests/test-applications//tests/`). Testing Node SDK instrumentation + against real envelope output → node-integration-tests (lives under + `dev-packages/node-integration-tests/suites/`). + + **Parameterization differs by framework — pick the right one:** + + | Framework | How to parameterize | + | ---------------------- | ------------------------------------------------------------- | + | Vitest | `it.each` / `it.for` (runner-integrated, one test each) | + | Playwright E2E | `.forEach()` outside `test()` (registers separate tests) | + | Node integration tests | Loops **inside** a single `test()` body (one Node.js process) | 2. **Read 2–3 existing test files** in the target `test/` directory. Specifically note: - Which `vi.mock` style they use (string path or import form) @@ -299,6 +309,64 @@ describe('patchRoute', () => { --- +## Writing node-integration-tests + +Node integration tests (`dev-packages/node-integration-tests/`) use `createEsmAndCjsTests` to +run a real Node scenario file and assert on captured Sentry envelopes. + +### Minimize `test()` calls — each one spawns a separate Node process + +**This is the opposite of the Playwright rule.** In Playwright, each `test()` is cheap — use +`.forEach()` to register many tests. In node-integration-tests, each `test()` forks a fresh Node +process with full startup cost. A `describe.each` matrix that looks reasonable in a unit test +context balloons into dozens of cold starts and slows CI by a large factor. + +**Rule: loop inside the test body, not around `test()` calls.** + +```typescript +// Bad: 2 routes × 5 methods = 10 separate Node processes +createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + describe.each(['/sync', '/async'])('when using %s route', route => { + describe.each(['get', 'post', 'put', 'delete', 'patch'])('when using %s method', method => { + test('handles transaction', async () => { + // ... + }); + }); + }); +}); +``` + +```typescript +// Good: one Node process, all combinations asserted in a single test run +createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('handles transactions for all route/method/path combinations', async () => { + const runner = createRunner(); + const requests: Array<{ method: string; url: string }> = []; + + for (const route of ['/sync', '/async']) { + for (const method of ['get', 'post', 'put', 'delete', 'patch']) { + const fullPath = `${route}${path}`; + runner.expect({ + transaction: { transaction: `${method.toUpperCase()} ${fullPath}` }, + }); + requests.push({ method, url: fullPath }); + } + } + + const started = runner.start(); + for (const req of requests) { + await started.makeRequest(req.method, req.url); + } + await started.completed(); + }, 60_000); +}); +``` + +If a subset of cases has meaningfully different expectations (e.g., error vs. success), split +into two tests — not thirty. + +--- + ## Writing Playwright E2E tests ### When to write E2E tests @@ -366,17 +434,35 @@ expect(mechanism?.type).toBe('auto.http.hono.context_error'); ### Parameterized E2E tests -For Playwright tests (unlike Vitest), `for...of` loops are the established codebase convention. -Use `for...of` (not `.forEach()`) so Playwright's test registration works correctly: +For Playwright tests (unlike Vitest), use standard JS `.forEach()` as this is recommended by Playwright, +**not** `it.each` or `it.for`, which are Vitest-only APIs. The `.forEach()` runs at discovery time, registering +each case as its own independent test. All cases then run separately at execution time. ```typescript -for (const { name, prefix } of SCENARIOS) { - test.describe(name, () => { - test('captures named middleware span', async ({ baseURL }) => { - // ... - }); +[ + { a: 1, b: 1, expected: 2 }, + { a: 1, b: 2, expected: 3 }, + { a: 2, b: 1, expected: 3 }, +].forEach(({ a, b, expected }) => { + test(`given ${a} and ${b} as arguments, returns ${expected}`, ({ page }) => { + expect(a + b).toEqual(expected); }); -} +}); +``` + +**Don't put the loop inside a single test.** That collapses all cases into one test body — a +failure in one iteration aborts the rest, and the runner reports a single failure with no +per-case visibility: + +```typescript +// Bad: all routes tested in one test — a failure on /users skips /posts entirely +test('captures transactions for all routes', async ({ baseURL }) => { + for (const route of ['/users', '/posts', '/comments']) { + const txn = await waitForTransaction(APP_NAME, e => e.transaction === `GET ${route}`); + await fetch(`${baseURL}${route}`); + expect(txn.contexts?.trace?.op).toBe('http.server'); + } +}); ``` ### Common pitfalls From 99564760ce7914898d85ab83762b1531aec3d53a Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:06:37 +0200 Subject: [PATCH 11/84] test(e2e): Remove remaining `npmrc` pointing to Verdaccio (#20611) pnpm overrides handle local package installs via local `file:` paths. The `npmrc` is not needed anymore but 3 files were still there as the E2E tests were added after merging the PR linked below. Builds on top of https://github.com/getsentry/sentry-javascript/pull/20386 --- dev-packages/e2e-tests/test-applications/effect-3-node/.npmrc | 2 -- .../e2e-tests/test-applications/effect-4-browser/.npmrc | 2 -- dev-packages/e2e-tests/test-applications/hono-4/.npmrc | 2 -- 3 files changed, 6 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/effect-3-node/.npmrc delete mode 100644 dev-packages/e2e-tests/test-applications/effect-4-browser/.npmrc delete mode 100644 dev-packages/e2e-tests/test-applications/hono-4/.npmrc diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/.npmrc b/dev-packages/e2e-tests/test-applications/effect-3-node/.npmrc deleted file mode 100644 index 070f80f05092..000000000000 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -@sentry:registry=http://127.0.0.1:4873 -@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/effect-4-browser/.npmrc b/dev-packages/e2e-tests/test-applications/effect-4-browser/.npmrc deleted file mode 100644 index 070f80f05092..000000000000 --- a/dev-packages/e2e-tests/test-applications/effect-4-browser/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -@sentry:registry=http://127.0.0.1:4873 -@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/hono-4/.npmrc b/dev-packages/e2e-tests/test-applications/hono-4/.npmrc deleted file mode 100644 index 070f80f05092..000000000000 --- a/dev-packages/e2e-tests/test-applications/hono-4/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -@sentry:registry=http://127.0.0.1:4873 -@sentry-internal:registry=http://127.0.0.1:4873 From 47d94278bf72c34184ac4866a270808ce8a259ad Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 4 May 2026 08:53:55 +0200 Subject: [PATCH 12/84] test(nextjs): Fix flaky node runtime metrics E2E tests (#20624) Replace `request.get()` with `fetch()` for the server warmup calls in the nextjs-16 runtime metrics tests. All these flakes fail this initial request. The same runtime metrics tests also exists for `node-express-v5`, which hasn't flaked so far. That one uses `fetch()` for this request so I suspect that this might explain the flakiness. Closes #20590 Closes #20414 Closes #20565 Closes #20560 Co-authored-by: Claude Opus 4.6 (1M context) --- .../nextjs-16/tests/node-runtime-metrics.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts index 0efd0d8f7d79..5295101f1e20 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts @@ -8,7 +8,7 @@ const EXPECTED_ATTRIBUTES = { 'sentry.origin': { value: 'auto.node.runtime_metrics', type: 'string' }, }; -test('Should emit node runtime memory metrics', async ({ request }) => { +test('Should emit node runtime memory metrics', async ({ baseURL }) => { const rssPromise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.mem.rss'; }); @@ -22,7 +22,7 @@ test('Should emit node runtime memory metrics', async ({ request }) => { }); // Trigger a request to ensure the server is running and metrics start being collected - await request.get('/'); + await fetch(`${baseURL}/`); const rss = await rssPromise; const heapUsed = await heapUsedPromise; @@ -59,12 +59,12 @@ test('Should emit node runtime memory metrics', async ({ request }) => { }); }); -test('Should emit node runtime CPU utilization metric', async ({ request }) => { +test('Should emit node runtime CPU utilization metric', async ({ baseURL }) => { const cpuUtilPromise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.cpu.utilization'; }); - await request.get('/'); + await fetch(`${baseURL}/`); const cpuUtil = await cpuUtilPromise; @@ -78,7 +78,7 @@ test('Should emit node runtime CPU utilization metric', async ({ request }) => { }); }); -test('Should emit node runtime event loop metrics', async ({ request }) => { +test('Should emit node runtime event loop metrics', async ({ baseURL }) => { const elDelayP50Promise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.event_loop.delay.p50'; }); @@ -91,7 +91,7 @@ test('Should emit node runtime event loop metrics', async ({ request }) => { return metric.name === 'node.runtime.event_loop.utilization'; }); - await request.get('/'); + await fetch(`${baseURL}/`); const elDelayP50 = await elDelayP50Promise; const elDelayP99 = await elDelayP99Promise; @@ -127,12 +127,12 @@ test('Should emit node runtime event loop metrics', async ({ request }) => { }); }); -test('Should emit node runtime uptime counter', async ({ request }) => { +test('Should emit node runtime uptime counter', async ({ baseURL }) => { const uptimePromise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.process.uptime'; }); - await request.get('/'); + await fetch(`${baseURL}/`); const uptime = await uptimePromise; From 3535cb5815850db3426548e472cff512b8cc789e Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 4 May 2026 09:51:23 +0200 Subject: [PATCH 13/84] test(browser): Fix flaky browser integration test for profiles (#20587) This updates a browser integration test to make it less flaky. Instead of first calling `await page.goto(...)` we now combine this with `getMultipleSentryEnvelopeRequests` to avoid possible race conditions. Fixes https://github.com/getsentry/sentry-javascript/issues/20585 Fixes https://github.com/getsentry/sentry-javascript/issues/20524 Fixes https://github.com/getsentry/sentry-javascript/issues/20612 --- .../suites/profiling/test-utils.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts index 39e6d2ca20b7..a5acab8b9de3 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts @@ -7,6 +7,9 @@ interface ValidateProfileOptions { isChunkFormat?: boolean; } +/** Seconds — consecutive chunk timestamps can jitter slightly below float precision (see profiling flakes). */ +const CHUNK_SAMPLE_TIMESTAMP_EPSILON_SEC = 1e-5; + /** * Validates the metadata of a profile chunk envelope. * https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ @@ -66,9 +69,9 @@ export function validateProfile( const ts = chunkProfileSample.timestamp; expect(Number.isFinite(ts)).toBe(true); expect(ts).toBeGreaterThan(0); - // Monotonic non-decreasing timestamps - expect(ts).toBeGreaterThanOrEqual(previousTimestamp); - previousTimestamp = ts; + // Monotonic non-decreasing timestamps (epsilon: jitter / IEEE754 around ~1e9 epoch seconds) + expect(ts).toBeGreaterThanOrEqual(previousTimestamp - CHUNK_SAMPLE_TIMESTAMP_EPSILON_SEC); + previousTimestamp = Math.max(previousTimestamp, ts); } else { // Legacy format uses elapsed_since_start_ns as a string const legacyProfileSample = sample as ThreadCpuProfile['samples'][number]; From f5b30eb1d68884fcb493aa8bc4c5732b0f38a9c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 10:02:32 +0200 Subject: [PATCH 14/84] ci(deps): bump actions/checkout from 4 to 6 (#20620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
Release notes

Sourced from actions/checkout's releases.

v6.0.0

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5.0.0...v6.0.0

v6-beta

What's Changed

Updated persist-credentials to store the credentials under $RUNNER_TEMP instead of directly in the local git config.

This requires a minimum Actions Runner version of v2.329.0 to access the persisted credentials for Docker container action scenarios.

v5.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5...v5.0.1

v5.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

Make sure your runner is updated to this version or newer to use this release.

Full Changelog: https://github.com/actions/checkout/compare/v4...v5.0.0

v4.3.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v4...v4.3.1

v4.3.0

What's Changed

... (truncated)

Changelog

Sourced from actions/checkout's changelog.

Changelog

v6.0.2

v6.0.1

v6.0.0

v5.0.1

v5.0.0

v4.3.1

v4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4&new-version=6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pr-review-reminder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml index 8674dc760dde..a4534dad16dc 100644 --- a/.github/workflows/pr-review-reminder.yml +++ b/.github/workflows/pr-review-reminder.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Remind pending reviewers uses: actions/github-script@v7 From 968082111951cb13d3ad2d9099d6fc420dae12ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 4 May 2026 10:17:54 +0200 Subject: [PATCH 15/84] ci(workflow): Skip PR review reminders when already approved (#20629) Only allow PR nudges when the PR has not yet been approved. Co-authored-by: Claude Opus 4.5 --- scripts/pr-review-reminder.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/pr-review-reminder.mjs b/scripts/pr-review-reminder.mjs index 535f2d430331..aba64c62512e 100644 --- a/scripts/pr-review-reminder.mjs +++ b/scripts/pr-review-reminder.mjs @@ -174,6 +174,16 @@ export default async function run({ github, context, core }) { const pendingTeams = requested.teams; // team reviewers if (pendingReviewers.length === 0 && pendingTeams.length === 0) continue; + // Skip if the PR already has at least one approval — no need to nudge remaining reviewers + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + const hasApproval = reviews.some(r => r.state === 'APPROVED'); + if (hasApproval) continue; + // Fetch the PR timeline to determine when each review was (last) requested const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, From a15173f1a94168d8a7a710a6f6a15e91c29d5487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 4 May 2026 10:27:42 +0200 Subject: [PATCH 16/84] test(cloudflare): Use Node v24 for Cloudflare e2e tests (#20628) Wrangler `4.87.0` requires Node v22, which is fine, as Cloudflare has its own runtime and specifies `compatibility_date`s for versioning their code. --- .../test-applications/astro-5-cf-workers/package.json | 1 + .../test-applications/astro-6-cf-workers/package.json | 2 +- .../e2e-tests/test-applications/cloudflare-hono/package.json | 3 ++- .../test-applications/cloudflare-local-workers/package.json | 1 + .../test-applications/cloudflare-mcp-agent/package.json | 1 + .../e2e-tests/test-applications/cloudflare-mcp/package.json | 1 + .../test-applications/cloudflare-workers/package.json | 1 + .../cloudflare-workersentrypoint/package.json | 1 + dev-packages/e2e-tests/test-applications/hono-4/package.json | 1 + .../test-applications/nextjs-16-cf-workers/package.json | 1 + .../test-applications/sveltekit-cloudflare-pages/package.json | 3 ++- 11 files changed, 13 insertions(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json index fabe0ce2333b..9d7bac9204cc 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json @@ -21,6 +21,7 @@ "wrangler": "^4.63.0" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json index 4869975f7519..722ed1d8c71e 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json @@ -22,7 +22,7 @@ "wrangler": "^4.72.0" }, "volta": { - "node": "22.22.0", + "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json index b3b8695148fb..78f6f702f7af 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json @@ -19,9 +19,10 @@ "@cloudflare/workers-types": "^4.20250521.0", "typescript": "^5.9.3", "vitest": "3.1.0", - "wrangler": "4.61.0" + "wrangler": "^4.61.0" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json index 7433244fc417..0fa111d91e5d 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json @@ -28,6 +28,7 @@ "ws": "^8.18.3" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { 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 190582ec8d71..3571edc1fad7 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 @@ -27,6 +27,7 @@ "wrangler": "^4.86.0" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json index bae4b4ffd272..282c0353d120 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json @@ -31,6 +31,7 @@ "ws": "^8.18.3" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json index b8b028797805..689637868455 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json @@ -28,6 +28,7 @@ "ws": "^8.18.3" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json index 83ac7bce286d..4270a204cdad 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json @@ -28,6 +28,7 @@ "ws": "^8.18.3" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { 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 ba07bb7db4ca..b4b1a901e95e 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -28,6 +28,7 @@ "wrangler": "^4.61.0" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json index 59f192d9bd1b..04cbff8d9eed 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json @@ -36,6 +36,7 @@ "wrangler": "^4.61.0" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json index 8081e11fe19f..354e10bf6ab3 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json @@ -26,9 +26,10 @@ "svelte-check": "^4.1.4", "typescript": "^5.0.0", "vite": "^6.1.1", - "wrangler": "4.61.0" + "wrangler": "^4.61.0" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" } } From 267c9ed132909754602437d2b3890da0dd9d6831 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 4 May 2026 10:49:52 +0200 Subject: [PATCH 17/84] fix(browser): Add `ingest_settings` to v2 log envelope payload (#20453) Adds `version: 2` and `ingest_settings` to the log envelope payload so Relay can infer the end-user IP address and User-Agent from the incoming request ([link to spec](https://develop.sentry.dev/sdk/telemetry/logs/#wire-format)). This is only emitted by the browser SDK. Both settings are currently gated behind `sendDefaultPii` (modeled after how `event.sdk.settings.infer_ip`). This slightly changes behavior in this case because we were always inferring some user data on logs before (e.g. `browser.name/version`). This data will not be there anymore after this change, unless `sendDefaultPii` is enabled. Closes https://github.com/getsentry/sentry-javascript/issues/20277 --- CHANGELOG.md | 4 ++ .../public-api/logger/integration/test.ts | 2 + .../public-api/logger/scopeAttributes/test.ts | 2 + .../suites/public-api/logger/simple/test.ts | 2 + .../suites/light-mode/logs/test.ts | 1 + .../suites/public-api/logs/test.ts | 1 + .../suites/public-api/logger/test.ts | 1 + packages/core/src/logs/envelope.ts | 14 ++++- packages/core/src/logs/internal.ts | 8 ++- packages/core/src/types-hoist/log.ts | 5 ++ packages/core/test/lib/logs/envelope.test.ts | 62 ++++++++++++++++--- 11 files changed, 91 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index face72452b64..0658797b2093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(browser): Add `ingest_settings` to v2 log envelope payload ([#20453](https://github.com/getsentry/sentry-javascript/pull/20453))** + + Inference of user data (e.g. IP address, browser name/version) on log events is now gated behind the `sendDefaultPii` option. Previously, this data was always inferred by default. + ## 10.51.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index 7315e8cf4f36..8312c2a13e4d 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -23,6 +23,8 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page content_type: 'application/vnd.sentry.items.log+json', }, { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts index 4d7970945436..07af615712ff 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts @@ -22,6 +22,8 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page content_type: 'application/vnd.sentry.items.log+json', }, { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts index db6d174820d7..0a464d896c5d 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts @@ -23,6 +23,8 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page content_type: 'application/vnd.sentry.items.log+json', }, { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts index 25096f1be7e5..858e80e0718d 100644 --- a/dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts +++ b/dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts @@ -11,6 +11,7 @@ describe('light mode logs', () => { .expect({ log: logsContainer => { expect(logsContainer).toEqual({ + version: 2, items: [ { attributes: { diff --git a/dev-packages/node-core-integration-tests/suites/public-api/logs/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/logs/test.ts index 53c80a6194c5..8afc4402475d 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/logs/test.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/logs/test.ts @@ -11,6 +11,7 @@ describe('logger public API', () => { .expect({ log: logsContainer => { expect(logsContainer).toEqual({ + version: 2, items: [ { attributes: { diff --git a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts index 6b9f43e738d2..e992d70c4de3 100644 --- a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts @@ -39,6 +39,7 @@ describe('logs', () => { .expect({ log: logsContainer => { expect(logsContainer).toEqual({ + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/packages/core/src/logs/envelope.ts b/packages/core/src/logs/envelope.ts index c1d5b23e1575..3e30a5680316 100644 --- a/packages/core/src/logs/envelope.ts +++ b/packages/core/src/logs/envelope.ts @@ -4,14 +4,18 @@ import type { SerializedLog } from '../types-hoist/log'; import type { SdkMetadata } from '../types-hoist/sdkmetadata'; import { dsnToString } from '../utils/dsn'; import { createEnvelope } from '../utils/envelope'; +import { isBrowser } from '../utils/isBrowser'; /** * Creates a log container envelope item for a list of logs. * * @param items - The logs to include in the envelope. + * @param inferUserData - If true, tells Relay to infer the end-user IP and User-Agent from the incoming request. + * Only emitted as `ingest_settings` in browser environments. * @returns The created log container envelope item. */ -export function createLogContainerEnvelopeItem(items: Array): LogContainerItem { +export function createLogContainerEnvelopeItem(items: Array, inferUserData?: boolean): LogContainerItem { + const inferSetting = inferUserData ? 'auto' : 'never'; return [ { type: 'log', @@ -19,6 +23,10 @@ export function createLogContainerEnvelopeItem(items: Array): Log content_type: 'application/vnd.sentry.items.log+json', }, { + version: 2, + ...(isBrowser() && { + ingest_settings: { infer_ip: inferSetting, infer_user_agent: inferSetting }, + }), items, }, ]; @@ -33,6 +41,7 @@ export function createLogContainerEnvelopeItem(items: Array): Log * @param metadata - The metadata to include in the envelope. * @param tunnel - The tunnel to include in the envelope. * @param dsn - The DSN to include in the envelope. + * @param inferUserData - If true, tells Relay to infer the end-user IP and User-Agent from the incoming request. * @returns The created envelope. */ export function createLogEnvelope( @@ -40,6 +49,7 @@ export function createLogEnvelope( metadata?: SdkMetadata, tunnel?: string, dsn?: DsnComponents, + inferUserData?: boolean, ): LogEnvelope { const headers: LogEnvelope[0] = {}; @@ -54,5 +64,5 @@ export function createLogEnvelope( headers.dsn = dsnToString(dsn); } - return createEnvelope(headers, [createLogContainerEnvelopeItem(logs)]); + return createEnvelope(headers, [createLogContainerEnvelopeItem(logs, inferUserData)]); } diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 097ffbb6906e..c1eff9f50fcf 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -192,7 +192,13 @@ export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array } const clientOptions = client.getOptions(); - const envelope = createLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn()); + const envelope = createLogEnvelope( + logBuffer, + clientOptions._metadata, + clientOptions.tunnel, + client.getDsn(), + clientOptions.sendDefaultPii, + ); // Clear the log buffer after envelopes have been constructed. _getBufferMap().set(client, []); diff --git a/packages/core/src/types-hoist/log.ts b/packages/core/src/types-hoist/log.ts index 7c704d3caf77..0f84ebbcbdda 100644 --- a/packages/core/src/types-hoist/log.ts +++ b/packages/core/src/types-hoist/log.ts @@ -64,5 +64,10 @@ export interface SerializedLog { } export type SerializedLogContainer = { + version?: number; + ingest_settings?: { + infer_ip?: 'auto' | 'never'; + infer_user_agent?: 'auto' | 'never'; + }; items: Array; }; diff --git a/packages/core/test/lib/logs/envelope.test.ts b/packages/core/test/lib/logs/envelope.test.ts index 7fbe1a439910..86626364f506 100644 --- a/packages/core/test/lib/logs/envelope.test.ts +++ b/packages/core/test/lib/logs/envelope.test.ts @@ -5,6 +5,7 @@ import type { SerializedLog } from '../../../src/types-hoist/log'; import type { SdkMetadata } from '../../../src/types-hoist/sdkmetadata'; import * as utilsDsn from '../../../src/utils/dsn'; import * as utilsEnvelope from '../../../src/utils/envelope'; +import { isBrowser } from '../../../src/utils/isBrowser'; // Mock utils functions vi.mock('../../../src/utils/dsn', () => ({ @@ -13,20 +14,65 @@ vi.mock('../../../src/utils/dsn', () => ({ vi.mock('../../../src/utils/envelope', () => ({ createEnvelope: vi.fn((_headers, items) => [_headers, items]), })); +vi.mock('../../../src/utils/isBrowser', () => ({ + isBrowser: vi.fn(() => false), +})); + +afterEach(() => { + vi.mocked(isBrowser).mockReturnValue(false); +}); describe('createLogContainerEnvelopeItem', () => { - it('creates an envelope item with correct structure', () => { + it('emits version: 2 without ingest_settings when not in browser', () => { + const mockLog: SerializedLog = { + timestamp: 1713859200, + level: 'info', + body: 'Test log message', + }; + + const result = createLogContainerEnvelopeItem([mockLog], true); + + expect(result[0]).toEqual({ type: 'log', item_count: 1, content_type: 'application/vnd.sentry.items.log+json' }); + expect(result[1]).toEqual({ + version: 2, + items: [mockLog], + }); + }); + + it("includes ingest_settings with 'auto' values when in browser and inferUserData is true", () => { + vi.mocked(isBrowser).mockReturnValue(true); + const mockLog: SerializedLog = { timestamp: 1713859200, - level: 'error', - body: 'Test error message', + level: 'info', + body: 'Test log message', }; - const result = createLogContainerEnvelopeItem([mockLog, mockLog]); + const result = createLogContainerEnvelopeItem([mockLog], true); + + expect(result[1]).toEqual({ + version: 2, + ingest_settings: { infer_ip: 'auto', infer_user_agent: 'auto' }, + items: [mockLog], + }); + }); + + it("includes ingest_settings with 'never' values when in browser and inferUserData is false", () => { + vi.mocked(isBrowser).mockReturnValue(true); - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ type: 'log', item_count: 2, content_type: 'application/vnd.sentry.items.log+json' }); - expect(result[1]).toEqual({ items: [mockLog, mockLog] }); + const mockLog: SerializedLog = { + timestamp: 1713859200, + level: 'info', + body: 'Test log message', + }; + + const result = createLogContainerEnvelopeItem([mockLog], false); + + expect(result[1]).toEqual({ + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, + items: [mockLog], + }); }); }); @@ -133,7 +179,7 @@ describe('createLogEnvelope', () => { expect.arrayContaining([ expect.arrayContaining([ { type: 'log', item_count: 2, content_type: 'application/vnd.sentry.items.log+json' }, - { items: mockLogs }, + { version: 2, items: mockLogs }, ]), ]), ); From 5f77c76762fc01cdac19b5aad723cf4245a604c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 4 May 2026 11:32:08 +0200 Subject: [PATCH 18/84] feat(cloudflare): Support rpc trace propagation for WorkerEntrypoint (#20523) closes getsentry/sentry-javascript#15942 closes [JS-212](https://linear.app/getsentry/issue/JS-212/add-support-for-cloudflare-workerentrypoint) We already have support for `WorkerEntrypoint`, but they were not yet fully supported because we didn't have trace propagation for them. This PR basically adds RPC trace propagation (and therefore full support for `WorkerEntrypoint`) by instrumenting `env` for the `WorkerEntrypoint`. The base implementation is pretty straight forward, it is using `instrumentEnv` and overwrites the `env` of the entrypoint instance. The rest are tests and different scenarios showing how it works. Most tests are the same as the normal exported handler tests: `worker-do-rpc` is the same as `workerentrypoint-do-rpc`, just with a `WorkerEntrypoint` rather than the `ExportedHandler`. The only additional test scenario is `worker-workerentrypoint-rpc` which showcases if `ExportedHandler` and `WorkerEntrypoint` are working out together. --- .../worker-do-rpc-disabled/index.ts | 21 +- .../worker-do-rpc-disabled/test.ts | 54 +++- .../index-sub-worker.ts | 32 ++ .../worker-workerentrypoint-rpc/index.ts | 33 ++ .../worker-workerentrypoint-rpc/test.ts | 129 ++++++++ .../wrangler-sub-worker.jsonc | 6 + .../wrangler.jsonc | 12 + .../workerentrypoint-do-rpc-disabled/index.ts | 49 +++ .../workerentrypoint-do-rpc-disabled/test.ts | 66 ++++ .../wrangler.jsonc | 20 ++ .../workerentrypoint-do-rpc/index.ts | 55 ++++ .../workerentrypoint-do-rpc/test.ts | 123 ++++++++ .../workerentrypoint-do-rpc/wrangler.jsonc | 20 ++ .../index-sub-worker.ts | 47 +++ .../index.ts | 30 ++ .../test.ts | 105 +++++++ .../wrangler-sub-worker.jsonc | 20 ++ .../wrangler.jsonc | 12 + .../instrumentWorkerEntrypoint.ts | 13 +- .../instrumentWorkerEntrypoint.test.ts | 290 +++++++++++++++++- 20 files changed, 1112 insertions(+), 25 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler-sub-worker.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler-sub-worker.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts index eb21c2918155..941f988971bc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts @@ -1,24 +1,25 @@ import * as Sentry from '@sentry/cloudflare'; import { DurableObject } from 'cloudflare:workers'; -import type { RpcTarget } from 'cloudflare:workers'; interface Env { SENTRY_DSN: string; MY_DURABLE_OBJECT: DurableObjectNamespace; } -class MyDurableObjectBase extends DurableObject implements RpcTarget { - async sayHello(name: string): Promise { - return `Hello, ${name}!`; +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === '/hello') { + return new Response('Hello, World!'); + } + return new Response('Not found', { status: 404 }); } } -// enableRpcTracePropagation is NOT enabled, so RPC methods won't be instrumented export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, - // enableRpcTracePropagation: false (default) }), MyDurableObjectBase, ); @@ -34,9 +35,11 @@ export default Sentry.withSentry( const id = env.MY_DURABLE_OBJECT.idFromName('test'); const stub = env.MY_DURABLE_OBJECT.get(id); - if (url.pathname === '/rpc/hello') { - const result = await stub.sayHello('World'); - return new Response(result); + if (url.pathname === '/do/hello') { + // Call DO via fetch instead of RPC + const doResponse = await stub.fetch(new Request('http://do/hello')); + const text = await doResponse.text(); + return new Response(text); } return new Response('Not found', { status: 404 }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts index cba40af5a43d..4fe2b98956d5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts @@ -2,39 +2,65 @@ import { expect, it } from 'vitest'; import type { Event } from '@sentry/core'; import { createRunner } from '../../../../runner'; -it('does not create RPC transaction when enableRpcTracePropagation is disabled', async ({ signal }) => { - let receivedTransactions: string[] = []; +it('does not propagate trace when enableRpcTracePropagation is disabled', async ({ signal }) => { + let workerTraceId: string | undefined; + let doTraceId: string | undefined; const runner = createRunner(__dirname) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as Event; - // Should only receive the worker HTTP transaction, not the DO RPC transaction expect(transactionEvent).toEqual( expect.objectContaining({ contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', }), }), - transaction: 'GET /rpc/hello', }), ); - receivedTransactions.push(transactionEvent.transaction as string); + + const txName = transactionEvent.transaction as string; + const traceId = transactionEvent.contexts?.trace?.trace_id as string; + + if (txName === 'GET /do/hello') { + workerTraceId = traceId; + } else if (txName === 'GET /hello') { + doTraceId = traceId; + } + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + }), + ); + + const txName = transactionEvent.transaction as string; + const traceId = transactionEvent.contexts?.trace?.trace_id as string; + + if (txName === 'GET /do/hello') { + workerTraceId = traceId; + } else if (txName === 'GET /hello') { + doTraceId = traceId; + } }) + .unordered() .start(signal); - // The RPC call should still work, just not be instrumented - const response = await runner.makeRequest('get', '/rpc/hello'); + const response = await runner.makeRequest('get', '/do/hello'); expect(response).toBe('Hello, World!'); await runner.completed(); - // Verify we only got the worker transaction, no RPC transaction - expect(receivedTransactions).toEqual(['GET /rpc/hello']); - expect(receivedTransactions).not.toContain('sayHello'); + // Both transactions should exist but have different trace IDs (no propagation) + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).not.toBe(doTraceId); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts new file mode 100644 index 000000000000..5e59441803e5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/cloudflare'; +import { WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; +} + +class MySubWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === '/answer') { + return new Response('The answer is 42'); + } + + if (url.pathname === '/greet') { + const name = url.searchParams.get('name') || 'Anonymous'; + return new Response(`Hello, ${name}!`); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MySubWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts new file mode 100644 index 000000000000..e46d7ffd4daf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SUB_WORKER: Fetcher; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/call-entrypoint') { + const response = await env.SUB_WORKER.fetch(new Request('http://fake-host/answer')); + const text = await response.text(); + return new Response(text); + } + + if (url.pathname === '/call-entrypoint-greet') { + const response = await env.SUB_WORKER.fetch(new Request('http://fake-host/greet?name=World')); + const text = await response.text(); + return new Response(text); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts new file mode 100644 index 000000000000..3b76e28e9e88 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts @@ -0,0 +1,129 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from Worker (ExportedHandler) to WorkerEntrypoint via service binding fetch', async ({ + signal, +}) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let entrypointTraceId: string | undefined; + let entrypointParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Main worker HTTP server transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /call-entrypoint', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // WorkerEntrypoint HTTP server transaction (from service binding fetch) + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /answer', + }), + ); + entrypointTraceId = transactionEvent.contexts?.trace?.trace_id as string; + entrypointParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/call-entrypoint'); + expect(response).toBe('The answer is 42'); + + await runner.completed(); + + // Both transactions should share the same trace_id + expect(workerTraceId).toBeDefined(); + expect(entrypointTraceId).toBeDefined(); + expect(workerTraceId).toBe(entrypointTraceId); + + // Verify the parent-child relationship: Worker -> WorkerEntrypoint + expect(workerSpanId).toBeDefined(); + expect(entrypointParentSpanId).toBeDefined(); + expect(entrypointParentSpanId).toBe(workerSpanId); +}); + +it('propagates trace for request with query params from Worker to WorkerEntrypoint', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let entrypointTraceId: string | undefined; + let entrypointParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + transaction: 'GET /call-entrypoint-greet', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + transaction: 'GET /greet', + }), + ); + entrypointTraceId = transactionEvent.contexts?.trace?.trace_id as string; + entrypointParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/call-entrypoint-greet'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(entrypointTraceId).toBeDefined(); + expect(workerTraceId).toBe(entrypointTraceId); + + expect(workerSpanId).toBeDefined(); + expect(entrypointParentSpanId).toBeDefined(); + expect(entrypointParentSpanId).toBe(workerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler-sub-worker.jsonc new file mode 100644 index 000000000000..13de99007e1f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler-sub-worker.jsonc @@ -0,0 +1,6 @@ +{ + "name": "cloudflare-worker-workerentrypoint-rpc-sub", + "main": "index-sub-worker.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc new file mode 100644 index 000000000000..1638b8a00a18 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "cloudflare-worker-workerentrypoint-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "services": [ + { + "binding": "SUB_WORKER", + "service": "cloudflare-worker-workerentrypoint-rpc-sub", + }, + ], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts new file mode 100644 index 000000000000..222ac72599b2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === '/hello') { + return new Response('Hello, World!'); + } + return new Response('Not found', { status: 404 }); + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyDurableObjectBase, +); + +class MyWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const id = this.env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = this.env.MY_DURABLE_OBJECT.get(id); + + if (url.pathname === '/do/hello') { + const doResponse = await stub.fetch(new Request('http://do/hello')); + const text = await doResponse.text(); + return new Response(text); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts new file mode 100644 index 000000000000..4882f09ccaaa --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts @@ -0,0 +1,66 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('does not propagate trace when enableRpcTracePropagation is disabled (WorkerEntrypoint)', async ({ signal }) => { + let workerTraceId: string | undefined; + let doTraceId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + }), + ); + + const txName = transactionEvent.transaction as string; + const traceId = transactionEvent.contexts?.trace?.trace_id as string; + + if (txName === 'GET /do/hello') { + workerTraceId = traceId; + } else if (txName === 'GET /hello') { + doTraceId = traceId; + } + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + }), + ); + + const txName = transactionEvent.transaction as string; + const traceId = transactionEvent.contexts?.trace?.trace_id as string; + + if (txName === 'GET /do/hello') { + workerTraceId = traceId; + } else if (txName === 'GET /hello') { + doTraceId = traceId; + } + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/do/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + // Both transactions should exist but have different trace IDs (no propagation) + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).not.toBe(doTraceId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/wrangler.jsonc new file mode 100644 index 000000000000..78303e091bf4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-workerentrypoint-do-rpc-disabled", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts new file mode 100644 index 000000000000..63876045722b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts @@ -0,0 +1,55 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject { + async sayHello(name: string): Promise { + return `Hello, ${name}!`; + } + + async multiply(a: number, b: number): Promise { + return a * b; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +class MyWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const id = this.env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = this.env.MY_DURABLE_OBJECT.get(id); + + if (url.pathname === '/rpc/hello') { + const result = await stub.sayHello('World'); + return new Response(result); + } + + if (url.pathname === '/rpc/multiply') { + const result = await stub.multiply(6, 7); + return new Response(String(result)); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts new file mode 100644 index 000000000000..2dd17269ae23 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts @@ -0,0 +1,123 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from WorkerEntrypoint to durable object via this.env RPC call', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'sayHello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /rpc/hello', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); + +it('propagates trace for RPC method with multiple arguments via this.env', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + }), + }), + transaction: 'multiply', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + transaction: 'GET /rpc/multiply', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/multiply'); + expect(response).toBe('42'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..e0d4024f8b8b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-workerentrypoint-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts new file mode 100644 index 000000000000..4ff513ccfd03 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts @@ -0,0 +1,47 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async computeAnswer(): Promise { + return 42; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +class MySubWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === '/call-do') { + const id = this.env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = this.env.MY_DURABLE_OBJECT.get(id); + const result = await stub.computeAnswer(); + return new Response(`The answer is ${result}`); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MySubWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts new file mode 100644 index 000000000000..19ebc32abc55 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/cloudflare'; +import { WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + SUB_WORKER: Fetcher; +} + +class MyWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === '/chain') { + const response = await this.env.SUB_WORKER.fetch(new Request('http://fake-host/call-do')); + const text = await response.text(); + return new Response(text); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts new file mode 100644 index 000000000000..474624fa2145 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts @@ -0,0 +1,105 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from WorkerEntrypoint to WorkerEntrypoint to durable object (3 levels deep)', async ({ + signal, +}) => { + let mainWorkerTraceId: string | undefined; + let mainWorkerSpanId: string | undefined; + let subWorkerTraceId: string | undefined; + let subWorkerSpanId: string | undefined; + let subWorkerParentSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Main worker HTTP server transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /chain', + }), + ); + mainWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + mainWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Sub-worker HTTP server transaction (from service binding fetch) + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /call-do', + }), + ); + subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + subWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Durable Object RPC transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'computeAnswer', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/chain'); + expect(response).toBe('The answer is 42'); + + await runner.completed(); + + // All three transactions should share the same trace_id + expect(mainWorkerTraceId).toBeDefined(); + expect(subWorkerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(mainWorkerTraceId).toBe(subWorkerTraceId); + expect(subWorkerTraceId).toBe(doTraceId); + + // Verify the parent-child relationships form a chain: + // Main WorkerEntrypoint -> Sub WorkerEntrypoint -> DO + expect(mainWorkerSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBe(mainWorkerSpanId); + + expect(subWorkerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(subWorkerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler-sub-worker.jsonc new file mode 100644 index 000000000000..873f66317fc8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler-sub-worker.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-workerentrypoint-workerentrypoint-do-rpc-sub", + "main": "index-sub-worker.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..45dfacf580a1 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "cloudflare-workerentrypoint-workerentrypoint-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "services": [ + { + "binding": "SUB_WORKER", + "service": "cloudflare-workerentrypoint-workerentrypoint-do-rpc-sub", + }, + ], +} diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index 6a9daf83ec1c..e8b2466da821 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -7,6 +7,7 @@ import { instrumentWorkerEntrypointScheduled } from './worker/instrumentSchedule import { instrumentWorkerEntrypointTail } from './worker/instrumentTail'; import { getFinalOptions } from '../options'; import { instrumentContext } from '../utils/instrumentContext'; +import { instrumentEnv } from './worker/instrumentEnv'; export type WorkerEntrypointConstructor = new ( ctx: ExecutionContext, @@ -63,7 +64,8 @@ export function instrumentWorkerEntrypoint { expect(obj.methodTwo()).toBe('two'); }); }); + + describe('env instrumentation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('passes instrumented env to the constructor when enableRpcTracePropagation is enabled', () => { + const mockContext = createMockExecutionContext(); + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace, SENTRY_DSN: 'dsn' }; + + let constructorEnv: unknown; + const TestClass = class extends WorkerEntrypoint { + constructor(ctx: ExecutionContext, env: typeof mockEnv) { + super(); + constructorEnv = env; + } + fetch() { + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + Reflect.construct(instrumented, [mockContext, mockEnv]); + + expect(constructorEnv).not.toBe(mockEnv); + }); + + it('passes original env to the constructor when enableRpcTracePropagation is disabled', () => { + const mockContext = createMockExecutionContext(); + const mockEnv = { SENTRY_DSN: 'dsn' }; + + let constructorEnv: unknown; + const TestClass = class extends WorkerEntrypoint { + constructor(ctx: ExecutionContext, env: typeof mockEnv) { + super(); + constructorEnv = env; + } + fetch() { + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: false }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + Reflect.construct(instrumented, [mockContext, mockEnv]); + + expect(constructorEnv).toBe(mockEnv); + }); + + it('exposes instrumented DurableObjectNamespace via this.env when enableRpcTracePropagation is enabled', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const mockStub = { + id: { toString: () => 'stub-id' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }; + const doNamespace = { + idFromName: vi.fn().mockReturnValue({ toString: () => 'id-1' }), + idFromString: vi.fn(), + get: vi.fn().mockReturnValue(mockStub), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + const stub = this.env.COUNTER.get(this.env.COUNTER.idFromName('test')); + (stub as any).myRpcMethod('arg1'); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('returns original DurableObjectNamespace via this.env when enableRpcTracePropagation is disabled', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const mockStub = { + id: { toString: () => 'stub-id' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }; + const doNamespace = { + idFromName: vi.fn().mockReturnValue({ toString: () => 'id-1' }), + idFromString: vi.fn(), + get: vi.fn().mockReturnValue(mockStub), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + const stub = this.env.COUNTER.get(this.env.COUNTER.idFromName('test')); + (stub as any).myRpcMethod('arg1'); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: false }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1'); + }); + + it('injects Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is enabled', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const mockEnv = { SERVICE: jsrpcProxy }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + (this.env.SERVICE as any).myRpcMethod('arg1', 42); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('does not inject Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is disabled', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const mockEnv = { SERVICE: jsrpcProxy }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + (this.env.SERVICE as any).myRpcMethod('arg1', 42); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: false }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42); + }); + + it('caches instrumented bindings across multiple accesses via this.env', () => { + const mockContext = createMockExecutionContext(); + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace }; + + let firstAccess: unknown; + let secondAccess: unknown; + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + firstAccess = this.env.COUNTER; + secondAccess = this.env.COUNTER; + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(firstAccess).toBe(secondAccess); + }); + + it('primitive env values are returned unchanged', () => { + const mockContext = createMockExecutionContext(); + const mockEnv = { SENTRY_DSN: 'https://key@sentry.io/123', PORT: 8080, DEBUG: true }; + + let capturedDsn: unknown; + let capturedPort: unknown; + let capturedDebug: unknown; + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + capturedDsn = this.env.SENTRY_DSN; + capturedPort = this.env.PORT; + capturedDebug = this.env.DEBUG; + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(capturedDsn).toBe('https://key@sentry.io/123'); + expect(capturedPort).toBe(8080); + expect(capturedDebug).toBe(true); + }); + }); }); From b63cfa3025444e0a0bd96514dda918fe08a49525 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:34:03 +0200 Subject: [PATCH 19/84] ci(deps): bump hono from 4.12.12 to 4.12.14 in /dev-packages/e2e-tests/test-applications/cloudflare-hono (#20339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [hono](https://github.com/honojs/hono) from 4.12.12 to 4.12.14.
Release notes

Sourced from hono's releases.

v4.12.14

Security fixes

This release includes fixes for the following security issues:

Improper handling of JSX attribute names in hono/jsx SSR

Affects: hono/jsx. Fixes missing validation of JSX attribute names during server-side rendering, which could allow malformed attribute keys to corrupt the generated HTML output and inject unintended attributes or elements. GHSA-458j-xx4x-4375

Other changes

  • fix(aws-lambda): handle invalid header names in request processing (#4883) fa2c74fe

v4.12.13

What's Changed

New Contributors

Full Changelog: https://github.com/honojs/hono/compare/v4.12.12...v4.12.13

Commits
  • cf2d2b7 4.12.14
  • 66daa2e Merge commit from fork
  • fa2c74f fix(aws-lambda): handle invalid header names in request processing (#4883)
  • 3779927 4.12.13
  • faa6c46 feat(cache): add onCacheNotAvailable option (#4876)
  • f23e97b feat(trailing-slash): add skip option (#4862)
  • 1aa32fb fix(types): infer response type from last handler in app.on 9- and 10-handler...
  • See full diff in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../e2e-tests/test-applications/cloudflare-hono/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json index 78f6f702f7af..5c4cbe43a5d0 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", - "hono": "4.12.12" + "hono": "4.12.14" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.31", From a2f70af3fbdf2ae092b58729a9eda38aa42e4303 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:39:50 +0200 Subject: [PATCH 20/84] ci(deps): bump next from 16.1.7 to 16.2.3 in /dev-packages/e2e-tests/test-applications/nextjs-16-bun (#20219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [next](https://github.com/vercel/next.js) from 16.1.7 to 16.2.3.
Release notes

Sourced from next's releases.

v16.2.3

[!NOTE] This release is backporting security and bug fixes. For more information about the fixed security vulnerability, please see https://vercel.com/changelog/summary-of-cve-2026-23869. The release does not include all pending features/changes on canary.

Core Changes

  • Ensure app-page reports stale ISR revalidation errors via onRequestError (#92282)
  • Fix [Bug]: manifest.ts breaks HMR in Next.js 16.2 (#91981 through #92273)
  • Deduplicate output assets and detect content conflicts on emit (#92292)
  • Fix styled-jsx race condition: styles lost due to concurrent rendering (#92459)
  • turbo-tasks-backend: stability fixes for task cancellation and error handling (#92254)

Credits

Huge thanks to @​icyJoseph, @​sokra, @​wbinnssmith, @​eps1lon and @​ztanner for helping!

v16.2.2

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • backport: Move expanded adapters docs to API reference (#92115) (#92129)
  • Backport: TypeScript v6 deprecations for baseUrl and moduleResolution (#92130)
  • [create-next-app] Skip interactive prompts when CLI flags are provided (#91840)
  • next.config.js: Accept an option for serverFastRefresh (#91968)
  • Turbopack: enable server HMR for app route handlers (#91466)
  • Turbopack: exclude metadata routes from server HMR (#92034)
  • Fix CI for glibc linux builds
  • Backport: disable bmi2 in qfilter #92177
  • [backport] Fix CSS HMR on Safari (#92174)

Credits

Huge thanks to @​nextjs-bot, @​icyJoseph, @​ijjk, @​gaojude, @​wbinnssmith, @​lukesandberg, and @​bgw for helping!

v16.2.1

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • docs: post release amends (#91715)
  • docs: fix broken Activity Patterns demo link in preserving UI state guide (#91698)
  • Fix adapter outputs for dynamic metadata routes (#91680)
  • Turbopack: fix webpack loader runner layer (#91727)
  • Fix server actions in standalone mode with cacheComponents (#91711)
  • turbo-persistence: remove Unmergeable mmap advice (#91713)
  • Fix layout segment optimization: move app-page imports to server-utility transition (#91701)
  • Turbopack: lazy require metadata and handle TLA (#91705)
  • [turbopack] Respect {eval:true} in worker_threads constructors (#91666)

... (truncated)

Commits
  • d5f649b v16.2.3
  • 2873928 [16.x] Avoid consuming cyclic models multiple times (#75)
  • d7c7765 [backport]: Ensure app-page reports stale ISR revalidation errors via onReque...
  • c573e8c fix(server-hmr): metadata routes overwrite page runtime HMR handler (#92273)
  • 57b8f65 next-core: deduplicate output assets and detect content conflicts on emit (#9...
  • f158df1 Fix styled-jsx race condition: styles lost due to concurrent rendering (#92459)
  • 356d605 turbo-tasks-backend: stability fixes for task cancellation and error handling...
  • 3b77a6e Fix DashMap read-write self-deadlock in task_cache causing hangs (#92210)
  • b2f208a Backport: new view-transitions guide, update and fixes (#92264)
  • 52faae3 v16.2.2
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../e2e-tests/test-applications/nextjs-16-bun/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json index deb955b58daf..509c2b2c3a9f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json @@ -15,7 +15,7 @@ "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "import-in-the-middle": "^2", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^8" From 137609c14faa096e82104880d5fac772b99b7b19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:44:59 +0200 Subject: [PATCH 21/84] ci(deps): bump getsentry/craft from 2.24.1 to 2.26.2 (#20621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [getsentry/craft](https://github.com/getsentry/craft) from 2.24.1 to 2.26.2.
Release notes

Sourced from getsentry/craft's releases.

2.26.2

Security 🔒

  • (deps) Bump uuid to ^14.0.0 (fix GHSA-w5hq-g745-h8pq) by @​BYK in #810

Bug Fixes 🐛

  • (prepare) Remove --allow-remote-config gate by @​BYK in #809

Internal Changes 🔧

2.26.1

Security 🔒

  • (release-env) Allowlist GITHUB_* and RUNNER_* by prefix by @​BYK in #807

Bug Fixes 🐛

  • (npm) Tolerate workspace:* deps in version bump and bun.lock patching by @​BYK in #805

Internal Changes 🔧

  • Fix Node 20 + app-id deprecation warnings, refresh Node matrix by @​BYK in #803

2.26.0

Security 🔒

  • (ci) Pin third-party GitHub Actions to commit SHAs by @​BYK in #801
  • (commit-repo) Replace execSync tar with node-tar by @​BYK in #799
  • (gpg) Pipe private key via stdin instead of writing to /tmp by @​BYK in #798
  • (publish) Move publish-state file out of repo cwd by @​BYK in #797
  • (spawn) Strip dynamic-linker env vars from subprocess env by @​BYK in #800

New Features ✨

  • Recognize security: commit prefix for changelog and versioning by @​BYK in #802

2.25.5

Bug Fixes 🐛

Security

  • Move workflow to pull_request and do not persist creds by @​geoffg-sentry in #796
  • Disable .craft.env reading and harden release subprocess env by @​BYK in #794

2.25.4

Bug Fixes 🐛

... (truncated)

Changelog

Sourced from getsentry/craft's changelog.

Changelog

2.26.2

Security 🔒

  • (deps) Bump uuid to ^14.0.0 (fix GHSA-w5hq-g745-h8pq) by @​BYK in #810

Bug Fixes 🐛

  • (prepare) Remove --allow-remote-config gate by @​BYK in #809

Internal Changes 🔧

2.26.1

Security 🔒

  • (release-env) Allowlist GITHUB_* and RUNNER_* by prefix by @​BYK in #807

Bug Fixes 🐛

  • (npm) Tolerate workspace:* deps in version bump and bun.lock patching by @​BYK in #805

Internal Changes 🔧

  • Fix Node 20 + app-id deprecation warnings, refresh Node matrix by @​BYK in #803

2.26.0

Security 🔒

  • (ci) Pin third-party GitHub Actions to commit SHAs by @​BYK in #801
  • (commit-repo) Replace execSync tar with node-tar by @​BYK in #799
  • (gpg) Pipe private key via stdin instead of writing to /tmp by @​BYK in #798
  • (publish) Move publish-state file out of repo cwd by @​BYK in #797
  • (spawn) Strip dynamic-linker env vars from subprocess env by @​BYK in #800

New Features ✨

  • Recognize security: commit prefix for changelog and versioning by @​BYK in #802

2.25.5

Bug Fixes 🐛

Security

... (truncated)

Commits
  • 3dc647f release: 2.26.2
  • 3739fa1 security(deps): bump uuid to ^14.0.0 (fix GHSA-w5hq-g745-h8pq) (#810)
  • 86fd3da fix(prepare): remove --allow-remote-config gate (#809)
  • 3294203 build(deps): bump astro from 5.18.1 to 6.1.6 in /docs (#806)
  • ad5568e build(deps-dev): bump fast-xml-parser from 5.5.7 to 5.7.0 (#808)
  • 19b7281 meta: Bump new development version
  • a8d2fd6 Merge branch 'release/2.26.1'
  • c248b38 release: 2.26.1
  • c5c5d7a security(release-env): allowlist GITHUB_* and RUNNER_* by prefix (#807)
  • 2ec39c0 fix(npm): tolerate workspace:* deps in version bump and bun.lock patching (#805)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=getsentry/craft&package-manager=github_actions&previous-version=2.24.1&new-version=2.26.2)](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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-release.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 241900f4b6ff..4ba84974f6fd 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -51,7 +51,7 @@ jobs: node-version-file: 'package.json' - name: Prepare release - uses: getsentry/craft@013a7b2113c2cac0ff32d5180cfeaefc7c9ce5b6 # v2.24.1 + uses: getsentry/craft@3dc647fee3586e57c7c31eb900fdec7cbb44f23f # v2.26.2 if: github.event.pull_request.merged == true && steps.version-regex.outputs.match != '' && steps.get_version.outputs.version != '' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d966e35e9671..f00562e1df73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: with: node-version-file: 'package.json' - name: Prepare release - uses: getsentry/craft@013a7b2113c2cac0ff32d5180cfeaefc7c9ce5b6 # v2.24.1 + uses: getsentry/craft@3dc647fee3586e57c7c31eb900fdec7cbb44f23f # v2.26.2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 68d9e1f7e56c66e82709fd4c6499effb13c3e326 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:47:24 +0200 Subject: [PATCH 22/84] ci(deps): bump next from 16.1.7 to 16.2.3 in /dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash (#20247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [next](https://github.com/vercel/next.js) from 16.1.7 to 16.2.3.
Release notes

Sourced from next's releases.

v16.2.3

[!NOTE] This release is backporting security and bug fixes. For more information about the fixed security vulnerability, please see https://vercel.com/changelog/summary-of-cve-2026-23869. The release does not include all pending features/changes on canary.

Core Changes

  • Ensure app-page reports stale ISR revalidation errors via onRequestError (#92282)
  • Fix [Bug]: manifest.ts breaks HMR in Next.js 16.2 (#91981 through #92273)
  • Deduplicate output assets and detect content conflicts on emit (#92292)
  • Fix styled-jsx race condition: styles lost due to concurrent rendering (#92459)
  • turbo-tasks-backend: stability fixes for task cancellation and error handling (#92254)

Credits

Huge thanks to @​icyJoseph, @​sokra, @​wbinnssmith, @​eps1lon and @​ztanner for helping!

v16.2.2

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • backport: Move expanded adapters docs to API reference (#92115) (#92129)
  • Backport: TypeScript v6 deprecations for baseUrl and moduleResolution (#92130)
  • [create-next-app] Skip interactive prompts when CLI flags are provided (#91840)
  • next.config.js: Accept an option for serverFastRefresh (#91968)
  • Turbopack: enable server HMR for app route handlers (#91466)
  • Turbopack: exclude metadata routes from server HMR (#92034)
  • Fix CI for glibc linux builds
  • Backport: disable bmi2 in qfilter #92177
  • [backport] Fix CSS HMR on Safari (#92174)

Credits

Huge thanks to @​nextjs-bot, @​icyJoseph, @​ijjk, @​gaojude, @​wbinnssmith, @​lukesandberg, and @​bgw for helping!

v16.2.1

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • docs: post release amends (#91715)
  • docs: fix broken Activity Patterns demo link in preserving UI state guide (#91698)
  • Fix adapter outputs for dynamic metadata routes (#91680)
  • Turbopack: fix webpack loader runner layer (#91727)
  • Fix server actions in standalone mode with cacheComponents (#91711)
  • turbo-persistence: remove Unmergeable mmap advice (#91713)
  • Fix layout segment optimization: move app-page imports to server-utility transition (#91701)
  • Turbopack: lazy require metadata and handle TLA (#91705)
  • [turbopack] Respect {eval:true} in worker_threads constructors (#91666)

... (truncated)

Commits
  • d5f649b v16.2.3
  • 2873928 [16.x] Avoid consuming cyclic models multiple times (#75)
  • d7c7765 [backport]: Ensure app-page reports stale ISR revalidation errors via onReque...
  • c573e8c fix(server-hmr): metadata routes overwrite page runtime HMR handler (#92273)
  • 57b8f65 next-core: deduplicate output assets and detect content conflicts on emit (#9...
  • f158df1 Fix styled-jsx race condition: styles lost due to concurrent rendering (#92459)
  • 356d605 turbo-tasks-backend: stability fixes for task cancellation and error handling...
  • 3b77a6e Fix DashMap read-write self-deadlock in task_cache causing hangs (#92210)
  • b2f208a Backport: new view-transitions guide, update and fixes (#92264)
  • 52faae3 v16.2.2
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../test-applications/nextjs-16-trailing-slash/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json index 03035b9ddb33..98896eae5d1b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json @@ -16,7 +16,7 @@ "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "import-in-the-middle": "^2", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^8" From 9ac7d0b8fde11a6f503237752ca05bed960a425e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 11:48:14 +0200 Subject: [PATCH 23/84] test(deps): Bump Next.js in E2E test apps to fix Server Components DoS (#20633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Bumps `next` 15.5.14 → 15.5.15 in `nextjs-15`, `nextjs-15-intl` - Bumps `next` 16.1.7 → 16.2.3 in `nextjs-16`, `nextjs-16-cacheComponents`, `nextjs-16-trailing-slash`, `nextjs-16-bun`, `nextjs-16-tunnel`, `nextjs-sourcemaps` - Fixes high-severity DoS vulnerability in Next.js Server Components Co-authored-by: Claude Opus 4.6 (1M context) --- .../e2e-tests/test-applications/nextjs-15-intl/package.json | 2 +- dev-packages/e2e-tests/test-applications/nextjs-15/package.json | 2 +- .../test-applications/nextjs-16-cacheComponents/package.json | 2 +- .../e2e-tests/test-applications/nextjs-16-tunnel/package.json | 2 +- dev-packages/e2e-tests/test-applications/nextjs-16/package.json | 2 +- .../e2e-tests/test-applications/nextjs-sourcemaps/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) 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 b0c7f2852e01..fc3c7f813b5f 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 @@ -15,7 +15,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.5.14", + "next": "15.5.15", "next-intl": "^4.3.12", "react": "latest", "react-dom": "latest", 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 9e453cf0edf5..acbe56d0b5f1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -20,7 +20,7 @@ "@types/react": "18.0.26", "@types/react-dom": "18.0.9", "ai": "^3.0.0", - "next": "15.5.14", + "next": "15.5.15", "react": "latest", "react-dom": "latest", "typescript": "~5.0.0", 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 c1070677f383..22beb292ca79 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 @@ -26,7 +26,7 @@ "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "import-in-the-middle": "^1", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^7", 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 0821c63d43f5..7dfa8f923f6e 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 @@ -27,7 +27,7 @@ "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "ai": "^3.0.0", "import-in-the-middle": "^1", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^7", 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 944102e188b3..beda2252d915 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -28,7 +28,7 @@ "@vercel/queue": "^0.1.3", "ai": "^3.0.0", "import-in-the-middle": "^2", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^8", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json index 9667f17865f1..70d7802b28db 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "typescript": "~5.0.0" From 6e940f8543ed5f191ab1983a860ed2ad13725c8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:49:37 +0200 Subject: [PATCH 24/84] ci(deps): bump mshick/add-pr-comment from e7516d74559b5514092f5b096ed29a629a1237c6 to 8e4927817251f1ff60c001f04568532b38e0b4a0 (#20619) Bumps [mshick/add-pr-comment](https://github.com/mshick/add-pr-comment) from e7516d74559b5514092f5b096ed29a629a1237c6 to 8e4927817251f1ff60c001f04568532b38e0b4a0.
Changelog

Sourced from mshick/add-pr-comment's changelog.

Changelog

3.11.0 (2026-04-23)

Features

  • add NOW template variable with configurable date format (#193) (87fe9ef)

3.10.1 (2026-04-23)

Bug Fixes

  • skip comment creation when deleteOnStatus matches status (#187) (f160eba)

3.10.0 (2026-04-02)

Features

  • add truncate-separator input and markdown termination (#184) (6bd445f)

3.9.1 (2026-03-31)

Bug Fixes

3.9.0 (2026-03-14)

Features

  • add library exports for programmatic usage (#169) (277cebd)

3.8.0 (2026-03-14)

Features

  • automatic message truncation for oversized comments (#167) (38989f3)

3.7.0 (2026-03-14)

Features

... (truncated)

Commits
  • 8e49278 chore(main): release 3.11.0 (#194)
  • 87fe9ef feat: add NOW template variable with configurable date format (#193)
  • be5d48d chore(main): release 3.10.1 (#191)
  • 14d916e chore(deps): bump fast-xml-parser from 5.5.9 to 5.7.1 in the npm_and_yarn gro...
  • f160eba fix: skip comment creation when deleteOnStatus matches status (#187)
  • 9302b90 chore(deps): bump vite from 8.0.0 to 8.0.7 in the npm_and_yarn group across 1...
  • 4191f5b chore(deps): bump lodash from 4.17.23 to 4.18.1 in the npm_and_yarn group acr...
  • 64b8e91 chore(main): release 3.10.0 (#185)
  • 6bd445f feat: add truncate-separator input and markdown termination (#184)
  • See full diff in compare view

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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ffcfe94821b4..090a09369706 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -274,7 +274,7 @@ jobs: pull-requests: write steps: - name: PR is opened against master - uses: mshick/add-pr-comment@e7516d74559b5514092f5b096ed29a629a1237c6 + uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 if: ${{ github.base_ref == 'master' && !startsWith(github.head_ref, 'prepare-release/') }} with: message: | From 7bdab8fc329b8f0bf61ba6e2a9bd410f931da544 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 11:49:50 +0200 Subject: [PATCH 25/84] chore(skills): Use `yarn-update-dependency` (#20635) Updates the `fix-security-vulnerability` skill to make use of our internal util. --- .../skills/fix-security-vulnerability/SKILL.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.agents/skills/fix-security-vulnerability/SKILL.md b/.agents/skills/fix-security-vulnerability/SKILL.md index ca37ed5d558e..db1d3e72d5d5 100644 --- a/.agents/skills/fix-security-vulnerability/SKILL.md +++ b/.agents/skills/fix-security-vulnerability/SKILL.md @@ -92,7 +92,7 @@ git pull origin develop git checkout -b fix/dependabot-alert- ``` -Then apply the fix commands from Step 5 of the single-alert workflow (edit `package.json`, `yarn install`, `yarn dedupe-deps:fix`, verify) — but **skip the "Do NOT commit" instruction**, since user approval was already obtained in Step 2b. After applying: +Then apply the fix commands from Step 5 of the single-alert workflow (`npx yarn-update-dependency@latest `, `yarn dedupe-deps:fix`, verify) — but **skip the "Do NOT commit" instruction**, since user approval was already obtained in Step 2b. After applying: ```bash # 3. Stage and commit the changes @@ -263,8 +263,8 @@ Present findings and **wait for user approval** before making changes: ### Proposed Fix -1. Update : "": "" -2. yarn install && yarn dedupe-deps:fix +1. npx yarn-update-dependency@latest +2. yarn dedupe-deps:fix 3. Verify with: yarn why Proceed? @@ -273,15 +273,14 @@ Proceed? ### Step 5: Apply Fix (After Approval) ```bash -# 1. Edit package.json -# 2. Update lockfile -yarn install -# 3. Deduplicate +# 1. Upgrade the package (updates package.json + lockfile) +npx yarn-update-dependency@latest +# 2. Deduplicate yarn dedupe-deps:fix -# 4. Verify +# 3. Verify yarn dedupe-deps:check yarn why -# 5. Show changes +# 4. Show changes git diff ``` @@ -325,6 +324,7 @@ gh api --method PATCH repos/getsentry/sentry-javascript/dependabot/alerts/` | Upgrade package across repo | | `yarn why ` | Show dependency tree | | `yarn dedupe-deps:fix` | Fix duplicates in yarn.lock | | `yarn dedupe-deps:check` | Verify no duplicate issues | From 054252e60632a9c686d4adc3a7594b87f72a497b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:53:06 +0200 Subject: [PATCH 26/84] feat(deps): bump follow-redirects from 1.15.11 to 1.16.0 (#20267) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
Commits
  • 0c23a22 Release version 1.16.0 of the npm package.
  • 844c4d3 Add sensitiveHeaders option.
  • 5e8b8d0 ci: add Node.js 24.x to the CI matrix
  • 7953e22 ci: upgrade GitHub Actions to use setup-node@v6 and checkout@v6
  • 86dc1f8 Sanitizing input.
  • See full diff in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 072b85ad3012..2db5349df752 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17716,9 +17716,9 @@ fn.name@1.x.x: integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== follow-redirects@^1.0.0, follow-redirects@^1.15.11: - version "1.15.11" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== for-each@^0.3.3, for-each@^0.3.5: version "0.3.5" From cada1391a27ed140e94a6097c0f4c8e5db6f1dcc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:53:40 +0200 Subject: [PATCH 27/84] feat(deps): bump @xmldom/xmldom from 0.8.12 to 0.8.13 (#20457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.12 to 0.8.13.
Release notes

Sourced from @​xmldom/xmldom's releases.

0.8.13

Commits

Fixed

  • Security: XMLSerializer.serializeToString() (and Node.toString(), NodeList.toString()) now accept a requireWellFormed option (fourth argument, after isHtml and nodeFilter). When { requireWellFormed: true } is passed, the serializer throws InvalidStateError for injection-prone node content, preventing XML injection via attacker-controlled node data. GHSA-j759-j44w-7fr8 GHSA-x6wf-f3px-wcqx GHSA-f6ww-3ggp-fr8h
    • Comment: throws when data contains -->
    • ProcessingInstruction: throws when data contains ?>
    • DocumentType: throws when publicId fails PubidLiteral, systemId fails SystemLiteral, or internalSubset contains ]>
  • Security: DOM traversal operations (XMLSerializer.serializeToString(), Node.prototype.normalize(), Node.prototype.cloneNode(true), Document.prototype.importNode(node, true), node.textContent getter, getElementsByTagName() / getElementsByTagNameNS() / getElementsByClassName() / getElementById()) are now iterative. Previously, deeply nested DOM trees would exhaust the JavaScript call stack and throw an unrecoverable RangeError. GHSA-2v35-w6hq-6mfw

Thank you, @​Jvr2022, @​praveen-kv, @​TharVid, @​decsecre583, @​tlsbollei, @​KarimTantawey, for your contributions

Changelog

Sourced from @​xmldom/xmldom's changelog.

0.8.13

Fixed

  • Security: XMLSerializer.serializeToString() (and Node.toString(), NodeList.toString()) now accept a requireWellFormed option (fourth argument, after isHtml and nodeFilter). When { requireWellFormed: true } is passed, the serializer throws InvalidStateError for injection-prone node content, preventing XML injection via attacker-controlled node data. GHSA-j759-j44w-7fr8 GHSA-x6wf-f3px-wcqx GHSA-f6ww-3ggp-fr8h
    • Comment: throws when data contains -->
    • ProcessingInstruction: throws when data contains ?>
    • DocumentType: throws when publicId fails PubidLiteral, systemId fails SystemLiteral, or internalSubset contains ]>
  • Security: DOM traversal operations (XMLSerializer.serializeToString(), Node.prototype.normalize(), Node.prototype.cloneNode(true), Document.prototype.importNode(node, true), node.textContent getter, getElementsByTagName() / getElementsByTagNameNS() / getElementsByClassName() / getElementById()) are now iterative. Previously, deeply nested DOM trees would exhaust the JavaScript call stack and throw an unrecoverable RangeError. GHSA-2v35-w6hq-6mfw

Thank you, @​Jvr2022, @​praveen-kv, @​TharVid, @​decsecre583, @​tlsbollei, @​KarimTantawey, for your contributions

0.9.9

Added

Fixed

Code that passes a string containing "]]>" to createCDATASection and relied on the previously unsafe behavior will now receive InvalidCharacterError. Use a mutation method such as appendData if you intentionally need "]]>" in a CDATASection node's data.

Chore

  • updated dependencies

Thank you, @​stevenobiajulu, @​yoshi389111, @​thesmartshadow, for your contributions

Commits
  • e5c1480 0.8.13
  • 9611e20 style: drop unused import in test file
  • dc4dff3 docs: add 0.8.13 changelog entry
  • 842fa38 fix: prevent stack overflow in normalize (GHSA-2v35-w6hq-6mfw)
  • aeff69f test: add normalize behavioral coverage to node.test.js
  • cbdb0d7 fix: make walkDOM iterative to prevent stack overflow (GHSA-2v35-w6hq-6mfw)
  • 0b543d3 test: assert namespace declarations are isolated between siblings in serializ...
  • c007c51 refactor: migrate serializeToString to walkDOM
  • 2bb3899 test: add serializeToString coverage for uncovered branches
  • e69f38d refactor: migrate importNode to walkDOM
  • Additional commits viewable in compare view
Maintainer changes

This version was pushed to npm by karfau, a new releaser for @​xmldom/xmldom since your current version.


Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2db5349df752..dd5d26641611 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10924,9 +10924,9 @@ tslib "^2.6.3" "@xmldom/xmldom@^0.8.0": - version "0.8.12" - resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz#cf488a5435fa06c7374ad1449c69cea0f823624b" - integrity sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg== + version "0.8.13" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz#00d1dd940b218dff2e49309d410d8bb212159225" + integrity sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw== "@xstate/fsm@^1.4.0": version "1.6.5" From 7c3546374fef8384deb0104be3d2eb35cfe00e30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 10:09:36 +0000 Subject: [PATCH 28/84] ci(deps): bump actions/create-github-app-token from 2 to 3 (#20079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 2 to 3.
Release notes

Sourced from actions/create-github-app-token's releases.

v3.0.0

3.0.0 (2026-03-14)

Bug Fixes

BREAKING CHANGES

  • Custom proxy handling has been removed. If you use HTTP_PROXY or HTTPS_PROXY, you must now also set NODE_USE_ENV_PROXY=1 on the action step.
  • Requires Actions Runner v2.327.1 or later if you are using a self-hosted runner.

v3.0.0-beta.6

3.0.0-beta.6 (2026-03-13)

Bug Fixes

  • deps: bump @​actions/core from 1.11.1 to 3.0.0 (#337) (b044133)
  • deps: bump minimatch from 9.0.5 to 9.0.9 (#335) (5cbc656)
  • deps: bump the production-dependencies group with 4 updates (#336) (6bda5bc)
  • deps: bump undici from 7.16.0 to 7.18.2 (#323) (b4f638f)

v3.0.0-beta.5

3.0.0-beta.5 (2026-03-13)

  • fix!: require NODE_USE_ENV_PROXY for proxy support (#342) (d53a1cd)

BREAKING CHANGES

  • Custom proxy handling has been removed. If you use HTTP_PROXY or HTTPS_PROXY, you must now also set NODE_USE_ENV_PROXY=1 on the action step.

v3.0.0-beta.4

3.0.0-beta.4 (2026-03-13)

Bug Fixes

  • deps: bump @​octokit/auth-app from 7.2.1 to 8.0.1 (#257) (bef1eaf)
  • deps: bump @​octokit/request from 9.2.3 to 10.0.2 (#256) (5d7307b)
  • deps: bump glob from 10.4.5 to 10.5.0 (#305) (5480f43)
  • deps: bump p-retry from 6.2.1 to 7.1.0 (#294) (dce3be8)

... (truncated)

Commits
  • 1b10c78 build(release): 3.1.1 [skip ci]
  • 07e2b76 fix: improve error message when app identifier is empty (#362)
  • ea01216 ci: remove publish-immutable-action workflow (#361)
  • 7bd0371 build(release): 3.1.0 [skip ci]
  • e6bd4e6 feat: add client-id input and deprecate app-id (#353)
  • 076e948 feat: update permission inputs (#358)
  • 3bbe07d fix(deps): bump p-retry from 7.1.1 to 8.0.0 (#357)
  • 28a99e3 build(deps-dev): bump c8 from 10.1.3 to 11.0.0
  • 4df5060 build(deps-dev): bump open-cli from 8.0.0 to 9.0.0
  • 4843c53 build(deps-dev): bump the development-dependencies group with 3 updates
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-release.yml | 2 +- .github/workflows/bump-size-limits.yml | 2 +- .github/workflows/external-contributors.yml | 2 +- .github/workflows/gitflow-sync-develop.yml | 2 +- .github/workflows/release.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 4ba84974f6fd..52aaf1cc6cc3 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/bump-size-limits.yml b/.github/workflows/bump-size-limits.yml index d837fc254bf2..b79c01f61c05 100644 --- a/.github/workflows/bump-size-limits.yml +++ b/.github/workflows/bump-size-limits.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.GITFLOW_APP_ID }} private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml index 64a6f82478e5..5b0eb76351bc 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -37,7 +37,7 @@ jobs: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.GITFLOW_APP_ID }} private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} diff --git a/.github/workflows/gitflow-sync-develop.yml b/.github/workflows/gitflow-sync-develop.yml index 1ff55f46a008..5e5c8e1d8f7c 100644 --- a/.github/workflows/gitflow-sync-develop.yml +++ b/.github/workflows/gitflow-sync-develop.yml @@ -27,7 +27,7 @@ jobs: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.GITFLOW_APP_ID }} private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f00562e1df73..c88e2aad22fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From 23ab0f1ad445cd25e8e435744d5b585a1ad251ed Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 4 May 2026 12:49:39 +0200 Subject: [PATCH 29/84] test(react-router): Fix flaky E2E tests (#20630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/getsentry/sentry-javascript/issues/20532 This hopefully makes react-router framework E2E tests a bit less flaky. ## AI Summary of the problem That app uses client-only hydration (hydrateRoot + HydratedRouter). The pageload transaction and React Router’s instrumentHydratedRouter updates can land in a race with page.goto finishing: sometimes CI loses that race, so the test times out or assertions fail. This matches how other E2E apps avoid the same class of bug—for example the SvelteKit helpers wait for hydration / UI together with the pageload transaction so routing and tracing line up. ### What we changed After each page.goto for the performance routes, the tests now wait for the route’s \

before awaiting waitForTransaction: /performance → Performance Page /performance/with/sentry → Dynamic Parameter Page So the HydratedRouter has rendered the matched route before the test blocks on the envelope, which stabilizes timing relative to router instrumentation. --- .../tests/performance/pageload.client.test.ts | 2 ++ .../tests/performance/pageload.client.test.ts | 2 ++ .../test/reactrouter-cross-usage.test.tsx | 28 ++++++++++++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts index d32fd24c75a6..1f41355a8ebd 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts @@ -9,6 +9,7 @@ test.describe('client - pageload performance', () => { }); await page.goto(`/performance`); + await page.getByRole('heading', { name: 'Performance Page' }).waitFor(); const transaction = await txPromise; @@ -62,6 +63,7 @@ test.describe('client - pageload performance', () => { }); await page.goto(`/performance/with/sentry`); + await page.getByRole('heading', { name: 'Dynamic Parameter Page' }).waitFor(); const transaction = await txPromise; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts index d32fd24c75a6..1f41355a8ebd 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts @@ -9,6 +9,7 @@ test.describe('client - pageload performance', () => { }); await page.goto(`/performance`); + await page.getByRole('heading', { name: 'Performance Page' }).waitFor(); const transaction = await txPromise; @@ -62,6 +63,7 @@ test.describe('client - pageload performance', () => { }); await page.goto(`/performance/with/sentry`); + await page.getByRole('heading', { name: 'Dynamic Parameter Page' }).waitFor(); const transaction = await txPromise; diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx index 424821a9ad98..c158f831c381 100644 --- a/packages/react/test/reactrouter-cross-usage.test.tsx +++ b/packages/react/test/reactrouter-cross-usage.test.tsx @@ -627,9 +627,14 @@ describe('React Router cross usage of wrappers', () => { await act(async () => { router.navigate('/settings'); - await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); }); + await waitFor(() => { + expect(router.state.navigation.state).toBe('idle'); + expect(router.state.location.pathname).toBe('/settings'); + }); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { name: '/settings', attributes: { @@ -641,9 +646,14 @@ describe('React Router cross usage of wrappers', () => { await act(async () => { router.navigate('/profile'); - await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); }); + await waitFor(() => { + expect(router.state.navigation.state).toBe('idle'); + expect(router.state.location.pathname).toBe('/profile'); + }); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); const calls = mockStartBrowserTracingNavigationSpan.mock.calls; @@ -734,9 +744,14 @@ describe('React Router cross usage of wrappers', () => { await act(async () => { router.navigate('/user/2'); - await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); }); + await waitFor(() => { + expect(router.state.navigation.state).toBe('idle'); + expect(router.state.location.pathname).toBe('/user/2'); + }); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledWith(expect.any(BrowserClient), { name: '/user/:id', @@ -749,9 +764,14 @@ describe('React Router cross usage of wrappers', () => { await act(async () => { router.navigate('/user/3'); - await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); }); + await waitFor(() => { + expect(router.state.navigation.state).toBe('idle'); + expect(router.state.location.pathname).toBe('/user/3'); + }); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); + // Should create 2 spans - different concrete paths are different user actions expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenNthCalledWith(2, expect.any(BrowserClient), { From 4034c523a6a33722ace4e9678a4a6e871e7aaeb5 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 4 May 2026 13:01:16 +0200 Subject: [PATCH 30/84] fix(core): Guard against undefined chained in copyProps (#20637) Non-standard thenables (e.g. Fastify reply objects) can return `undefined` from `.then()`, which crashes `copyProps` with `TypeError: Cannot use 'in' operator to search for 'raw' in undefined`. Added a guard to return the original object as fallback when `chained` is falsy. Closes https://github.com/getsentry/sentry-javascript/issues/20623 Co-authored-by: Claude Opus 4.6 (1M context) --- .../src/utils/chain-and-copy-promiselike.ts | 1 + .../utils/chain-and-copy-promiselike.test.ts | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/core/src/utils/chain-and-copy-promiselike.ts b/packages/core/src/utils/chain-and-copy-promiselike.ts index 4d8db088d22e..ea04d77e015e 100644 --- a/packages/core/src/utils/chain-and-copy-promiselike.ts +++ b/packages/core/src/utils/chain-and-copy-promiselike.ts @@ -32,6 +32,7 @@ export const chainAndCopyPromiseLike = >( // eslint-disable-next-line @typescript-eslint/no-explicit-any const copyProps = >(original: T, chained: T): T => { + if (!chained) return original; let mutated = false; //oxlint-disable-next-line guard-for-in for (const key in original) { diff --git a/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts b/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts index 2f4415940dc8..d357ffd9bb34 100644 --- a/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts +++ b/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts @@ -53,4 +53,25 @@ describe('chain and copy promiselike objects', () => { expect(success).toBe(true); expect(error).toBe(false); }); + + it('returns original when .then() returns undefined', () => { + const original = { + value: 42, + then() { + return undefined; + }, + customMethod() { + return 'hello'; + }, + } as unknown as PromiseLike & { customMethod: () => string }; + + const q = chainAndCopyPromiseLike( + original, + () => {}, + () => {}, + ); + + expect(q).toBe(original); + expect((q as typeof original).customMethod()).toBe('hello'); + }); }); From 61b0eaa3b65e8ae40599af205a62a9fc707db265 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 13:13:34 +0200 Subject: [PATCH 31/84] chore(size-limit): weekly auto-bump (#20618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Size limit auto-bump | Entry | Old limit | New limit | Δ | | --- | --- | --- | --- | | CDN Bundle (incl. Tracing, Logs, Metrics) | 53 KB | 54 KB | +1 KB | | CDN Bundle (incl. Tracing, Replay) | 89 KB | 90 KB | +1 KB | | CDN Bundle (incl. Tracing, Replay, Logs, Metrics) | 90 KB | 91 KB | +1 KB | | CDN Bundle (incl. Tracing) - uncompressed | 145 KB | 146 KB | +1 KB | | CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed | 266 KB | 267 KB | +1 KB | | CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed | 276 KB | 277 KB | +1 KB | | CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed | 280 KB | 281 KB | +1 KB | | @sentry/node-core | 64 KB | 65 KB | +1 KB | | @sentry/node - without tracing | 103 KB | 102 KB | -1 KB | | @sentry/aws-serverless | 120 KB | 119 KB | -1 KB | | @sentry/cloudflare (withSentry) | 412 KiB | 414 KiB | +2 KiB | Co-authored-by: chargome <20254395+chargome@users.noreply.github.com> Co-authored-by: Jan Peer Stöcklmair --- .size-limit.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 34ea3e254e90..f6732eacafe2 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -226,7 +226,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '53 KB', + limit: '54 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -240,14 +240,14 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '89 KB', + limit: '90 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '90 KB', + limit: '91 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -278,7 +278,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '145 KB', + limit: '146 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -318,7 +318,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '266 KB', + limit: '267 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -326,7 +326,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '276 KB', + limit: '277 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -334,7 +334,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '280 KB', + limit: '281 KB', disablePlugins: ['@size-limit/esbuild'], }, // Next.js SDK (ESM) @@ -364,7 +364,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '64 KB', + limit: '65 KB', disablePlugins: ['@size-limit/esbuild'], }, // Node SDK (ESM) @@ -382,7 +382,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '103 KB', + limit: '102 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '120 KB', + limit: '119 KB', disablePlugins: ['@size-limit/esbuild'], }, // Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output @@ -437,7 +437,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '412 KiB', + limit: '414 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { From d7e1a11e35ab0b81bffcc48e11e5a27d20e67620 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 13:37:47 +0200 Subject: [PATCH 32/84] chore(deps): Bump vulnerable testem version (#20634) closes 1. [xmldom: Uncontrolled recursion in XML serialization leads to DoS](https://github.com/getsentry/sentry-javascript/security/dependabot/1413) 2. [XML node injection through unvalidated comment serialization](https://github.com/getsentry/sentry-javascript/security/dependabot/1412) 3. [XML injection through unvalidated DocumentType serialization](https://github.com/getsentry/sentry-javascript/security/dependabot/1411) 4. [XML node injection through unvalidated processing instruction serialization](https://github.com/getsentry/sentry-javascript/security/dependabot/1410) --- yarn.lock | 296 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 165 insertions(+), 131 deletions(-) diff --git a/yarn.lock b/yarn.lock index dd5d26641611..9fd21186821a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7711,6 +7711,11 @@ "@angular-devkit/schematics" "14.2.13" jsonc-parser "3.1.0" +"@sec-ant/readable-stream@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" + integrity sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg== + "@sentry-internal/node-cpu-profiler@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.2.0.tgz#0640d4aebb4d36031658ccff83dc22b76f437ede" @@ -10923,10 +10928,10 @@ dependencies: tslib "^2.6.3" -"@xmldom/xmldom@^0.8.0": - version "0.8.13" - resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz#00d1dd940b218dff2e49309d410d8bb212159225" - integrity sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw== +"@xmldom/xmldom@^0.9.9": + version "0.9.10" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz#a0ad5a26fe8aa996310870726e1704977f769dee" + integrity sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw== "@xstate/fsm@^1.4.0": version "1.6.5" @@ -11762,11 +11767,6 @@ async@^3.2.3, async@^3.2.4: resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== -async@~0.2.9: - version "0.2.10" - resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" - integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E= - asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -12065,10 +12065,10 @@ babel-preset-solid@^1.8.4: dependencies: babel-plugin-jsx-dom-expressions "^0.37.20" -backbone@^1.1.2: - version "1.4.0" - resolved "https://registry.yarnpkg.com/backbone/-/backbone-1.4.0.tgz#54db4de9df7c3811c3f032f34749a4cd27f3bd12" - integrity sha512-RLmDrRXkVdouTg38jcgHhyQ/2zjg7a8E6sz2zxfz21Hh17xDJYUHBZimVIt5fUyS8vbfpeSmTL3gUjTEvUV3qQ== +backbone@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/backbone/-/backbone-1.6.1.tgz#6e067777767f54b9e150d3de825f7d66e7ed77d0" + integrity sha512-YQzWxOrIgL6BoFnZjThVN99smKYhyEXXFyJJ2lsF1wJLyo4t+QjmkLrH8/fN22FZ4ykF70Xq7PgTugJVR4zS9Q== dependencies: underscore ">=1.8.3" @@ -12283,11 +12283,6 @@ blank-object@^1.0.1: resolved "https://registry.yarnpkg.com/blank-object/-/blank-object-1.0.2.tgz#f990793fbe9a8c8dd013fb3219420bec81d5f4b9" integrity sha1-+ZB5P76ajI3QE/syGUIL7IHV9Lk= -bluebird@^3.4.6, bluebird@^3.7.2: - version "3.7.2" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" - integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== - body-parser@^2.2.1, body-parser@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.2.tgz#1a32cdb966beaf68de50a9dfbe5b58f83cb8890c" @@ -13307,10 +13302,10 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== -charm@^1.0.0: +charm@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/charm/-/charm-1.0.2.tgz#8add367153a6d9a581331052c4090991da995e35" - integrity sha1-it02cVOm2aWBMxBSxAkJkdqZXjU= + integrity sha512-wqW3VdPnlSWT4eRiYX+hcs+C6ViBPUWk1qTCd+37qw9kEm/a5n2qcyQDMBWvSYKN/ctqZzeXNQaeBjOetJJUkw== dependencies: inherits "^2.0.1" @@ -13715,7 +13710,12 @@ commander@^12.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== -commander@^2.20.0, commander@^2.6.0: +commander@^14.0.3: + version "14.0.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" + integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + +commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -13783,7 +13783,7 @@ compressible@~2.0.18: dependencies: mime-db ">= 1.43.0 < 2" -compression@^1.7.4: +compression@^1.7.4, compression@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/compression/-/compression-1.8.1.tgz#4a45d909ac16509195a9a28bd91094889c180d79" integrity sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w== @@ -13881,12 +13881,10 @@ console-ui@^3.0.4, console-ui@^3.1.2: ora "^3.4.0" through2 "^3.0.1" -consolidate@^0.16.0: - version "0.16.0" - resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.16.0.tgz#a11864768930f2f19431660a65906668f5fbdc16" - integrity sha512-Nhl1wzCslqXYTJVDyJCu3ODohy9OfBMB5uD2BiBTzd7w+QY0lBzafkR8y8755yMYHAaMD4NuzbAw03/xzfw+eQ== - dependencies: - bluebird "^3.7.2" +consolidate@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-1.0.4.tgz#9052e88bf3cf89a444df3cb61f1d4c6b9c8afcf0" + integrity sha512-RuZ3xnqEDsxiwaoIkqVeeK3gg9qxw7+YKYX2tKhLs1eukVKMgSr4VYI3iYFsRHi4TloHYDlugrz3kvkjs3nynA== content-disposition@^1.0.0: version "1.0.1" @@ -14455,7 +14453,7 @@ debug@^3.0.1, debug@^3.1.0, debug@^3.2.6, debug@^3.2.7: dependencies: ms "^2.1.1" -debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: +debug@~4.3.1, debug@~4.3.4: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== @@ -16961,10 +16959,10 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== -events-to-array@^1.0.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/events-to-array/-/events-to-array-1.1.2.tgz#2d41f563e1fe400ed4962fe1a4d5c6a7539df7f6" - integrity sha1-LUH1Y+H+QA7Uli/hpNXGp1Od9/Y= +events-to-array@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/events-to-array/-/events-to-array-2.0.3.tgz#0cd5ee538baae3ea9ec07539d778a2a6056699bc" + integrity sha512-f/qE2gImHRa4Cp2y1stEOSgw8wTFyUdVJX7G//bMwbaV9JqISFxg99NbmVQeP7YLnDUZ2un851jlaDrlpmGehQ== events@^3.0.0, events@^3.2.0, events@^3.3.0: version "3.3.0" @@ -17054,6 +17052,24 @@ execa@^8.0.1: signal-exit "^4.1.0" strip-final-newline "^3.0.0" +execa@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-9.6.1.tgz#5b90acedc6bdc0fa9b9a6ddf8f9cbb0c75a7c471" + integrity sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA== + dependencies: + "@sindresorhus/merge-streams" "^4.0.0" + cross-spawn "^7.0.6" + figures "^6.1.0" + get-stream "^9.0.0" + human-signals "^8.0.1" + is-plain-obj "^4.1.0" + is-stream "^4.0.1" + npm-run-path "^6.0.0" + pretty-ms "^9.2.0" + signal-exit "^4.1.0" + strip-final-newline "^4.0.0" + yoctocolors "^2.1.1" + exit-hook@2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593" @@ -17094,7 +17110,7 @@ expect-type@^1.2.1: resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.1.tgz#af76d8b357cf5fa76c41c09dafb79c549e75f71f" integrity sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw== -express@5.2.1: +express@5.2.1, express@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== @@ -17128,7 +17144,7 @@ express@5.2.1: type-is "^2.0.1" vary "^1.1.2" -express@^4.10.7, express@^4.17.3, express@^4.18.1, express@^4.21.2: +express@^4.17.3, express@^4.18.1, express@^4.21.2: version "4.22.1" resolved "https://registry.yarnpkg.com/express/-/express-4.22.1.tgz#1de23a09745a4fffdb39247b344bb5eaff382069" integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g== @@ -17436,6 +17452,13 @@ figures@^2.0.0: dependencies: escape-string-regexp "^1.0.5" +figures@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-6.1.0.tgz#935479f51865fa7479f6fa94fc6fc7ac14e62c4a" + integrity sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg== + dependencies: + is-unicode-supported "^2.0.0" + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -17641,17 +17664,6 @@ findup-sync@^4.0.0: micromatch "^4.0.2" resolve-dir "^1.0.1" -fireworm@^0.7.2: - version "0.7.2" - resolved "https://registry.yarnpkg.com/fireworm/-/fireworm-0.7.2.tgz#bc5736515b48bd30bf3293a2062e0b0e0361537a" - integrity sha512-GjebTzq+NKKhfmDxjKq3RXwQcN9xRmZWhnnuC9L+/x5wBQtR0aaQM50HsjrzJ2wc28v1vSdfOpELok0TKR4ddg== - dependencies: - async "~0.2.9" - is-type "0.0.1" - lodash.debounce "^3.1.1" - lodash.flatten "^3.0.2" - minimatch "^3.0.2" - fixturify-project@^1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/fixturify-project/-/fixturify-project-1.10.0.tgz#091c452a9bb15f09b6b9cc7cf5c0ad559f1d9aad" @@ -18142,6 +18154,14 @@ get-stream@^8.0.1: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-8.0.1.tgz#def9dfd71742cd7754a7761ed43749a27d02eca2" integrity sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA== +get-stream@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-9.0.1.tgz#95157d21df8eb90d1647102b63039b1df60ebd27" + integrity sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA== + dependencies: + "@sec-ant/readable-stream" "^0.4.1" + is-stream "^4.0.1" + get-symbol-description@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" @@ -18260,7 +18280,7 @@ glob@^5.0.10: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.0, glob@^7.0.4, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.3, glob@~7.2.0: +glob@^7.0.0, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.3, glob@~7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -19101,7 +19121,7 @@ http-proxy-middleware@^2.0.3: is-plain-obj "^3.0.0" micromatch "^4.0.2" -http-proxy@^1.13.1, http-proxy@^1.18.1: +http-proxy@^1.18.1: version "1.18.1" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== @@ -19171,6 +19191,11 @@ human-signals@^5.0.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== +human-signals@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-8.0.1.tgz#f08bb593b6d1db353933d06156cedec90abe51fb" + integrity sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ== + humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" @@ -19850,7 +19875,7 @@ is-plain-obj@^3.0.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA== -is-plain-obj@^4.0.0: +is-plain-obj@^4.0.0, is-plain-obj@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== @@ -19938,6 +19963,11 @@ is-stream@^3.0.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== +is-stream@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-4.0.1.tgz#375cf891e16d2e4baec250b85926cffc14720d9b" + integrity sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A== + is-string@^1.0.7, is-string@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" @@ -19962,13 +19992,6 @@ is-symbol@^1.0.4, is-symbol@^1.1.1: has-symbols "^1.1.0" safe-regex-test "^1.1.0" -is-type@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/is-type/-/is-type-0.0.1.tgz#f651d85c365d44955d14a51d8d7061f3f6b4779c" - integrity sha1-9lHYXDZdRJVdFKUdjXBh8/a0d5w= - dependencies: - core-util-is "~1.0.0" - is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15, is-typed-array@^1.1.3: version "1.1.15" resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" @@ -19991,6 +20014,11 @@ is-unicode-supported@^1.1.0, is-unicode-supported@^1.3.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz#d824984b616c292a2e198207d4a609983842f714" integrity sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ== +is-unicode-supported@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" + integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== + is-url-superb@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/is-url-superb/-/is-url-superb-4.0.0.tgz#b54d1d2499bb16792748ac967aa3ecb41a33a8c2" @@ -20278,7 +20306,7 @@ js-tokens@^9.0.1: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== -js-yaml@^3.10.0, js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.2.5, js-yaml@^3.2.7: +js-yaml@^3.10.0, js-yaml@^3.13.0, js-yaml@^3.13.1: version "3.14.2" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz" integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg== @@ -20286,7 +20314,7 @@ js-yaml@^3.10.0, js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.2.5, js-yaml@^3.2. argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0: +js-yaml@^4.1.0, js-yaml@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz" integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== @@ -20905,14 +20933,6 @@ lodash._basecopy@^3.0.0: resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36" integrity sha1-jaDmqHbPNEwK2KVIghEd08XHyjY= -lodash._baseflatten@^3.0.0: - version "3.1.4" - resolved "https://registry.yarnpkg.com/lodash._baseflatten/-/lodash._baseflatten-3.1.4.tgz#0770ff80131af6e34f3b511796a7ba5214e65ff7" - integrity sha1-B3D/gBMa9uNPO1EXlqe6UhTmX/c= - dependencies: - lodash.isarguments "^3.0.0" - lodash.isarray "^3.0.0" - lodash._bindcallback@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" @@ -20961,13 +20981,6 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= -lodash.debounce@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-3.1.1.tgz#812211c378a94cc29d5aa4e3346cf0bfce3a7df5" - integrity sha1-gSIRw3ipTMKdWqTjNGzwv846ffU= - dependencies: - lodash._getnative "^3.0.0" - lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -20983,14 +20996,6 @@ lodash.defaultsdeep@^4.6.1: resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz#512e9bd721d272d94e3d3a63653fa17516741ca6" integrity sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA== -lodash.flatten@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-3.0.2.tgz#de1cf57758f8f4479319d35c3e9cc60c4501938c" - integrity sha1-3hz1d1j49EeTGdNcPpzGDEUBk4w= - dependencies: - lodash._baseflatten "^3.0.0" - lodash._isiterateecall "^3.0.0" - lodash.foreach@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" @@ -21105,7 +21110,7 @@ lodash@4.17.23, lodash@~4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== -lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21: +lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.18.1: version "4.18.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== @@ -22162,7 +22167,7 @@ minimalistic-assert@^1.0.0: resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimatch@10.2.4, minimatch@10.2.5, minimatch@^10.2.2, minimatch@^10.2.4: +minimatch@10.2.4, minimatch@10.2.5, minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5: version "10.2.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== @@ -22261,14 +22266,6 @@ minipass-sized@^1.0.3: dependencies: minipass "^3.0.0" -minipass@^2.2.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" - integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - minipass@^3.0.0, minipass@^3.1.1, minipass@^3.1.6: version "3.3.6" resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" @@ -23073,7 +23070,7 @@ node-modules-path@^1.0.0: resolved "https://registry.yarnpkg.com/node-modules-path/-/node-modules-path-1.0.2.tgz#e3acede9b7baf4bc336e3496b58e5b40d517056e" integrity sha512-6Gbjq+d7uhkO7epaKi5DNgUJn7H0gEyA4Jg0Mo1uQOi3Rk50G83LtmhhFyw0LxnAFhtlspkiiw52ISP13qzcBg== -node-notifier@^10.0.0: +node-notifier@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-10.0.1.tgz#0e82014a15a8456c4cfcdb25858750399ae5f1c7" integrity sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ== @@ -24217,6 +24214,11 @@ parse-ms@^2.1.0: resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d" integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA== +parse-ms@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-4.0.0.tgz#c0c058edd47c2a590151a718990533fd62803df4" + integrity sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw== + parse-node-version@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" @@ -25479,6 +25481,13 @@ pretty-ms@^7.0.1: dependencies: parse-ms "^2.1.0" +pretty-ms@^9.2.0: + version "9.3.0" + resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-9.3.0.tgz#dd2524fcb3c326b4931b2272dfd1e1a8ed9a9f5a" + integrity sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ== + dependencies: + parse-ms "^4.0.0" + printf@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/printf/-/printf-0.6.1.tgz#b9afa3d3b55b7f2e8b1715272479fc756ed88650" @@ -25521,6 +25530,11 @@ proc-log@^3.0.0: resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-3.0.0.tgz#fb05ef83ccd64fd7b20bbe9c8c1070fc08338dd8" integrity sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A== +proc-log@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-6.1.0.tgz#18519482a37d5198e231133a70144a50f21f0215" + integrity sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ== + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -27752,15 +27766,15 @@ socket.io-parser@~4.2.4: "@socket.io/component-emitter" "~3.1.0" debug "~4.4.1" -socket.io@^4.5.4: - version "4.8.1" - resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.1.tgz#fa0eaff965cc97fdf4245e8d4794618459f7558a" - integrity sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg== +socket.io@^4.8.3: + version "4.8.3" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.3.tgz#ca6ba1431c69532e1e0a6f496deebeb601dbc4df" + integrity sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A== dependencies: accepts "~1.3.4" base64id "~2.0.0" cors "~2.8.5" - debug "~4.3.2" + debug "~4.4.1" engine.io "~6.6.0" socket.io-adapter "~2.5.2" socket.io-parser "~4.2.4" @@ -28445,6 +28459,11 @@ strip-final-newline@^3.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== +strip-final-newline@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz#35a369ec2ac43df356e3edd5dcebb6429aa1fa5c" + integrity sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw== + strip-indent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" @@ -28553,7 +28572,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" @@ -28721,14 +28739,21 @@ tagged-tag@^1.0.0: resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6" integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== -tap-parser@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/tap-parser/-/tap-parser-7.0.0.tgz#54db35302fda2c2ccc21954ad3be22b2cba42721" - integrity sha512-05G8/LrzqOOFvZhhAk32wsGiPZ1lfUrl+iV7+OkKgfofZxiceZWMHkKmow71YsyVQ8IvGBP2EjcIjE5gL4l5lA== +tap-parser@^18.3.0: + version "18.3.4" + resolved "https://registry.yarnpkg.com/tap-parser/-/tap-parser-18.3.4.tgz#503b6c8f20f37476d2e802e7e30b5a25d220cbbb" + integrity sha512-CiqzdpWn2CvONcWp7UNMF9/rCPJwCz0es+qykkgJruu1Y/rAS8A5MEQujmjx9NErfst3dGiZJU3lDS2jBsgbPA== dependencies: - events-to-array "^1.0.1" - js-yaml "^3.2.7" - minipass "^2.2.0" + events-to-array "^2.0.3" + tap-yaml "4.4.2" + +tap-yaml@4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/tap-yaml/-/tap-yaml-4.4.2.tgz#450ee4dcefcb6261bdf7d299b81ee6d9aca61d97" + integrity sha512-03mQI7QhfVZHJqGgFyxNTgUbgsG41ZzpWSb7k1Gangmf9hF71Jpb0Fczs7KtOdUDaHx+KxlPUdM2pQJaijebGA== + dependencies: + yaml "^2.8.3" + yaml-types "^0.4.0" tapable@^2.0.0, tapable@^2.1.1, tapable@^2.3.0: version "2.3.0" @@ -28895,35 +28920,34 @@ test-exclude@^7.0.1: minimatch "^9.0.4" testem@^3.10.1: - version "3.15.2" - resolved "https://registry.yarnpkg.com/testem/-/testem-3.15.2.tgz#abd6a96077a6576cd730f3d2e476039044c5cb34" - integrity sha512-mRzqZktqTCWi/rUP/RQOKXvMtuvY3lxuzBVb1xGXPnRNGMEj/1DaLGn6X447yOsz6SlWxSsZfcNuiE7fT1MOKg== - dependencies: - "@xmldom/xmldom" "^0.8.0" - backbone "^1.1.2" - bluebird "^3.4.6" - charm "^1.0.0" - commander "^2.6.0" - compression "^1.7.4" - consolidate "^0.16.0" - execa "^1.0.0" - express "^4.10.7" - fireworm "^0.7.2" - glob "^7.0.4" - http-proxy "^1.13.1" - js-yaml "^3.2.5" - lodash "^4.17.21" + version "3.20.0" + resolved "https://registry.yarnpkg.com/testem/-/testem-3.20.0.tgz#7d6cf0e5ed9e271cf0d6b6617c555fa5c823e7e9" + integrity sha512-SSFfJQK/SGruISFjoKG2jCYwK596wWNPJFj2Wo77GzeIUxZ8ZjuwpyF01uekTLu4ITL6i9R4m1sWaKPK/HsunA== + dependencies: + "@xmldom/xmldom" "^0.9.9" + backbone "^1.6.1" + charm "^1.0.2" + chokidar "^5.0.0" + commander "^14.0.3" + compression "^1.8.1" + consolidate "^1.0.4" + execa "^9.6.1" + express "^5.2.1" + glob "^13.0.6" + http-proxy "^1.18.1" + js-yaml "^4.1.1" + lodash "^4.18.1" + minimatch "^10.2.5" mkdirp "^3.0.1" mustache "^4.2.0" - node-notifier "^10.0.0" - npmlog "^6.0.0" + node-notifier "^10.0.1" printf "^0.6.1" - rimraf "^3.0.2" - socket.io "^4.5.4" + proc-log "^6.1.0" + rimraf "^6.1.3" + socket.io "^4.8.3" spawn-args "^0.2.0" styled_string "0.0.1" - tap-parser "^7.0.0" - tmp "0.0.33" + tap-parser "^18.3.0" text-decoder@^1.1.0: version "1.2.3" @@ -29084,7 +29108,7 @@ tmp@0.0.28: dependencies: os-tmpdir "~1.0.1" -tmp@0.0.33, tmp@^0.0.33: +tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== @@ -31349,7 +31373,7 @@ yallist@4.0.0, yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yallist@^3.0.0, yallist@^3.0.2: +yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== @@ -31367,6 +31391,11 @@ yam@^1.0.0: fs-extra "^4.0.2" lodash.merge "^4.6.0" +yaml-types@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/yaml-types/-/yaml-types-0.4.0.tgz#e0cab9fb563cbf6f5fc0a40dd3b8cc7bfa06365e" + integrity sha512-XfbA30NUg4/LWUiplMbiufUiwYhgB9jvBhTWel7XQqjV+GaB79c2tROu/8/Tu7jO0HvDvnKWtBk5ksWRrhQ/0g== + yaml@^1.10.0: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" @@ -31459,6 +31488,11 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +yoctocolors@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yoctocolors/-/yoctocolors-2.1.2.tgz#d795f54d173494e7d8db93150cec0ed7f678c83a" + integrity sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug== + youch-core@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/youch-core/-/youch-core-0.3.3.tgz#c5d3d85aeea0d8bc7b36e9764ed3f14b7ceddc7d" From fe5cad53f5511bf551f976032bc951ee0ea4a3a1 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 4 May 2026 13:39:34 +0200 Subject: [PATCH 33/84] fix: Bump fast-xml-parser to fix vulnerability (#20644) Closes https://github.com/getsentry/sentry-javascript/issues/20009 Closes GHSA: - https://github.com/advisories/GHSA-m7jm-9gc2-mpf2 - https://github.com/getsentry/sentry-javascript/security/dependabot/1414 - https://github.com/getsentry/sentry-javascript/security/dependabot/1246 - https://github.com/getsentry/sentry-javascript/security/dependabot/1247 --- .../node-integration-tests/package.json | 2 +- yarn.lock | 1411 +++++++++-------- 2 files changed, 746 insertions(+), 667 deletions(-) diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index d8c0f93282a7..c78a73bc7440 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -25,7 +25,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.63.0", "@apollo/server": "^5.5.0", - "@aws-sdk/client-s3": "^3.993.0", + "@aws-sdk/client-s3": "^3.1041.0", "@google/genai": "^1.20.0", "@growthbook/growthbook": "^1.6.1", "@hapi/hapi": "^21.3.10", diff --git a/yarn.lock b/yarn.lock index 9fd21186821a..499d9d2d498b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -734,65 +734,65 @@ "@smithy/util-utf8" "^4.2.0" tslib "^2.6.2" -"@aws-sdk/client-s3@^3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.993.0.tgz#3e40e3631d88100dab51bcde017abbb14e3274b6" - integrity sha512-0slCxdbo9O3rfzqD7/PsBOrZ6vcwFzPAvGeUu5NZApI5WyjEfMLLi2T9QW8R9N9TQeUfiUQiHkg/NV0LPS61/g== +"@aws-sdk/client-s3@^3.1041.0": + version "3.1041.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.1041.0.tgz#474b3f99a688554b51d5c65aed713605408adbc8" + integrity sha512-sQV14bIqslnBHuSlLMD+fc3pH+ajop6vnrFlJ4wM4JDqcYwVik4O+9srnZUrkesFw5y+CN0GfOQ06CAgtC4mjQ== dependencies: "@aws-crypto/sha1-browser" "5.2.0" "@aws-crypto/sha256-browser" "5.2.0" "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/credential-provider-node" "^3.972.10" - "@aws-sdk/middleware-bucket-endpoint" "^3.972.3" - "@aws-sdk/middleware-expect-continue" "^3.972.3" - "@aws-sdk/middleware-flexible-checksums" "^3.972.9" - "@aws-sdk/middleware-host-header" "^3.972.3" - "@aws-sdk/middleware-location-constraint" "^3.972.3" - "@aws-sdk/middleware-logger" "^3.972.3" - "@aws-sdk/middleware-recursion-detection" "^3.972.3" - "@aws-sdk/middleware-sdk-s3" "^3.972.11" - "@aws-sdk/middleware-ssec" "^3.972.3" - "@aws-sdk/middleware-user-agent" "^3.972.11" - "@aws-sdk/region-config-resolver" "^3.972.3" - "@aws-sdk/signature-v4-multi-region" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.993.0" - "@aws-sdk/util-user-agent-browser" "^3.972.3" - "@aws-sdk/util-user-agent-node" "^3.972.9" - "@smithy/config-resolver" "^4.4.6" - "@smithy/core" "^3.23.2" - "@smithy/eventstream-serde-browser" "^4.2.8" - "@smithy/eventstream-serde-config-resolver" "^4.3.8" - "@smithy/eventstream-serde-node" "^4.2.8" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/hash-blob-browser" "^4.2.9" - "@smithy/hash-node" "^4.2.8" - "@smithy/hash-stream-node" "^4.2.8" - "@smithy/invalid-dependency" "^4.2.8" - "@smithy/md5-js" "^4.2.8" - "@smithy/middleware-content-length" "^4.2.8" - "@smithy/middleware-endpoint" "^4.4.16" - "@smithy/middleware-retry" "^4.4.33" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/node-http-handler" "^4.4.10" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.32" - "@smithy/util-defaults-mode-node" "^4.2.35" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/util-stream" "^4.5.12" - "@smithy/util-utf8" "^4.2.0" - "@smithy/util-waiter" "^4.2.8" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/credential-provider-node" "^3.972.39" + "@aws-sdk/middleware-bucket-endpoint" "^3.972.10" + "@aws-sdk/middleware-expect-continue" "^3.972.10" + "@aws-sdk/middleware-flexible-checksums" "^3.974.16" + "@aws-sdk/middleware-host-header" "^3.972.10" + "@aws-sdk/middleware-location-constraint" "^3.972.10" + "@aws-sdk/middleware-logger" "^3.972.10" + "@aws-sdk/middleware-recursion-detection" "^3.972.11" + "@aws-sdk/middleware-sdk-s3" "^3.972.37" + "@aws-sdk/middleware-ssec" "^3.972.10" + "@aws-sdk/middleware-user-agent" "^3.972.38" + "@aws-sdk/region-config-resolver" "^3.972.13" + "@aws-sdk/signature-v4-multi-region" "^3.996.25" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-endpoints" "^3.996.8" + "@aws-sdk/util-user-agent-browser" "^3.972.10" + "@aws-sdk/util-user-agent-node" "^3.973.24" + "@smithy/config-resolver" "^4.4.17" + "@smithy/core" "^3.23.17" + "@smithy/eventstream-serde-browser" "^4.2.14" + "@smithy/eventstream-serde-config-resolver" "^4.3.14" + "@smithy/eventstream-serde-node" "^4.2.14" + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/hash-blob-browser" "^4.2.15" + "@smithy/hash-node" "^4.2.14" + "@smithy/hash-stream-node" "^4.2.14" + "@smithy/invalid-dependency" "^4.2.14" + "@smithy/md5-js" "^4.2.14" + "@smithy/middleware-content-length" "^4.2.14" + "@smithy/middleware-endpoint" "^4.4.32" + "@smithy/middleware-retry" "^4.5.7" + "@smithy/middleware-serde" "^4.2.20" + "@smithy/middleware-stack" "^4.2.14" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/protocol-http" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.49" + "@smithy/util-defaults-mode-node" "^4.2.54" + "@smithy/util-endpoints" "^3.4.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" + "@smithy/util-waiter" "^4.3.0" tslib "^2.6.2" "@aws-sdk/client-sso@3.993.0": @@ -839,31 +839,32 @@ "@smithy/util-utf8" "^4.2.0" tslib "^2.6.2" -"@aws-sdk/core@^3.973.11", "@aws-sdk/core@^3.973.5", "@aws-sdk/core@^3.973.6": - version "3.973.11" - resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.973.11.tgz#3aaf1493dc1d1793a348c84fe302e59a198996c1" - integrity sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA== - dependencies: - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/xml-builder" "^3.972.5" - "@smithy/core" "^3.23.2" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/property-provider" "^4.2.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/signature-v4" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-utf8" "^4.2.0" +"@aws-sdk/core@^3.973.11", "@aws-sdk/core@^3.973.5", "@aws-sdk/core@^3.973.6", "@aws-sdk/core@^3.974.8": + version "3.974.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.974.8.tgz#cdd51195a31322f1e429e66919eb18da8944c081" + integrity sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/xml-builder" "^3.972.22" + "@smithy/core" "^3.23.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/signature-v4" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/crc64-nvme@3.972.0": - version "3.972.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz#c5e6d14428c9fb4e6bb0646b73a0fa68e6007e24" - integrity sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw== +"@aws-sdk/crc64-nvme@^3.972.7": + version "3.972.7" + resolved "https://registry.yarnpkg.com/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz#0e56fb3ccc0242ed05ffd0bc993d724ce8b3dde2" + integrity sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" "@aws-sdk/credential-provider-cognito-identity@^3.972.3": @@ -877,122 +878,122 @@ "@smithy/types" "^4.12.0" tslib "^2.6.2" -"@aws-sdk/credential-provider-env@^3.972.4", "@aws-sdk/credential-provider-env@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.9.tgz#1290fb0aa49fb2a8d650e3f7886512add3ed97a1" - integrity sha512-ZptrOwQynfupubvcngLkbdIq/aXvl/czdpEG8XJ8mN8Nb19BR0jaK0bR+tfuMU36Ez9q4xv7GGkHFqEEP2hUUQ== +"@aws-sdk/credential-provider-env@^3.972.34", "@aws-sdk/credential-provider-env@^3.972.4", "@aws-sdk/credential-provider-env@^3.972.9": + version "3.972.34" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz#9d420adf02e7604094a641ae613a353aa86e1b83" + integrity sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ== dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/types" "^4.12.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-http@^3.972.11", "@aws-sdk/credential-provider-http@^3.972.6": - version "3.972.11" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.11.tgz#5af1e077aca5d6173c49eb63deaffc7f1184370a" - integrity sha512-hECWoOoH386bGr89NQc9vA/abkGf5TJrMREt+lhNcnSNmoBS04fK7vc3LrJBSQAUGGVj0Tz3f4dHB3w5veovig== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/types" "^3.973.1" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/node-http-handler" "^4.4.10" - "@smithy/property-provider" "^4.2.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/util-stream" "^4.5.12" +"@aws-sdk/credential-provider-http@^3.972.11", "@aws-sdk/credential-provider-http@^3.972.36", "@aws-sdk/credential-provider-http@^3.972.6": + version "3.972.36" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz#842268559da2ffc5855cde1e90e7302d53639c08" + integrity sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/property-provider" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-stream" "^4.5.25" tslib "^2.6.2" -"@aws-sdk/credential-provider-ini@^3.972.4", "@aws-sdk/credential-provider-ini@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.9.tgz#befbaefe54384bdb4c677d03127e627e733b35aa" - integrity sha512-zr1csEu9n4eDiHMTYJabX1mDGuGLgjgUnNckIivvk43DocJC9/f6DefFrnUPZXE+GHtbW50YuXb+JIxKykU74A== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/credential-provider-env" "^3.972.9" - "@aws-sdk/credential-provider-http" "^3.972.11" - "@aws-sdk/credential-provider-login" "^3.972.9" - "@aws-sdk/credential-provider-process" "^3.972.9" - "@aws-sdk/credential-provider-sso" "^3.972.9" - "@aws-sdk/credential-provider-web-identity" "^3.972.9" - "@aws-sdk/nested-clients" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/credential-provider-imds" "^4.2.8" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" +"@aws-sdk/credential-provider-ini@^3.972.38", "@aws-sdk/credential-provider-ini@^3.972.4", "@aws-sdk/credential-provider-ini@^3.972.9": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz#e20955fdfe4a88149b20dc7e25a517542e1dfd9f" + integrity sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/credential-provider-env" "^3.972.34" + "@aws-sdk/credential-provider-http" "^3.972.36" + "@aws-sdk/credential-provider-login" "^3.972.38" + "@aws-sdk/credential-provider-process" "^3.972.34" + "@aws-sdk/credential-provider-sso" "^3.972.38" + "@aws-sdk/credential-provider-web-identity" "^3.972.38" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/credential-provider-imds" "^4.2.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-login@^3.972.4", "@aws-sdk/credential-provider-login@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.9.tgz#ce71a9b2a42f4294fdc035adde8173fc99331bae" - integrity sha512-m4RIpVgZChv0vWS/HKChg1xLgZPpx8Z+ly9Fv7FwA8SOfuC6I3htcSaBz2Ch4bneRIiBUhwP4ziUo0UZgtJStQ== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/nested-clients" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" +"@aws-sdk/credential-provider-login@^3.972.38", "@aws-sdk/credential-provider-login@^3.972.4", "@aws-sdk/credential-provider-login@^3.972.9": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz#278437712c02a3ad1785f70c93b4f591cb3f6491" + integrity sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-node@^3.972.10", "@aws-sdk/credential-provider-node@^3.972.4", "@aws-sdk/credential-provider-node@^3.972.5": - version "3.972.10" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.10.tgz#577df01a8511ef6602b090e96832fc612bc81b03" - integrity sha512-70nCESlvnzjo4LjJ8By8MYIiBogkYPSXl3WmMZfH9RZcB/Nt9qVWbFpYj6Fk1vLa4Vk8qagFVeXgxdieMxG1QA== - dependencies: - "@aws-sdk/credential-provider-env" "^3.972.9" - "@aws-sdk/credential-provider-http" "^3.972.11" - "@aws-sdk/credential-provider-ini" "^3.972.9" - "@aws-sdk/credential-provider-process" "^3.972.9" - "@aws-sdk/credential-provider-sso" "^3.972.9" - "@aws-sdk/credential-provider-web-identity" "^3.972.9" - "@aws-sdk/types" "^3.973.1" - "@smithy/credential-provider-imds" "^4.2.8" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" +"@aws-sdk/credential-provider-node@^3.972.39", "@aws-sdk/credential-provider-node@^3.972.4", "@aws-sdk/credential-provider-node@^3.972.5": + version "3.972.39" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz#71f87848b7615dda4f31a57b113be9666e4bbd1a" + integrity sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.34" + "@aws-sdk/credential-provider-http" "^3.972.36" + "@aws-sdk/credential-provider-ini" "^3.972.38" + "@aws-sdk/credential-provider-process" "^3.972.34" + "@aws-sdk/credential-provider-sso" "^3.972.38" + "@aws-sdk/credential-provider-web-identity" "^3.972.38" + "@aws-sdk/types" "^3.973.8" + "@smithy/credential-provider-imds" "^4.2.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-process@^3.972.4", "@aws-sdk/credential-provider-process@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.9.tgz#efe60d47e54b42ac4ce901810a96152371249744" - integrity sha512-gOWl0Fe2gETj5Bk151+LYKpeGi2lBDLNu+NMNpHRlIrKHdBmVun8/AalwMK8ci4uRfG5a3/+zvZBMpuen1SZ0A== +"@aws-sdk/credential-provider-process@^3.972.34", "@aws-sdk/credential-provider-process@^3.972.4", "@aws-sdk/credential-provider-process@^3.972.9": + version "3.972.34" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz#c964275be1a528ac73ade6d98c309fb6b7cdfb68" + integrity sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q== dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-sso@^3.972.4", "@aws-sdk/credential-provider-sso@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.9.tgz#d9c79aa26a6a90dc4f4b527546e5fb9cb5b845de" - integrity sha512-ey7S686foGTArvFhi3ifQXmgptKYvLSGE2250BAQceMSXZddz7sUSNERGJT2S7u5KIe/kgugxrt01hntXVln6w== - dependencies: - "@aws-sdk/client-sso" "3.993.0" - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/token-providers" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" +"@aws-sdk/credential-provider-sso@^3.972.38", "@aws-sdk/credential-provider-sso@^3.972.4", "@aws-sdk/credential-provider-sso@^3.972.9": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz#ec754bfecb2426a3307e19ef7e6c6b6438a327c6" + integrity sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/token-providers" "3.1041.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-web-identity@^3.972.4", "@aws-sdk/credential-provider-web-identity@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.9.tgz#147c6daefdbb03f718daf86d1286558759510769" - integrity sha512-8LnfS76nHXoEc9aRRiMMpxZxJeDG0yusdyo3NvPhCgESmBUgpMa4luhGbClW5NoX/qRcGxxM6Z/esqANSNMTow== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/nested-clients" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" +"@aws-sdk/credential-provider-web-identity@^3.972.38", "@aws-sdk/credential-provider-web-identity@^3.972.4", "@aws-sdk/credential-provider-web-identity@^3.972.9": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz#149951ef6e12db5292118e8ed5d95133c24ad719" + integrity sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" "@aws-sdk/credential-providers@^3.186.0": @@ -1021,128 +1022,129 @@ "@smithy/types" "^4.12.0" tslib "^2.6.2" -"@aws-sdk/middleware-bucket-endpoint@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.3.tgz#158507d55505e5e7b5b8cdac9f037f6aa326f202" - integrity sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg== - dependencies: - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-arn-parser" "^3.972.2" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-config-provider" "^4.2.0" +"@aws-sdk/middleware-bucket-endpoint@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.10.tgz#d26aa88b441d6d1b6e9275ffdc61e0fbfb55a513" + integrity sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-arn-parser" "^3.972.3" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/middleware-expect-continue@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.3.tgz#c60bd81e81dde215b9f3f67e3c5448b608afd530" - integrity sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg== +"@aws-sdk/middleware-expect-continue@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.10.tgz#b685287951156a5d093cfdd37364894c6a8c966c" + integrity sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-flexible-checksums@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.9.tgz#37d2662dc00854fe121d5d090c855d40487bbfdc" - integrity sha512-E663+r/UQpvF3aJkD40p5ZANVQFsUcbE39jifMtN7wc0t1M0+2gJJp3i75R49aY9OiSX5lfVyPUNjN/BNRCCZA== +"@aws-sdk/middleware-flexible-checksums@^3.974.16": + version "3.974.16" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.16.tgz#89b78cb0ad389aba7d12d060f46017e1fa3784a9" + integrity sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA== dependencies: "@aws-crypto/crc32" "5.2.0" "@aws-crypto/crc32c" "5.2.0" "@aws-crypto/util" "5.2.0" - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/crc64-nvme" "3.972.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/is-array-buffer" "^4.2.0" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-stream" "^4.5.12" - "@smithy/util-utf8" "^4.2.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/crc64-nvme" "^3.972.7" + "@aws-sdk/types" "^3.973.8" + "@smithy/is-array-buffer" "^4.2.2" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/middleware-host-header@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz#47c161dec62d89c66c89f4d17ff4434021e04af5" - integrity sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA== +"@aws-sdk/middleware-host-header@^3.972.10", "@aws-sdk/middleware-host-header@^3.972.3": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz#e63b91959ce46948d789582351b2a44c4876e924" + integrity sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-location-constraint@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.3.tgz#b4f504f75baa19064b7457e5c6e3c8cecb4c32eb" - integrity sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g== +"@aws-sdk/middleware-location-constraint@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz#5265ea472f735c50b016bb5d1b46c7a616653733" + integrity sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-logger@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz#ef1afd4a0b70fe72cf5f7c817f82da9f35c7e836" - integrity sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA== +"@aws-sdk/middleware-logger@^3.972.10", "@aws-sdk/middleware-logger@^3.972.3": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz#d92b3374dcaddd523930bdff441207946343c270" + integrity sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-recursion-detection@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz#5b95dcecff76a0d2963bd954bdef87700d1b1c8c" - integrity sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q== +"@aws-sdk/middleware-recursion-detection@^3.972.11", "@aws-sdk/middleware-recursion-detection@^3.972.3": + version "3.972.11" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz#5659982a34fa58c69cbd358c2987c32aefd2bd91" + integrity sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ== dependencies: - "@aws-sdk/types" "^3.973.1" + "@aws-sdk/types" "^3.973.8" "@aws/lambda-invoke-store" "^0.2.2" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-sdk-s3@^3.972.11": - version "3.972.11" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.11.tgz#db6fc30c5ff70ee9b0a616f7fe3802bccbf73777" - integrity sha512-Qr0T7ZQTRMOuR6ahxEoJR1thPVovfWrKB2a6KBGR+a8/ELrFodrgHwhq50n+5VMaGuLtGhHiISU3XGsZmtmVXQ== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-arn-parser" "^3.972.2" - "@smithy/core" "^3.23.2" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/signature-v4" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/util-config-provider" "^4.2.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-stream" "^4.5.12" - "@smithy/util-utf8" "^4.2.0" +"@aws-sdk/middleware-sdk-s3@^3.972.37": + version "3.972.37" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz#82ef4953cddd3373d2942d07a5d2baf443bbf3ea" + integrity sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-arn-parser" "^3.972.3" + "@smithy/core" "^3.23.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/signature-v4" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/middleware-ssec@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.3.tgz#4f81d310fd91164e6e18ba3adab6bcf906920333" - integrity sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg== +"@aws-sdk/middleware-ssec@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz#46b5c030c0116f51110e18042ad3cf863ab5c81c" + integrity sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-user-agent@^3.972.11", "@aws-sdk/middleware-user-agent@^3.972.5", "@aws-sdk/middleware-user-agent@^3.972.6": - version "3.972.11" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.11.tgz#9723b323fd67ee4b96ff613877bb2fca4e3fc560" - integrity sha512-R8CvPsPHXwzIHCAza+bllY6PrctEk4lYq/SkHJz9NLoBHCcKQrbOcsfXxO6xmipSbUNIbNIUhH0lBsJGgsRdiw== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.993.0" - "@smithy/core" "^3.23.2" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" +"@aws-sdk/middleware-user-agent@^3.972.11", "@aws-sdk/middleware-user-agent@^3.972.38", "@aws-sdk/middleware-user-agent@^3.972.5", "@aws-sdk/middleware-user-agent@^3.972.6": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz#626d9a2499f5a6398a4db917abeeaac14b54c6cb" + integrity sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-endpoints" "^3.996.8" + "@smithy/core" "^3.23.17" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-retry" "^4.3.6" tslib "^2.6.2" "@aws-sdk/nested-clients@3.983.0": @@ -1233,27 +1235,85 @@ "@smithy/util-utf8" "^4.2.0" tslib "^2.6.2" -"@aws-sdk/region-config-resolver@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz#25af64235ca6f4b6b21f85d4b3c0b432efc4ae04" - integrity sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow== +"@aws-sdk/nested-clients@^3.997.6": + version "3.997.6" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz#17433cfac2160ec620a14cbff9d2b33675712cae" + integrity sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/config-resolver" "^4.4.6" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/types" "^4.12.0" + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/middleware-host-header" "^3.972.10" + "@aws-sdk/middleware-logger" "^3.972.10" + "@aws-sdk/middleware-recursion-detection" "^3.972.11" + "@aws-sdk/middleware-user-agent" "^3.972.38" + "@aws-sdk/region-config-resolver" "^3.972.13" + "@aws-sdk/signature-v4-multi-region" "^3.996.25" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-endpoints" "^3.996.8" + "@aws-sdk/util-user-agent-browser" "^3.972.10" + "@aws-sdk/util-user-agent-node" "^3.973.24" + "@smithy/config-resolver" "^4.4.17" + "@smithy/core" "^3.23.17" + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/hash-node" "^4.2.14" + "@smithy/invalid-dependency" "^4.2.14" + "@smithy/middleware-content-length" "^4.2.14" + "@smithy/middleware-endpoint" "^4.4.32" + "@smithy/middleware-retry" "^4.5.7" + "@smithy/middleware-serde" "^4.2.20" + "@smithy/middleware-stack" "^4.2.14" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/protocol-http" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.49" + "@smithy/util-defaults-mode-node" "^4.2.54" + "@smithy/util-endpoints" "^3.4.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/signature-v4-multi-region@3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.993.0.tgz#1bc2fe7936e53c33c6cb396568042ec3d5d1bc1c" - integrity sha512-6l20k27TJdqTozJOm+s20/1XDey3aj+yaeIdbtqXuYNhQiWHajvYThcI1sHx2I1W4NelZTOmYEF+dj1mya01eg== +"@aws-sdk/region-config-resolver@^3.972.13", "@aws-sdk/region-config-resolver@^3.972.3": + version "3.972.13" + resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz#bd32748c2d41b62be838fec76c4b87d4370939c6" + integrity sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A== dependencies: - "@aws-sdk/middleware-sdk-s3" "^3.972.11" - "@aws-sdk/types" "^3.973.1" - "@smithy/protocol-http" "^5.3.8" - "@smithy/signature-v4" "^5.3.8" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/config-resolver" "^4.4.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/signature-v4-multi-region@^3.996.25": + version "3.996.25" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz#b50651b7e4f9c82482416caa9953ad17645d4a2d" + integrity sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw== + dependencies: + "@aws-sdk/middleware-sdk-s3" "^3.972.37" + "@aws-sdk/types" "^3.973.8" + "@smithy/protocol-http" "^5.3.14" + "@smithy/signature-v4" "^5.3.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/token-providers@3.1041.0": + version "3.1041.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz#f3f068010780fc85fc4a7faa6a080cfb8afd73a4" + integrity sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" "@aws-sdk/token-providers@3.993.0": @@ -1269,18 +1329,18 @@ "@smithy/types" "^4.12.0" tslib "^2.6.2" -"@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.1": - version "3.973.1" - resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.1.tgz#1b2992ec6c8380c3e74c9bd2c74703e9a807d6e0" - integrity sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg== +"@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.1", "@aws-sdk/types@^3.973.8": + version "3.973.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.8.tgz#7352cb74a5f8bae1218eee63e714cf94302911c5" + integrity sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/util-arn-parser@^3.972.2": - version "3.972.2" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.2.tgz#ef18ba889e8ef35f083f1e962018bc0ce70acef3" - integrity sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg== +"@aws-sdk/util-arn-parser@^3.972.3": + version "3.972.3" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz#ed989862bbb172ce16d9e1cd5790e5fe367219c2" + integrity sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA== dependencies: tslib "^2.6.2" @@ -1317,6 +1377,17 @@ "@smithy/util-endpoints" "^3.2.8" tslib "^2.6.2" +"@aws-sdk/util-endpoints@^3.996.8": + version "3.996.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz#ad5c4f09b93482c0861d49d8a025edc2b0d2f5ec" + integrity sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-endpoints" "^3.4.2" + tslib "^2.6.2" + "@aws-sdk/util-locate-window@^3.0.0": version "3.535.0" resolved "https://registry.yarnpkg.com/@aws-sdk/util-locate-window/-/util-locate-window-3.535.0.tgz#0200a336fddd47dd6567ce15d01f62be50a315d7" @@ -1324,34 +1395,36 @@ dependencies: tslib "^2.6.2" -"@aws-sdk/util-user-agent-browser@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz#1363b388cb3af86c5322ef752c0cf8d7d25efa8a" - integrity sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw== +"@aws-sdk/util-user-agent-browser@^3.972.10", "@aws-sdk/util-user-agent-browser@^3.972.3": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz#e29be10389db9db12b2d8246ad247a89038f4c60" + integrity sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" bowser "^2.11.0" tslib "^2.6.2" -"@aws-sdk/util-user-agent-node@^3.972.3", "@aws-sdk/util-user-agent-node@^3.972.4", "@aws-sdk/util-user-agent-node@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.9.tgz#23f03f29daa06192d2308e5c52757c2515e761c8" - integrity sha512-JNswdsLdQemxqaSIBL2HRhsHPUBBziAgoi5RQv6/9avmE5g5RSdt1hWr3mHJ7OxqRYf+KeB11ExWbiqfrnoeaA== +"@aws-sdk/util-user-agent-node@^3.972.3", "@aws-sdk/util-user-agent-node@^3.972.4", "@aws-sdk/util-user-agent-node@^3.972.9", "@aws-sdk/util-user-agent-node@^3.973.24": + version "3.973.24" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz#cf44a63b92adfecaeb8cb9f948b390456310566a" + integrity sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw== dependencies: - "@aws-sdk/middleware-user-agent" "^3.972.11" - "@aws-sdk/types" "^3.973.1" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/types" "^4.12.0" + "@aws-sdk/middleware-user-agent" "^3.972.38" + "@aws-sdk/types" "^3.973.8" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/xml-builder@^3.972.5": - version "3.972.5" - resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.5.tgz#cde05cf1fa9021a8935e1e594fe8eacdce05f5a8" - integrity sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA== +"@aws-sdk/xml-builder@^3.972.22", "@aws-sdk/xml-builder@^3.972.5": + version "3.972.22" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz#1e44ca9fd9c3fdc3d9af9540ced024f34cfc60b2" + integrity sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA== dependencies: - "@smithy/types" "^4.12.0" - fast-xml-parser "5.3.6" + "@nodable/entities" "2.1.0" + "@smithy/types" "^4.14.1" + fast-xml-parser "5.7.2" tslib "^2.6.2" "@aws/lambda-invoke-store@^0.2.2": @@ -5585,6 +5658,11 @@ resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-14.2.13.tgz#859b38aaa57ffe1351d08f9166724936c9e6b365" integrity sha512-RQx/rGX7K/+R55x1R6Ax1JzyeHi8cW11dEXpzHWipyuSpusQLUN53F02eMB4VTakXsL3mFNWWy4bX3/LSq8/9w== +"@nodable/entities@2.1.0", "@nodable/entities@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-2.1.0.tgz#f543e5c6446720d4cf9e498a83019dd159973bc2" + integrity sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -8033,151 +8111,151 @@ "@smithy/types" "^4.12.0" tslib "^2.6.2" -"@smithy/chunked-blob-reader-native@^4.2.1": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz#380266951d746b522b4ab2b16bfea6b451147b41" - integrity sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ== +"@smithy/chunked-blob-reader-native@^4.2.3": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz#9e79a80d8d44798e7ce7a8f968cbbbaf5a40d950" + integrity sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw== dependencies: - "@smithy/util-base64" "^4.3.0" + "@smithy/util-base64" "^4.3.2" tslib "^2.6.2" -"@smithy/chunked-blob-reader@^5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz#776fec5eaa5ab5fa70d0d0174b7402420b24559c" - integrity sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA== +"@smithy/chunked-blob-reader@^5.2.2": + version "5.2.2" + resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz#3af48e37b10e5afed478bb31d2b7bc03c81d196c" + integrity sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw== dependencies: tslib "^2.6.2" -"@smithy/config-resolver@^4.4.6": - version "4.4.6" - resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.4.6.tgz#bd7f65b3da93f37f1c97a399ade0124635c02297" - integrity sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ== +"@smithy/config-resolver@^4.4.17", "@smithy/config-resolver@^4.4.6": + version "4.4.17" + resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.4.17.tgz#5bd7ccf461e126c79072ce84c6b0f3d00b3409bc" + integrity sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ== dependencies: - "@smithy/node-config-provider" "^4.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-config-provider" "^4.2.0" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" + "@smithy/util-endpoints" "^3.4.2" + "@smithy/util-middleware" "^4.2.14" tslib "^2.6.2" -"@smithy/core@^3.22.0", "@smithy/core@^3.23.2": - version "3.23.2" - resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.23.2.tgz#9300fe6fa6e8ceb19ecbbb9090ccea04942a37f0" - integrity sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA== - dependencies: - "@smithy/middleware-serde" "^4.2.9" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-stream" "^4.5.12" - "@smithy/util-utf8" "^4.2.0" - "@smithy/uuid" "^1.1.0" +"@smithy/core@^3.22.0", "@smithy/core@^3.23.17", "@smithy/core@^3.23.2": + version "3.23.17" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.23.17.tgz#23d02277c8d6d30a1605afd756696265e48ed67e" + integrity sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ== + dependencies: + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" + "@smithy/uuid" "^1.1.2" tslib "^2.6.2" -"@smithy/credential-provider-imds@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz#b2f4bf759ab1c35c0dd00fa3470263c749ebf60f" - integrity sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw== +"@smithy/credential-provider-imds@^4.2.14", "@smithy/credential-provider-imds@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz#b5dcc198ee240eaf68069e7449bcec29ce279827" + integrity sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg== dependencies: - "@smithy/node-config-provider" "^4.3.8" - "@smithy/property-provider" "^4.2.8" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" tslib "^2.6.2" -"@smithy/eventstream-codec@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz#2f431f4bac22e40aa6565189ea350c6fcb5efafd" - integrity sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw== +"@smithy/eventstream-codec@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz#4963ca27242b80c5b1d11dcd3ea1bee2a3c5f96d" + integrity sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw== dependencies: "@aws-crypto/crc32" "5.2.0" - "@smithy/types" "^4.12.0" - "@smithy/util-hex-encoding" "^4.2.0" + "@smithy/types" "^4.14.1" + "@smithy/util-hex-encoding" "^4.2.2" tslib "^2.6.2" -"@smithy/eventstream-serde-browser@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz#04e2e1fad18e286d5595fbc0bff22e71251fca38" - integrity sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw== +"@smithy/eventstream-serde-browser@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz#b483667ea358975afb2170cd2618b9aa53a0fb29" + integrity sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ== dependencies: - "@smithy/eventstream-serde-universal" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/eventstream-serde-universal" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/eventstream-serde-config-resolver@^4.3.8": - version "4.3.8" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz#b913d23834c6ebf1646164893e1bec89dffe4f3b" - integrity sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ== +"@smithy/eventstream-serde-config-resolver@^4.3.14": + version "4.3.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz#2eb23acad43414b9bc0b43f34ae9afbd5464e484" + integrity sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/eventstream-serde-node@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz#5f2dfa2cbb30bf7564c8d8d82a9832e9313f5243" - integrity sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A== +"@smithy/eventstream-serde-node@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz#402c2a3b0437b7ac9747090a38a60d3642813490" + integrity sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw== dependencies: - "@smithy/eventstream-serde-universal" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/eventstream-serde-universal" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/eventstream-serde-universal@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz#a62b389941c28a8c3ab44a0c8ba595447e0258a7" - integrity sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ== +"@smithy/eventstream-serde-universal@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz#1e1d29c111e580a93f3c197139c5ca8c976ec205" + integrity sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg== dependencies: - "@smithy/eventstream-codec" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/eventstream-codec" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/fetch-http-handler@^5.3.9": - version "5.3.9" - resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz#edfc9e90e0c7538c81e22e748d62c0066cc91d58" - integrity sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA== +"@smithy/fetch-http-handler@^5.3.17", "@smithy/fetch-http-handler@^5.3.9": + version "5.3.17" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz#bf13a4b03eb8afe101775fef59a1758f8fb5cd4b" + integrity sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw== dependencies: - "@smithy/protocol-http" "^5.3.8" - "@smithy/querystring-builder" "^4.2.8" - "@smithy/types" "^4.12.0" - "@smithy/util-base64" "^4.3.0" + "@smithy/protocol-http" "^5.3.14" + "@smithy/querystring-builder" "^4.2.14" + "@smithy/types" "^4.14.1" + "@smithy/util-base64" "^4.3.2" tslib "^2.6.2" -"@smithy/hash-blob-browser@^4.2.9": - version "4.2.9" - resolved "https://registry.yarnpkg.com/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz#4f8e19b12b5a1000b7292b30f5ee237d32216af3" - integrity sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg== +"@smithy/hash-blob-browser@^4.2.15": + version "4.2.15" + resolved "https://registry.yarnpkg.com/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.15.tgz#1323f9717cad352b3e18065b738387bb9684f993" + integrity sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA== dependencies: - "@smithy/chunked-blob-reader" "^5.2.0" - "@smithy/chunked-blob-reader-native" "^4.2.1" - "@smithy/types" "^4.12.0" + "@smithy/chunked-blob-reader" "^5.2.2" + "@smithy/chunked-blob-reader-native" "^4.2.3" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/hash-node@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.2.8.tgz#c21eb055041716cd492dda3a109852a94b6d47bb" - integrity sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA== +"@smithy/hash-node@^4.2.14", "@smithy/hash-node@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.2.14.tgz#e3ed33dc614e26fff5f043e097750c6931b48592" + integrity sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g== dependencies: - "@smithy/types" "^4.12.0" - "@smithy/util-buffer-from" "^4.2.0" - "@smithy/util-utf8" "^4.2.0" + "@smithy/types" "^4.14.1" + "@smithy/util-buffer-from" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/hash-stream-node@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz#d541a31c714ac9c85ae9fec91559e81286707ddb" - integrity sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w== +"@smithy/hash-stream-node@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/hash-stream-node/-/hash-stream-node-4.2.14.tgz#98bc14e79e2be852d04ff6cbfe4b0babe48fb10d" + integrity sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ== dependencies: - "@smithy/types" "^4.12.0" - "@smithy/util-utf8" "^4.2.0" + "@smithy/types" "^4.14.1" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/invalid-dependency@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz#c578bc6d5540c877aaed5034b986b5f6bd896451" - integrity sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ== +"@smithy/invalid-dependency@^4.2.14", "@smithy/invalid-dependency@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz#a52766f9d4299abcd9d6cd23b5a76f34fc59c7a0" + integrity sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" "@smithy/is-array-buffer@^2.2.0": @@ -8187,209 +8265,210 @@ dependencies: tslib "^2.6.2" -"@smithy/is-array-buffer@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz#b0f874c43887d3ad44f472a0f3f961bcce0550c2" - integrity sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ== +"@smithy/is-array-buffer@^4.2.0", "@smithy/is-array-buffer@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz#c401ce54b12a16529eb1c938a0b6c2247cb763b8" + integrity sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow== dependencies: tslib "^2.6.2" -"@smithy/md5-js@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/md5-js/-/md5-js-4.2.8.tgz#d354dbf9aea7a580be97598a581e35eef324ce22" - integrity sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ== +"@smithy/md5-js@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/md5-js/-/md5-js-4.2.14.tgz#c066572ec84def147af24e55a402c44d0d7dcd7b" + integrity sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA== dependencies: - "@smithy/types" "^4.12.0" - "@smithy/util-utf8" "^4.2.0" + "@smithy/types" "^4.14.1" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/middleware-content-length@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz#82c1df578fa70fe5800cf305b8788b9d2836a3e4" - integrity sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A== +"@smithy/middleware-content-length@^4.2.14", "@smithy/middleware-content-length@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz#d8b17f94c4d8f9c3b7992f1db84d3299c83efe78" + integrity sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw== dependencies: - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/middleware-endpoint@^4.4.12", "@smithy/middleware-endpoint@^4.4.16": - version "4.4.16" - resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.16.tgz#46408512c6737c4719c5d8abb9f99820824441e7" - integrity sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA== - dependencies: - "@smithy/core" "^3.23.2" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-middleware" "^4.2.8" +"@smithy/middleware-endpoint@^4.4.12", "@smithy/middleware-endpoint@^4.4.16", "@smithy/middleware-endpoint@^4.4.32": + version "4.4.32" + resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz#4c7dcf06b637b40dfcc53d3b18d1a784a747c530" + integrity sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q== + dependencies: + "@smithy/core" "^3.23.17" + "@smithy/middleware-serde" "^4.2.20" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-middleware" "^4.2.14" tslib "^2.6.2" -"@smithy/middleware-retry@^4.4.29", "@smithy/middleware-retry@^4.4.33": - version "4.4.33" - resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.4.33.tgz#37ac0f72683757a83074f66f7328d4f7d5150d75" - integrity sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA== - dependencies: - "@smithy/node-config-provider" "^4.3.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/service-error-classification" "^4.2.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/uuid" "^1.1.0" +"@smithy/middleware-retry@^4.4.29", "@smithy/middleware-retry@^4.4.33", "@smithy/middleware-retry@^4.5.7": + version "4.5.7" + resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz#a2da0c472d631ee408ff566186c99571b3efb70b" + integrity sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg== + dependencies: + "@smithy/core" "^3.23.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/service-error-classification" "^4.3.1" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/uuid" "^1.1.2" tslib "^2.6.2" -"@smithy/middleware-serde@^4.2.9": - version "4.2.9" - resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz#fd9d9b02b265aef67c9a30f55c2a5038fc9ca791" - integrity sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ== +"@smithy/middleware-serde@^4.2.20", "@smithy/middleware-serde@^4.2.9": + version "4.2.20" + resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz#76862c8f9b39b08501539440a2e6bca7a77de508" + integrity sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ== dependencies: - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" + "@smithy/core" "^3.23.17" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/middleware-stack@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz#4fa9cfaaa05f664c9bb15d45608f3cb4f6da2b76" - integrity sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA== +"@smithy/middleware-stack@^4.2.14", "@smithy/middleware-stack@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz#23a4cf643ccdbde52c8780fe5cc080611efef1c7" + integrity sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/node-config-provider@^4.3.8": - version "4.3.8" - resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz#85a0683448262b2eb822f64c14278d4887526377" - integrity sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg== +"@smithy/node-config-provider@^4.3.14", "@smithy/node-config-provider@^4.3.8": + version "4.3.14" + resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz#8ca13b86b6123cbb0425d669bd847fcd333ca4bd" + integrity sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg== dependencies: - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/node-http-handler@^4.4.10", "@smithy/node-http-handler@^4.4.8": - version "4.4.10" - resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.4.10.tgz#4945e2c2e61174ec1471337e3ddd50b8e4921204" - integrity sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA== +"@smithy/node-http-handler@^4.4.10", "@smithy/node-http-handler@^4.4.8", "@smithy/node-http-handler@^4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz#cb25b9445e46294a6f0dfb1566dbf2a1a19510af" + integrity sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg== dependencies: - "@smithy/abort-controller" "^4.2.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/querystring-builder" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/protocol-http" "^5.3.14" + "@smithy/querystring-builder" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/property-provider@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.2.8.tgz#6e37b30923d2d31370c50ce303a4339020031472" - integrity sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w== +"@smithy/property-provider@^4.2.14", "@smithy/property-provider@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.2.14.tgz#8072418672d8c29d3f9ef35e452437ba2c59100a" + integrity sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/protocol-http@^5.3.8": - version "5.3.8" - resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.8.tgz#0938f69a3c3673694c2f489a640fce468ce75006" - integrity sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ== +"@smithy/protocol-http@^5.3.14", "@smithy/protocol-http@^5.3.8": + version "5.3.14" + resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.14.tgz#ed1e65cdb0fffb7fd00dce997c04baa236f180cc" + integrity sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/querystring-builder@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz#2fa72d29eb1844a6a9933038bbbb14d6fe385e93" - integrity sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw== +"@smithy/querystring-builder@^4.2.14", "@smithy/querystring-builder@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz#102429e0fb004108babf219edfcf6f111e66d782" + integrity sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A== dependencies: - "@smithy/types" "^4.12.0" - "@smithy/util-uri-escape" "^4.2.0" + "@smithy/types" "^4.14.1" + "@smithy/util-uri-escape" "^4.2.2" tslib "^2.6.2" -"@smithy/querystring-parser@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz#aa3f2456180ce70242e89018d0b1ebd4782a6347" - integrity sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA== +"@smithy/querystring-parser@^4.2.14", "@smithy/querystring-parser@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz#c479ba1f346656b9f8ce46d9a91c229e4e50420f" + integrity sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/service-error-classification@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz#6d89dbad4f4978d7b75a44af8c18c22455a16cdc" - integrity sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ== +"@smithy/service-error-classification@^4.2.8", "@smithy/service-error-classification@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz#5303d4fc3c3eea0f79c3b88cb4436498a31e9f12" + integrity sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" -"@smithy/shared-ini-file-loader@^4.4.3": - version "4.4.3" - resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz#6054215ecb3a6532b13aa49a9fbda640b63be50e" - integrity sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg== +"@smithy/shared-ini-file-loader@^4.4.3", "@smithy/shared-ini-file-loader@^4.4.9": + version "4.4.9" + resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz#fb3719b401d101a65a682380b40efd3a116162f0" + integrity sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/signature-v4@^5.3.8": - version "5.3.8" - resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.8.tgz#796619b10b7cc9467d0625b0ebd263ae04fdfb76" - integrity sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg== - dependencies: - "@smithy/is-array-buffer" "^4.2.0" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-hex-encoding" "^4.2.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-uri-escape" "^4.2.0" - "@smithy/util-utf8" "^4.2.0" +"@smithy/signature-v4@^5.3.14", "@smithy/signature-v4@^5.3.8": + version "5.3.14" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.14.tgz#2b28c7d190301a67a520227a2343d1e7bb1c6d22" + integrity sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA== + dependencies: + "@smithy/is-array-buffer" "^4.2.2" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-hex-encoding" "^4.2.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-uri-escape" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/smithy-client@^4.11.1", "@smithy/smithy-client@^4.11.5": - version "4.11.5" - resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.11.5.tgz#4e2de632a036cffbf77337aac277131e85fcf399" - integrity sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ== - dependencies: - "@smithy/core" "^3.23.2" - "@smithy/middleware-endpoint" "^4.4.16" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-stream" "^4.5.12" +"@smithy/smithy-client@^4.11.1", "@smithy/smithy-client@^4.11.5", "@smithy/smithy-client@^4.12.13": + version "4.12.13" + resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.12.13.tgz#dec184a1d2d5027370ae1582bddbdbc068c97da5" + integrity sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA== + dependencies: + "@smithy/core" "^3.23.17" + "@smithy/middleware-endpoint" "^4.4.32" + "@smithy/middleware-stack" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-stream" "^4.5.25" tslib "^2.6.2" -"@smithy/types@^4.12.0": - version "4.12.0" - resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.12.0.tgz#55d2479080922bda516092dbf31916991d9c6fee" - integrity sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw== +"@smithy/types@^4.12.0", "@smithy/types@^4.14.1": + version "4.14.1" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.14.1.tgz#aba92b4cdb406f2a2b062e82f1e3728d809a7c23" + integrity sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg== dependencies: tslib "^2.6.2" -"@smithy/url-parser@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/url-parser/-/url-parser-4.2.8.tgz#b44267cd704abe114abcd00580acdd9e4acc1177" - integrity sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA== +"@smithy/url-parser@^4.2.14", "@smithy/url-parser@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/url-parser/-/url-parser-4.2.14.tgz#349a442a62eb5907533f204b73a010618198b073" + integrity sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ== dependencies: - "@smithy/querystring-parser" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/querystring-parser" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-base64@^4.3.0": - version "4.3.0" - resolved "https://registry.yarnpkg.com/@smithy/util-base64/-/util-base64-4.3.0.tgz#5e287b528793aa7363877c1a02cd880d2e76241d" - integrity sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ== +"@smithy/util-base64@^4.3.0", "@smithy/util-base64@^4.3.2": + version "4.3.2" + resolved "https://registry.yarnpkg.com/@smithy/util-base64/-/util-base64-4.3.2.tgz#be02bcb29a87be744356467ea25ffa413e695cea" + integrity sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ== dependencies: - "@smithy/util-buffer-from" "^4.2.0" - "@smithy/util-utf8" "^4.2.0" + "@smithy/util-buffer-from" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/util-body-length-browser@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz#04e9fc51ee7a3e7f648a4b4bcdf96c350cfa4d61" - integrity sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg== +"@smithy/util-body-length-browser@^4.2.0", "@smithy/util-body-length-browser@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz#c4404277d22039872abdb80e7800f9a63f263862" + integrity sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ== dependencies: tslib "^2.6.2" -"@smithy/util-body-length-node@^4.2.1": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz#79c8a5d18e010cce6c42d5cbaf6c1958523e6fec" - integrity sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA== +"@smithy/util-body-length-node@^4.2.1", "@smithy/util-body-length-node@^4.2.3": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz#f923ca530defb86a9ac3ca2d3066bcca7b304fbc" + integrity sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g== dependencies: tslib "^2.6.2" @@ -8401,95 +8480,95 @@ "@smithy/is-array-buffer" "^2.2.0" tslib "^2.6.2" -"@smithy/util-buffer-from@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz#7abd12c4991b546e7cee24d1e8b4bfaa35c68a9d" - integrity sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew== +"@smithy/util-buffer-from@^4.2.0", "@smithy/util-buffer-from@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz#2c6b7857757dfd88f6cd2d36016179a40ccc913b" + integrity sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q== dependencies: - "@smithy/is-array-buffer" "^4.2.0" + "@smithy/is-array-buffer" "^4.2.2" tslib "^2.6.2" -"@smithy/util-config-provider@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz#2e4722937f8feda4dcb09672c59925a4e6286cfc" - integrity sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q== +"@smithy/util-config-provider@^4.2.0", "@smithy/util-config-provider@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz#52ebf9d8942838d18bc5fb1520de1e8699d7aad6" + integrity sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ== dependencies: tslib "^2.6.2" -"@smithy/util-defaults-mode-browser@^4.3.28", "@smithy/util-defaults-mode-browser@^4.3.32": - version "4.3.32" - resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.32.tgz#683496a0b38a3e5231a25ca7cce8028eb437f3b2" - integrity sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg== +"@smithy/util-defaults-mode-browser@^4.3.28", "@smithy/util-defaults-mode-browser@^4.3.32", "@smithy/util-defaults-mode-browser@^4.3.49": + version "4.3.49" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz#926ce84bf65e56307f25cce7a13b427d33442939" + integrity sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw== dependencies: - "@smithy/property-provider" "^4.2.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" + "@smithy/property-provider" "^4.2.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-defaults-mode-node@^4.2.31", "@smithy/util-defaults-mode-node@^4.2.35": - version "4.2.35" - resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.35.tgz#110575d6e85c282bb9b9283da886a8cf2fb68c6a" - integrity sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q== - dependencies: - "@smithy/config-resolver" "^4.4.6" - "@smithy/credential-provider-imds" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/property-provider" "^4.2.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" +"@smithy/util-defaults-mode-node@^4.2.31", "@smithy/util-defaults-mode-node@^4.2.35", "@smithy/util-defaults-mode-node@^4.2.54": + version "4.2.54" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz#32c4ea9f8a8c74ef9fe0ca5e3d6a10df0327f87e" + integrity sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw== + dependencies: + "@smithy/config-resolver" "^4.4.17" + "@smithy/credential-provider-imds" "^4.2.14" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-endpoints@^3.2.8": - version "3.2.8" - resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz#5650bda2adac989ff2e562606088c5de3dcb1b36" - integrity sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw== +"@smithy/util-endpoints@^3.2.8", "@smithy/util-endpoints@^3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz#ee59c42d039a642b6c6eb2d38e0ae3db6fc48e97" + integrity sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg== dependencies: - "@smithy/node-config-provider" "^4.3.8" - "@smithy/types" "^4.12.0" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-hex-encoding@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz#1c22ea3d1e2c3a81ff81c0a4f9c056a175068a7b" - integrity sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw== +"@smithy/util-hex-encoding@^4.2.0", "@smithy/util-hex-encoding@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz#4abf3335dd1eb884041d8589ca7628d81a6fd1d3" + integrity sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg== dependencies: tslib "^2.6.2" -"@smithy/util-middleware@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.2.8.tgz#1da33f29a74c7ebd9e584813cb7e12881600a80a" - integrity sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A== +"@smithy/util-middleware@^4.2.14", "@smithy/util-middleware@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.2.14.tgz#9985dd82b4036db2d03835229b9b0c63d2bb85fa" + integrity sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-retry@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.2.8.tgz#23f3f47baf0681233fd0c37b259e60e268c73b11" - integrity sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg== +"@smithy/util-retry@^4.2.8", "@smithy/util-retry@^4.3.6": + version "4.3.8" + resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.3.8.tgz#7f904ed8e5bad2b5f2e6aa1e193db2b46b2c57df" + integrity sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw== dependencies: - "@smithy/service-error-classification" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/service-error-classification" "^4.3.1" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-stream@^4.5.12": - version "4.5.12" - resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-4.5.12.tgz#f8734a01dce2e51530231e6afc8910397d3e300a" - integrity sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg== - dependencies: - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/node-http-handler" "^4.4.10" - "@smithy/types" "^4.12.0" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-buffer-from" "^4.2.0" - "@smithy/util-hex-encoding" "^4.2.0" - "@smithy/util-utf8" "^4.2.0" +"@smithy/util-stream@^4.5.12", "@smithy/util-stream@^4.5.25": + version "4.5.25" + resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-4.5.25.tgz#f48385a284151c7e099395af4e5fb0978fffe4ff" + integrity sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA== + dependencies: + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/types" "^4.14.1" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-buffer-from" "^4.2.2" + "@smithy/util-hex-encoding" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/util-uri-escape@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz#096a4cec537d108ac24a68a9c60bee73fc7e3a9e" - integrity sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA== +"@smithy/util-uri-escape@^4.2.0", "@smithy/util-uri-escape@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz#48e40206e7fe9daefc8d44bb43a1ab17e76abf4a" + integrity sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw== dependencies: tslib "^2.6.2" @@ -8501,27 +8580,26 @@ "@smithy/util-buffer-from" "^2.2.0" tslib "^2.6.2" -"@smithy/util-utf8@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-4.2.0.tgz#8b19d1514f621c44a3a68151f3d43e51087fed9d" - integrity sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw== +"@smithy/util-utf8@^4.2.0", "@smithy/util-utf8@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-4.2.2.tgz#21db686982e6f3393ac262e49143b42370130f13" + integrity sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw== dependencies: - "@smithy/util-buffer-from" "^4.2.0" + "@smithy/util-buffer-from" "^4.2.2" tslib "^2.6.2" -"@smithy/util-waiter@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/util-waiter/-/util-waiter-4.2.8.tgz#35d7bd8b2be7a2ebc12d8c38a0818c501b73e928" - integrity sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg== +"@smithy/util-waiter@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@smithy/util-waiter/-/util-waiter-4.3.0.tgz#6122ce27939edb5550d1d6c7c8d506323f3a17f7" + integrity sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA== dependencies: - "@smithy/abort-controller" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/uuid@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@smithy/uuid/-/uuid-1.1.0.tgz#9fd09d3f91375eab94f478858123387df1cda987" - integrity sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw== +"@smithy/uuid@^1.1.0", "@smithy/uuid@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@smithy/uuid/-/uuid-1.1.2.tgz#b6e97c7158615e4a3c775e809c00d8c269b5a12e" + integrity sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g== dependencies: tslib "^2.6.2" @@ -17367,10 +17445,10 @@ fast-uri@^3.0.1: resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.6.tgz#88f130b77cfaea2378d56bf970dea21257a68748" integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw== -fast-xml-builder@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz#0c407a1d9d5996336c0cd76f7ff785cac6413017" - integrity sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg== +fast-xml-builder@^1.1.5: + version "1.1.7" + resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.1.7.tgz#b445dfa48d5e7636a50d7ff39c7f4254552bfdff" + integrity sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ== dependencies: path-expression-matcher "^1.1.3" @@ -17381,21 +17459,22 @@ fast-xml-parser@5.3.6: dependencies: strnum "^2.1.2" -fast-xml-parser@^4.4.1: - version "4.5.4" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz#64e52ddf1308001893bd225d5b1768840511c797" - integrity sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ== +fast-xml-parser@5.7.2, fast-xml-parser@^5.0.7: + version "5.7.2" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz#fecd0b054c6c132fc03dab994a413da781e0eb9f" + integrity sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w== dependencies: - strnum "^1.0.5" + "@nodable/entities" "^2.1.0" + fast-xml-builder "^1.1.5" + path-expression-matcher "^1.5.0" + strnum "^2.2.3" -fast-xml-parser@^5.0.7: - version "5.5.8" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz#929571ed8c5eb96e6d9bd572ba14fc4b84875716" - integrity sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ== +fast-xml-parser@^4.4.1: + version "4.5.6" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz#4ff57d4aca13a2d11aa42ad460495cf00f32b655" + integrity sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A== dependencies: - fast-xml-builder "^1.1.4" - path-expression-matcher "^1.2.0" - strnum "^2.2.0" + strnum "^1.0.5" fastq@^1.6.0: version "1.19.1" @@ -24306,10 +24385,10 @@ path-exists@^5.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-5.0.0.tgz#a6aad9489200b21fab31e49cf09277e5116fb9e7" integrity sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ== -path-expression-matcher@^1.1.3, path-expression-matcher@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz#9bdae3787f43b0857b0269e9caaa586c12c8abee" - integrity sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ== +path-expression-matcher@^1.1.3, path-expression-matcher@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz#3b98545dc88ffebb593e2d8458d0929da9275f4a" + integrity sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ== path-is-absolute@1.0.1, path-is-absolute@^1.0.0: version "1.0.1" @@ -28493,10 +28572,10 @@ strnum@^1.0.5: resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4" integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA== -strnum@^2.1.2, strnum@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.1.tgz#d28f896b4ef9985212494ce8bcf7ca304fad8368" - integrity sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg== +strnum@^2.1.2, strnum@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.3.tgz#0119fce02749a11bb126a4d686ac5dbdf6e57586" + integrity sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg== strtok3@^10.3.4: version "10.3.4" From 05d2eb6c326cf9621fe5392a82acdd777fb1944a Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 4 May 2026 14:06:30 +0200 Subject: [PATCH 34/84] test(cloudflare): Reduce flakiness for cloudflare with sub workers (#20632) Fixes https://github.com/getsentry/sentry-javascript/issues/20626 It seems that cloudflare integration tests can be flaky. After some digging into this with some clanker help, the idea came up that this is due to sub workers not being ready when DEV_SERVER_READY is emitted. To accomodate this, in the case of a sub worker existing this just adds a little delay (100ms, random number) to wait for before we continue, hopefully reducing flakes. AI wanted to look at more complex things like retrying requests etc. but this felt a bit overkill, I _think_ this is likely good enough if that is indeed the problem...? --------- Co-authored-by: JPeer264 --- .../cloudflare-integration-tests/runner.ts | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index 542ffe82b802..0d19e2e4384b 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -208,22 +208,33 @@ export function createRunner(...paths: string[]) { if (process.env.DEBUG) log('Starting scenario', testPath); - const stdio: ('inherit' | 'ipc' | 'ignore')[] = process.env.DEBUG - ? ['inherit', 'inherit', 'inherit', 'ipc'] - : ['ignore', 'ignore', 'ignore', 'ipc']; - const onChildError = (e: Error) => { // eslint-disable-next-line no-console console.error('Error starting child process:', e); reject(e); }; - function onChildMessage(message: string, onReady?: (port: number) => void): void { - const msg = JSON.parse(message) as { event: string; port?: number }; - if (msg.event === 'DEV_SERVER_READY' && typeof msg.port === 'number') { - if (process.env.DEBUG) log('worker ready on port', msg.port); - onReady?.(msg.port); - } + // Inspired by workers-sdk: https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/e2e/helpers/wrangler.ts + function waitForReady(childProcess: ReturnType): Promise { + return new Promise((resolve, reject) => { + const stdout = childProcess.stdout; + if (!stdout) { + reject(new Error('No stdout available')); + return; + } + + let output = ''; + stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + if (process.env.DEBUG) process.stdout.write(text); + output += text; + + const match = output.match(/Ready on (https?:\/\/[^\s]+)/); + if (match?.[1]) { + resolve(parseInt(new URL(match[1]).port, 10)); + } + }); + }); } if (existsSync(join(testPath, 'wrangler-sub-worker.jsonc'))) { @@ -242,17 +253,15 @@ export function createRunner(...paths: string[]) { '--inspector-port', '0', ], - { stdio, signal }, + { stdio: ['ignore', 'pipe', 'inherit'], signal }, ); - // Wait for the sub-worker to be ready before starting the main worker - await new Promise((resolveSubWorker, rejectSubWorker) => { - childSubWorker!.on('message', (msg: string) => onChildMessage(msg, () => resolveSubWorker())); - childSubWorker!.on('error', rejectSubWorker); - childSubWorker!.on('exit', code => { - rejectSubWorker(new Error(`Sub-worker exited with code ${code}`)); - }); + childSubWorker.on('error', onChildError); + childSubWorker.on('exit', code => { + onChildError(new Error(`Sub-worker exited with code ${code}`)); }); + + await waitForReady(childSubWorker); } child = spawn( @@ -273,7 +282,7 @@ export function createRunner(...paths: string[]) { '0', ...extraWranglerArgs, ], - { stdio, signal }, + { stdio: ['ignore', 'pipe', 'inherit'], signal }, ); CLEANUP_STEPS.add(() => { @@ -283,7 +292,10 @@ export function createRunner(...paths: string[]) { childSubWorker?.on('error', onChildError); child.on('error', onChildError); - child.on('message', (msg: string) => onChildMessage(msg, setWorkerPort)); + + const workerPort = await waitForReady(child); + + setWorkerPort(workerPort); }) .catch(e => reject(e)); From 96955b9b962ff403055a2eb27f57e617e9173211 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 14:12:13 +0200 Subject: [PATCH 35/84] fix(deps): Bump rollup-plugin-license to fix lodash vulnerabilities (#20636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Bumps `rollup-plugin-license` from 3.3.1 → 3.7.1 - This updates the lodash constraint from `~4.17.21` to `^4.17.21`, allowing resolution to patched lodash 4.18.x - Removes orphaned `lodash@4.17.23` lockfile entry - Fixes [Dependabot alert 1281](https://github.com/getsentry/sentry-javascript/security/dependabot/1281) (CVE-2026-4800, code injection via `_.template`) - Fixes [Dependabot alert 1280](https://github.com/getsentry/sentry-javascript/security/dependabot/1280) (CVE-2026-2950, prototype pollution via `_.unset`/`_.omit`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- package.json | 4 +- yarn.lock | 234 ++++++++++++--------------------------------------- 2 files changed, 55 insertions(+), 183 deletions(-) diff --git a/package.json b/package.json index 396d361ade40..8abe6b6598ad 100644 --- a/package.json +++ b/package.json @@ -122,9 +122,9 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@rollup/pluginutils": "^5.1.0", + "@size-limit/esbuild": "~12.1.0", "@size-limit/file": "~12.1.0", "@size-limit/webpack": "~12.1.0", - "@size-limit/esbuild": "~12.1.0", "@types/jsdom": "^21.1.6", "@types/node": "^18.19.1", "@vitest/coverage-v8": "^3.2.4", @@ -142,7 +142,7 @@ "rimraf": "^5.0.10", "rollup": "^4.59.0", "rollup-plugin-cleanup": "^3.2.1", - "rollup-plugin-license": "^3.3.1", + "rollup-plugin-license": "^3.7.1", "size-limit": "~12.1.0", "sucrase": "^3.35.0", "ts-node": "10.9.2", diff --git a/yarn.lock b/yarn.lock index 499d9d2d498b..f86a464627ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -795,51 +795,7 @@ "@smithy/util-waiter" "^4.3.0" tslib "^2.6.2" -"@aws-sdk/client-sso@3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso/-/client-sso-3.993.0.tgz#6948256598d84eb4b5ee953a8a1be1ed375aafef" - integrity sha512-VLUN+wIeNX24fg12SCbzTUBnBENlL014yMKZvRhPkcn4wHR6LKgNrjsG3fZ03Xs0XoKaGtNFi1VVrq666sGBoQ== - dependencies: - "@aws-crypto/sha256-browser" "5.2.0" - "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/middleware-host-header" "^3.972.3" - "@aws-sdk/middleware-logger" "^3.972.3" - "@aws-sdk/middleware-recursion-detection" "^3.972.3" - "@aws-sdk/middleware-user-agent" "^3.972.11" - "@aws-sdk/region-config-resolver" "^3.972.3" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.993.0" - "@aws-sdk/util-user-agent-browser" "^3.972.3" - "@aws-sdk/util-user-agent-node" "^3.972.9" - "@smithy/config-resolver" "^4.4.6" - "@smithy/core" "^3.23.2" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/hash-node" "^4.2.8" - "@smithy/invalid-dependency" "^4.2.8" - "@smithy/middleware-content-length" "^4.2.8" - "@smithy/middleware-endpoint" "^4.4.16" - "@smithy/middleware-retry" "^4.4.33" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/node-http-handler" "^4.4.10" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.32" - "@smithy/util-defaults-mode-node" "^4.2.35" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/util-utf8" "^4.2.0" - tslib "^2.6.2" - -"@aws-sdk/core@^3.973.11", "@aws-sdk/core@^3.973.5", "@aws-sdk/core@^3.973.6", "@aws-sdk/core@^3.974.8": +"@aws-sdk/core@^3.973.5", "@aws-sdk/core@^3.973.6", "@aws-sdk/core@^3.974.8": version "3.974.8" resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.974.8.tgz#cdd51195a31322f1e429e66919eb18da8944c081" integrity sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw== @@ -878,7 +834,7 @@ "@smithy/types" "^4.12.0" tslib "^2.6.2" -"@aws-sdk/credential-provider-env@^3.972.34", "@aws-sdk/credential-provider-env@^3.972.4", "@aws-sdk/credential-provider-env@^3.972.9": +"@aws-sdk/credential-provider-env@^3.972.34", "@aws-sdk/credential-provider-env@^3.972.4": version "3.972.34" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz#9d420adf02e7604094a641ae613a353aa86e1b83" integrity sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ== @@ -889,7 +845,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-http@^3.972.11", "@aws-sdk/credential-provider-http@^3.972.36", "@aws-sdk/credential-provider-http@^3.972.6": +"@aws-sdk/credential-provider-http@^3.972.36", "@aws-sdk/credential-provider-http@^3.972.6": version "3.972.36" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz#842268559da2ffc5855cde1e90e7302d53639c08" integrity sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg== @@ -905,7 +861,7 @@ "@smithy/util-stream" "^4.5.25" tslib "^2.6.2" -"@aws-sdk/credential-provider-ini@^3.972.38", "@aws-sdk/credential-provider-ini@^3.972.4", "@aws-sdk/credential-provider-ini@^3.972.9": +"@aws-sdk/credential-provider-ini@^3.972.38", "@aws-sdk/credential-provider-ini@^3.972.4": version "3.972.38" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz#e20955fdfe4a88149b20dc7e25a517542e1dfd9f" integrity sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ== @@ -925,7 +881,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-login@^3.972.38", "@aws-sdk/credential-provider-login@^3.972.4", "@aws-sdk/credential-provider-login@^3.972.9": +"@aws-sdk/credential-provider-login@^3.972.38", "@aws-sdk/credential-provider-login@^3.972.4": version "3.972.38" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz#278437712c02a3ad1785f70c93b4f591cb3f6491" integrity sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww== @@ -957,7 +913,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-process@^3.972.34", "@aws-sdk/credential-provider-process@^3.972.4", "@aws-sdk/credential-provider-process@^3.972.9": +"@aws-sdk/credential-provider-process@^3.972.34", "@aws-sdk/credential-provider-process@^3.972.4": version "3.972.34" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz#c964275be1a528ac73ade6d98c309fb6b7cdfb68" integrity sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q== @@ -969,7 +925,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-sso@^3.972.38", "@aws-sdk/credential-provider-sso@^3.972.4", "@aws-sdk/credential-provider-sso@^3.972.9": +"@aws-sdk/credential-provider-sso@^3.972.38", "@aws-sdk/credential-provider-sso@^3.972.4": version "3.972.38" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz#ec754bfecb2426a3307e19ef7e6c6b6438a327c6" integrity sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA== @@ -983,7 +939,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-web-identity@^3.972.38", "@aws-sdk/credential-provider-web-identity@^3.972.4", "@aws-sdk/credential-provider-web-identity@^3.972.9": +"@aws-sdk/credential-provider-web-identity@^3.972.38", "@aws-sdk/credential-provider-web-identity@^3.972.4": version "3.972.38" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz#149951ef6e12db5292118e8ed5d95133c24ad719" integrity sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw== @@ -1133,7 +1089,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-user-agent@^3.972.11", "@aws-sdk/middleware-user-agent@^3.972.38", "@aws-sdk/middleware-user-agent@^3.972.5", "@aws-sdk/middleware-user-agent@^3.972.6": +"@aws-sdk/middleware-user-agent@^3.972.38", "@aws-sdk/middleware-user-agent@^3.972.5", "@aws-sdk/middleware-user-agent@^3.972.6": version "3.972.38" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz#626d9a2499f5a6398a4db917abeeaac14b54c6cb" integrity sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A== @@ -1191,50 +1147,6 @@ "@smithy/util-utf8" "^4.2.0" tslib "^2.6.2" -"@aws-sdk/nested-clients@3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.993.0.tgz#9d93d9b3bf3f031d6addd9ee58cd90148f59362d" - integrity sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ== - dependencies: - "@aws-crypto/sha256-browser" "5.2.0" - "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/middleware-host-header" "^3.972.3" - "@aws-sdk/middleware-logger" "^3.972.3" - "@aws-sdk/middleware-recursion-detection" "^3.972.3" - "@aws-sdk/middleware-user-agent" "^3.972.11" - "@aws-sdk/region-config-resolver" "^3.972.3" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.993.0" - "@aws-sdk/util-user-agent-browser" "^3.972.3" - "@aws-sdk/util-user-agent-node" "^3.972.9" - "@smithy/config-resolver" "^4.4.6" - "@smithy/core" "^3.23.2" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/hash-node" "^4.2.8" - "@smithy/invalid-dependency" "^4.2.8" - "@smithy/middleware-content-length" "^4.2.8" - "@smithy/middleware-endpoint" "^4.4.16" - "@smithy/middleware-retry" "^4.4.33" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/node-http-handler" "^4.4.10" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.32" - "@smithy/util-defaults-mode-node" "^4.2.35" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/util-utf8" "^4.2.0" - tslib "^2.6.2" - "@aws-sdk/nested-clients@^3.997.6": version "3.997.6" resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz#17433cfac2160ec620a14cbff9d2b33675712cae" @@ -1316,19 +1228,6 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/token-providers@3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.993.0.tgz#4d52a67e7699acbea356a50504943ace883fe03c" - integrity sha512-+35g4c+8r7sB9Sjp1KPdM8qxGn6B/shBjJtEUN4e+Edw9UEQlZKIzioOGu3UAbyE0a/s450LdLZr4wbJChtmww== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/nested-clients" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" - tslib "^2.6.2" - "@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.1", "@aws-sdk/types@^3.973.8": version "3.973.8" resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.8.tgz#7352cb74a5f8bae1218eee63e714cf94302911c5" @@ -1366,17 +1265,6 @@ "@smithy/util-endpoints" "^3.2.8" tslib "^2.6.2" -"@aws-sdk/util-endpoints@3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.993.0.tgz#60a11de23df02e76142a06dd20878b47255fee56" - integrity sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw== - dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-endpoints" "^3.2.8" - tslib "^2.6.2" - "@aws-sdk/util-endpoints@^3.996.8": version "3.996.8" resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz#ad5c4f09b93482c0861d49d8a025edc2b0d2f5ec" @@ -1405,7 +1293,7 @@ bowser "^2.11.0" tslib "^2.6.2" -"@aws-sdk/util-user-agent-node@^3.972.3", "@aws-sdk/util-user-agent-node@^3.972.4", "@aws-sdk/util-user-agent-node@^3.972.9", "@aws-sdk/util-user-agent-node@^3.973.24": +"@aws-sdk/util-user-agent-node@^3.972.3", "@aws-sdk/util-user-agent-node@^3.972.4", "@aws-sdk/util-user-agent-node@^3.973.24": version "3.973.24" resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz#cf44a63b92adfecaeb8cb9f948b390456310566a" integrity sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw== @@ -1417,7 +1305,7 @@ "@smithy/util-config-provider" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/xml-builder@^3.972.22", "@aws-sdk/xml-builder@^3.972.5": +"@aws-sdk/xml-builder@^3.972.22": version "3.972.22" resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz#1e44ca9fd9c3fdc3d9af9540ced024f34cfc60b2" integrity sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA== @@ -8103,14 +7991,6 @@ nanoid "^5.1.7" webpack "^5.106.1" -"@smithy/abort-controller@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/abort-controller/-/abort-controller-4.2.8.tgz#3bfd7a51acce88eaec9a65c3382542be9f3a053a" - integrity sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw== - dependencies: - "@smithy/types" "^4.12.0" - tslib "^2.6.2" - "@smithy/chunked-blob-reader-native@^4.2.3": version "4.2.3" resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz#9e79a80d8d44798e7ce7a8f968cbbbaf5a40d950" @@ -8138,7 +8018,7 @@ "@smithy/util-middleware" "^4.2.14" tslib "^2.6.2" -"@smithy/core@^3.22.0", "@smithy/core@^3.23.17", "@smithy/core@^3.23.2": +"@smithy/core@^3.22.0", "@smithy/core@^3.23.17": version "3.23.17" resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.23.17.tgz#23d02277c8d6d30a1605afd756696265e48ed67e" integrity sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ== @@ -8265,7 +8145,7 @@ dependencies: tslib "^2.6.2" -"@smithy/is-array-buffer@^4.2.0", "@smithy/is-array-buffer@^4.2.2": +"@smithy/is-array-buffer@^4.2.2": version "4.2.2" resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz#c401ce54b12a16529eb1c938a0b6c2247cb763b8" integrity sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow== @@ -8290,7 +8170,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/middleware-endpoint@^4.4.12", "@smithy/middleware-endpoint@^4.4.16", "@smithy/middleware-endpoint@^4.4.32": +"@smithy/middleware-endpoint@^4.4.12", "@smithy/middleware-endpoint@^4.4.32": version "4.4.32" resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz#4c7dcf06b637b40dfcc53d3b18d1a784a747c530" integrity sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q== @@ -8304,7 +8184,7 @@ "@smithy/util-middleware" "^4.2.14" tslib "^2.6.2" -"@smithy/middleware-retry@^4.4.29", "@smithy/middleware-retry@^4.4.33", "@smithy/middleware-retry@^4.5.7": +"@smithy/middleware-retry@^4.4.29", "@smithy/middleware-retry@^4.5.7": version "4.5.7" resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz#a2da0c472d631ee408ff566186c99571b3efb70b" integrity sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg== @@ -8348,7 +8228,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/node-http-handler@^4.4.10", "@smithy/node-http-handler@^4.4.8", "@smithy/node-http-handler@^4.6.1": +"@smithy/node-http-handler@^4.4.8", "@smithy/node-http-handler@^4.6.1": version "4.6.1" resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz#cb25b9445e46294a6f0dfb1566dbf2a1a19510af" integrity sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg== @@ -8374,7 +8254,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/querystring-builder@^4.2.14", "@smithy/querystring-builder@^4.2.8": +"@smithy/querystring-builder@^4.2.14": version "4.2.14" resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz#102429e0fb004108babf219edfcf6f111e66d782" integrity sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A== @@ -8383,7 +8263,7 @@ "@smithy/util-uri-escape" "^4.2.2" tslib "^2.6.2" -"@smithy/querystring-parser@^4.2.14", "@smithy/querystring-parser@^4.2.8": +"@smithy/querystring-parser@^4.2.14": version "4.2.14" resolved "https://registry.yarnpkg.com/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz#c479ba1f346656b9f8ce46d9a91c229e4e50420f" integrity sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw== @@ -8391,14 +8271,14 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/service-error-classification@^4.2.8", "@smithy/service-error-classification@^4.3.1": +"@smithy/service-error-classification@^4.3.1": version "4.3.1" resolved "https://registry.yarnpkg.com/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz#5303d4fc3c3eea0f79c3b88cb4436498a31e9f12" integrity sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw== dependencies: "@smithy/types" "^4.14.1" -"@smithy/shared-ini-file-loader@^4.4.3", "@smithy/shared-ini-file-loader@^4.4.9": +"@smithy/shared-ini-file-loader@^4.4.9": version "4.4.9" resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz#fb3719b401d101a65a682380b40efd3a116162f0" integrity sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ== @@ -8406,7 +8286,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/signature-v4@^5.3.14", "@smithy/signature-v4@^5.3.8": +"@smithy/signature-v4@^5.3.14": version "5.3.14" resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.14.tgz#2b28c7d190301a67a520227a2343d1e7bb1c6d22" integrity sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA== @@ -8420,7 +8300,7 @@ "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/smithy-client@^4.11.1", "@smithy/smithy-client@^4.11.5", "@smithy/smithy-client@^4.12.13": +"@smithy/smithy-client@^4.11.1", "@smithy/smithy-client@^4.12.13": version "4.12.13" resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.12.13.tgz#dec184a1d2d5027370ae1582bddbdbc068c97da5" integrity sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA== @@ -8480,7 +8360,7 @@ "@smithy/is-array-buffer" "^2.2.0" tslib "^2.6.2" -"@smithy/util-buffer-from@^4.2.0", "@smithy/util-buffer-from@^4.2.2": +"@smithy/util-buffer-from@^4.2.2": version "4.2.2" resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz#2c6b7857757dfd88f6cd2d36016179a40ccc913b" integrity sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q== @@ -8488,14 +8368,14 @@ "@smithy/is-array-buffer" "^4.2.2" tslib "^2.6.2" -"@smithy/util-config-provider@^4.2.0", "@smithy/util-config-provider@^4.2.2": +"@smithy/util-config-provider@^4.2.2": version "4.2.2" resolved "https://registry.yarnpkg.com/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz#52ebf9d8942838d18bc5fb1520de1e8699d7aad6" integrity sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ== dependencies: tslib "^2.6.2" -"@smithy/util-defaults-mode-browser@^4.3.28", "@smithy/util-defaults-mode-browser@^4.3.32", "@smithy/util-defaults-mode-browser@^4.3.49": +"@smithy/util-defaults-mode-browser@^4.3.28", "@smithy/util-defaults-mode-browser@^4.3.49": version "4.3.49" resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz#926ce84bf65e56307f25cce7a13b427d33442939" integrity sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw== @@ -8505,7 +8385,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-defaults-mode-node@^4.2.31", "@smithy/util-defaults-mode-node@^4.2.35", "@smithy/util-defaults-mode-node@^4.2.54": +"@smithy/util-defaults-mode-node@^4.2.31", "@smithy/util-defaults-mode-node@^4.2.54": version "4.2.54" resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz#32c4ea9f8a8c74ef9fe0ca5e3d6a10df0327f87e" integrity sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw== @@ -8527,7 +8407,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-hex-encoding@^4.2.0", "@smithy/util-hex-encoding@^4.2.2": +"@smithy/util-hex-encoding@^4.2.2": version "4.2.2" resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz#4abf3335dd1eb884041d8589ca7628d81a6fd1d3" integrity sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg== @@ -8551,7 +8431,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-stream@^4.5.12", "@smithy/util-stream@^4.5.25": +"@smithy/util-stream@^4.5.25": version "4.5.25" resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-4.5.25.tgz#f48385a284151c7e099395af4e5fb0978fffe4ff" integrity sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA== @@ -8565,7 +8445,7 @@ "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/util-uri-escape@^4.2.0", "@smithy/util-uri-escape@^4.2.2": +"@smithy/util-uri-escape@^4.2.2": version "4.2.2" resolved "https://registry.yarnpkg.com/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz#48e40206e7fe9daefc8d44bb43a1ab17e76abf4a" integrity sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw== @@ -8596,7 +8476,7 @@ "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/uuid@^1.1.0", "@smithy/uuid@^1.1.2": +"@smithy/uuid@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@smithy/uuid/-/uuid-1.1.2.tgz#b6e97c7158615e4a3c775e809c00d8c269b5a12e" integrity sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g== @@ -13818,7 +13698,7 @@ comment-parser@1.4.1, comment-parser@^1.1.2: resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.4.1.tgz#bdafead37961ac079be11eb7ec65c4d021eaf9cc" integrity sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg== -commenting@~1.1.0: +commenting@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/commenting/-/commenting-1.1.0.tgz#fae14345c6437b8554f30bc6aa6c1e1633033590" integrity sha512-YeNK4tavZwtH7jEgK1ZINXzLKm6DZdEMfsaaieOsCAN0S8vsY7UeuO3Q7d/M018EFgE+IeUAuBOKkFccBZsUZA== @@ -17452,13 +17332,6 @@ fast-xml-builder@^1.1.5: dependencies: path-expression-matcher "^1.1.3" -fast-xml-parser@5.3.6: - version "5.3.6" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz#85a69117ca156b1b3c52e426495b6de266cb6a4b" - integrity sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA== - dependencies: - strnum "^2.1.2" - fast-xml-parser@5.7.2, fast-xml-parser@^5.0.7: version "5.7.2" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz#fecd0b054c6c132fc03dab994a413da781e0eb9f" @@ -17497,7 +17370,7 @@ fb-watchman@^2.0.0, fb-watchman@^2.0.1: dependencies: bser "2.1.1" -fdir@^6.2.0, fdir@^6.4.4, fdir@^6.5.0: +fdir@^6.2.0, fdir@^6.4.3, fdir@^6.4.4, fdir@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== @@ -18359,7 +18232,7 @@ glob@^5.0.10: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.0, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.3, glob@~7.2.0: +glob@^7.0.0, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.3: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -21184,7 +21057,7 @@ lodash.uniq@^4.2.0, lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@4.17.23, lodash@~4.17.21: +lodash@4.17.23: version "4.17.23" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== @@ -22412,7 +22285,7 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mkdirp@^3.0.1, mkdirp@~3.0.0: +mkdirp@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== @@ -22479,7 +22352,7 @@ module-lookup-amd@^9.0.3: requirejs "^2.3.7" requirejs-config-file "^4.0.0" -moment@~2.30.1: +moment@^2.30.1: version "2.30.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== @@ -24210,7 +24083,7 @@ package-json-from-dist@^1.0.0, package-json-from-dist@^1.0.1: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== -package-name-regex@~2.0.6: +package-name-regex@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/package-name-regex/-/package-name-regex-2.0.6.tgz#b54bcb04d950e38082b7bb38fa558e01c1679334" integrity sha512-gFL35q7kbE/zBaPA3UKhp2vSzcPYx2ecbYuwv1ucE9Il6IIgBDweBlH8D68UFGZic2MkllKa2KHCfC1IQBQUYA== @@ -26883,20 +26756,19 @@ rollup-plugin-dts@^6.0.0: optionalDependencies: "@babel/code-frame" "^7.24.2" -rollup-plugin-license@^3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/rollup-plugin-license/-/rollup-plugin-license-3.3.1.tgz#73b68e33477524198d6f3f9befc905f59bf37c53" - integrity sha512-lwZ/J8QgSnP0unVOH2FQuOBkeiyp0EBvrbYdNU33lOaYD8xP9Zoki+PGoWMD31EUq8Q07GGocSABTYlWMKkwuw== - dependencies: - commenting "~1.1.0" - glob "~7.2.0" - lodash "~4.17.21" - magic-string "~0.30.0" - mkdirp "~3.0.0" - moment "~2.30.1" - package-name-regex "~2.0.6" - spdx-expression-validate "~2.0.0" - spdx-satisfies "~5.0.1" +rollup-plugin-license@^3.7.1: + version "3.7.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-license/-/rollup-plugin-license-3.7.1.tgz#b99329f1c840142559789e3d6cb9f69e9e5b36ef" + integrity sha512-FcGXUbAmPvRSLxjVdjp/r/MUtKBlttVQd+ApUyvKfREnsoAfAZA6Ic2fE1Tz4RL0f9XqEQU9UIRNUMdtQtliDw== + dependencies: + commenting "^1.1.0" + fdir "^6.4.3" + lodash "^4.17.21" + magic-string "^0.30.0" + moment "^2.30.1" + package-name-regex "^2.0.6" + spdx-expression-validate "^2.0.0" + spdx-satisfies "^5.0.1" rollup-plugin-sourcemaps@^0.6.3: version "0.6.3" @@ -28100,7 +27972,7 @@ spdx-expression-parse@^4.0.0: spdx-exceptions "^2.1.0" spdx-license-ids "^3.0.0" -spdx-expression-validate@~2.0.0: +spdx-expression-validate@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/spdx-expression-validate/-/spdx-expression-validate-2.0.0.tgz#25c9408e1c63fad94fff5517bb7101ffcd23350b" integrity sha512-b3wydZLM+Tc6CFvaRDBOF9d76oGIHNCLYFeHbftFXUWjnfZWganmDmvtM5sm1cRwJc/VDBMLyGGrsLFd1vOxbg== @@ -28117,7 +27989,7 @@ spdx-ranges@^2.0.0: resolved "https://registry.yarnpkg.com/spdx-ranges/-/spdx-ranges-2.1.1.tgz#87573927ba51e92b3f4550ab60bfc83dd07bac20" integrity sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA== -spdx-satisfies@~5.0.1: +spdx-satisfies@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/spdx-satisfies/-/spdx-satisfies-5.0.1.tgz#9feeb2524686c08e5f7933c16248d4fdf07ed6a6" integrity sha512-Nwor6W6gzFp8XX4neaKQ7ChV4wmpSh2sSDemMFSzHxpTw460jxFYeOn+jq4ybnSSw/5sc3pjka9MQPouksQNpw== @@ -28572,7 +28444,7 @@ strnum@^1.0.5: resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4" integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA== -strnum@^2.1.2, strnum@^2.2.3: +strnum@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.3.tgz#0119fce02749a11bb126a4d686ac5dbdf6e57586" integrity sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg== From ac6721bffcc53ea6af23189d737f87ee8de3ffcb Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 4 May 2026 14:14:39 +0200 Subject: [PATCH 36/84] fix: Bump vite versions to fix vulnerability (#20646) closes https://github.com/getsentry/sentry-javascript/issues/20309 closes https://github.com/getsentry/sentry-javascript/issues/20236 closes https://github.com/getsentry/sentry-javascript/issues/20235 Related CVE: - https://github.com/advisories/GHSA-p9ff-h696-f583 --- .../test-applications/browser-webworker-vite/package.json | 2 +- .../react-router-7-framework-spa-node-20-18/package.json | 2 +- .../react-router-7-framework-spa/package.json | 2 +- .../test-applications/react-router-7-spa/package.json | 2 +- .../test-applications/solid-tanstack-router/package.json | 2 +- .../sveltekit-2-kit-tracing/package.json | 2 +- .../sveltekit-cloudflare-pages/package.json | 2 +- packages/react-router/package.json | 2 +- packages/remix/package.json | 2 +- packages/remix/test/integration/package.json | 4 ++-- yarn.lock | 8 ++++---- 11 files changed, 15 insertions(+), 15 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json index 1c391eb7cf5e..71cae14a0120 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json @@ -15,7 +15,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.3.2" }, "dependencies": { "@sentry/browser": "file:../../packed/sentry-browser-packed.tgz", diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/package.json index 56c4b7d052d7..2c78f5adb154 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/package.json @@ -35,7 +35,7 @@ "@types/react-dom": "^19.1.2", "tailwindcss": "^4.1.4", "typescript": "^5.8.3", - "vite": "^6.3.3", + "vite": "^6.4.2", "vite-tsconfig-paths": "^5.1.4" }, "browserslist": { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json index 28f189bcd1f3..80535ff38302 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json @@ -35,7 +35,7 @@ "@types/react-dom": "^19.1.2", "tailwindcss": "^4.1.4", "typescript": "^5.8.3", - "vite": "^6.3.3", + "vite": "^6.4.2", "vite-tsconfig-paths": "^5.1.4" }, "browserslist": { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json index c792359c5a3f..eee79f453d56 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json @@ -13,7 +13,7 @@ "devDependencies": { "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", - "vite": "^6.0.1", + "vite": "^6.4.2", "@vitejs/plugin-react": "^4.3.4", "typescript": "~5.0.0" }, diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json index 04a2fd2adeec..0a8fbefff393 100644 --- a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json @@ -25,7 +25,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "typescript": "^5.7.2", - "vite": "^7.1.7", + "vite": "^7.3.2", "vite-plugin-solid": "^2.11.2" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json index 162c148d3a86..12f39178da15 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json @@ -28,7 +28,7 @@ "svelte-check": "^4.3.1", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^7.1.3" + "vite": "^7.3.2" }, "type": "module", "volta": { diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json index 354e10bf6ab3..b95f2348ba3b 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json @@ -25,7 +25,7 @@ "svelte": "^5.20.2", "svelte-check": "^4.1.4", "typescript": "^5.0.0", - "vite": "^6.1.1", + "vite": "^6.4.2", "wrangler": "^4.61.0" }, "volta": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index e0baac716684..2aaad75f6e04 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -62,7 +62,7 @@ "@react-router/node": "^7.13.1", "react": "^18.3.1", "react-router": "^7.13.0", - "vite": "^6.1.0" + "vite": "^6.4.2" }, "peerDependencies": { "@react-router/node": "7.x", diff --git a/packages/remix/package.json b/packages/remix/package.json index 6efceb2daeb1..5f99d0eba51d 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -81,7 +81,7 @@ "@types/express": "^4.17.14", "react": "^18.3.1", "react-dom": "^18.3.1", - "vite": "^6.0.0" + "vite": "^6.4.2" }, "peerDependencies": { "@remix-run/node": "2.x", diff --git a/packages/remix/test/integration/package.json b/packages/remix/test/integration/package.json index 40652b48b905..71a325233cb6 100644 --- a/packages/remix/test/integration/package.json +++ b/packages/remix/test/integration/package.json @@ -22,7 +22,7 @@ "@types/react-dom": "^18", "nock": "^13.5.5", "typescript": "~5.8.0", - "vite": "^6.0.0" + "vite": "^6.4.2" }, "resolutions": { "@sentry/browser": "file:../../../browser", @@ -39,7 +39,7 @@ "@vanilla-extract/css": "1.13.0", "@vanilla-extract/integration": "6.2.4", "@types/mime": "^3.0.0", - "vite": "^6.0.0" + "vite": "^6.4.2" }, "engines": { "node": ">=18" diff --git a/yarn.lock b/yarn.lock index f86a464627ee..48e1cb955544 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30443,10 +30443,10 @@ vite@^5.0.0, vite@^5.4.11, vite@^5.4.21: optionalDependencies: fsevents "~2.3.3" -"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.0.0, vite@^6.1.0, vite@^6.3.5, vite@^6.4.1: - version "6.4.1" - resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.1.tgz#afbe14518cdd6887e240a4b0221ab6d0ce733f96" - integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g== +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.5, vite@^6.4.1, vite@^6.4.2: + version "6.4.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.2.tgz#a4e548ca3a90ca9f3724582cab35e1ba15efc6f2" + integrity sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ== dependencies: esbuild "^0.25.0" fdir "^6.4.4" From 803d715c1c0c0e29fe9c5aa1ec0f0593cf4c7c5b Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 24 Apr 2026 18:03:02 -0700 Subject: [PATCH 37/84] fix(opentelemetry): Respect OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES (#20509) This uses the string passed into `getSentryResource` as a fallback, preferring instead to use the value in `env.OTEL_SERVICE_NAME` if set, or the `service.name` field in the comma-delimited key=value pairs in `env.OTEL_RESOURCE_ATTRIBUTES` pairs. Additional `env.OTEL_RESOURCE_ATTRIBUTES` are also attached to the resource attributes. fix: js-2280 fix: #20502 --- packages/opentelemetry/src/resource.ts | 44 ++++++- packages/opentelemetry/test/resource.test.ts | 131 +++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 packages/opentelemetry/test/resource.test.ts diff --git a/packages/opentelemetry/src/resource.ts b/packages/opentelemetry/src/resource.ts index 9c1f95a7179c..89b84927ee65 100644 --- a/packages/opentelemetry/src/resource.ts +++ b/packages/opentelemetry/src/resource.ts @@ -39,18 +39,58 @@ class SentryResource { } } +/** + * Parses `OTEL_RESOURCE_ATTRIBUTES` env var (comma-separated `key=value` pairs). + * Values are URL-decoded per the OTel spec. + */ +function parseOtelResourceAttributes(raw: string | undefined): Attributes { + if (!raw) { + return {}; + } + const result: Attributes = {}; + for (const pair of raw.split(',')) { + const eq = pair.indexOf('='); + if (eq === -1) { + continue; + } + const key = pair.substring(0, eq).trim(); + const value = pair.substring(eq + 1).trim(); + if (key) { + try { + result[key] = decodeURIComponent(value); + } catch { + result[key] = value; + } + } + } + return result; +} + /** * Returns a Resource for use in Sentry's OpenTelemetry TracerProvider setup. * * Combines the default OTel SDK telemetry attributes with Sentry-specific * service attributes, equivalent to what was previously done via: * `defaultResource().merge(resourceFromAttributes({ ... }))` + * + * Respects OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES environment variables + * per the OpenTelemetry specification. */ -export function getSentryResource(serviceName: string): SentryResource { +export function getSentryResource(serviceNameFallback: string): SentryResource { + const env = typeof process !== 'undefined' ? process.env : {}; + const otelServiceName = env.OTEL_SERVICE_NAME; + const otelResourceAttrs = parseOtelResourceAttributes(env.OTEL_RESOURCE_ATTRIBUTES); + return new SentryResource({ - [ATTR_SERVICE_NAME]: serviceName, + // Lowest priority: Sentry defaults // eslint-disable-next-line deprecation/deprecation [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', + [ATTR_SERVICE_NAME]: serviceNameFallback, + // OTEL_RESOURCE_ATTRIBUTES overrides defaults (including service.name and service.namespace) + ...otelResourceAttrs, + // OTEL_SERVICE_NAME explicitly overrides service.name + ...(otelServiceName ? { [ATTR_SERVICE_NAME]: otelServiceName } : {}), + // Highest priority: Sentry SDK telemetry attrs (cannot be overridden by env vars) [ATTR_SERVICE_VERSION]: SDK_VERSION, [ATTR_TELEMETRY_SDK_LANGUAGE]: SDK_INFO[ATTR_TELEMETRY_SDK_LANGUAGE], [ATTR_TELEMETRY_SDK_NAME]: SDK_INFO[ATTR_TELEMETRY_SDK_NAME], diff --git a/packages/opentelemetry/test/resource.test.ts b/packages/opentelemetry/test/resource.test.ts new file mode 100644 index 000000000000..1a6ebedf34d4 --- /dev/null +++ b/packages/opentelemetry/test/resource.test.ts @@ -0,0 +1,131 @@ +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, + ATTR_TELEMETRY_SDK_LANGUAGE, + ATTR_TELEMETRY_SDK_NAME, + ATTR_TELEMETRY_SDK_VERSION, + SEMRESATTRS_SERVICE_NAMESPACE, +} from '@opentelemetry/semantic-conventions'; +import { SDK_VERSION } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getSentryResource } from '../src/resource'; +import { SDK_INFO } from '@opentelemetry/core'; + +describe('getSentryResource', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Clone env so mutations are isolated + process.env = { ...originalEnv }; + delete process.env['OTEL_SERVICE_NAME']; + delete process.env['OTEL_RESOURCE_ATTRIBUTES']; + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('uses serviceNameFallback when no env vars are set', () => { + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('node'); + }); + + it('uses OTEL_SERVICE_NAME over the fallback', () => { + process.env['OTEL_SERVICE_NAME'] = 'my-service'; + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('my-service'); + }); + + it('ignores empty OTEL_SERVICE_NAME and falls back to serviceNameFallback', () => { + process.env['OTEL_SERVICE_NAME'] = ''; + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('node'); + }); + + it('includes OTEL_RESOURCE_ATTRIBUTES key=value pairs', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'custom.key=custom-value,another.key=another-value'; + const resource = getSentryResource('node'); + expect(resource.attributes['custom.key']).toBe('custom-value'); + expect(resource.attributes['another.key']).toBe('another-value'); + }); + + it('OTEL_RESOURCE_ATTRIBUTES can override service.name (but OTEL_SERVICE_NAME takes precedence over it)', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'service.name=from-attrs'; + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('from-attrs'); + }); + + it('OTEL_SERVICE_NAME takes precedence over service.name from OTEL_RESOURCE_ATTRIBUTES', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'service.name=from-attrs'; + process.env['OTEL_SERVICE_NAME'] = 'from-service-name'; + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('from-service-name'); + }); + + it('OTEL_RESOURCE_ATTRIBUTES can override service.namespace', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'service.namespace=my-namespace'; + const resource = getSentryResource('node'); + // eslint-disable-next-line deprecation/deprecation + expect(resource.attributes[SEMRESATTRS_SERVICE_NAMESPACE]).toBe('my-namespace'); + }); + + it('Sentry SDK telemetry attrs cannot be overridden by OTEL_RESOURCE_ATTRIBUTES', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = + 'telemetry.sdk.name=evil,telemetry.sdk.language=evil,telemetry.sdk.version=0.0.0'; + const resource = getSentryResource('node'); + // not evil or 0.0.0 + expect(resource.attributes[ATTR_TELEMETRY_SDK_NAME]).toBe(SDK_INFO[ATTR_TELEMETRY_SDK_NAME]); + expect(resource.attributes[ATTR_TELEMETRY_SDK_LANGUAGE]).toBe(SDK_INFO[ATTR_TELEMETRY_SDK_LANGUAGE]); + expect(resource.attributes[ATTR_TELEMETRY_SDK_VERSION]).toBe(SDK_INFO[ATTR_TELEMETRY_SDK_VERSION]); + }); + + it('Sentry SDK telemetry attrs cannot be overridden by OTEL_SERVICE_NAME (service.version)', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'service.version=0.0.0'; + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_VERSION]).toBe(SDK_VERSION); + }); + + it('always includes Sentry SDK telemetry attributes', () => { + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_TELEMETRY_SDK_LANGUAGE]).toBeDefined(); + expect(resource.attributes[ATTR_TELEMETRY_SDK_NAME]).toBeDefined(); + expect(resource.attributes[ATTR_TELEMETRY_SDK_VERSION]).toBeDefined(); + expect(resource.attributes[ATTR_SERVICE_VERSION]).toBe(SDK_VERSION); + }); + + it('always sets service.namespace to sentry by default', () => { + const resource = getSentryResource('node'); + // eslint-disable-next-line deprecation/deprecation + expect(resource.attributes[SEMRESATTRS_SERVICE_NAMESPACE]).toBe('sentry'); + }); + + it('URL-decodes values in OTEL_RESOURCE_ATTRIBUTES', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'custom.key=hello%20world'; + const resource = getSentryResource('node'); + expect(resource.attributes['custom.key']).toBe('hello world'); + }); + + it('handles malformed OTEL_RESOURCE_ATTRIBUTES gracefully (no = sign)', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'badentry,custom.key=value'; + expect(() => getSentryResource('node')).not.toThrow(); + const resource = getSentryResource('node'); + expect(resource.attributes['custom.key']).toBe('value'); + }); + + it('handles empty OTEL_RESOURCE_ATTRIBUTES gracefully', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = ''; + expect(() => getSentryResource('node')).not.toThrow(); + }); + + it('does not crash when process is undefined', () => { + const saved = global.process; + // @ts-expect-error — simulating edge runtime where process may be undefined + global.process = undefined; + try { + expect(() => getSentryResource('node')).not.toThrow(); + } finally { + global.process = saved; + } + }); +}); From 5637aa090de1496c5cbd6dcab5b62cb004ae5e68 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 15:01:35 +0200 Subject: [PATCH 38/84] chore(deps): Bump @nestjs packages to fix path-to-regexp ReDoS (#20642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Bumps `@nestjs/core` and `@nestjs/platform-express` from 11.1.6 → 11.1.19 in integration tests - Updates `path-to-regexp` from 8.2.0/8.3.0 → 8.4.2 (deduplicates all 8.x entries) - Fixes [Dependabot alert 1276](https://github.com/getsentry/sentry-javascript/security/dependabot/1276) (CVE-2026-4926, DoS via sequential optional groups) - Fixes [Dependabot alert 1277](https://github.com/getsentry/sentry-javascript/security/dependabot/1277) (CVE-2026-4923, ReDoS via multiple wildcards) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- yarn.lock | 86 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 62 insertions(+), 24 deletions(-) diff --git a/yarn.lock b/yarn.lock index 48e1cb955544..8833ef477507 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5436,6 +5436,17 @@ dependencies: "@tybys/wasm-util" "^0.10.1" +"@nestjs/common@11.1.19", "@nestjs/common@^11": + version "11.1.19" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.19.tgz#50ba93ae45ebaeda6163554b8e2ecec545a25c92" + integrity sha512-qeiTt2tv+e5QyDKqG8HlVZb2wx64FEaSGFJouqTSRs+kG44iTfl3xlz1XqVped+rihx4hmjWgL5gkhtdK3E6+Q== + dependencies: + uid "2.0.2" + file-type "21.3.4" + iterare "1.2.1" + load-esm "1.0.3" + tslib "2.8.1" + "@nestjs/common@^10.0.0": version "10.4.15" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70" @@ -5445,15 +5456,16 @@ iterare "1.2.1" tslib "2.8.1" -"@nestjs/common@^11": - version "11.1.17" - resolved "https://registry.npmjs.org/@nestjs/common/-/common-11.1.17.tgz" - integrity sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg== +"@nestjs/core@11.1.19": + version "11.1.19" + resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.19.tgz#d724f1afc0caac29e005464f0f659425fc80235b" + integrity sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw== dependencies: uid "2.0.2" - file-type "21.3.2" + "@nuxt/opencollective" "0.4.1" + fast-safe-stringify "2.1.1" iterare "1.2.1" - load-esm "1.0.3" + path-to-regexp "8.4.2" tslib "2.8.1" "@nestjs/core@^10.0.0": @@ -5469,26 +5481,37 @@ tslib "2.8.1" "@nestjs/core@^11": - version "11.1.6" - resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.6.tgz#9d54882f121168b2fa2b07fa1db0858161a80626" - integrity sha512-siWX7UDgErisW18VTeJA+x+/tpNZrJewjTBsRPF3JVxuWRuAB1kRoiJcxHgln8Lb5UY9NdvklITR84DUEXD0Cg== + version "11.1.19" + resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.19.tgz#d724f1afc0caac29e005464f0f659425fc80235b" + integrity sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw== dependencies: uid "2.0.2" "@nuxt/opencollective" "0.4.1" fast-safe-stringify "2.1.1" iterare "1.2.1" - path-to-regexp "8.2.0" + path-to-regexp "8.4.2" + tslib "2.8.1" + +"@nestjs/platform-express@11.1.19": + version "11.1.19" + resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.19.tgz#e55f5078396b2285344f95f2b530b648e844cd4c" + integrity sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg== + dependencies: + cors "2.8.6" + express "5.2.1" + multer "2.1.1" + path-to-regexp "8.4.2" tslib "2.8.1" "@nestjs/platform-express@^11": - version "11.1.13" - resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.13.tgz#272e350cb3938ec0f383aa083c7f1d5d44fae2dc" - integrity sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w== + version "11.1.19" + resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.19.tgz#e55f5078396b2285344f95f2b530b648e844cd4c" + integrity sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg== dependencies: cors "2.8.6" express "5.2.1" - multer "2.0.2" - path-to-regexp "8.3.0" + multer "2.1.1" + path-to-regexp "8.4.2" tslib "2.8.1" "@next/env@14.2.35": @@ -17428,6 +17451,16 @@ file-type@21.3.2: token-types "^6.1.1" uint8array-extras "^1.4.0" +file-type@21.3.4: + version "21.3.4" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-21.3.4.tgz#e3f902faee8ec4aa152909fc902a7a77f9c06725" + integrity sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g== + dependencies: + "@tokenizer/inflate" "^0.4.1" + strtok3 "^10.3.4" + token-types "^6.1.1" + uint8array-extras "^1.4.0" + file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -22543,6 +22576,16 @@ multer@2.0.2: type-is "^1.6.18" xtend "^4.0.2" +multer@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/multer/-/multer-2.1.1.tgz#122d819244fbdfee1efddd9147426691014385b7" + integrity sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A== + dependencies: + append-field "^1.0.0" + busboy "^1.6.0" + concat-stream "^2.0.0" + type-is "^1.6.18" + multicast-dns@^7.2.5: version "7.2.5" resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-7.2.5.tgz#77eb46057f4d7adbd16d9290fa7299f6fa64cced" @@ -24336,15 +24379,10 @@ path-to-regexp@6.3.0, path-to-regexp@^6.2.1: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz#2b6a26a337737a8e1416f9272ed0766b1c0389f4" integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== -path-to-regexp@8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4" - integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== - -path-to-regexp@8.3.0, path-to-regexp@^8.0.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz#aa818a6981f99321003a08987d3cec9c3474cd1f" - integrity sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA== +path-to-regexp@8.4.2, path-to-regexp@^8.0.0: + version "8.4.2" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" + integrity sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== path-to-regexp@^1.5.3, path-to-regexp@^1.7.0: version "1.9.0" From 9a4b9b27ce0ba45595868c2589c7512da438372b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 15:19:15 +0200 Subject: [PATCH 39/84] feat(deno): Add `processSegmentSpan` to Deno context integration (#20613) Adds `processSegmentSpan` to the `denoContextIntegration` for span streaming support. Attributes added in https://github.com/getsentry/sentry-conventions/pull/347 closes https://github.com/getsentry/sentry-javascript/issues/20381 --- .../deno-streamed/package.json | 2 +- .../deno-streamed/tests/spans.test.ts | 25 +++++++ packages/deno/src/integrations/context.ts | 75 +++++++++++-------- 3 files changed, 70 insertions(+), 32 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/package.json b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json index 7bbaeaf631f8..282f8abb6492 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/package.json +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "scripts": { - "start": "deno run --allow-net --allow-env --allow-read src/app.ts", + "start": "deno run --allow-net --allow-env --allow-read --allow-sys src/app.ts", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install", diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts index 023429b07f41..15d7eaf99d9a 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts @@ -3,6 +3,10 @@ import { waitForStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; const SEGMENT_SPAN = { attributes: { + 'app.start_time': { + type: 'string', + value: expect.any(String), + }, 'client.address': { type: 'string', value: expect.any(String), @@ -11,6 +15,11 @@ const SEGMENT_SPAN = { type: 'integer', value: expect.any(Number), }, + // TODO: 'device.archs' is set but arrays are not yet serialized in span attributes + 'device.processor_count': { + type: 'integer', + value: expect.any(Number), + }, 'http.request.header.accept': { type: 'string', value: '*/*', @@ -51,6 +60,14 @@ const SEGMENT_SPAN = { type: 'integer', value: expect.any(Number), }, + 'os.name': { + type: 'string', + value: expect.any(String), + }, + 'os.version': { + type: 'string', + value: expect.any(String), + }, 'sentry.environment': { type: 'string', value: 'qa', @@ -115,6 +132,14 @@ const SEGMENT_SPAN = { type: 'string', value: 'node', }, + 'process.runtime.engine.name': { + type: 'string', + value: 'v8', + }, + 'process.runtime.engine.version': { + type: 'string', + value: expect.any(String), + }, }, end_timestamp: expect.any(Number), is_segment: true, diff --git a/packages/deno/src/integrations/context.ts b/packages/deno/src/integrations/context.ts index 979ffff7d0e8..62ea66807171 100644 --- a/packages/deno/src/integrations/context.ts +++ b/packages/deno/src/integrations/context.ts @@ -1,5 +1,5 @@ -import type { Event, IntegrationFn } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core'; const INTEGRATION_NAME = 'DenoContext'; @@ -22,41 +22,54 @@ async function getOSRelease(): Promise { : undefined; } -async function addDenoRuntimeContext(event: Event): Promise { - event.contexts = { - ...{ - app: { - app_start_time: new Date(Date.now() - performance.now()).toISOString(), - }, - device: { - arch: Deno.build.arch, - // eslint-disable-next-line no-restricted-globals - processor_count: navigator.hardwareConcurrency, - }, - os: { - name: getOSName(), - version: await getOSRelease(), - }, - v8: { - name: 'v8', - version: Deno.version.v8, - }, - typescript: { - name: 'TypeScript', - version: Deno.version.typescript, - }, - }, - ...event.contexts, +const _denoContextIntegration = (() => { + const appStartTime = new Date(Date.now() - performance.now()).toISOString(); + const osName = getOSName(); + const arch = Deno.build.arch; + // eslint-disable-next-line no-restricted-globals + const processorCount = navigator.hardwareConcurrency; + const v8Version = Deno.version.v8; + const tsVersion = Deno.version.typescript; + + const cachedContext = { + app: { app_start_time: appStartTime }, + device: { arch, processor_count: processorCount }, + os: { name: osName } as { name: string; version?: string }, + v8: { name: 'v8', version: v8Version }, + typescript: { name: 'TypeScript', version: tsVersion }, }; - return event; -} + const cachedSpanAttributes: Record = { + 'app.start_time': appStartTime, + // Convention uses 'device.archs' (string[]), but array attributes are not yet serialized. + // Once array serialization lands, this will start appearing on spans automatically. + 'device.archs': [arch], + 'device.processor_count': processorCount, + 'os.name': osName, + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': v8Version, + }; + + getOSRelease() + .then(release => { + cachedContext.os.version = release; + cachedSpanAttributes['os.version'] = release; + }) + .catch(() => { + // Ignore - os.version will be undefined + }); -const _denoContextIntegration = (() => { return { name: INTEGRATION_NAME, processEvent(event) { - return addDenoRuntimeContext(event); + event.contexts = { + ...cachedContext, + ...event.contexts, + }; + return event; + }, + processSegmentSpan(span) { + safeSetSpanJSONAttributes(span, cachedSpanAttributes); }, }; }) satisfies IntegrationFn; From b1b3c592c6a88009b1d8aa748643b39678d3c891 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 16:18:06 +0200 Subject: [PATCH 40/84] ci(flaky-tests): Apply bug label for auto-triage (#20657) Applying `Bug` to the auto generated issues will trigger our triaging agent (until we got something better for auto-fix). --- .github/FLAKY_CI_FAILURE_TEMPLATE.md | 2 +- scripts/report-ci-failures.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/FLAKY_CI_FAILURE_TEMPLATE.md b/.github/FLAKY_CI_FAILURE_TEMPLATE.md index c105f6928d27..a293cf4bcd8a 100644 --- a/.github/FLAKY_CI_FAILURE_TEMPLATE.md +++ b/.github/FLAKY_CI_FAILURE_TEMPLATE.md @@ -1,6 +1,6 @@ --- title: '[Flaky CI]: {{ env.JOB_NAME }} - {{ env.TEST_NAME }}' -labels: Tests +labels: Tests, Bug --- ### Flakiness Type diff --git a/scripts/report-ci-failures.mjs b/scripts/report-ci-failures.mjs index b407eac157c0..f57278e5a9d4 100644 --- a/scripts/report-ci-failures.mjs +++ b/scripts/report-ci-failures.mjs @@ -102,7 +102,7 @@ export default async function run({ github, context, core }) { repo, title, body: issueBody.trim(), - labels: ['Tests'], + labels: ['Tests', 'Bug'], }); core.info(`Created issue #${newIssue.data.number} for "${testName}" in ${jobName}`); } From ca36617f3fc2716305cac6044d167e0120759ffe Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 17:46:05 +0200 Subject: [PATCH 41/84] chore(core): Fix typo in comment (#20658) Fixes typos. --- packages/core/src/utils/spanUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 248f0d89dfba..88a6bceb6a3e 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -328,7 +328,7 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef } /** - * Convert the various statuses to the simple onces expected by Sentry for steamed spans ('ok' is default). + * Convert the various statuses to the simple ones expected by Sentry for streamed spans ('ok' is default). */ export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | 'error' { return !status || From a5f619884e1d1cfbaca0de73ab85d25e9c895f81 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 17:53:58 +0200 Subject: [PATCH 42/84] chore(core): Fix typo in comment (#20658) Fixes typos. From ffa4a37d39c11d432cce5d73d7c59e0c835be79e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 4 May 2026 18:16:56 +0200 Subject: [PATCH 43/84] test(test-utils): Add MemoryProfiler for heap snapshot testing via CDP (#20555) Adds CDPClient and MemoryProfiler to test-utils for V8 heap profiling. This PR prevents #20407 entirely by comparing heap snapshots. Within a Playwright test following can now be used: ```ts const profiler = new MemoryProfiler({ port: INSPECTOR_PORT }); await profiler.connect(); // ... make initial requests to let the runtime settle ... const baselineSnapshot = await profiler.takeHeapSnapshot(); // ... run some operations that might leak memory ... const finalSnapshot = await profiler.takeHeapSnapshot(); const result = profiler.compareSnapshots(baselineSnapshot, finalSnapshot); expect(result.nodeGrowthPercent).toBeLessThan(1); await profiler.close(); ``` This works by using the Chrome Developer Protocol (CDP). There is also a [CDPSession](https://playwright.dev/docs/api/class-cdpsession) API available from Playwright, but that would only work for sessions which run in the browser. Theoretically, this could also work in integration tests, but the idea is that this could in the future also be extended to use the CDPSession from Playwright for browser tests. --- .../cloudflare-workers/playwright.config.ts | 3 +- .../cloudflare-workers/tests/memory.test.ts | 31 ++ dev-packages/test-utils/package.json | 4 +- dev-packages/test-utils/src/cdp-client.ts | 310 +++++++++++++++++ dev-packages/test-utils/src/index.ts | 6 + .../test-utils/src/memory-profiler.ts | 317 ++++++++++++++++++ 6 files changed, 669 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/memory.test.ts create mode 100644 dev-packages/test-utils/src/cdp-client.ts create mode 100644 dev-packages/test-utils/src/memory-profiler.ts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts index 73abbd951b90..5c49d7c8e302 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts @@ -6,10 +6,11 @@ if (!testEnv) { } const APP_PORT = 38787; +export const INSPECTOR_PORT = 9230; const config = getPlaywrightConfig( { - startCommand: `pnpm dev --port ${APP_PORT}`, + startCommand: `pnpm dev --port ${APP_PORT} --inspector-port ${INSPECTOR_PORT}`, port: APP_PORT, }, { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/memory.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/memory.test.ts new file mode 100644 index 000000000000..740961b3083f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/memory.test.ts @@ -0,0 +1,31 @@ +import { MemoryProfiler } from '@sentry-internal/test-utils'; +import { expect, test } from '@playwright/test'; +import { INSPECTOR_PORT } from '../playwright.config'; + +test.describe('Worker V8 isolate memory tests', () => { + test('worker memory is reclaimed after GC', async ({ baseURL }) => { + const profiler = new MemoryProfiler({ port: INSPECTOR_PORT }); + + // Warm up: make initial requests and let the runtime settle + for (let i = 0; i < 5; i++) { + await fetch(baseURL!); + } + + await profiler.connect(); + + const baselineSnapshot = await profiler.takeHeapSnapshot(); + + for (let i = 0; i < 50; i++) { + const res = await fetch(baseURL!); + expect(res.status).toBe(200); + await res.text(); + } + + const finalSnapshot = await profiler.takeHeapSnapshot(); + const result = profiler.compareSnapshots(baselineSnapshot, finalSnapshot); + + expect(result.nodeGrowthPercent).toBeLessThan(1); + + await profiler.close(); + }); +}); diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index e66c45403dd8..c304082d87f7 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -44,11 +44,13 @@ "@playwright/test": "~1.56.0" }, "dependencies": { - "express": "^4.21.2" + "express": "^4.21.2", + "ws": "^8.20.0" }, "devDependencies": { "@playwright/test": "~1.56.0", "@sentry/core": "10.51.0", + "@types/ws": "^8.18.1", "eslint-plugin-regexp": "^1.15.0" }, "volta": { diff --git a/dev-packages/test-utils/src/cdp-client.ts b/dev-packages/test-utils/src/cdp-client.ts new file mode 100644 index 000000000000..491d7bfa77d9 --- /dev/null +++ b/dev-packages/test-utils/src/cdp-client.ts @@ -0,0 +1,310 @@ +import { WebSocket } from 'ws'; + +/** + * Configuration options for the Chrome Developer Protocol (CDP) client. + */ +export interface CDPClientOptions { + /** + * WebSocket URL to connect to (e.g., 'ws://127.0.0.1:9229/ws'). + * Can also use the format 'ws://host:port' without path for standard V8 inspector. + */ + url: string; + + /** + * Number of connection retry attempts before giving up. + * @default 5 + */ + retries?: number; + + /** + * Delay in milliseconds between retry attempts. + * @default 1000 + */ + retryDelayMs?: number; + + /** + * Connection timeout in milliseconds. + * @default 10000 + */ + connectionTimeoutMs?: number; + + /** + * Default timeout for CDP method calls in milliseconds. + * @default 30000 + */ + defaultTimeoutMs?: number; + + /** + * Whether to log debug messages. + * @default false + */ + debug?: boolean; +} + +interface CDPResponse { + id?: number; + method?: string; + params?: unknown; + error?: { message: string }; + result?: unknown; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +} + +type EventHandler = (params: unknown) => void; + +/** + * Low-level CDP client for connecting to V8 inspector endpoints. + * + * For memory profiling, prefer using `MemoryProfiler` which provides a higher-level API. + * + * @example + * ```typescript + * const cdp = new CDPClient({ url: 'ws://127.0.0.1:9229/ws' }); + * await cdp.connect(); + * await cdp.send('Runtime.enable'); + * await cdp.close(); + * ``` + */ +export class CDPClient { + #ws: WebSocket | null; + #messageId: number; + #pendingRequests: Map; + #eventHandlers: Map>; + #connected: boolean; + readonly #options: Required; + + public constructor(options: CDPClientOptions) { + this.#ws = null; + this.#messageId = 0; + this.#pendingRequests = new Map(); + this.#eventHandlers = new Map(); + this.#connected = false; + this.#options = { + retries: 5, + retryDelayMs: 1000, + connectionTimeoutMs: 10000, + defaultTimeoutMs: 30000, + debug: false, + ...options, + }; + } + + /** + * Connect to the V8 inspector WebSocket endpoint. + * Will retry according to the configured retry settings. + */ + public async connect(): Promise { + const { retries, retryDelayMs } = this.#options; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + await this.#tryConnect(); + return; + } catch (err) { + this.#log(`Connection attempt ${attempt}/${retries} failed:`, (err as Error).message); + if (attempt < retries) { + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + } else { + throw err; + } + } + } + } + + /** + * Send a CDP method call and wait for the response. + * + * @param method - The CDP method name (e.g., 'HeapProfiler.enable') + * @param params - Optional parameters for the method + * @param timeoutMs - Timeout in milliseconds (defaults to configured defaultTimeoutMs) + * @returns The result from the CDP method + */ + public async send(method: string, params?: Record, timeoutMs?: number): Promise { + if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket not connected'); + } + + const timeout = timeoutMs ?? this.#options.defaultTimeoutMs; + const id = ++this.#messageId; + const message = JSON.stringify({ id, method, params }); + + this.#log('Sending:', method, params || ''); + + return new Promise((resolve, reject) => { + this.#pendingRequests.set(id, { + resolve: value => resolve(value as T), + reject, + }); + this.#ws!.send(message); + + setTimeout(() => { + if (this.#pendingRequests.has(id)) { + this.#pendingRequests.delete(id); + reject(new Error(`CDP request ${method} timed out after ${timeout}ms`)); + } + }, timeout); + }); + } + + /** + * Send a CDP method call without waiting for a response. + * Useful for commands that may not return responses in certain V8 environments. + * + * @param method - The CDP method name + * @param params - Optional parameters for the method + * @param settleDelayMs - Time to wait after sending (default: 100ms) + */ + public async sendFireAndForget(method: string, params?: Record, settleDelayMs = 100): Promise { + if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket not connected'); + } + + const id = ++this.#messageId; + const message = JSON.stringify({ id, method, params }); + + this.#log('Sending (fire-and-forget):', method, params || ''); + + this.#ws.send(message); + + // Give the command time to execute + await new Promise(resolve => setTimeout(resolve, settleDelayMs)); + } + + /** + * Register a handler for a CDP event method (e.g., 'HeapProfiler.addHeapSnapshotChunk'). + * Returns a function that, when called, removes the handler. + */ + public on(method: string, handler: EventHandler): () => void { + let handlers = this.#eventHandlers.get(method); + if (!handlers) { + handlers = new Set(); + this.#eventHandlers.set(method, handlers); + } + handlers.add(handler); + + return () => { + handlers.delete(handler); + if (handlers.size === 0) { + this.#eventHandlers.delete(method); + } + }; + } + + /** + * Check if the client is currently connected. + */ + public isConnected(): boolean { + return this.#connected && this.#ws?.readyState === WebSocket.OPEN; + } + + /** + * Close the WebSocket connection. + */ + public async close(): Promise { + if (this.#ws) { + this.#ws.close(); + this.#ws = null; + this.#connected = false; + } + } + + #log(...args: unknown[]): void { + if (this.#options.debug) { + // eslint-disable-next-line no-console + console.log('[CDPClient]', ...args); + } + } + + async #tryConnect(): Promise { + const { url, connectionTimeoutMs } = this.#options; + + return new Promise((resolve, reject) => { + this.#ws = new WebSocket(url); + + const timeoutId = setTimeout(() => { + // Close the WebSocket to prevent state corruption from orphaned sockets on retry + this.#ws?.close(); + reject(new Error(`Connection to ${url} timed out after ${connectionTimeoutMs}ms`)); + }, connectionTimeoutMs); + + this.#ws.on('open', () => { + clearTimeout(timeoutId); + this.#connected = true; + this.#log('WebSocket connected to', url); + resolve(); + }); + + this.#ws.on('error', (err: Error) => { + clearTimeout(timeoutId); + this.#ws?.close(); + reject(new Error(`Failed to connect to inspector at ${url}: ${err.message}`)); + }); + + this.#ws.on('close', () => { + this.#connected = false; + }); + + this.#setupMessageHandler(); + }); + } + + #setupMessageHandler(): void { + this.#ws?.on('message', (data: Buffer) => { + try { + const rawMessage = data.toString(); + this.#log('Received raw message:', rawMessage.slice(0, 500)); + + const message = JSON.parse(rawMessage) as CDPResponse; + + if (message.method) { + this.#handleCdpEvent(message); + return; + } + + if (message.id !== undefined) { + this.#handleCdpResponse(message); + } + } catch (e) { + this.#log('Failed to parse CDP message:', e); + } + }); + } + + #handleCdpEvent(message: CDPResponse): void { + this.#log('CDP event:', message.method); + + const handlers = this.#eventHandlers.get(message.method!); + + if (handlers) { + for (const handler of handlers) { + try { + handler(message.params); + } catch (err) { + this.#log('Event handler threw:', err); + } + } + } + } + + #handleCdpResponse(message: CDPResponse): void { + this.#log('CDP response for id:', message.id, 'error:', message.error, 'has result:', message.result !== undefined); + + const pending = this.#pendingRequests.get(message.id!); + + if (pending) { + this.#pendingRequests.delete(message.id!); + + if (message.error) { + pending.reject(new Error(`CDP error: ${message.error.message}`)); + } else { + pending.resolve(message.result); + } + } else { + this.#log('No pending request found for id:', message.id); + } + } +} diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 54e5d11749b4..c47f46fcde5e 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -20,3 +20,9 @@ export { createBasicSentryServer, createTestServer } from './server'; export { startMockSentryServer } from './mock-sentry-server'; export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server'; export * from './sourcemap-upload-utils'; + +export { CDPClient } from './cdp-client'; +export type { CDPClientOptions } from './cdp-client'; + +export { MemoryProfiler } from './memory-profiler'; +export type { MemoryProfilerOptions, SnapshotStats, SnapshotComparisonResult } from './memory-profiler'; diff --git a/dev-packages/test-utils/src/memory-profiler.ts b/dev-packages/test-utils/src/memory-profiler.ts new file mode 100644 index 000000000000..f6ad1d67227e --- /dev/null +++ b/dev-packages/test-utils/src/memory-profiler.ts @@ -0,0 +1,317 @@ +import { mkdir, writeFile } from 'fs/promises'; +import { dirname } from 'path'; +import { CDPClient } from './cdp-client'; + +/** + * Options for creating a MemoryProfiler. + */ +export interface MemoryProfilerOptions { + /** + * Inspector port number. + * @default 9229 + */ + port?: number; + + /** + * WebSocket path (e.g., '/ws' for wrangler, '' for Node.js inspector). + * @default '/ws' + */ + path?: string; + + /** + * Host address. + * @default '127.0.0.1' + */ + host?: string; + + /** + * Number of connection retry attempts. + * @default 10 + */ + retries?: number; + + /** + * Delay between retry attempts in milliseconds. + * @default 2000 + */ + retryDelayMs?: number; + + /** + * Delay after garbage collection in milliseconds. + * This gives V8 time to complete GC before measuring. + * @default 2000 + */ + gcSettleDelayMs?: number; + + /** + * Enable debug logging. + * @default false + */ + debug?: boolean; +} + +/** + * V8 heap snapshot format (partial). + */ +interface V8HeapSnapshot { + snapshot: { + meta: { + node_fields: string[]; + edge_fields: string[]; + }; + }; + nodes: number[]; + edges: number[]; +} + +/** + * Parsed snapshot statistics. + */ +export interface SnapshotStats { + nodeCount: number; + edgeCount: number; + totalSize: number; +} + +/** + * Result from comparing two heap snapshots. + */ +export interface SnapshotComparisonResult { + baseline: SnapshotStats; + final: SnapshotStats; + nodeGrowth: number; + nodeGrowthPercent: number; + edgeGrowth: number; + edgeGrowthPercent: number; + sizeGrowth: number; + sizeGrowthPercent: number; +} + +/** + * High-level memory profiler for V8 inspector endpoints. + * + * Provides a simple API for memory testing via CDP (Chrome DevTools Protocol). + * Works with any V8 inspector endpoint including: + * - Wrangler dev server (Cloudflare Workers) + * - Node.js inspector (--inspect flag) + * + * @example + * ```typescript + * const profiler = new MemoryProfiler({ port: 9229 }); + * await profiler.connect(); + * + * // ... make initial requests to let the runtime settle ... + * + * const baseline = await profiler.takeHeapSnapshot(); + * + * // ... run some operations that might leak memory ... + * + * const final = await profiler.takeHeapSnapshot(); + * + * const result = profiler.compareSnapshots(baseline, final); + * console.log(`Node growth: ${result.nodeGrowthPercent.toFixed(2)}%`); + * + * await profiler.close(); + * ``` + */ +export class MemoryProfiler { + readonly #cdp: CDPClient; + readonly #gcSettleDelayMs: number; + readonly #debug: boolean; + + #initialized: boolean; + + public constructor(options: MemoryProfilerOptions = {}) { + const { + port = 9229, + path = '/ws', + host = '127.0.0.1', + retries = 10, + retryDelayMs = 2000, + gcSettleDelayMs = 3000, + debug = false, + } = options; + + this.#debug = debug; + + this.#cdp = new CDPClient({ + url: `ws://${host}:${port}${path}`, + retries, + retryDelayMs, + debug, + }); + this.#gcSettleDelayMs = gcSettleDelayMs; + this.#initialized = false; + } + + /** + * Connect to the V8 inspector and enable required CDP domains. + */ + public async connect(): Promise { + await this.#cdp.connect(); + await this.#cdp.send('HeapProfiler.enable'); + await this.#cdp.send('Runtime.enable'); + this.#initialized = true; + } + + /** + * Check if the profiler is connected to the inspector. + */ + public isConnected(): boolean { + return this.#cdp.isConnected() && this.#initialized; + } + + /** + * Capture a V8 heap snapshot. If `outputPath` is provided, the snapshot is written there + * as a `.heapsnapshot` file that can be loaded into Chrome DevTools (Memory tab → Load). + * + * Some V8 inspectors (e.g., wrangler) stream chunks via `HeapProfiler.addHeapSnapshotChunk` + * but never send a response to the `takeHeapSnapshot` request. We work around that by + * resolving once chunk events go idle for `chunkIdleMs` (default 2s). + * + * @param outputPath - Optional file path to save the snapshot + * @param chunkIdleMs - How long to wait after the last chunk before considering the snapshot complete + * @param overallTimeoutMs - Maximum time to wait for any chunks before throwing (prevents infinite hang) + * @returns The full snapshot string. + */ + public async takeHeapSnapshot(outputPath?: string, chunkIdleMs = 2000, overallTimeoutMs = 5000): Promise { + this.#ensureConnected(); + await this.#collectGarbage(); + + const chunks: string[] = []; + const startedAt = Date.now(); + let lastChunkAt = Date.now(); + let receivedAny = false; + + const unsubscribe = this.#cdp.on('HeapProfiler.addHeapSnapshotChunk', params => { + const chunk = (params as { chunk?: string }).chunk; + if (typeof chunk === 'string') { + chunks.push(chunk); + lastChunkAt = Date.now(); + receivedAny = true; + } + }); + + try { + await this.#cdp.sendFireAndForget('HeapProfiler.takeHeapSnapshot', { + reportProgress: false, + captureNumericValue: false, + }); + + // Poll until chunks stop arriving for `chunkIdleMs`, or we hit the overall timeout + const pollInterval = 200; + + while (!receivedAny || Date.now() - lastChunkAt < chunkIdleMs) { + if (!receivedAny && Date.now() - startedAt > overallTimeoutMs) { + throw new Error(`Heap snapshot timed out after ${overallTimeoutMs}ms: no chunks received from V8 inspector`); + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } finally { + unsubscribe(); + } + + const snapshot = chunks.join(''); + + if (outputPath) { + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, snapshot, 'utf8'); + } + + return snapshot; + } + + /** + * Compare two heap snapshots and return growth metrics. + * This is more reliable than `Runtime.getHeapUsage` for leak detection + * as it measures actual retained objects rather than V8 internal metrics. + */ + public compareSnapshots(baselineSnapshot: string, finalSnapshot: string): SnapshotComparisonResult { + const baseline = this.#parseSnapshotStats(baselineSnapshot); + const final = this.#parseSnapshotStats(finalSnapshot); + + const nodeGrowth = final.nodeCount - baseline.nodeCount; + const edgeGrowth = final.edgeCount - baseline.edgeCount; + const sizeGrowth = final.totalSize - baseline.totalSize; + + const result: SnapshotComparisonResult = { + baseline, + final, + nodeGrowth, + nodeGrowthPercent: baseline.nodeCount > 0 ? (nodeGrowth / baseline.nodeCount) * 100 : 0, + edgeGrowth, + edgeGrowthPercent: baseline.edgeCount > 0 ? (edgeGrowth / baseline.edgeCount) * 100 : 0, + sizeGrowth, + sizeGrowthPercent: baseline.totalSize > 0 ? (sizeGrowth / baseline.totalSize) * 100 : 0, + }; + + if (this.#debug) { + // eslint-disable-next-line no-console + console.log('Snapshot comparison:', { + baselineNodes: baseline.nodeCount, + finalNodes: final.nodeCount, + nodeGrowth, + nodeGrowthPercent: `${result.nodeGrowthPercent.toFixed(2)}%`, + sizeGrowthKB: (sizeGrowth / 1024).toFixed(2), + }); + } + + return result; + } + + /** + * Parse a heap snapshot string and extract statistics. + */ + #parseSnapshotStats(snapshotJson: string): SnapshotStats { + const snapshot = JSON.parse(snapshotJson) as V8HeapSnapshot; + const meta = snapshot.snapshot?.meta; + + if (!meta?.node_fields) { + throw new Error('Invalid heap snapshot format: missing meta.node_fields'); + } + + if (!meta?.edge_fields) { + throw new Error('Invalid heap snapshot format: missing meta.edge_fields'); + } + + const nodeFieldCount = meta.node_fields.length; + const nodeCount = snapshot.nodes.length / nodeFieldCount; + const edgeCount = snapshot.edges.length / meta.edge_fields.length; + + const selfSizeIdx = meta.node_fields.indexOf('self_size'); + let totalSize = 0; + + if (selfSizeIdx !== -1) { + for (let i = 0; i < snapshot.nodes.length; i += nodeFieldCount) { + totalSize += snapshot.nodes[i + selfSizeIdx] ?? 0; + } + } + + return { nodeCount, edgeCount, totalSize }; + } + + /** + * Close the connection to the inspector. + */ + public async close(): Promise { + await this.#cdp.close(); + this.#initialized = false; + } + + #ensureConnected(): void { + if (!this.#initialized) { + throw new Error('MemoryProfiler not connected. Call connect() first.'); + } + } + + async #collectGarbage(): Promise { + // V8 uses generational GC (young/old generations) and incremental marking. + // A single GC call may only collect young generation objects. Multiple passes + // ensure objects are promoted to old generation and fully collected, giving + // more stable heap measurements for leak detection. + for (let i = 0; i < 3; i++) { + await this.#cdp.sendFireAndForget('HeapProfiler.collectGarbage', undefined, 500); + } + await new Promise(resolve => setTimeout(resolve, this.#gcSettleDelayMs)); + } +} From ac03cfc349cdd0699680c7c7ae39be088077c027 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 24 Apr 2026 17:13:33 -0700 Subject: [PATCH 44/84] feat(node): vendor ioredis, redis instrumentations (#20510) Vendor in the Redis and IORedis instrumentation code and unit tests, and update everything in Sentry to use our vendored code instead of the external dependency. A subsequent commit will update the node-redis instrumentation to use its recently-added Diagnostics Channel support. See: https://github.com/redis/node-redis/blob/master/docs/diagnostics-channel.md --- .oxlintrc.base.json | 10 + packages/node/package.json | 2 - .../tracing/{redis.ts => redis/index.ts} | 17 +- .../redis/vendored/ioredis-instrumentation.ts | 271 +++++++ .../tracing/redis/vendored/redis-common.ts | 69 ++ .../redis/vendored/redis-instrumentation.ts | 731 ++++++++++++++++++ .../tracing/redis/vendored/semconv.ts | 36 + .../tracing/redis/vendored/types.ts | 92 +++ packages/node/src/utils/redisCache.ts | 2 +- .../redis/ioredis-instrumentation.test.ts | 151 ++++ .../tracing/redis/redis-common.test.ts | 93 +++ .../redis/redis-instrumentation.test.ts | 193 +++++ yarn.lock | 23 - 13 files changed, 1656 insertions(+), 34 deletions(-) rename packages/node/src/integrations/tracing/{redis.ts => redis/index.ts} (90%) create mode 100644 packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts create mode 100644 packages/node/src/integrations/tracing/redis/vendored/redis-common.ts create mode 100644 packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts create mode 100644 packages/node/src/integrations/tracing/redis/vendored/semconv.ts create mode 100644 packages/node/src/integrations/tracing/redis/vendored/types.ts create mode 100644 packages/node/test/integrations/tracing/redis/ioredis-instrumentation.test.ts create mode 100644 packages/node/test/integrations/tracing/redis/redis-common.test.ts create mode 100644 packages/node/test/integrations/tracing/redis/redis-instrumentation.test.ts diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index 91ba709d0e7f..87021fa59c58 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -130,6 +130,16 @@ "no-param-reassign": "off" } }, + { + "files": ["**/integrations/tracing/redis/vendored/**/*.ts"], + "rules": { + "typescript/no-explicit-any": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-this-alias": "off", + "max-lines": "off", + "no-bitwise": "off" + } + }, { "files": [ "**/scenarios/**", diff --git a/packages/node/package.json b/packages/node/package.json index 4c0ae2e5e5d8..c681a3ed7a46 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -76,7 +76,6 @@ "@opentelemetry/instrumentation-graphql": "0.62.0", "@opentelemetry/instrumentation-hapi": "0.60.0", "@opentelemetry/instrumentation-http": "0.214.0", - "@opentelemetry/instrumentation-ioredis": "0.62.0", "@opentelemetry/instrumentation-kafkajs": "0.23.0", "@opentelemetry/instrumentation-knex": "0.58.0", "@opentelemetry/instrumentation-koa": "0.62.0", @@ -86,7 +85,6 @@ "@opentelemetry/instrumentation-mysql": "0.60.0", "@opentelemetry/instrumentation-mysql2": "0.60.0", "@opentelemetry/instrumentation-pg": "0.66.0", - "@opentelemetry/instrumentation-redis": "0.62.0", "@opentelemetry/instrumentation-tedious": "0.33.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", diff --git a/packages/node/src/integrations/tracing/redis.ts b/packages/node/src/integrations/tracing/redis/index.ts similarity index 90% rename from packages/node/src/integrations/tracing/redis.ts rename to packages/node/src/integrations/tracing/redis/index.ts index f8be12352ae0..280cdc51d06e 100644 --- a/packages/node/src/integrations/tracing/redis.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -1,7 +1,4 @@ import type { Span } from '@opentelemetry/api'; -import type { RedisResponseCustomAttributeFunction } from '@opentelemetry/instrumentation-ioredis'; -import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'; -import { RedisInstrumentation } from '@opentelemetry/instrumentation-redis'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, @@ -14,6 +11,7 @@ import { truncate, } from '@sentry/core'; import { generateInstrumentOnce } from '@sentry/node-core'; +import type { IORedisCommandArgs } from '../../../utils/redisCache'; import { calculateCacheItemSize, GET_COMMANDS, @@ -21,7 +19,10 @@ import { getCacheOperation, isInCommands, shouldConsiderForCache, -} from '../../utils/redisCache'; +} from '../../../utils/redisCache'; +import type { IORedisResponseCustomAttributeFunction } from './vendored/types'; +import { IORedisInstrumentation } from './vendored/ioredis-instrumentation'; +import { RedisInstrumentation } from './vendored/redis-instrumentation'; interface RedisOptions { /** @@ -46,11 +47,11 @@ const INTEGRATION_NAME = 'Redis'; export let _redisOptions: RedisOptions = {}; /* Only exported for testing purposes */ -export const cacheResponseHook: RedisResponseCustomAttributeFunction = ( +export const cacheResponseHook: IORedisResponseCustomAttributeFunction = ( span: Span, - redisCommand, - cmdArgs, - response, + redisCommand: string, + cmdArgs: IORedisCommandArgs, + response: unknown, ) => { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.redis'); diff --git a/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts b/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts new file mode 100644 index 000000000000..d55cb2e31420 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts @@ -0,0 +1,271 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-ioredis-v0.62.0/packages/instrumentation-ioredis + * - Upstream version: @opentelemetry/instrumentation-ioredis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-ioredis */ + +import { context, diag, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { Span } from '@opentelemetry/api'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + isWrapped, + safeExecuteInTheMiddle, + SemconvStability, + semconvStabilityFromStr, +} from '@opentelemetry/instrumentation'; +import { + ATTR_DB_QUERY_TEXT, + ATTR_DB_SYSTEM_NAME, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, +} from '@opentelemetry/semantic-conventions'; + +import { defaultDbStatementSerializer } from './redis-common'; +import { + ATTR_DB_CONNECTION_STRING, + ATTR_DB_STATEMENT, + ATTR_DB_SYSTEM, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, + DB_SYSTEM_NAME_VALUE_REDIS, + DB_SYSTEM_VALUE_REDIS, +} from './semconv'; +import type { IORedisInstrumentationConfig } from './types'; + +const PACKAGE_NAME = '@opentelemetry/instrumentation-ioredis'; +const PACKAGE_VERSION = '0.62.0'; + +// ---- utils ---- + +function endSpan(span: Span, err: Error | null | undefined): void { + if (err) { + span.recordException(err); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }); + } + span.end(); +} + +// ---- IORedisInstrumentation ---- + +const DEFAULT_CONFIG: IORedisInstrumentationConfig = { + requireParentSpan: true, +}; + +export class IORedisInstrumentation extends InstrumentationBase { + _netSemconvStability!: SemconvStability; + _dbSemconvStability!: SemconvStability; + + constructor(config: IORedisInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, { ...DEFAULT_CONFIG, ...config }); + this._setSemconvStabilityFromEnv(); + } + + _setSemconvStabilityFromEnv(): void { + this._netSemconvStability = semconvStabilityFromStr('http', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + this._dbSemconvStability = semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + override setConfig(config: IORedisInstrumentationConfig = {}): void { + super.setConfig({ ...DEFAULT_CONFIG, ...config }); + } + + init() { + return [ + new InstrumentationNodeModuleDefinition( + 'ioredis', + ['>=2.0.0 <6'], + (module: any, moduleVersion?: string) => { + const moduleExports = + module[Symbol.toStringTag] === 'Module' + ? module.default // ESM + : module; // CommonJS + if (isWrapped(moduleExports.prototype.sendCommand)) { + this._unwrap(moduleExports.prototype, 'sendCommand'); + } + this._wrap(moduleExports.prototype, 'sendCommand', this._patchSendCommand(moduleVersion)); + if (isWrapped(moduleExports.prototype.connect)) { + this._unwrap(moduleExports.prototype, 'connect'); + } + this._wrap(moduleExports.prototype, 'connect', this._patchConnection()); + return module; + }, + (module: any) => { + if (module === undefined) return; + const moduleExports = + module[Symbol.toStringTag] === 'Module' + ? module.default // ESM + : module; // CommonJS + this._unwrap(moduleExports.prototype, 'sendCommand'); + this._unwrap(moduleExports.prototype, 'connect'); + }, + ), + ]; + } + + private _patchSendCommand(moduleVersion?: string) { + return (original: Function) => { + return this._traceSendCommand(original, moduleVersion); + }; + } + + private _patchConnection() { + return (original: Function) => { + return this._traceConnection(original); + }; + } + + private _traceSendCommand(original: Function, moduleVersion?: string) { + const instrumentation = this; + return function (this: any, cmd: any) { + if (arguments.length < 1 || typeof cmd !== 'object') { + return original.apply(this, arguments); + } + const config = instrumentation.getConfig(); + const dbStatementSerializer = config.dbStatementSerializer || defaultDbStatementSerializer; + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (config.requireParentSpan === true && hasNoParentSpan) { + return original.apply(this, arguments); + } + const attributes: Record = {}; + const { host, port } = this.options; + const dbQueryText = dbStatementSerializer(cmd.name, cmd.args); + if (instrumentation._dbSemconvStability & SemconvStability.OLD) { + attributes[ATTR_DB_SYSTEM] = DB_SYSTEM_VALUE_REDIS; + attributes[ATTR_DB_STATEMENT] = dbQueryText; + attributes[ATTR_DB_CONNECTION_STRING] = `redis://${host}:${port}`; + } + if (instrumentation._dbSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_SYSTEM_NAME] = DB_SYSTEM_NAME_VALUE_REDIS; + attributes[ATTR_DB_QUERY_TEXT] = dbQueryText; + } + if (instrumentation._netSemconvStability & SemconvStability.OLD) { + attributes[ATTR_NET_PEER_NAME] = host; + attributes[ATTR_NET_PEER_PORT] = port; + } + if (instrumentation._netSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_SERVER_ADDRESS] = host; + attributes[ATTR_SERVER_PORT] = port; + } + const span = instrumentation.tracer.startSpan(cmd.name, { + kind: SpanKind.CLIENT, + attributes, + }); + const { requestHook } = config; + if (requestHook) { + safeExecuteInTheMiddle( + () => + requestHook(span, { + moduleVersion, + cmdName: cmd.name, + cmdArgs: cmd.args, + }), + (e: Error | undefined) => { + if (e) { + diag.error('ioredis instrumentation: request hook failed', e); + } + }, + true, + ); + } + try { + const result = original.apply(this, arguments); + const origResolve = cmd.resolve; + cmd.resolve = function (result: unknown) { + safeExecuteInTheMiddle( + () => config.responseHook?.(span, cmd.name, cmd.args, result), + (e: Error | undefined) => { + if (e) { + diag.error('ioredis instrumentation: response hook failed', e); + } + }, + true, + ); + endSpan(span, null); + origResolve(result); + }; + const origReject = cmd.reject; + cmd.reject = function (err: Error) { + endSpan(span, err); + origReject(err); + }; + return result; + } catch (error) { + endSpan(span, error as Error); + throw error; + } + }; + } + + private _traceConnection(original: Function) { + const instrumentation = this; + return function (this: any) { + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (instrumentation.getConfig().requireParentSpan === true && hasNoParentSpan) { + return original.apply(this, arguments); + } + const attributes: Record = {}; + const { host, port } = this.options; + if (instrumentation._dbSemconvStability & SemconvStability.OLD) { + attributes[ATTR_DB_SYSTEM] = DB_SYSTEM_VALUE_REDIS; + attributes[ATTR_DB_STATEMENT] = 'connect'; + attributes[ATTR_DB_CONNECTION_STRING] = `redis://${host}:${port}`; + } + if (instrumentation._dbSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_SYSTEM_NAME] = DB_SYSTEM_NAME_VALUE_REDIS; + attributes[ATTR_DB_QUERY_TEXT] = 'connect'; + } + if (instrumentation._netSemconvStability & SemconvStability.OLD) { + attributes[ATTR_NET_PEER_NAME] = host; + attributes[ATTR_NET_PEER_PORT] = port; + } + if (instrumentation._netSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_SERVER_ADDRESS] = host; + attributes[ATTR_SERVER_PORT] = port; + } + const span = instrumentation.tracer.startSpan('connect', { + kind: SpanKind.CLIENT, + attributes, + }); + try { + const result = original.apply(this, arguments); + if (typeof result?.then === 'function') { + return result.then( + (value: unknown) => { + endSpan(span, null); + return value; + }, + (error: Error) => { + endSpan(span, error); + return Promise.reject(error); + }, + ); + } + endSpan(span, null); + return result; + } catch (error) { + endSpan(span, error as Error); + throw error; + } + }; + } +} diff --git a/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts b/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts new file mode 100644 index 000000000000..58f94cfb66c9 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts @@ -0,0 +1,69 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/redis-common + * - Upstream version: @opentelemetry/redis-common@0.38.2 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/redis-common */ + +/** + * List of regexes and the number of arguments that should be serialized for matching commands. + * For example, HSET should serialize which key and field it's operating on, but not its value. + * Setting the subset to -1 will serialize all arguments. + * Commands without a match will have their first argument serialized. + * + * Refer to https://redis.io/commands/ for the full list. + */ +const serializationSubsets = [ + { + regex: /^ECHO/i, + args: 0, + }, + { + regex: /^(LPUSH|MSET|PFA|PUBLISH|RPUSH|SADD|SET|SPUBLISH|XADD|ZADD)/i, + args: 1, + }, + { + regex: /^(HSET|HMSET|LSET|LINSERT)/i, + args: 2, + }, + { + regex: + /^(ACL|BIT|B[LRZ]|CLIENT|CLUSTER|CONFIG|COMMAND|DECR|DEL|EVAL|EX|FUNCTION|GEO|GET|HINCR|HMGET|HSCAN|INCR|L[TRLM]|MEMORY|P[EFISTU]|RPOP|S[CDIMORSU]|XACK|X[CDGILPRT]|Z[CDILMPRS])/i, + args: -1, + }, +]; + +/** + * Given the redis command name and arguments, return a combination of the + * command name + the allowed arguments according to `serializationSubsets`. + */ +export const defaultDbStatementSerializer = ( + cmdName: string, + cmdArgs: Array, +): string => { + if (Array.isArray(cmdArgs) && cmdArgs.length) { + const nArgsToSerialize = serializationSubsets.find(({ regex }) => regex.test(cmdName))?.args ?? 0; + const argsToSerialize: Array = + nArgsToSerialize >= 0 ? cmdArgs.slice(0, nArgsToSerialize) : cmdArgs.slice(); + if (cmdArgs.length > argsToSerialize.length) { + argsToSerialize.push(`[${cmdArgs.length - nArgsToSerialize} other arguments]`); + } + return `${cmdName} ${argsToSerialize.join(' ')}`; + } + return cmdName; +}; diff --git a/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts b/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts new file mode 100644 index 000000000000..8801962522aa --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts @@ -0,0 +1,731 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/instrumentation-redis + * - Upstream version: @opentelemetry/instrumentation-redis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-redis */ + +import { context, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { DiagLogger, Span, TracerProvider } from '@opentelemetry/api'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, + isWrapped, + safeExecuteInTheMiddle, + SemconvStability, + semconvStabilityFromStr, +} from '@opentelemetry/instrumentation'; +import { + ATTR_DB_OPERATION_NAME, + ATTR_DB_QUERY_TEXT, + ATTR_DB_SYSTEM_NAME, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, +} from '@opentelemetry/semantic-conventions'; + +import { defaultDbStatementSerializer } from './redis-common'; +import { + ATTR_DB_CONNECTION_STRING, + ATTR_DB_STATEMENT, + ATTR_DB_SYSTEM, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, + DB_SYSTEM_NAME_VALUE_REDIS, + DB_SYSTEM_VALUE_REDIS, +} from './semconv'; +import type { RedisInstrumentationConfig } from './types'; + +const PACKAGE_NAME = '@opentelemetry/instrumentation-redis'; +const PACKAGE_VERSION = '0.62.0'; + +// ---- Internal types ---- + +interface RedisPluginClientTypes { + connection_options?: { + port?: string | number; + host?: string; + }; + address?: string; +} + +interface RedisCommand { + command: string; + args: string[]; + buffer_args: boolean; + callback: (err: Error | null, reply: unknown) => void; + call_on_write: boolean; +} + +interface MultiErrorReply extends Error { + replies: unknown[]; + errorIndexes: Array; +} + +interface OpenSpanInfo { + span: Span; + commandName: string; + commandArgs: Array; +} + +const OTEL_OPEN_SPANS = Symbol('opentelemetry.instrumentation.redis.open_spans'); +const MULTI_COMMAND_OPTIONS = Symbol('opentelemetry.instrumentation.redis.multi_command_options'); + +// ---- v4-v5 utils ---- + +function removeCredentialsFromDBConnectionStringAttribute( + diagLogger: DiagLogger, + url: string | undefined, +): string | undefined { + if (typeof url !== 'string' || !url) { + return undefined; + } + try { + const u = new URL(url); + u.searchParams.delete('user_pwd'); + u.username = ''; + u.password = ''; + return u.href; + } catch (err) { + diagLogger.error('failed to sanitize redis connection url', err); + } + return undefined; +} + +function getClientAttributes( + diagLogger: DiagLogger, + options: any, + semconvStability: SemconvStability, +): Record { + const attributes: Record = {}; + if (semconvStability & SemconvStability.OLD) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + [ATTR_NET_PEER_NAME]: options?.socket?.host, + [ATTR_NET_PEER_PORT]: options?.socket?.port, + [ATTR_DB_CONNECTION_STRING]: removeCredentialsFromDBConnectionStringAttribute(diagLogger, options?.url), + }); + } + if (semconvStability & SemconvStability.STABLE) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, + [ATTR_SERVER_ADDRESS]: options?.socket?.host, + [ATTR_SERVER_PORT]: options?.socket?.port, + }); + } + return attributes; +} + +// ---- v2-v3 utils ---- + +function endSpanV2(span: Span, err: Error | null | undefined): void { + if (err) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }); + } + span.end(); +} + +function getTracedCreateClient(original: Function): Function { + return function createClientTrace(this: any) { + const client = original.apply(this, arguments); + return context.bind(context.active(), client); + }; +} + +function getTracedCreateStreamTrace(original: Function): Function { + return function create_stream_trace(this: any) { + if (!Object.prototype.hasOwnProperty.call(this, 'stream')) { + Object.defineProperty(this, 'stream', { + get() { + return this._patched_redis_stream; + }, + set(val: any) { + context.bind(context.active(), val); + this._patched_redis_stream = val; + }, + }); + } + return original.apply(this, arguments); + }; +} + +// ---- RedisInstrumentationV2_V3 ---- + +class RedisInstrumentationV2_V3 extends InstrumentationBase { + static COMPONENT = 'redis'; + _semconvStability: SemconvStability; + + constructor(config: RedisInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + override setConfig(config: RedisInstrumentationConfig = {}): void { + super.setConfig(config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + init() { + return [ + new InstrumentationNodeModuleDefinition( + 'redis', + ['>=2.6.0 <4'], + (moduleExports: any) => { + if (isWrapped(moduleExports.RedisClient.prototype['internal_send_command'])) { + this._unwrap(moduleExports.RedisClient.prototype, 'internal_send_command'); + } + this._wrap(moduleExports.RedisClient.prototype, 'internal_send_command', this._getPatchInternalSendCommand()); + if (isWrapped(moduleExports.RedisClient.prototype['create_stream'])) { + this._unwrap(moduleExports.RedisClient.prototype, 'create_stream'); + } + this._wrap(moduleExports.RedisClient.prototype, 'create_stream', this._getPatchCreateStream()); + if (isWrapped(moduleExports.createClient)) { + this._unwrap(moduleExports, 'createClient'); + } + this._wrap(moduleExports, 'createClient', this._getPatchCreateClient()); + return moduleExports; + }, + (moduleExports: any) => { + if (moduleExports === undefined) return; + this._unwrap(moduleExports.RedisClient.prototype, 'internal_send_command'); + this._unwrap(moduleExports.RedisClient.prototype, 'create_stream'); + this._unwrap(moduleExports, 'createClient'); + }, + ), + ]; + } + + private _getPatchInternalSendCommand() { + const instrumentation = this; + return function internal_send_command(original: Function) { + return function internal_send_command_trace(this: RedisPluginClientTypes, cmd: RedisCommand) { + if (arguments.length !== 1 || typeof cmd !== 'object') { + return original.apply(this, arguments); + } + const config = instrumentation.getConfig(); + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (config.requireParentSpan === true && hasNoParentSpan) { + return original.apply(this, arguments); + } + const dbStatementSerializer = config?.dbStatementSerializer || defaultDbStatementSerializer; + const attributes: Record = {}; + if (instrumentation._semconvStability & SemconvStability.OLD) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + [ATTR_DB_STATEMENT]: dbStatementSerializer(cmd.command, cmd.args), + }); + } + if (instrumentation._semconvStability & SemconvStability.STABLE) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, + [ATTR_DB_OPERATION_NAME]: cmd.command, + [ATTR_DB_QUERY_TEXT]: dbStatementSerializer(cmd.command, cmd.args), + }); + } + const span = instrumentation.tracer.startSpan(`${RedisInstrumentationV2_V3.COMPONENT}-${cmd.command}`, { + kind: SpanKind.CLIENT, + attributes, + }); + if (this.connection_options) { + const connectionAttributes: Record = {}; + if (instrumentation._semconvStability & SemconvStability.OLD) { + Object.assign(connectionAttributes, { + [ATTR_NET_PEER_NAME]: this.connection_options.host, + [ATTR_NET_PEER_PORT]: this.connection_options.port, + }); + } + if (instrumentation._semconvStability & SemconvStability.STABLE) { + Object.assign(connectionAttributes, { + [ATTR_SERVER_ADDRESS]: this.connection_options.host, + [ATTR_SERVER_PORT]: this.connection_options.port, + }); + } + span.setAttributes(connectionAttributes); + } + if (this.address && instrumentation._semconvStability & SemconvStability.OLD) { + span.setAttribute(ATTR_DB_CONNECTION_STRING, `redis://${this.address}`); + } + const originalCallback = arguments[0].callback; + if (originalCallback) { + const originalContext = context.active(); + arguments[0].callback = function callback(this: any, err: Error | null, reply: unknown) { + if (config?.responseHook) { + const responseHook = config.responseHook; + safeExecuteInTheMiddle( + () => { + responseHook(span, cmd.command, cmd.args, reply); + }, + (e: Error | undefined) => { + if (e) { + instrumentation._diag.error('Error executing responseHook', e); + } + }, + true, + ); + } + endSpanV2(span, err); + return context.with(originalContext, originalCallback, this, ...arguments); + }; + } + try { + return original.apply(this, arguments); + } catch (rethrow) { + endSpanV2(span, rethrow as Error); + throw rethrow; + } + }; + }; + } + + private _getPatchCreateClient() { + return function createClient(original: Function) { + return getTracedCreateClient(original); + }; + } + + private _getPatchCreateStream() { + return function createReadStream(original: Function) { + return getTracedCreateStreamTrace(original); + }; + } +} + +// ---- RedisInstrumentationV4_V5 ---- + +class RedisInstrumentationV4_V5 extends InstrumentationBase { + static COMPONENT = 'redis'; + _semconvStability: SemconvStability; + + constructor(config: RedisInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + override setConfig(config: RedisInstrumentationConfig = {}): void { + super.setConfig(config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + init() { + return [ + this._getInstrumentationNodeModuleDefinition('@redis/client'), + this._getInstrumentationNodeModuleDefinition('@node-redis/client'), + ]; + } + + private _getInstrumentationNodeModuleDefinition(basePackageName: string) { + const commanderModuleFile = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/commander.js`, + ['^1.0.0'], + (moduleExports: any, moduleVersion?: string) => { + const transformCommandArguments = moduleExports.transformCommandArguments; + if (!transformCommandArguments) { + this._diag.error('internal instrumentation error, missing transformCommandArguments function'); + return moduleExports; + } + const functionToPatch = moduleVersion?.startsWith('1.0.') ? 'extendWithCommands' : 'attachCommands'; + if (isWrapped(moduleExports?.[functionToPatch])) { + this._unwrap(moduleExports, functionToPatch); + } + this._wrap(moduleExports, functionToPatch, this._getPatchExtendWithCommands(transformCommandArguments)); + return moduleExports; + }, + (moduleExports: any) => { + if (isWrapped(moduleExports?.extendWithCommands)) { + this._unwrap(moduleExports, 'extendWithCommands'); + } + if (isWrapped(moduleExports?.attachCommands)) { + this._unwrap(moduleExports, 'attachCommands'); + } + }, + ); + + const multiCommanderModule = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/client/multi-command.js`, + ['^1.0.0', '^5.0.0'], + (moduleExports: any) => { + const redisClientMultiCommandPrototype = moduleExports?.default?.prototype; + if (isWrapped(redisClientMultiCommandPrototype?.exec)) { + this._unwrap(redisClientMultiCommandPrototype, 'exec'); + } + this._wrap(redisClientMultiCommandPrototype, 'exec', this._getPatchMultiCommandsExec(false)); + if (isWrapped(redisClientMultiCommandPrototype?.execAsPipeline)) { + this._unwrap(redisClientMultiCommandPrototype, 'execAsPipeline'); + } + this._wrap(redisClientMultiCommandPrototype, 'execAsPipeline', this._getPatchMultiCommandsExec(true)); + if (isWrapped(redisClientMultiCommandPrototype?.addCommand)) { + this._unwrap(redisClientMultiCommandPrototype, 'addCommand'); + } + this._wrap(redisClientMultiCommandPrototype, 'addCommand', this._getPatchMultiCommandsAddCommand()); + return moduleExports; + }, + (moduleExports: any) => { + const redisClientMultiCommandPrototype = moduleExports?.default?.prototype; + if (isWrapped(redisClientMultiCommandPrototype?.exec)) { + this._unwrap(redisClientMultiCommandPrototype, 'exec'); + } + if (isWrapped(redisClientMultiCommandPrototype?.execAsPipeline)) { + this._unwrap(redisClientMultiCommandPrototype, 'execAsPipeline'); + } + if (isWrapped(redisClientMultiCommandPrototype?.addCommand)) { + this._unwrap(redisClientMultiCommandPrototype, 'addCommand'); + } + }, + ); + + const clientIndexModule = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/client/index.js`, + ['^1.0.0', '^5.0.0'], + (moduleExports: any) => { + const redisClientPrototype = moduleExports?.default?.prototype; + if (redisClientPrototype?.multi) { + if (isWrapped(redisClientPrototype?.multi)) { + this._unwrap(redisClientPrototype, 'multi'); + } + this._wrap(redisClientPrototype, 'multi', this._getPatchRedisClientMulti()); + } + if (redisClientPrototype?.MULTI) { + if (isWrapped(redisClientPrototype?.MULTI)) { + this._unwrap(redisClientPrototype, 'MULTI'); + } + this._wrap(redisClientPrototype, 'MULTI', this._getPatchRedisClientMulti()); + } + if (isWrapped(redisClientPrototype?.sendCommand)) { + this._unwrap(redisClientPrototype, 'sendCommand'); + } + this._wrap(redisClientPrototype, 'sendCommand', this._getPatchRedisClientSendCommand()); + if (isWrapped(redisClientPrototype?.connect)) { + this._unwrap(redisClientPrototype, 'connect'); + } + this._wrap(redisClientPrototype, 'connect', this._getPatchedClientConnect()); + return moduleExports; + }, + (moduleExports: any) => { + const redisClientPrototype = moduleExports?.default?.prototype; + if (isWrapped(redisClientPrototype?.multi)) { + this._unwrap(redisClientPrototype, 'multi'); + } + if (isWrapped(redisClientPrototype?.MULTI)) { + this._unwrap(redisClientPrototype, 'MULTI'); + } + if (isWrapped(redisClientPrototype?.sendCommand)) { + this._unwrap(redisClientPrototype, 'sendCommand'); + } + if (isWrapped(redisClientPrototype?.connect)) { + this._unwrap(redisClientPrototype, 'connect'); + } + }, + ); + + return new InstrumentationNodeModuleDefinition( + basePackageName, + ['^1.0.0', '^5.0.0'], + (moduleExports: any) => moduleExports, + () => {}, + [commanderModuleFile, multiCommanderModule, clientIndexModule], + ); + } + + private _getPatchExtendWithCommands(transformCommandArguments: Function) { + const plugin = this; + return function extendWithCommandsPatchWrapper(original: Function) { + return function extendWithCommandsPatch(this: any, config: any) { + if (config?.BaseClass?.name !== 'RedisClient') { + return original.apply(this, arguments); + } + const origExecutor = config.executor; + config.executor = function (this: any, command: any, args: any) { + const redisCommandArguments = transformCommandArguments(command, args).args; + return plugin._traceClientCommand(origExecutor, this, arguments, redisCommandArguments); + }; + return original.apply(this, arguments); + }; + }; + } + + private _getPatchMultiCommandsExec(isPipeline: boolean) { + const plugin = this; + return function execPatchWrapper(original: Function) { + return function execPatch(this: any) { + const execRes = original.apply(this, arguments); + if (typeof execRes?.then !== 'function') { + plugin._diag.error('non-promise result when patching exec/execAsPipeline'); + return execRes; + } + return execRes + .then((redisRes: unknown[]) => { + const openSpans: OpenSpanInfo[] = this[OTEL_OPEN_SPANS]; + plugin._endSpansWithRedisReplies(openSpans, redisRes, isPipeline); + return redisRes; + }) + .catch((err: any) => { + const openSpans: OpenSpanInfo[] = this[OTEL_OPEN_SPANS]; + if (!openSpans) { + plugin._diag.error('cannot find open spans to end for multi/pipeline'); + } else { + const replies = + err.constructor.name === 'MultiErrorReply' + ? (err as MultiErrorReply).replies + : new Array(openSpans.length).fill(err); + plugin._endSpansWithRedisReplies(openSpans, replies, isPipeline); + } + return Promise.reject(err); + }); + }; + }; + } + + private _getPatchMultiCommandsAddCommand() { + const plugin = this; + return function addCommandWrapper(original: Function) { + return function addCommandPatch(this: any, args: any) { + return plugin._traceClientCommand(original, this, arguments, args); + }; + }; + } + + private _getPatchRedisClientMulti() { + return function multiPatchWrapper(original: Function) { + return function multiPatch(this: any) { + const multiRes: any = original.apply(this, arguments); + multiRes[MULTI_COMMAND_OPTIONS] = this.options; + return multiRes; + }; + }; + } + + private _getPatchRedisClientSendCommand() { + const plugin = this; + return function sendCommandWrapper(original: Function) { + return function sendCommandPatch(this: any, args: any) { + return plugin._traceClientCommand(original, this, arguments, args); + }; + }; + } + + private _getPatchedClientConnect() { + const plugin = this; + return function connectWrapper(original: Function) { + return function patchedConnect(this: any) { + const options = this.options; + const attributes = getClientAttributes(plugin._diag, options, plugin._semconvStability); + const span = plugin.tracer.startSpan(`${RedisInstrumentationV4_V5.COMPONENT}-connect`, { + kind: SpanKind.CLIENT, + attributes, + }); + const res = context.with(trace.setSpan(context.active(), span), () => { + return original.apply(this); + }); + return res + .then((result: any) => { + span.end(); + return result; + }) + .catch((error: Error) => { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + span.end(); + return Promise.reject(error); + }); + }; + }; + } + + _traceClientCommand( + origFunction: Function, + origThis: any, + origArguments: IArguments, + redisCommandArguments: Array, + ): any { + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (hasNoParentSpan && this.getConfig().requireParentSpan) { + return origFunction.apply(origThis, origArguments); + } + const clientOptions = origThis.options || origThis[MULTI_COMMAND_OPTIONS]; + const commandName = redisCommandArguments[0] as string; + const commandArgs = redisCommandArguments.slice(1); + const dbStatementSerializer = this.getConfig().dbStatementSerializer || defaultDbStatementSerializer; + const attributes = getClientAttributes(this._diag, clientOptions, this._semconvStability); + if (this._semconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_OPERATION_NAME] = commandName; + } + try { + const dbStatement = dbStatementSerializer(commandName, commandArgs); + if (dbStatement != null) { + if (this._semconvStability & SemconvStability.OLD) { + attributes[ATTR_DB_STATEMENT] = dbStatement; + } + if (this._semconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_QUERY_TEXT] = dbStatement; + } + } + } catch (e) { + this._diag.error('dbStatementSerializer throw an exception', e, { commandName }); + } + const span = this.tracer.startSpan(`${RedisInstrumentationV4_V5.COMPONENT}-${commandName}`, { + kind: SpanKind.CLIENT, + attributes, + }); + const res = context.with(trace.setSpan(context.active(), span), () => { + return origFunction.apply(origThis, origArguments); + }); + if (typeof res?.then === 'function') { + res.then( + (redisRes: unknown) => { + this._endSpanWithResponse(span, commandName, commandArgs, redisRes, undefined); + }, + (err: Error) => { + this._endSpanWithResponse(span, commandName, commandArgs, null, err); + }, + ); + } else { + const redisClientMultiCommand: any = res; + redisClientMultiCommand[OTEL_OPEN_SPANS] = redisClientMultiCommand[OTEL_OPEN_SPANS] || []; + redisClientMultiCommand[OTEL_OPEN_SPANS].push({ + span, + commandName, + commandArgs, + }); + } + return res; + } + + _endSpansWithRedisReplies(openSpans: OpenSpanInfo[] | undefined, replies: unknown[], isPipeline = false): void { + if (!openSpans) { + return this._diag.error('cannot find open spans to end for redis multi/pipeline'); + } + if (replies.length !== openSpans.length) { + return this._diag.error('number of multi command spans does not match response from redis'); + } + const allCommands = openSpans.map(s => s.commandName); + const allSameCommand = allCommands.every(cmd => cmd === allCommands[0]); + const operationName = allSameCommand + ? (isPipeline ? 'PIPELINE ' : 'MULTI ') + allCommands[0] + : isPipeline + ? 'PIPELINE' + : 'MULTI'; + for (let i = 0; i < openSpans.length; i++) { + const { span, commandArgs } = openSpans[i]!; + const currCommandRes = replies[i]; + const [res, err] = currCommandRes instanceof Error ? [null, currCommandRes] : [currCommandRes, undefined]; + if (this._semconvStability & SemconvStability.STABLE) { + span.setAttribute(ATTR_DB_OPERATION_NAME, operationName); + } + this._endSpanWithResponse(span, allCommands[i]!, commandArgs, res, err); + } + } + + _endSpanWithResponse( + span: Span, + commandName: string, + commandArgs: Array, + response: unknown, + error: Error | null | undefined, + ): void { + const { responseHook } = this.getConfig(); + if (!error && responseHook) { + try { + responseHook(span, commandName, commandArgs, response); + } catch (err) { + this._diag.error('responseHook throw an exception', err); + } + } + if (error) { + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message }); + } + span.end(); + } +} + +// ---- RedisInstrumentation (wrapper) ---- + +const DEFAULT_CONFIG: RedisInstrumentationConfig = { + requireParentSpan: false, +}; + +export class RedisInstrumentation extends InstrumentationBase { + private instrumentationV2_V3: RedisInstrumentationV2_V3; + private instrumentationV4_V5: RedisInstrumentationV4_V5; + private initialized = false; + + constructor(config: RedisInstrumentationConfig = {}) { + const resolvedConfig = { ...DEFAULT_CONFIG, ...config }; + super(PACKAGE_NAME, PACKAGE_VERSION, resolvedConfig); + this.instrumentationV2_V3 = new RedisInstrumentationV2_V3(this.getConfig()); + this.instrumentationV4_V5 = new RedisInstrumentationV4_V5(this.getConfig()); + this.initialized = true; + } + + override setConfig(config: RedisInstrumentationConfig = {}): void { + const newConfig = { ...DEFAULT_CONFIG, ...config }; + super.setConfig(newConfig); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.setConfig(newConfig); + this.instrumentationV4_V5.setConfig(newConfig); + } + + init() {} + + override getModuleDefinitions() { + return [...this.instrumentationV2_V3.getModuleDefinitions(), ...this.instrumentationV4_V5.getModuleDefinitions()]; + } + + override setTracerProvider(tracerProvider: TracerProvider): void { + super.setTracerProvider(tracerProvider); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.setTracerProvider(tracerProvider); + this.instrumentationV4_V5.setTracerProvider(tracerProvider); + } + + override enable(): void { + super.enable(); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.enable(); + this.instrumentationV4_V5.enable(); + } + + override disable(): void { + super.disable(); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.disable(); + this.instrumentationV4_V5.disable(); + } +} diff --git a/packages/node/src/integrations/tracing/redis/vendored/semconv.ts b/packages/node/src/integrations/tracing/redis/vendored/semconv.ts new file mode 100644 index 000000000000..ab26f76282d9 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/semconv.ts @@ -0,0 +1,36 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/instrumentation-redis + * - Upstream version: @opentelemetry/instrumentation-redis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-redis */ + +/* + * This file contains a copy of unstable semantic convention definitions + * used by the vendored redis/ioredis instrumentations. + * @see https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv + */ + +// Deprecated constants kept for backwards compatibility with older semconv +export const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +export const ATTR_DB_STATEMENT = 'db.statement'; +export const ATTR_DB_SYSTEM = 'db.system'; +export const ATTR_NET_PEER_NAME = 'net.peer.name'; +export const ATTR_NET_PEER_PORT = 'net.peer.port'; +export const DB_SYSTEM_NAME_VALUE_REDIS = 'redis'; +export const DB_SYSTEM_VALUE_REDIS = 'redis'; diff --git a/packages/node/src/integrations/tracing/redis/vendored/types.ts b/packages/node/src/integrations/tracing/redis/vendored/types.ts new file mode 100644 index 000000000000..24b3817857d5 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/types.ts @@ -0,0 +1,92 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/instrumentation-redis + * - Upstream version: @opentelemetry/instrumentation-redis@0.62.0 and @opentelemetry/instrumentation-ioredis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-redis */ + +import type { Span } from '@opentelemetry/api'; +import type { InstrumentationConfig, SemconvStability } from '@opentelemetry/instrumentation'; + +// ---- redis types ---- + +/** + * Function that can be used to serialize db.statement tag + * @param cmdName - The name of the command (eg. set, get, mset) + * @param cmdArgs - Array of arguments passed to the command + * @returns serialized string that will be used as the db.statement attribute. + */ +export type DbStatementSerializer = (cmdName: string, cmdArgs: Array) => string; + +/** + * Function that can be used to add custom attributes to span on response from redis server + */ +export interface RedisResponseCustomAttributeFunction { + (span: Span, cmdName: string, cmdArgs: Array, response: unknown): void; +} + +export interface RedisInstrumentationConfig extends InstrumentationConfig { + /** Custom serializer function for the db.statement tag */ + dbStatementSerializer?: DbStatementSerializer; + /** Function for adding custom attributes on db response */ + responseHook?: RedisResponseCustomAttributeFunction; + /** Require parent to create redis span, default when unset is false */ + requireParentSpan?: boolean; + /** + * Controls which semantic-convention attributes are emitted on spans. + * Default: 'OLD'. + */ + semconvStability?: SemconvStability; +} + +// ---- ioredis types ---- + +export type CommandArgs = Array; + +/** + * Function that can be used to serialize db.statement tag for ioredis + */ +export type IORedisDbStatementSerializer = (cmdName: string, cmdArgs: CommandArgs) => string; + +export interface IORedisRequestHookInformation { + moduleVersion?: string; + cmdName: string; + cmdArgs: CommandArgs; +} + +export interface RedisRequestCustomAttributeFunction { + (span: Span, requestInfo: IORedisRequestHookInformation): void; +} + +/** + * Function that can be used to add custom attributes to span on response from redis server (ioredis) + */ +export interface IORedisResponseCustomAttributeFunction { + (span: Span, cmdName: string, cmdArgs: CommandArgs, response: unknown): void; +} + +export interface IORedisInstrumentationConfig extends InstrumentationConfig { + /** Custom serializer function for the db.statement tag */ + dbStatementSerializer?: IORedisDbStatementSerializer; + /** Function for adding custom attributes on db request */ + requestHook?: RedisRequestCustomAttributeFunction; + /** Function for adding custom attributes on db response */ + responseHook?: IORedisResponseCustomAttributeFunction; + /** Require parent to create ioredis span, default when unset is true */ + requireParentSpan?: boolean; +} diff --git a/packages/node/src/utils/redisCache.ts b/packages/node/src/utils/redisCache.ts index 476a257fbc6d..60e8218efdeb 100644 --- a/packages/node/src/utils/redisCache.ts +++ b/packages/node/src/utils/redisCache.ts @@ -1,4 +1,4 @@ -import type { CommandArgs as IORedisCommandArgs } from '@opentelemetry/instrumentation-ioredis'; +export type IORedisCommandArgs = Array; const SINGLE_ARG_COMMANDS = ['get', 'set', 'setex']; diff --git a/packages/node/test/integrations/tracing/redis/ioredis-instrumentation.test.ts b/packages/node/test/integrations/tracing/redis/ioredis-instrumentation.test.ts new file mode 100644 index 000000000000..9e7b24d3dd0a --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/ioredis-instrumentation.test.ts @@ -0,0 +1,151 @@ +/* + * Tests ported from @opentelemetry/instrumentation-ioredis@0.62.0 + * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-ioredis + * Licensed under the Apache License, Version 2.0 + */ + +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { IORedisInstrumentation } from '../../../../src/integrations/tracing/redis/vendored/ioredis-instrumentation'; + +const memoryExporter = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(memoryExporter)] }); + +describe('IORedisInstrumentation', () => { + let instrumentation: IORedisInstrumentation; + + beforeEach(() => { + instrumentation = new IORedisInstrumentation(); + instrumentation.setTracerProvider(provider); + memoryExporter.reset(); + }); + + afterEach(() => { + instrumentation.disable(); + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create an instance with default config (requireParentSpan = true)', () => { + const inst = new IORedisInstrumentation(); + expect(inst).toBeInstanceOf(IORedisInstrumentation); + expect(inst.getConfig().requireParentSpan).toBe(true); + }); + + it('should create an instance with custom config', () => { + const inst = new IORedisInstrumentation({ requireParentSpan: false }); + expect(inst.getConfig().requireParentSpan).toBe(false); + }); + }); + + describe('setConfig', () => { + it('should preserve default requireParentSpan = true when config is empty', () => { + instrumentation.setConfig({}); + expect(instrumentation.getConfig().requireParentSpan).toBe(true); + }); + + it('should allow overriding requireParentSpan', () => { + instrumentation.setConfig({ requireParentSpan: false }); + expect(instrumentation.getConfig().requireParentSpan).toBe(false); + }); + }); + + describe('init', () => { + it('should return module definitions for ioredis', () => { + const defs = instrumentation.init(); + expect(Array.isArray(defs)).toBe(true); + expect(defs).toHaveLength(1); + expect(defs[0]!.name).toBe('ioredis'); + }); + + it('should support ioredis versions >=2.0.0 <6', () => { + const defs = instrumentation.init(); + const supportedVersions = defs[0]!.supportedVersions; + expect(supportedVersions).toContain('>=2.0.0 <6'); + }); + }); + + describe('_patchSendCommand', () => { + it('should skip tracing when no parent span and requireParentSpan is true', () => { + instrumentation.setConfig({ requireParentSpan: true }); + const original = vi.fn().mockReturnValue(Promise.resolve('OK')); + + const patchFn = (instrumentation as any)._patchSendCommand(); + const patched = patchFn(original); + + const fakeThis = { + options: { host: 'localhost', port: 6379 }, + }; + const fakeCmd = { + name: 'get', + args: ['mykey'], + resolve: vi.fn(), + reject: vi.fn(), + }; + + patched.call(fakeThis, fakeCmd); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + + it('should not trace when called with less than 1 argument', () => { + const original = vi.fn().mockReturnValue(undefined); + const patchFn = (instrumentation as any)._patchSendCommand(); + const patched = patchFn(original); + + const fakeThis = { options: { host: 'localhost', port: 6379 } }; + + patched.call(fakeThis); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + + it('should not trace when cmd is not an object', () => { + const original = vi.fn().mockReturnValue(undefined); + const patchFn = (instrumentation as any)._patchSendCommand(); + const patched = patchFn(original); + + const fakeThis = { options: { host: 'localhost', port: 6379 } }; + + patched.call(fakeThis, 'not-an-object'); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + }); + + describe('_patchConnection', () => { + it('should skip tracing when no parent span and requireParentSpan is true', () => { + instrumentation.setConfig({ requireParentSpan: true }); + const original = vi.fn().mockReturnValue({ connected: true }); + + const patchFn = (instrumentation as any)._patchConnection(); + const patched = patchFn(original); + + const fakeThis = { options: { host: 'localhost', port: 6379 } }; + + patched.call(fakeThis); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + }); + + describe('semconv stability', () => { + it('should initialize semconv stability from env', () => { + const inst = new IORedisInstrumentation(); + expect((inst as any)._netSemconvStability).toBeDefined(); + expect((inst as any)._dbSemconvStability).toBeDefined(); + }); + + it('should allow resetting semconv stability', () => { + const inst = new IORedisInstrumentation(); + const originalNet = (inst as any)._netSemconvStability; + inst._setSemconvStabilityFromEnv(); + expect((inst as any)._netSemconvStability).toBe(originalNet); + }); + }); +}); diff --git a/packages/node/test/integrations/tracing/redis/redis-common.test.ts b/packages/node/test/integrations/tracing/redis/redis-common.test.ts new file mode 100644 index 000000000000..6ceba6e63b12 --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/redis-common.test.ts @@ -0,0 +1,93 @@ +/* + * Tests ported from @opentelemetry/redis-common@0.38.2 + * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/redis-common + * Licensed under the Apache License, Version 2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { defaultDbStatementSerializer } from '../../../../src/integrations/tracing/redis/vendored/redis-common'; + +describe('defaultDbStatementSerializer()', () => { + const testCases: Array<{ + cmdName: string; + cmdArgs: Array; + expected: string; + }> = [ + { + cmdName: 'UNKNOWN', + cmdArgs: ['something'], + expected: 'UNKNOWN [1 other arguments]', + }, + { + cmdName: 'ECHO', + cmdArgs: ['echo'], + expected: 'ECHO [1 other arguments]', + }, + { + cmdName: 'LPUSH', + cmdArgs: ['list', 'value'], + expected: 'LPUSH list [1 other arguments]', + }, + { + cmdName: 'HSET', + cmdArgs: ['hash', 'field', 'value'], + expected: 'HSET hash field [1 other arguments]', + }, + { + cmdName: 'INCRBY', + cmdArgs: ['key', 5], + expected: 'INCRBY key 5', + }, + { + cmdName: 'GET', + cmdArgs: ['mykey'], + expected: 'GET mykey', + }, + { + cmdName: 'SET', + cmdArgs: ['mykey', 'myvalue'], + expected: 'SET mykey [1 other arguments]', + }, + { + cmdName: 'MSET', + cmdArgs: ['key1', 'val1', 'key2', 'val2'], + expected: 'MSET key1 [3 other arguments]', + }, + { + cmdName: 'HSET', + cmdArgs: ['myhash', 'field1', 'Hello'], + expected: 'HSET myhash field1 [1 other arguments]', + }, + { + cmdName: 'SET', + cmdArgs: [], + expected: 'SET', + }, + { + cmdName: 'DEL', + cmdArgs: ['key1', 'key2'], + expected: 'DEL key1 key2', + }, + { + cmdName: 'ZADD', + cmdArgs: ['myset', '1', 'one', '2', 'two'], + expected: 'ZADD myset [4 other arguments]', + }, + ]; + + it.each(testCases)( + 'should serialize the correct number of arguments for $cmdName', + ({ cmdName, cmdArgs, expected }) => { + expect(defaultDbStatementSerializer(cmdName, cmdArgs as any)).toBe(expected); + }, + ); + + it('should handle empty args array', () => { + expect(defaultDbStatementSerializer('GET', [])).toBe('GET'); + }); + + it('should handle Buffer arguments', () => { + const result = defaultDbStatementSerializer('GET', [Buffer.from('mykey')]); + expect(result).toBe('GET mykey'); + }); +}); diff --git a/packages/node/test/integrations/tracing/redis/redis-instrumentation.test.ts b/packages/node/test/integrations/tracing/redis/redis-instrumentation.test.ts new file mode 100644 index 000000000000..5ae0c5724033 --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/redis-instrumentation.test.ts @@ -0,0 +1,193 @@ +/* + * Tests ported from @opentelemetry/instrumentation-redis@0.62.0 + * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-redis + * Licensed under the Apache License, Version 2.0 + */ + +import { SpanStatusCode } from '@opentelemetry/api'; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RedisInstrumentation } from '../../../../src/integrations/tracing/redis/vendored/redis-instrumentation'; + +const memoryExporter = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(memoryExporter)] }); + +describe('RedisInstrumentation', () => { + let instrumentation: RedisInstrumentation; + + beforeEach(() => { + instrumentation = new RedisInstrumentation(); + instrumentation.setTracerProvider(provider); + memoryExporter.reset(); + }); + + afterEach(() => { + instrumentation.disable(); + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create an instance with default config', () => { + const inst = new RedisInstrumentation(); + expect(inst).toBeInstanceOf(RedisInstrumentation); + expect(inst.getConfig().requireParentSpan).toBe(false); + }); + + it('should create an instance with custom config', () => { + const inst = new RedisInstrumentation({ requireParentSpan: true }); + expect(inst.getConfig().requireParentSpan).toBe(true); + }); + + it('should enable and disable without throwing', () => { + const inst = new RedisInstrumentation(); + expect(() => inst.enable()).not.toThrow(); + expect(() => inst.disable()).not.toThrow(); + }); + }); + + describe('setConfig', () => { + it('should keep requireParentSpan default as false when config is empty', () => { + instrumentation.setConfig({}); + expect(instrumentation.getConfig().requireParentSpan).toBe(false); + }); + + it('should propagate config updates', () => { + const responseHook = vi.fn(); + instrumentation.setConfig({ responseHook }); + expect(instrumentation.getConfig().responseHook).toBe(responseHook); + }); + }); + + describe('getModuleDefinitions', () => { + it('should return module definitions from both v2-v3 and v4-v5 instrumentations', () => { + const defs = instrumentation.getModuleDefinitions(); + // v2-v3 instruments 'redis', v4-v5 instruments '@redis/client' and '@node-redis/client' + expect(defs.length).toBeGreaterThanOrEqual(3); + const moduleNames = defs.map((d: any) => d.name); + expect(moduleNames).toContain('redis'); + expect(moduleNames).toContain('@redis/client'); + expect(moduleNames).toContain('@node-redis/client'); + }); + }); + + describe('setTracerProvider', () => { + it('should accept a tracer provider', () => { + expect(() => instrumentation.setTracerProvider(provider)).not.toThrow(); + }); + }); + + describe('_endSpanWithResponse (v4-v5)', () => { + it('should call responseHook when no error occurs', () => { + const responseHook = vi.fn(); + const inst = new RedisInstrumentation({ responseHook }); + inst.setTracerProvider(provider); + + const span = provider.getTracer('test').startSpan('test-span'); + const v4v5 = (inst as any).instrumentationV4_V5; + v4v5._endSpanWithResponse(span, 'GET', ['mykey'], 'myvalue', undefined); + + expect(responseHook).toHaveBeenCalledWith(span, 'GET', ['mykey'], 'myvalue'); + }); + + it('should not call responseHook when error occurs', () => { + const responseHook = vi.fn(); + const inst = new RedisInstrumentation({ responseHook }); + inst.setTracerProvider(provider); + + const span = provider.getTracer('test').startSpan('test-span'); + const v4v5 = (inst as any).instrumentationV4_V5; + const error = new Error('connection failed'); + v4v5._endSpanWithResponse(span, 'GET', ['mykey'], null, error); + + expect(responseHook).not.toHaveBeenCalled(); + }); + + it('should set error status on span when error occurs', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + + const span = provider.getTracer('test').startSpan('test-span'); + const v4v5 = (inst as any).instrumentationV4_V5; + const error = new Error('connection failed'); + v4v5._endSpanWithResponse(span, 'GET', ['mykey'], null, error); + + const exportedSpans = memoryExporter.getFinishedSpans(); + expect(exportedSpans).toHaveLength(1); + expect(exportedSpans[0]!.status.code).toBe(SpanStatusCode.ERROR); + expect(exportedSpans[0]!.status.message).toBe('connection failed'); + }); + }); + + describe('_endSpansWithRedisReplies (v4-v5 multi/pipeline)', () => { + it('should end all spans with their corresponding replies', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + + const tracer = provider.getTracer('test'); + const span1 = tracer.startSpan('redis-SET'); + const span2 = tracer.startSpan('redis-GET'); + + const openSpans = [ + { span: span1, commandName: 'SET', commandArgs: ['key1', 'value1'] }, + { span: span2, commandName: 'GET', commandArgs: ['key1'] }, + ]; + + v4v5._endSpansWithRedisReplies(openSpans, ['OK', 'value1'], false); + + const exportedSpans = memoryExporter.getFinishedSpans(); + expect(exportedSpans).toHaveLength(2); + exportedSpans.forEach(s => { + expect(s.status.code).not.toBe(SpanStatusCode.ERROR); + }); + }); + + it('should handle error replies in multi commands', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + + const tracer = provider.getTracer('test'); + const span1 = tracer.startSpan('redis-SET'); + + const openSpans = [{ span: span1, commandName: 'SET', commandArgs: ['key1', 'value1'] }]; + const error = new Error('command error'); + + v4v5._endSpansWithRedisReplies(openSpans, [error], false); + + const exportedSpans = memoryExporter.getFinishedSpans(); + expect(exportedSpans).toHaveLength(1); + expect(exportedSpans[0]!.status.code).toBe(SpanStatusCode.ERROR); + }); + + it('should log error when openSpans is undefined', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + const diagSpy = vi.spyOn(v4v5._diag, 'error'); + + v4v5._endSpansWithRedisReplies(undefined, [], false); + + expect(diagSpy).toHaveBeenCalled(); + }); + + it('should log error when replies length does not match open spans', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + const diagSpy = vi.spyOn(v4v5._diag, 'error'); + + const tracer = provider.getTracer('test'); + const span1 = tracer.startSpan('redis-GET'); + + v4v5._endSpansWithRedisReplies( + [{ span: span1, commandName: 'GET', commandArgs: ['key'] }], + [], // wrong number of replies + false, + ); + + expect(diagSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8833ef477507..2c1e0ad5cf43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6294,15 +6294,6 @@ "@opentelemetry/semantic-conventions" "^1.29.0" forwarded-parse "2.1.2" -"@opentelemetry/instrumentation-ioredis@0.62.0": - version "0.62.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.62.0.tgz#4fd1775577132de5d92165caee6bbc0ae16a8c8a" - integrity sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ== - dependencies: - "@opentelemetry/instrumentation" "^0.214.0" - "@opentelemetry/redis-common" "^0.38.2" - "@opentelemetry/semantic-conventions" "^1.33.0" - "@opentelemetry/instrumentation-kafkajs@0.23.0": version "0.23.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.23.0.tgz#6b7d449d88d674ddc295a0d0cf2156f0f7d5889f" @@ -6390,15 +6381,6 @@ "@types/pg" "8.15.6" "@types/pg-pool" "2.0.7" -"@opentelemetry/instrumentation-redis@0.62.0": - version "0.62.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.62.0.tgz#ecde90337fa49fec8d243bcbb8d470ce1a9ee7a1" - integrity sha512-y3pPpot7WzR/8JtHcYlTYsyY8g+pbFhAqbwAuG5bLPnR6v6pt1rQc0DpH0OlGP/9CZbWBP+Zhwp9yFoygf/ZXQ== - dependencies: - "@opentelemetry/instrumentation" "^0.214.0" - "@opentelemetry/redis-common" "^0.38.2" - "@opentelemetry/semantic-conventions" "^1.27.0" - "@opentelemetry/instrumentation-tedious@0.33.0": version "0.33.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.33.0.tgz#00f6698f8afae1b350bf0c463a59eeae3c8d25d7" @@ -6456,11 +6438,6 @@ "@opentelemetry/sdk-trace-base" "2.6.1" protobufjs "^7.0.0" -"@opentelemetry/redis-common@^0.38.2": - version "0.38.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz#cefa4f3e79db1cd54f19e233b7dfb56621143955" - integrity sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA== - "@opentelemetry/resources@2.6.1", "@opentelemetry/resources@^2.6.1": version "2.6.1" resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.6.1.tgz#e1b02772c5f65c0e074d59e4743188f7575e97c7" From 7adeb9da3b063767d29f2efa0d1c50a4032020e0 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 4 May 2026 11:42:18 -0700 Subject: [PATCH 45/84] fix(redis): handle case when socket data is not available --- packages/node/src/integrations/tracing/redis/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/node/src/integrations/tracing/redis/index.ts b/packages/node/src/integrations/tracing/redis/index.ts index 280cdc51d06e..c2bff42e4107 100644 --- a/packages/node/src/integrations/tracing/redis/index.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -70,8 +70,12 @@ export const cacheResponseHook: IORedisResponseCustomAttributeFunction = ( // otel/ioredis seems to be using the old standard, as there was a change to those params: https://github.com/open-telemetry/opentelemetry-specification/issues/3199 // We are using params based on the docs: https://opentelemetry.io/docs/specs/semconv/attributes-registry/network/ - const networkPeerAddress = spanToJSON(span).data['net.peer.name']; - const networkPeerPort = spanToJSON(span).data['net.peer.port']; + // Fall back to stable semconv attributes (server.address/server.port) when + // old-semconv ones are absent, eg OTEL_SEMCONV_STABILITY_OPT_IN=database + // set for node-redis v4/v5. + const spanData = spanToJSON(span).data; + const networkPeerAddress = spanData['net.peer.name'] ?? spanData['server.address']; + const networkPeerPort = spanData['net.peer.port'] ?? spanData['server.port']; if (networkPeerPort && networkPeerAddress) { span.setAttributes({ 'network.peer.address': networkPeerAddress, 'network.peer.port': networkPeerPort }); } From b64a3d7f154c178105a3ba08b608bbd30b73f2e7 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 5 May 2026 09:29:58 +0200 Subject: [PATCH 46/84] test(browser): Fix flaky loader test (#20655) Hopefully fixes https://github.com/getsentry/sentry-javascript/issues/20645 --- .../loader/noOnLoad/sdkLoadedInMeanwhile/test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts index d758ec5e7901..276bda3227ac 100644 --- a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts +++ b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts @@ -53,8 +53,9 @@ sentryTest('it does not download the SDK if the SDK was loaded in the meanwhile' // Still loaded the CDN bundle twice await expect.poll(() => cdnLoadedCount, { timeout: 15_000 }).toBe(2); - // But only sent to Sentry once - expect(sentryEventCount).toBe(1); + // But only sent to Sentry once (`waitForErrorRequest` can resolve before the DSN + // `page.route` handler increments — poll until the intercept has run) + await expect.poll(() => sentryEventCount, { timeout: 15_000 }).toBe(1); // Ensure loader does not overwrite init/config const options = await page.evaluate(() => (window as any).Sentry.getClient()?.getOptions()); From 6d0ebc42613037083a3543fb22cc6bfcdde8638a Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 5 May 2026 09:38:19 +0200 Subject: [PATCH 47/84] fix(hono): Do not capture 3xx and 4xx errors and add tests (#20640) Add tests around error capturing in Hono and adds condition to not capture 3xx and 4xx errors. Also removes some redundant tests and moves all error-related tests to `errors.test.ts` --- .../hono-4/src/route-groups/test-errors.ts | 45 +++ .../src/route-groups/test-route-patterns.ts | 29 -- .../test-applications/hono-4/src/routes.ts | 4 + .../hono-4/tests/errors.test.ts | 263 +++++++++++++++--- .../hono-4/tests/middleware.test.ts | 52 ++-- .../hono-4/tests/route-patterns.test.ts | 99 +------ .../hono-4/tests/tracing.test.ts | 22 +- packages/hono/src/shared/isExpectedError.ts | 17 ++ .../hono/src/shared/middlewareHandlers.ts | 3 +- .../hono/src/shared/wrapMiddlewareSpan.ts | 19 +- .../hono/test/shared/isExpectedError.test.ts | 78 ++++++ .../test/shared/middlewareHandlers.test.ts | 3 +- 12 files changed, 444 insertions(+), 190 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts create mode 100644 packages/hono/src/shared/isExpectedError.ts create mode 100644 packages/hono/test/shared/isExpectedError.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts new file mode 100644 index 000000000000..b8f2fd96fe93 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts @@ -0,0 +1,45 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +const errorRoutes = new Hono(); + +// Middleware that throws a 5xx HTTPException (should be captured) +errorRoutes.use('/middleware-http-exception/*', async (_c, _next) => { + throw new HTTPException(503, { message: 'Service Unavailable from middleware' }); +}); + +errorRoutes.get('/middleware-http-exception', c => c.text('should not reach')); + +// Middleware that throws a 4xx HTTPException (should NOT be captured) +errorRoutes.use('/middleware-http-exception-4xx/*', async (_c, _next) => { + throw new HTTPException(401, { message: 'Unauthorized from middleware' }); +}); + +errorRoutes.get('/middleware-http-exception-4xx', c => c.text('should not reach')); + +// Sub-app with a custom onError handler that swallows errors +const subAppWithOnError = new Hono(); + +subAppWithOnError.onError((err, c) => { + return c.text(`Handled by onError: ${err.message}`, 500); +}); + +subAppWithOnError.get('/fail', () => { + throw new Error('Error caught by custom onError'); +}); + +errorRoutes.route('/custom-on-error', subAppWithOnError); + +// Nested sub-apps: parent mounts child, child route throws +const childApp = new Hono(); + +childApp.get('/error', () => { + throw new Error('Nested child app error'); +}); + +const parentApp = new Hono(); +parentApp.route('/child', childApp); + +errorRoutes.route('/nested', parentApp); + +export { errorRoutes }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts index e32662fb3b18..598943cad868 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts @@ -1,5 +1,4 @@ import { Hono } from 'hono'; -import { HTTPException } from 'hono/http-exception'; const routePatterns = new Hono(); @@ -24,32 +23,4 @@ METHODS.forEach(method => { routePatterns.on(method, '/on', c => c.text(`${method} on response`)); }); -// Error routes for direct method registration -METHODS.forEach(method => { - routePatterns[method]('/500', () => { - throw new HTTPException(500, { message: 'response 500' }); - }); - routePatterns[method]('/401', () => { - throw new HTTPException(401, { message: 'response 401' }); - }); - routePatterns[method]('/402', () => { - throw new HTTPException(402, { message: 'response 402' }); - }); - routePatterns[method]('/403', () => { - throw new HTTPException(403, { message: 'response 403' }); - }); -}); - -// Error routes for .all() -routePatterns.all('/all/500', () => { - throw new HTTPException(500, { message: 'response 500' }); -}); - -// Error routes for .on() -METHODS.forEach(method => { - routePatterns.on(method, '/on/500', () => { - throw new HTTPException(500, { message: 'response 500' }); - }); -}); - export { routePatterns }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts index f6efc6dde03c..cfb13146b6f7 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts @@ -1,6 +1,7 @@ import type { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { failingMiddleware, middlewareA, middlewareB } from './middleware'; +import { errorRoutes } from './route-groups/test-errors'; import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware'; import { routePatterns } from './route-groups/test-route-patterns'; @@ -43,4 +44,7 @@ export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): v // Route patterns: HTTP methods, .all(), .on(), sync/async, errors app.route('/test-routes', routePatterns); + + // Error-specific routes: onError handler, nested sub-apps, middleware HTTPException + app.route('/test-errors', errorRoutes); } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts index 832204237946..98c81d30afeb 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts @@ -1,53 +1,250 @@ import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; -import { APP_NAME } from './constants'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME, RUNTIME } from './constants'; -test('captures error thrown in route handler', async ({ baseURL }) => { - const errorWaiter = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'This is a test error for Sentry!'; - }); +test.describe('route handler errors', () => { + test('captures error with mechanism and trace correlation', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'This is a test error for Sentry!'; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/error/'); + }); + + const response = await fetch(`${baseURL}/error/test-cause`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + const transactionEvent = await transactionPromise; + + expect(transactionEvent.transaction).toBe('GET /error/:cause'); + + expect(errorEvent.exception?.values).toHaveLength(1); + + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is a test error for Sentry!'); + expect(exception?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); - const response = await fetch(`${baseURL}/error/test-cause`); - expect(response.status).toBe(500); + expect(errorEvent.transaction).toBe('GET /error/:cause'); + expect(errorEvent.request?.method).toBe('GET'); + expect(errorEvent.request?.url).toContain('/error/test-cause'); - const event = await errorWaiter; - expect(event.exception?.values?.[0]?.value).toBe('This is a test error for Sentry!'); + expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + }); }); -test('captures HTTPException with 502 status', async ({ baseURL }) => { - const errorWaiter = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'HTTPException 502'; +test.describe('HTTPException errors', () => { + test('captures 5xx HTTPException', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'HTTPException 500'; + }); + + const response = await fetch(`${baseURL}/http-exception/500`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('HTTPException 500'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); }); - const response = await fetch(`${baseURL}/http-exception/502`); - expect(response.status).toBe(502); + // On Node/Bun, httpServerSpansIntegration drops transactions for 3xx/4xx responses (ignoreStatusCodes), so we just use a request guard. + // On Cloudflare the transaction is available, and we additionally verify its name. + [301, 302].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { + errorEventOccurred = true; + } + return false; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return RUNTIME === 'cloudflare' + ? event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/http-exception/') + : event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /'; + }); + + const response = await fetch(`${baseURL}/http-exception/${code}`, { redirect: 'manual' }); + expect(response.status).toBe(code); + + if (RUNTIME !== 'cloudflare') { + // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses + await fetch(`${baseURL}/`); + } + + const transaction = await transactionPromise; + + if (RUNTIME === 'cloudflare') { + expect(transaction.transaction).toBe('GET /http-exception/:code'); + } + + expect(errorEventOccurred).toBe(false); + }); + }); + + [401, 403, 404].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { + errorEventOccurred = true; + } + return false; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return RUNTIME === 'cloudflare' + ? event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/http-exception/') + : event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /'; + }); + + const response = await fetch(`${baseURL}/http-exception/${code}`); + expect(response.status).toBe(code); + + if (RUNTIME !== 'cloudflare') { + // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses + await fetch(`${baseURL}/`); + } + + const transaction = await transactionPromise; + + if (RUNTIME === 'cloudflare') { + expect(transaction.transaction).toBe('GET /http-exception/:code'); + } - const event = await errorWaiter; - expect(event.exception?.values?.[0]?.value).toBe('HTTPException 502'); + expect(errorEventOccurred).toBe(false); + }); + }); }); -// TODO: 401 and 404 HTTPExceptions should not be captured by Sentry by default, -// but currently they are. Fix the filtering and update these tests accordingly. -test('captures HTTPException with 401 status', async ({ baseURL }) => { - const errorWaiter = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'HTTPException 401'; +test.describe('middleware errors', () => { + test('captures 5xx HTTPException thrown in middleware with error span status', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Service Unavailable from middleware'; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return ( + event.contexts?.trace?.op === 'http.server' && + !!event.transaction?.includes('/test-errors/middleware-http-exception') + ); + }); + + const response = await fetch(`${baseURL}/test-errors/middleware-http-exception`); + expect(response.status).toBe(503); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('Service Unavailable from middleware'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.middleware.hono'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false); + + const transaction = await transactionPromise; + const middlewareSpan = (transaction.spans || []).find(s => s.op === 'middleware.hono'); + expect(middlewareSpan?.status).toBe('internal_error'); }); - const response = await fetch(`${baseURL}/http-exception/401`); - expect(response.status).toBe(401); + test('does not capture 4xx HTTPException thrown in middleware', async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === 'Unauthorized from middleware') { + errorEventOccurred = true; + } + return false; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + if (RUNTIME === 'cloudflare') { + return ( + event.contexts?.trace?.op === 'http.server' && + !!event.transaction?.includes('/test-errors/middleware-http-exception-4xx') + ); + } + return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /'; + }); - const event = await errorWaiter; - expect(event.exception?.values?.[0]?.value).toBe('HTTPException 401'); + const response = await fetch(`${baseURL}/test-errors/middleware-http-exception-4xx`); + expect(response.status).toBe(401); + + if (RUNTIME !== 'cloudflare') { + await fetch(`${baseURL}/`); + } + + const transaction = await transactionPromise; + + if (RUNTIME === 'cloudflare') { + expect(transaction.transaction).toBe('GET /test-errors/middleware-http-exception-4xx/*'); + + const middlewareSpan = (transaction.spans || []).find(s => s.op === 'middleware.hono'); + expect(middlewareSpan?.status).not.toBe('internal_error'); + } + + expect(errorEventOccurred).toBe(false); + }); }); -test('captures HTTPException with 404 status', async ({ baseURL }) => { - const errorWaiter = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'HTTPException 404'; +test.describe('nested sub-app errors', () => { + test('captures error from nested child sub-app', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Nested child app error'; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/nested/child/error'); + }); + + const response = await fetch(`${baseURL}/test-errors/nested/child/error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + const transaction = await transactionPromise; + + expect(transaction.transaction).toBe('GET /test-errors/nested/child/error'); + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Nested child app error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + expect(errorEvent.request?.url).toContain('/test-errors/nested/child/error'); }); +}); - const response = await fetch(`${baseURL}/http-exception/404`); - expect(response.status).toBe(404); +test.describe('custom onError handler', () => { + test('captures error even when onError handles the response', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Error caught by custom onError'; + }); - const event = await errorWaiter; - expect(event.exception?.values?.[0]?.value).toBe('HTTPException 404'); + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/custom-on-error/fail'); + }); + + const response = await fetch(`${baseURL}/test-errors/custom-on-error/fail`); + expect(response.status).toBe(500); + + const body = await response.text(); + expect(body).toContain('Handled by onError'); + + const errorEvent = await errorPromise; + const transaction = await transactionPromise; + + expect(transaction.transaction).toBe('GET /test-errors/custom-on-error/fail'); + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Error caught by custom onError'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts index e8431bed67ce..d984ac0d38a8 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts @@ -18,13 +18,15 @@ for (const { name, prefix } of SCENARIOS) { test.describe(name, () => { test('creates a span for named middleware', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/named`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${prefix}/named`); }); const response = await fetch(`${baseURL}${prefix}/named`); expect(response.status).toBe(200); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${prefix}/named`); + const spans = transaction.spans || []; const middlewareSpan = spans.find( @@ -37,9 +39,9 @@ for (const { name, prefix } of SCENARIOS) { description: 'middlewareA', op: 'middleware.hono', origin: 'auto.middleware.hono', - status: 'ok', }), ); + expect(middlewareSpan?.status).not.toBe('internal_error'); // @ts-expect-error timestamp is defined const durationMs = (middlewareSpan?.timestamp - middlewareSpan?.start_timestamp) * 1000; @@ -48,34 +50,37 @@ for (const { name, prefix } of SCENARIOS) { test('creates a span for anonymous middleware', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/anonymous`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${prefix}/anonymous`); }); const response = await fetch(`${baseURL}${prefix}/anonymous`); expect(response.status).toBe(200); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${prefix}/anonymous`); + const spans = transaction.spans || []; - expect(spans).toContainEqual( - expect.objectContaining({ - description: '', - op: 'middleware.hono', - origin: 'auto.middleware.hono', - status: 'ok', - }), + const anonymousSpan = spans.find( + (span: { description?: string; op?: string }) => + span.op === 'middleware.hono' && span.description === '', ); + expect(anonymousSpan).toBeDefined(); + expect(anonymousSpan?.origin).toBe('auto.middleware.hono'); + expect(anonymousSpan?.status).not.toBe('internal_error'); }); test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/multi`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${prefix}/multi`); }); const response = await fetch(`${baseURL}${prefix}/multi`); expect(response.status).toBe(200); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${prefix}/multi`); + const spans = transaction.spans || []; const middlewareSpans = spans.sort((a, b) => (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0)); @@ -115,12 +120,14 @@ for (const { name, prefix } of SCENARIOS) { test('sets error status on middleware span when middleware throws', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/error/*`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${prefix}/error`); }); await fetch(`${baseURL}${prefix}/error`); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${prefix}/error/*`); + const spans = transaction.spans || []; const failingSpan = spans.find( @@ -153,7 +160,8 @@ test.describe('.all() handler in sub-app', () => { test('does not create middleware span for .all() route handler', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { return ( - event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-subapp-middleware/all-handler' + event.contexts?.trace?.op === 'http.server' && + !!event.transaction?.includes('/test-subapp-middleware/all-handler') ); }); @@ -164,6 +172,8 @@ test.describe('.all() handler in sub-app', () => { expect(body).toEqual({ handler: 'all' }); const transaction = await transactionPromise; + expect(transaction.transaction).toBe('GET /test-subapp-middleware/all-handler'); + const spans = transaction.spans || []; // No middleware is called for this route, so there should be no spans. @@ -191,13 +201,14 @@ test.describe('inline middleware spans (sub-app)', () => { const fullPath = `${INLINE_PREFIX}${regPath}${mwPath}`; const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${fullPath}`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(fullPath); }); const response = await fetch(`${baseURL}${fullPath}`); expect(response.status).toBe(200); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${fullPath}`); const EXPECTED_DESCRIPTIONS: Record> = { '/direct': { '': 'inlineMiddleware', '/separately': 'inlineSeparateMiddleware' }, @@ -206,14 +217,11 @@ test.describe('inline middleware spans (sub-app)', () => { }; const expectedDescription = EXPECTED_DESCRIPTIONS[regPath]![mwPath]!; - expect(transaction.spans).toContainEqual( - expect.objectContaining({ - description: expectedDescription, - op: 'middleware.hono', - origin: 'auto.middleware.hono', - status: 'ok', - }), - ); + const inlineSpan = (transaction.spans || []).find(s => s.description === expectedDescription); + expect(inlineSpan).toBeDefined(); + expect(inlineSpan?.op).toBe('middleware.hono'); + expect(inlineSpan?.origin).toBe('auto.middleware.hono'); + expect(inlineSpan?.status).not.toBe('internal_error'); }); } } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts index fd6579fe3b17..decd1049b6c9 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForTransaction } from '@sentry-internal/test-utils'; import { APP_NAME } from './constants'; const PREFIX = '/test-routes'; @@ -11,45 +11,45 @@ const REGISTRATION_STYLES = [ ] as const; test.describe('HTTP methods', () => { - for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + ['POST', 'PUT', 'DELETE', 'PATCH'].forEach(method => { test(`sends transaction for ${method}`, async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `${method} ${PREFIX}`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(PREFIX); }); const response = await fetch(`${baseURL}${PREFIX}`, { method }); expect(response.status).toBe(200); const transaction = await transactionPromise; - expect(transaction.contexts?.trace?.op).toBe('http.server'); expect(transaction.transaction).toBe(`${method} ${PREFIX}`); + expect(transaction.contexts?.trace?.op).toBe('http.server'); }); - } + }); }); test.describe('route registration styles', () => { - for (const { name, path } of REGISTRATION_STYLES) { + REGISTRATION_STYLES.forEach(({ name, path }) => { test(`${name} sends transaction`, async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}${path}`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${PREFIX}${path}`); }); const response = await fetch(`${baseURL}${PREFIX}${path}`); expect(response.status).toBe(200); const transaction = await transactionPromise; - expect(transaction.contexts?.trace?.op).toBe('http.server'); expect(transaction.transaction).toBe(`GET ${PREFIX}${path}`); + expect(transaction.contexts?.trace?.op).toBe('http.server'); }); - } + }); - for (const { name, path } of [ + [ { name: '.all()', path: '/all' }, { name: '.on()', path: '/on' }, - ]) { + ].forEach(({ name, path }) => { test(`${name} responds to POST`, async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `POST ${PREFIX}${path}`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${PREFIX}${path}`); }); const response = await fetch(`${baseURL}${PREFIX}${path}`, { method: 'POST' }); @@ -58,87 +58,18 @@ test.describe('route registration styles', () => { const transaction = await transactionPromise; expect(transaction.transaction).toBe(`POST ${PREFIX}${path}`); }); - } + }); }); test('async handler sends transaction', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}/async`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${PREFIX}/async`); }); const response = await fetch(`${baseURL}${PREFIX}/async`); expect(response.status).toBe(200); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${PREFIX}/async`); expect(transaction.contexts?.trace?.op).toBe('http.server'); }); - -test.describe('500 HTTPException capture', () => { - for (const { name, path } of REGISTRATION_STYLES) { - test(`captures 500 from ${name} route with correct mechanism`, async ({ baseURL }) => { - const fullPath = `${PREFIX}${path}/500`; - - const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'response 500' && !!event.request?.url?.includes(fullPath); - }); - - const response = await fetch(`${baseURL}${fullPath}`); - expect(response.status).toBe(500); - - const errorEvent = await errorPromise; - expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500'); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( - expect.objectContaining({ - handled: false, - type: 'auto.http.hono.context_error', - }), - ); - }); - } - - test('captures 500 error with POST method', async ({ baseURL }) => { - const errorPromise = waitForError(APP_NAME, event => { - return ( - event.exception?.values?.[0]?.value === 'response 500' && - !!event.request?.url?.includes(`${PREFIX}/500`) && - event.request?.method === 'POST' - ); - }); - - const response = await fetch(`${baseURL}${PREFIX}/500`, { method: 'POST' }); - expect(response.status).toBe(500); - - const errorEvent = await errorPromise; - expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500'); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( - expect.objectContaining({ - handled: false, - type: 'auto.http.hono.context_error', - }), - ); - }); -}); - -test.describe('4xx HTTPException capture', () => { - for (const code of [401, 402, 403]) { - test(`captures ${code} HTTPException`, async ({ baseURL }) => { - const fullPath = `${PREFIX}/${code}`; - - const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === `response ${code}` && !!event.request?.url?.includes(fullPath); - }); - - const response = await fetch(`${baseURL}${fullPath}`); - expect(response.status).toBe(code); - - const errorEvent = await errorPromise; - expect(errorEvent.exception?.values?.[0]?.value).toBe(`response ${code}`); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( - expect.objectContaining({ - handled: false, - type: 'auto.http.hono.context_error', - }), - ); - }); - } -}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts index 1c33943f38f8..4d9644312913 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts @@ -3,38 +3,40 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; import { APP_NAME } from './constants'; test('sends a transaction for the index route', async ({ baseURL }) => { - const transactionWaiter = waitForTransaction(APP_NAME, event => { - return event.transaction === 'GET /'; + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /'; }); const response = await fetch(`${baseURL}/`); expect(response.status).toBe(200); - const transaction = await transactionWaiter; + const transaction = await transactionPromise; + expect(transaction.transaction).toBe('GET /'); expect(transaction.contexts?.trace?.op).toBe('http.server'); }); test('sends a transaction for a parameterized route', async ({ baseURL }) => { - const transactionWaiter = waitForTransaction(APP_NAME, event => { - return event.transaction === 'GET /test-param/:paramId'; + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/test-param/'); }); const response = await fetch(`${baseURL}/test-param/123`); expect(response.status).toBe(200); - const transaction = await transactionWaiter; - expect(transaction.contexts?.trace?.op).toBe('http.server'); + const transaction = await transactionPromise; expect(transaction.transaction).toBe('GET /test-param/:paramId'); + expect(transaction.contexts?.trace?.op).toBe('http.server'); }); test('sends a transaction for a route that throws', async ({ baseURL }) => { - const transactionWaiter = waitForTransaction(APP_NAME, event => { - return event.transaction === 'GET /error/:cause'; + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/error/'); }); await fetch(`${baseURL}/error/test-cause`); - const transaction = await transactionWaiter; + const transaction = await transactionPromise; + expect(transaction.transaction).toBe('GET /error/:cause'); expect(transaction.contexts?.trace?.op).toBe('http.server'); expect(transaction.contexts?.trace?.status).toBe('internal_error'); }); diff --git a/packages/hono/src/shared/isExpectedError.ts b/packages/hono/src/shared/isExpectedError.ts new file mode 100644 index 000000000000..f3014be0fb5e --- /dev/null +++ b/packages/hono/src/shared/isExpectedError.ts @@ -0,0 +1,17 @@ +/** + * 3xx and 4xx errors are expected (redirects, auth failures, not found, bad + * request) and should not be captured as Sentry error events. + * + * Checks any error-like value that carries a numeric `status` property — this + * covers Hono's `HTTPException`, third-party middleware errors, and custom + * error subclasses. + */ +export function isExpectedError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + + const status = (error as { status?: unknown }).status; + + return typeof status === 'number' && status >= 300 && status < 500; +} diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/hono/src/shared/middlewareHandlers.ts index a470733b47a8..41902d90f84f 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/hono/src/shared/middlewareHandlers.ts @@ -11,6 +11,7 @@ import { import type { Context } from 'hono'; import { routePath } from 'hono/route'; import { hasFetchEvent } from '../utils/hono-context'; +import { isExpectedError } from './isExpectedError'; /** * Request handler for Hono framework @@ -42,7 +43,7 @@ export function responseHandler(context: Context): void { getIsolationScope().setTransactionName(`${context.req.method} ${routePath(context)}`); - if (context.error) { + if (context.error && !isExpectedError(context.error)) { getClient()?.captureException(context.error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); diff --git a/packages/hono/src/shared/wrapMiddlewareSpan.ts b/packages/hono/src/shared/wrapMiddlewareSpan.ts index b93e5de0bded..13668e129c66 100644 --- a/packages/hono/src/shared/wrapMiddlewareSpan.ts +++ b/packages/hono/src/shared/wrapMiddlewareSpan.ts @@ -1,17 +1,17 @@ import { captureException, getActiveSpan, - getRootSpan, getOriginalFunction, + getRootSpan, markFunctionWrapped, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, - SPAN_STATUS_OK, startInactiveSpan, type WrappedFunction, } from '@sentry/core'; import { type MiddlewareHandler } from 'hono'; +import { isExpectedError } from './isExpectedError'; const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; @@ -41,14 +41,15 @@ export function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHa }); try { - const result = await handler(context, next); - span.setStatus({ code: SPAN_STATUS_OK }); - return result; + return await handler(context, next); } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { handled: false, type: MIDDLEWARE_ORIGIN }, - }); + if (!isExpectedError(error)) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { handled: false, type: MIDDLEWARE_ORIGIN }, + }); + } + throw error; } finally { span.end(); diff --git a/packages/hono/test/shared/isExpectedError.test.ts b/packages/hono/test/shared/isExpectedError.test.ts new file mode 100644 index 000000000000..235db79e1357 --- /dev/null +++ b/packages/hono/test/shared/isExpectedError.test.ts @@ -0,0 +1,78 @@ +import { HTTPException } from 'hono/http-exception'; +import { describe, expect, it } from 'vitest'; +import { isExpectedError } from '../../src/shared/isExpectedError'; + +describe('isExpectedError', () => { + describe('HTTPException', () => { + it('returns true for 4xx HTTPException', () => { + expect(isExpectedError(new HTTPException(400, { message: 'Bad Request' }))).toBe(true); + expect(isExpectedError(new HTTPException(401, { message: 'Unauthorized' }))).toBe(true); + expect(isExpectedError(new HTTPException(403, { message: 'Forbidden' }))).toBe(true); + expect(isExpectedError(new HTTPException(404, { message: 'Not Found' }))).toBe(true); + expect(isExpectedError(new HTTPException(422, { message: 'Unprocessable Entity' }))).toBe(true); + expect(isExpectedError(new HTTPException(499))).toBe(true); + }); + + it('returns false for 5xx HTTPException', () => { + expect(isExpectedError(new HTTPException(500, { message: 'Internal Server Error' }))).toBe(false); + expect(isExpectedError(new HTTPException(502, { message: 'Bad Gateway' }))).toBe(false); + expect(isExpectedError(new HTTPException(503, { message: 'Service Unavailable' }))).toBe(false); + }); + }); + + describe('custom error classes with status property', () => { + it('returns true for custom Error subclass with 4xx status', () => { + class AuthError extends Error { + status = 401; + } + expect(isExpectedError(new AuthError('unauthorized'))).toBe(true); + }); + + it('returns false for custom Error subclass with 5xx status', () => { + class DbError extends Error { + status = 500; + } + expect(isExpectedError(new DbError('connection lost'))).toBe(false); + }); + + it('returns true for plain object with 4xx status', () => { + expect(isExpectedError({ status: 404, message: 'Not Found' })).toBe(true); + expect(isExpectedError({ status: 400 })).toBe(true); + }); + + it('returns false for plain object with 5xx status', () => { + expect(isExpectedError({ status: 500, message: 'Internal Server Error' })).toBe(false); + }); + }); + + describe('non-expected errors', () => { + it('returns false for plain Error without status', () => { + expect(isExpectedError(new Error('something broke'))).toBe(false); + }); + + it('returns false for non-object values', () => { + expect(isExpectedError('string error')).toBe(false); + expect(isExpectedError(42)).toBe(false); + expect(isExpectedError(null)).toBe(false); + expect(isExpectedError(undefined)).toBe(false); + expect(isExpectedError(true)).toBe(false); + }); + + it('returns false when status is not a number', () => { + expect(isExpectedError({ status: '404' })).toBe(false); + expect(isExpectedError({ status: null })).toBe(false); + expect(isExpectedError({ status: undefined })).toBe(false); + }); + + it('returns true for 3xx status', () => { + expect(isExpectedError({ status: 301 })).toBe(true); + expect(isExpectedError({ status: 302 })).toBe(true); + expect(isExpectedError({ status: 399 })).toBe(true); + }); + + it('returns false for 2xx status', () => { + expect(isExpectedError({ status: 200 })).toBe(false); + expect(isExpectedError({ status: 299 })).toBe(false); + }); + }); +}); diff --git a/packages/hono/test/shared/middlewareHandlers.test.ts b/packages/hono/test/shared/middlewareHandlers.test.ts index 83099370320c..b8e4cdef1062 100644 --- a/packages/hono/test/shared/middlewareHandlers.test.ts +++ b/packages/hono/test/shared/middlewareHandlers.test.ts @@ -55,7 +55,7 @@ describe('responseHandler', () => { }); }); - it('captures error regardless of status code', () => { + it('captures 5xx HTTPException', () => { const mockCaptureException = vi.fn(); getClientMock.mockReturnValue({ captureException: mockCaptureException, @@ -101,7 +101,6 @@ describe('responseHandler', () => { // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(500, error) as any); - // captureException is called — it handles deduplication internally via checkOrSetAlreadyCaught expect(mockCaptureException).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); From b05b0cad03617c9a80288ee3c5d0f1754177395f Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 5 May 2026 09:45:25 +0200 Subject: [PATCH 48/84] test(node): Fix ANR test for flakiness (#20656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/getsentry/sentry-javascript/issues/20643 Reasoning by Cursor: ### Cause The from forked process case was different from the stable ANR scenarios (basic.mjs, etc.): forked.js used a hard-coded ingest DSN instead of process.env.SENTRY_DSN. The test did not call .withMockSentryServer(), so the runner never started the local mock ingest server or injected SENTRY_DSN. So the runner depended on the ANR worker’s debug stdout ([ANR Worker] … + JSON envelope) being forwarded through fork() + stdio: 'inherit'. That path is sensitive to timing, buffering, and CI load—the kind of setup that shows up as intermittent failures ([issue #20643](https://github.com/getsentry/sentry-javascript/issues/20643)). ### Fix forked.js — initialize with dsn: process.env.SENTRY_DSN, matching basic.mjs so the forked child uses the same DSN the runner configures when the mock server is enabled. test.ts — chain .withMockSentryServer() on the forker.js runner so events are delivered over HTTP to the mock server (same mechanism as the other ANR tests), instead of relying on debug lines on stdout. Not 100% sure if this will fix the flakiness but it def. seems like an overall good change to me anyhow! --- .../node-core-integration-tests/suites/anr/forked.js | 2 +- dev-packages/node-core-integration-tests/suites/anr/test.ts | 6 +++++- dev-packages/node-integration-tests/suites/anr/forked.js | 2 +- dev-packages/node-integration-tests/suites/anr/test.ts | 6 +++++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/dev-packages/node-core-integration-tests/suites/anr/forked.js b/dev-packages/node-core-integration-tests/suites/anr/forked.js index 90148e549ce1..2fd7a0678e8d 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/forked.js +++ b/dev-packages/node-core-integration-tests/suites/anr/forked.js @@ -9,7 +9,7 @@ setTimeout(() => { }, 10000); const client = Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', debug: true, integrations: [Sentry.anrIntegration({ captureStackTrace: true, anrThreshold: 100 })], diff --git a/dev-packages/node-core-integration-tests/suites/anr/test.ts b/dev-packages/node-core-integration-tests/suites/anr/test.ts index 406830c9b299..b1aabd2eb001 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-core-integration-tests/suites/anr/test.ts @@ -221,7 +221,11 @@ describe('should report ANR when event loop blocked', { timeout: 90_000 }, () => }); test('from forked process', async () => { - await createRunner(__dirname, 'forker.js').expect({ event: ANR_EVENT_WITH_SCOPE }).start().completed(); + await createRunner(__dirname, 'forker.js') + .withMockSentryServer() + .expect({ event: ANR_EVENT_WITH_SCOPE }) + .start() + .completed(); }); test('worker can be stopped and restarted', async () => { diff --git a/dev-packages/node-integration-tests/suites/anr/forked.js b/dev-packages/node-integration-tests/suites/anr/forked.js index 18720a7258af..e0e120f41256 100644 --- a/dev-packages/node-integration-tests/suites/anr/forked.js +++ b/dev-packages/node-integration-tests/suites/anr/forked.js @@ -8,7 +8,7 @@ setTimeout(() => { }, 10000); Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', debug: true, integrations: [Sentry.anrIntegration({ captureStackTrace: true, anrThreshold: 100 })], diff --git a/dev-packages/node-integration-tests/suites/anr/test.ts b/dev-packages/node-integration-tests/suites/anr/test.ts index c9a81ccb5db0..653483b64237 100644 --- a/dev-packages/node-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-integration-tests/suites/anr/test.ts @@ -210,7 +210,11 @@ describe('should report ANR when event loop blocked', { timeout: 90_000 }, () => }); test('from forked process', async () => { - await createRunner(__dirname, 'forker.js').expect({ event: ANR_EVENT_WITH_SCOPE }).start().completed(); + await createRunner(__dirname, 'forker.js') + .withMockSentryServer() + .expect({ event: ANR_EVENT_WITH_SCOPE }) + .start() + .completed(); }); test('worker can be stopped and restarted', async () => { From 410600eaa0de0e73f4da019d933da1d97aee6089 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 5 May 2026 10:25:50 +0200 Subject: [PATCH 49/84] test(node): Fix flaky node cron test (#20661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/getsentry/sentry-javascript/issues/20652 cron instrumentation used loggingTransport, which console.logs each envelope as JSON. The scenario also console.logs (You will see this message every second). The runner parses envelopes by splitting child stdout on newlines. Under CI load that mix can produce ordering / buffering issues or fragile parsing compared with sending envelopes over HTTP. ## Fix Align with the pattern used elsewhere (including the ANR fix): * scenario.ts — Drop loggingTransport and initialize with dsn: process.env.SENTRY_DSN so the runner-injected URL from the mock ingest server is used. * test.ts — Call .withMockSentryServer() so check-ins and the error event are received via the mock HTTP server instead of stdout. --- .../node-core-integration-tests/suites/cron/cron/scenario.ts | 4 +--- .../node-core-integration-tests/suites/cron/cron/test.ts | 1 + .../node-integration-tests/suites/cron/cron/scenario.ts | 4 +--- dev-packages/node-integration-tests/suites/cron/cron/test.ts | 1 + 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/dev-packages/node-core-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-core-integration-tests/suites/cron/cron/scenario.ts index 4ddd9f115189..3ef46faa9e90 100644 --- a/dev-packages/node-core-integration-tests/suites/cron/cron/scenario.ts +++ b/dev-packages/node-core-integration-tests/suites/cron/cron/scenario.ts @@ -1,12 +1,10 @@ import * as Sentry from '@sentry/node-core'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import { CronJob } from 'cron'; import { setupOtel } from '../../../utils/setupOtel'; const client = Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', - transport: loggingTransport, }); setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/cron/cron/test.ts b/dev-packages/node-core-integration-tests/suites/cron/cron/test.ts index 60edd2812b4b..8209e2332042 100644 --- a/dev-packages/node-core-integration-tests/suites/cron/cron/test.ts +++ b/dev-packages/node-core-integration-tests/suites/cron/cron/test.ts @@ -7,6 +7,7 @@ afterAll(() => { test('cron instrumentation', async () => { await createRunner(__dirname, 'scenario.ts') + .withMockSentryServer() .expect({ check_in: { check_in_id: expect.any(String), diff --git a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts index 6fe6838844de..a51eab1d8d6f 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts @@ -1,11 +1,9 @@ import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import { CronJob } from 'cron'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', - transport: loggingTransport, }); const CronJobWithCheckIn = Sentry.cron.instrumentCron(CronJob, 'my-cron-job'); diff --git a/dev-packages/node-integration-tests/suites/cron/cron/test.ts b/dev-packages/node-integration-tests/suites/cron/cron/test.ts index 078cc0997221..3606b4d02808 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/test.ts @@ -7,6 +7,7 @@ afterAll(() => { test('cron instrumentation', { timeout: 30_000 }, async () => { await createRunner(__dirname, 'scenario.ts') + .withMockSentryServer() .expect({ check_in: { check_in_id: expect.any(String), From 0012645a7deeb240ee71b8a43d554dea45a2de5f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 10:53:54 +0200 Subject: [PATCH 50/84] chore(deps): bump @hono/node-server from 1.19.10 to 1.19.13 (#20117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.10 to 1.19.13.
Release notes

Sourced from @​hono/node-server's releases.

v1.19.13

Security Fix

Fixed an issue in Serve Static Middleware where inconsistent handling of repeated slashes (//) between the router and static file resolution could allow middleware to be bypassed. Users of Serve Static Middleware are encouraged to upgrade to this version.

See GHSA-92pp-h63x-v22m for details.

v1.19.12

What's Changed

Full Changelog: https://github.com/honojs/node-server/compare/v1.19.11...v1.19.12

v1.19.11

What's Changed

Full Changelog: https://github.com/honojs/node-server/compare/v1.19.10...v1.19.11

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/node-integration-tests/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index c78a73bc7440..3fa61d5b6576 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -29,7 +29,7 @@ "@google/genai": "^1.20.0", "@growthbook/growthbook": "^1.6.1", "@hapi/hapi": "^21.3.10", - "@hono/node-server": "^1.19.10", + "@hono/node-server": "^1.19.13", "@langchain/anthropic": "^0.3.10", "@langchain/core": "^0.3.80", "@langchain/openai": "^0.5.0", diff --git a/yarn.lock b/yarn.lock index 2c1e0ad5cf43..00b3d8b42952 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4920,10 +4920,10 @@ "@hapi/bourne" "^3.0.0" "@hapi/hoek" "^11.0.2" -"@hono/node-server@^1.19.10": - version "1.19.10" - resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.10.tgz#e230fbb7fb31891cafc653d01deee03f437dd66b" - integrity sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw== +"@hono/node-server@^1.19.13": + version "1.19.13" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.13.tgz#4838c766a1237253d4dde3281cf7d5c65186fd32" + integrity sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ== "@humanwhocodes/config-array@^0.11.14": version "0.11.14" From 92b0e36455ba67aa92bcc4efacbbacbe38eabb70 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 5 May 2026 12:55:05 +0200 Subject: [PATCH 51/84] test(node): Unflake mongodb test (#20662) Another attempt to fix https://github.com/getsentry/sentry-javascript/issues/20653. This time, I simply changed it to allow the same spans but in any order, not dropping anything we asserted on before. If/when we rewrite/streamline the mongodb instrumentation, we need to revisit which of these things we actually care about. --- .../suites/tracing/mongodb/test.ts | 273 ++++++++---------- 1 file changed, 120 insertions(+), 153 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts index 8c16e8b36133..13fb7a12fa85 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts @@ -1,5 +1,7 @@ +import type { TransactionEvent } from '@sentry/core'; import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { assertSentryTransaction } from '../../../utils/assertions'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; describe('MongoDB experimental Test', () => { @@ -17,160 +19,125 @@ describe('MongoDB experimental Test', () => { cleanupChildProcesses(); }); - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction', - spans: [ - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': '$cmd', - 'db.operation': 'isMaster', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': - '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', - 'otel.kind': 'CLIENT', - }, - description: - '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': '$cmd', - 'db.operation': 'isMaster', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': - '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', - 'otel.kind': 'CLIENT', - }, - description: - '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': 'movies', - 'db.operation': 'insert', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"title":"?","_id":{"_bsontype":"?","id":"?"}}', - 'otel.kind': 'CLIENT', - }, - description: '{"title":"?","_id":{"_bsontype":"?","id":"?"}}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': 'movies', - 'db.operation': 'find', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"title":"?"}', - 'otel.kind': 'CLIENT', - }, - description: '{"title":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': 'movies', - 'db.operation': 'update', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"title":"?"}', - 'otel.kind': 'CLIENT', - }, - description: '{"title":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': 'movies', - 'db.operation': 'find', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"title":"?"}', - 'otel.kind': 'CLIENT', - }, - description: '{"title":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': 'movies', - 'db.operation': 'find', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"title":"?"}', - 'otel.kind': 'CLIENT', - }, - description: '{"title":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': '$cmd', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":"?"}}]}', - 'otel.kind': 'CLIENT', - }, - description: '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":"?"}}]}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - ], - }; + const SPAN_FIND_MATCHER = expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': 'find', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"title":"?"}', + 'otel.kind': 'CLIENT', + }, + description: '{"title":"?"}', + op: 'db', + origin: 'auto.db.otel.mongo', + }); + + const SPAN_INSERT_MATCHER = expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': 'insert', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"title":"?","_id":{"_bsontype":"?","id":"?"}}', + 'otel.kind': 'CLIENT', + }, + description: '{"title":"?","_id":{"_bsontype":"?","id":"?"}}', + op: 'db', + origin: 'auto.db.otel.mongo', + }); + + const SPAN_ISMASTER_MATCHER = expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': '$cmd', + 'db.operation': 'isMaster', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': + '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', + 'otel.kind': 'CLIENT', + }, + description: + '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', + op: 'db', + origin: 'auto.db.otel.mongo', + }); + + const SPAN_UPDATE_MATCHER = expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': 'update', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"title":"?"}', + 'otel.kind': 'CLIENT', + }, + description: '{"title":"?"}', + op: 'db', + origin: 'auto.db.otel.mongo', + }); + + const SPAN_ENDSESSIONS_MATCHER = expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': '$cmd', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":"?"}}]}', + 'otel.kind': 'CLIENT', + }, + description: '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":"?"}}]}', + op: 'db', + origin: 'auto.db.otel.mongo', + }); test('CJS - should auto-instrument `mongodb` package.', async () => { - await createRunner(__dirname, 'scenario.js').expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + await createRunner(__dirname, 'scenario.js') + .expect({ + transaction: (txn: TransactionEvent) => { + assertSentryTransaction(txn, { transaction: 'Test Transaction' }); + const spans = txn.spans || []; + expect(spans).toHaveLength(8); + + expect(spans).toContainEqual(SPAN_FIND_MATCHER); + expect(spans).toContainEqual(SPAN_INSERT_MATCHER); + expect(spans).toContainEqual(SPAN_ISMASTER_MATCHER); + expect(spans).toContainEqual(SPAN_UPDATE_MATCHER); + expect(spans).toContainEqual(SPAN_ENDSESSIONS_MATCHER); + + // Ensure duplicate spans are correctly there + const findSpans = spans.filter(span => span.data['db.operation'] === 'find'); + expect(findSpans).toHaveLength(3); + + const isMasterSpans = spans.filter(span => span.data['db.operation'] === 'isMaster'); + expect(isMasterSpans).toHaveLength(2); + }, + }) + .start() + .completed(); }); }); From 8fccce1739cea1f8fb092845fe55ec9ac7d54c5c Mon Sep 17 00:00:00 2001 From: Spencer Sargent <83440025+sbs44@users.noreply.github.com> Date: Tue, 5 May 2026 07:33:19 -0400 Subject: [PATCH 52/84] fix(core): drain buffers in flush() when there is no transport (#20207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Client.flush()` short-circuited before emitting the `'flush'` event when the client had no transport (e.g. no DSN was configured), leaving the weight-based flushers for logs and metrics with idle `setTimeout` handles that never resolved. Move `emit('flush')` above the `!transport` early return so the `setupWeightBasedFlushing` listeners on `'flushLogs'` / `'flushMetrics'` drain their buffers and clear the idle timer on every `flush()` call. We originally hit this in Supabase Deno edge function tests where a shared harness calls `Sentry.init({ dsn: '', enableLogs: true })` and records metrics per request, then calls `Sentry.flush(2000)` in the request finalizer. The Deno test sanitizer surfaces the unresolved `setTimeout` as a leaked op: ``` error: Leaks detected: - 2 timers were started in this test, but never completed. at setTimeout (node:timers:14:10) at .../@sentry/core/10.48.0/build/esm/client.js:108:9 at DenoClient.emit (.../@sentry/core/10.48.0/build/esm/client.js:572:17) ``` ## Behavior change Any subscriber registered via `client.on('flush', …)` will now fire when `flush()` is called on a transport-less client. I verified the in-repo subscribers all handle this safely: - `setupWeightBasedFlushing` → `_INTERNAL_flushLogsBuffer` / `_INTERNAL_flushMetricsBuffer` --> `client.sendEnvelope`, which short-circuits on `!this._transport` ([client.ts:1139](https://github.com/getsentry/sentry-javascript/blob/develop/packages/core/src/client.ts#L1139)) - `SpanBuffer.drain()` ([spanBuffer.ts:86](https://github.com/getsentry/sentry-javascript/blob/develop/packages/core/src/tracing/spans/spanBuffer.ts#L86)) — builds envelopes and hands them to the same `sendEnvelope` guard - `httpServerIntegration` --> `flushPendingClientAggregates` --> `client.sendSession`... also routed through `sendEnvelope` - `otlpIntegration` --> `_spanProcessor?.forceFlush()`: OTLP has its own exporter independent of the Sentry transport Integration authors with custom `'flush'` listeners that previously assumed a transport was present may want to audit their handlers. ## Tests Added two regression tests (one for logs, one for metrics) that fail on `develop` and pass with the fix. Both assert the observable end state (the buffer is drained via `'flushLogs'` / `'flushMetrics'`) rather than relying on an indirect `clearTimeout` spy. Before submitting a pull request, please take a look at our [Contributing](https://github.com/getsentry/sentry-javascript/blob/master/CONTRIBUTING.md) guidelines and verify: - [x] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (`yarn lint`) & (`yarn test`). - [x] Link an issue if there is one related to your pull request. If no issue is linked, one will be auto-generated and linked. Closes #20206 --------- Co-authored-by: JPeer264 Co-authored-by: Sigrid <32902192+s1gr1d@users.noreply.github.com> Co-authored-by: Nicolas Hrubec --- packages/core/src/client.ts | 8 +++-- packages/core/test/lib/client.test.ts | 44 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 2cf7c1afb171..766a0a4ecfdc 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -425,12 +425,16 @@ export abstract class Client { // @ts-expect-error - PromiseLike is a subset of Promise public async flush(timeout?: number): PromiseLike { const transport = this._transport; + + // Emit `flush` unconditionally so weight-based log/metric flushers drain + // their buffers and clear their idle timers, even when no transport is + // configured (e.g. no DSN). + this.emit('flush'); + if (!transport) { return true; } - this.emit('flush'); - const clientFinished = await this._isClientDoneProcessing(timeout); const transportFlushed = await transport.flush(timeout); diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 1548a4aecce4..a8971498cef8 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -3115,6 +3115,29 @@ describe('Client', () => { safeUnrefSpy.mockRestore(); }); + + it('flush() drains the log buffer when client has no transport', async () => { + // Client without DSN — _transport is undefined + const options = getDefaultTestClientOptions({ + enableLogs: true, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const flushLogsHandler = vi.fn(); + client.on('flushLogs', flushLogsHandler); + + // Capture a log which starts the weight-based flush timer + _INTERNAL_captureLog({ message: 'test log', level: 'info' }, scope); + + expect(flushLogsHandler).not.toHaveBeenCalled(); + + // flush() should drain the buffer (and clear the timer) even without a transport + await client.flush(); + + expect(flushLogsHandler).toHaveBeenCalledTimes(1); + }); }); describe('metric weight-based flushing', () => { @@ -3201,6 +3224,27 @@ describe('Client', () => { safeUnrefSpy.mockRestore(); }); + + it('flush() drains the metric buffer when client has no transport', async () => { + // Client without DSN — _transport is undefined + const options = getDefaultTestClientOptions({}); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const flushMetricsHandler = vi.fn(); + client.on('flushMetrics', flushMetricsHandler); + + // Capture a metric which starts the weight-based flush timer + _INTERNAL_captureMetric({ name: 'test_metric', value: 42, type: 'counter', attributes: {} }, { scope }); + + expect(flushMetricsHandler).not.toHaveBeenCalled(); + + // flush() should drain the buffer (and clear the timer) even without a transport + await client.flush(); + + expect(flushMetricsHandler).toHaveBeenCalledTimes(1); + }); }); describe('promise buffer usage', () => { From 2badb12701c0353654729ff46a7c5ea6f6464350 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 13:37:29 +0200 Subject: [PATCH 53/84] chore(dev-deps): bump @actions/io from 1.1.3 to 3.0.2 (#20090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@actions/io](https://github.com/actions/toolkit/tree/HEAD/packages/io) from 1.1.3 to 3.0.2.
Changelog

Sourced from @​actions/io's changelog.

3.0.2

  • Fix: update lock file version

3.0.1

  • Fix: export @actions/io/lib/io-util

3.0.0

  • Breaking change: Package is now ESM-only
    • CommonJS consumers must use dynamic import() instead of require()

2.0.0

  • Add support for Node 24 #2110
  • Ensures consistent behavior for paths on Node 24 with Windows
Commits
Maintainer changes

This version was pushed to npm by GitHub Actions, a new releaser for @​actions/io since your current version.


> **Note** > Automatic rebases have been disabled on this pull request as it has been open for over 30 days. Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/size-limit-gh-action/package.json | 2 +- yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json index 7f18d7acac0e..1499b59fbc6d 100644 --- a/dev-packages/size-limit-gh-action/package.json +++ b/dev-packages/size-limit-gh-action/package.json @@ -19,7 +19,7 @@ "@actions/exec": "1.1.1", "@actions/github": "^5.0.0", "@actions/glob": "0.6.1", - "@actions/io": "1.1.3", + "@actions/io": "3.0.2", "bytes-iec": "3.1.1", "markdown-table": "3.0.3" }, diff --git a/yarn.lock b/yarn.lock index 00b3d8b42952..4be50683a09f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -107,16 +107,16 @@ tunnel "^0.0.6" undici "^6.23.0" -"@actions/io@1.1.3", "@actions/io@^1.0.1": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@actions/io/-/io-1.1.3.tgz#4cdb6254da7962b07473ff5c335f3da485d94d71" - integrity sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q== - -"@actions/io@^3.0.2": +"@actions/io@3.0.2", "@actions/io@^3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@actions/io/-/io-3.0.2.tgz#6f89b27a159d109836d983efa283997c23b92284" integrity sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw== +"@actions/io@^1.0.1": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@actions/io/-/io-1.1.3.tgz#4cdb6254da7962b07473ff5c335f3da485d94d71" + integrity sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q== + "@adobe/css-tools@^4.0.1", "@adobe/css-tools@^4.4.0": version "4.4.3" resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.3.tgz#beebbefb0264fdeb32d3052acae0e0d94315a9a2" From eecc6f333837334db86d30318583ae1446b1489f Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 5 May 2026 13:43:45 +0200 Subject: [PATCH 54/84] docs(hono): Add new docs link and move to BETA release (#20666) --- packages/hono/README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/hono/README.md b/packages/hono/README.md index c0d791030134..bf1ce203d8c5 100644 --- a/packages/hono/README.md +++ b/packages/hono/README.md @@ -4,20 +4,18 @@

-# Official Sentry SDK for Hono (ALPHA) +# Official Sentry SDK for Hono (BETA) [![npm version](https://img.shields.io/npm/v/@sentry/hono.svg)](https://www.npmjs.com/package/@sentry/hono) [![npm dm](https://img.shields.io/npm/dm/@sentry/hono.svg)](https://www.npmjs.com/package/@sentry/hono) [![npm dt](https://img.shields.io/npm/dt/@sentry/hono.svg)](https://www.npmjs.com/package/@sentry/hono) -This SDK is compatible with Hono 4+ and is currently in ALPHA. Alpha features are still in progress, may have bugs and might include breaking changes. +This SDK is compatible with Hono 4+ and is currently in BETA. Beta features are still in progress and may have bugs. Please reach out on [GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have any feedback or concerns. ## Links -- [General SDK Docs](https://docs.sentry.io/quickstart/) - Official Docs for this Hono SDK are coming soon! - -The current [Hono SDK Docs](https://docs.sentry.io/platforms/javascript/guides/hono/) explain using Sentry in Hono by using other Sentry SDKs (e.g. `@sentry/node` or `@sentry/cloudflare`) +- [Official SDK Docs](https://docs.sentry.io/platforms/javascript/guides/hono/) ## Install From a3ae7dd406ffc233f18f0d7e10c22e84bd513058 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 11:44:46 +0000 Subject: [PATCH 55/84] ci(deps): Bump denoland/setup-deno from 2.0.3 to 2.0.4 (#20080) Bumps [denoland/setup-deno](https://github.com/denoland/setup-deno) from 2.0.3 to 2.0.4.
Release notes

Sourced from denoland/setup-deno's releases.

v2.0.4

Full Changelog: https://github.com/denoland/setup-deno/compare/v2.0.3...v2.0.4

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 090a09369706..daaf5effc2a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -533,7 +533,7 @@ jobs: with: node-version-file: 'package.json' - name: Set up Deno - uses: denoland/setup-deno@v2.0.3 + uses: denoland/setup-deno@v2.0.4 with: deno-version: v2.1.5 - name: Restore caches @@ -1057,7 +1057,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' - uses: denoland/setup-deno@v2.0.3 + uses: denoland/setup-deno@v2.0.4 with: deno-version: v2.1.5 - name: Restore caches From 90c92db2dd82c92d0c167f5a362d7a9d4472295c Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 13:49:51 +0200 Subject: [PATCH 56/84] chore: Add external contributor to CHANGELOG.md (#20672) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #20207 Co-authored-by: nicohrubec <29484629+nicohrubec@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0658797b2093..35efd12e575a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +Work in this release was contributed by @sbs44. Thank you for your contribution! + - **feat(browser): Add `ingest_settings` to v2 log envelope payload ([#20453](https://github.com/getsentry/sentry-javascript/pull/20453))** Inference of user data (e.g. IP address, browser name/version) on log events is now gated behind the `sendDefaultPii` option. Previously, this data was always inferred by default. From ff23d65b1010f69103bc650d216904a350c021c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 5 May 2026 14:03:46 +0200 Subject: [PATCH 57/84] feat(cloudflare): Capture request body via httpServerIntegration (#20614) closes #17079 closes [JS-751](https://linear.app/getsentry/issue/JS-751/capture-request-body-in-cloudflare-workers) This PR is basically adding and exporting `captureBodyFromWinterCGRequest` from `@sentry/core` and re-implementing `httpServerIntegration` for Cloudflare. There was no way to reuse the existing `httpServerIntegration`, as it is using `patchRequestToCaptureBody`, which wouldn't work in Cloudflare, as there is too late to be patched, so `captureBodyFromWinterCGRequest` was born. The original `httpServerIntegration` is also taking care of other SDK processing metadata via `httpRequestToRequestData`, which happens already for every request in Cloudflare via the `addHandler` in the [`scope-utils.ts`](https://github.com/getsentry/sentry-javascript/blob/99564760ce7914898d85ab83762b1531aec3d53a/packages/cloudflare/src/scope-utils.ts#L27). I still tried to reuse as much code as possible (that's the reason for the new exposed functionality in `@sentry/core`). Two options from the original integration are missing `sessions` and `sessionFlushingDelayMS`. I don't think this can be implemented 1:1, that is why I left it out, as the sessions seem to be aggregated, which wouldn't work in Cloudflare, as we generate a new client for each request. And I think it is better if we don't enable that by default, as otherwise every request would send a session by default. In theory this would also be usable for other runtimes like Bun or Deno if I'm not mistaken --- .../http-server-disabled/index.ts | 18 ++ .../integrations/http-server-disabled/test.ts | 26 ++ .../http-server-disabled/wrangler.jsonc | 6 + .../http-server-filtered/index.ts | 18 ++ .../integrations/http-server-filtered/test.ts | 26 ++ .../http-server-filtered/wrangler.jsonc | 6 + .../integrations/http-server-ignore/index.ts | 38 +++ .../integrations/http-server-ignore/test.ts | 56 ++++ .../http-server-ignore/wrangler.jsonc | 6 + .../integrations/http-server-small/index.ts | 29 ++ .../integrations/http-server-small/test.ts | 44 +++ .../http-server-small/wrangler.jsonc | 6 + .../suites/integrations/http-server/index.ts | 38 +++ .../suites/integrations/http-server/test.ts | 95 ++++++ .../integrations/http-server/wrangler.jsonc | 6 + packages/cloudflare/src/index.ts | 1 + .../cloudflare/src/integrations/httpServer.ts | 106 +++++++ packages/cloudflare/src/request.ts | 5 + packages/cloudflare/src/sdk.ts | 2 + .../instrumentWorkerEntrypoint.test.ts | 24 +- packages/cloudflare/test/request.test.ts | 136 ++++++++ packages/core/src/index.ts | 4 + packages/core/src/types-hoist/webfetchapi.ts | 2 + packages/core/src/utils/request.ts | 126 ++++++++ packages/core/test/lib/utils/request.test.ts | 292 +++++++++++++++++- .../src/integrations/http/constants.ts | 3 - .../node-core/src/utils/captureRequestBody.ts | 12 +- 27 files changed, 1106 insertions(+), 25 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server/wrangler.jsonc create mode 100644 packages/cloudflare/src/integrations/httpServer.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/index.ts new file mode 100644 index 000000000000..05ba6b6a7c71 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/index.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + integrations: [Sentry.httpServerIntegration({ maxRequestBodySize: 'none' })], + }), + { + async fetch(_request, _env, _ctx) { + Sentry.captureMessage('POST with disabled body capture'); + return new Response('ok'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/test.ts new file mode 100644 index 000000000000..20275fb47500 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('Does not capture request body when maxRequestBodySize is none', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('POST with disabled body capture'); + expect(event.request).toEqual( + expect.objectContaining({ + method: 'POST', + url: expect.any(String), + }), + ); + // Body should NOT be captured + expect((event.request as Record).data).toBeUndefined(); + }) + .start(signal); + + await runner.makeRequest('post', '/', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ secret: 'should-not-be-captured' }), + }); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/index.ts new file mode 100644 index 000000000000..a5aea20ed2d0 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/index.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + integrations: integrations => integrations.filter(i => i.name !== 'HttpServer'), + }), + { + async fetch(_request, _env, _ctx) { + Sentry.captureMessage('POST with filtered integration'); + return new Response('ok'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/test.ts new file mode 100644 index 000000000000..d8016550770a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('Does not capture request body when httpServerIntegration is filtered out', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('POST with filtered integration'); + expect(event.request).toEqual( + expect.objectContaining({ + method: 'POST', + url: expect.any(String), + }), + ); + // Body should NOT be captured when integration is filtered out + expect((event.request as Record).data).toBeUndefined(); + }) + .start(signal); + + await runner.makeRequest('post', '/', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ secret: 'should-not-be-captured' }), + }); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/index.ts new file mode 100644 index 000000000000..44bd88fe8044 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/index.ts @@ -0,0 +1,38 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + integrations: [ + Sentry.httpServerIntegration({ + ignoreRequestBody: url => url.includes('/health') || url.includes('/upload'), + }), + ], + }), + { + async fetch(request, _env, _ctx) { + const url = new URL(request.url); + + if (url.pathname === '/health') { + Sentry.captureMessage('Health check'); + return new Response('ok'); + } + + if (url.pathname === '/upload') { + Sentry.captureMessage('Upload request'); + return new Response('ok'); + } + + if (url.pathname === '/api') { + Sentry.captureMessage('API request'); + return new Response('ok'); + } + + return new Response('Not found', { status: 404 }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/test.ts new file mode 100644 index 000000000000..6ceab52c96c0 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/test.ts @@ -0,0 +1,56 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('Does not capture body for ignored URLs (health check)', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('Health check'); + // Body should NOT be captured because URL contains /health + expect((event.request as Record).data).toBeUndefined(); + }) + .start(signal); + + await runner.makeRequest('post', '/health', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ status: 'checking' }), + }); + + await runner.completed(); +}); + +it('Does not capture body for ignored URLs (upload)', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('Upload request'); + // Body should NOT be captured because URL contains /upload + expect((event.request as Record).data).toBeUndefined(); + }) + .start(signal); + + await runner.makeRequest('post', '/upload', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ file: 'large-data' }), + }); + + await runner.completed(); +}); + +it('Captures body for non-ignored URLs', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('API request'); + // Body SHOULD be captured because URL does not match ignore pattern + expect((event.request as Record).data).toBe('{"action":"submit"}'); + }) + .start(signal); + + await runner.makeRequest('post', '/api', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ action: 'submit' }), + }); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/index.ts new file mode 100644 index 000000000000..b4257939cfaf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/index.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + integrations: [Sentry.httpServerIntegration({ maxRequestBodySize: 'small' })], + }), + { + async fetch(request, _env, _ctx) { + const url = new URL(request.url); + + if (url.pathname === '/small-body') { + Sentry.captureMessage('Small body request'); + return new Response('ok'); + } + + if (url.pathname === '/large-body') { + Sentry.captureMessage('Large body request'); + return new Response('ok'); + } + + return new Response('Not found', { status: 404 }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/test.ts new file mode 100644 index 000000000000..173561f2fbb5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/test.ts @@ -0,0 +1,44 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('Captures request body under 1000 bytes with maxRequestBodySize: small', async ({ signal }) => { + const smallBody = JSON.stringify({ data: 'x'.repeat(100) }); + + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('Small body request'); + expect((event.request as Record).data).toBe(smallBody); + }) + .start(signal); + + await runner.makeRequest('post', '/small-body', { + headers: { 'content-type': 'application/json' }, + data: smallBody, + }); + + await runner.completed(); +}); + +it('Truncates request body over 1000 bytes with maxRequestBodySize: small', async ({ signal }) => { + const largeBody = JSON.stringify({ data: 'x'.repeat(2000) }); + + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('Large body request'); + const capturedBody = (event.request as Record).data as string; + // Body should be truncated to ~1000 bytes + "..." + expect(capturedBody).toBeDefined(); + expect(capturedBody.endsWith('...')).toBe(true); + expect(capturedBody.length).toBeLessThanOrEqual(1000); + }) + .start(signal); + + await runner.makeRequest('post', '/large-body', { + headers: { 'content-type': 'application/json' }, + data: largeBody, + }); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/index.ts new file mode 100644 index 000000000000..d8da65ad2e1a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/index.ts @@ -0,0 +1,38 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + }), + { + async fetch(request, _env, _ctx) { + const url = new URL(request.url); + + if (url.pathname === '/post-json') { + Sentry.captureMessage('POST JSON request'); + return new Response('ok'); + } + + if (url.pathname === '/post-form') { + Sentry.captureMessage('POST form request'); + return new Response('ok'); + } + + if (url.pathname === '/post-text') { + Sentry.captureMessage('POST text request'); + return new Response('ok'); + } + + if (url.pathname === '/post-no-body') { + Sentry.captureMessage('POST no body request'); + return new Response('ok'); + } + + return new Response('Not found', { status: 404 }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts new file mode 100644 index 000000000000..6773a4cf297e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts @@ -0,0 +1,95 @@ +import { expect, it } from 'vitest'; +import { eventEnvelope } from '../../../expect'; +import { createRunner } from '../../../runner'; + +it('Captures JSON request body', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect( + eventEnvelope({ + level: 'info', + message: 'POST JSON request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-json'), + data: '{"username":"test","action":"login"}', + }, + }), + ) + .start(signal); + + await runner.makeRequest('post', '/post-json', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ username: 'test', action: 'login' }), + }); + + await runner.completed(); +}); + +it('Captures form-urlencoded request body', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect( + eventEnvelope({ + level: 'info', + message: 'POST form request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-form'), + data: 'username=test&password=secret', + }, + }), + ) + .start(signal); + + await runner.makeRequest('post', '/post-form', { + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + data: 'username=test&password=secret', + }); + + await runner.completed(); +}); + +it('Captures plain text request body', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect( + eventEnvelope({ + level: 'info', + message: 'POST text request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-text'), + data: 'This is plain text content', + }, + }), + ) + .start(signal); + + await runner.makeRequest('post', '/post-text', { + headers: { 'content-type': 'text/plain' }, + data: 'This is plain text content', + }); + + await runner.completed(); +}); + +it('Does not capture body for POST without content', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect( + eventEnvelope({ + level: 'info', + message: 'POST no body request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-no-body'), + }, + }), + ) + .start(signal); + + await runner.makeRequest('post', '/post-no-body', {}); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index eaa9b3ddb032..fb4a9bf84882 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -122,6 +122,7 @@ export { wrapRequestHandler } from './request'; export { CloudflareClient } from './client'; export { getDefaultIntegrations } from './sdk'; +export { httpServerIntegration } from './integrations/httpServer'; export { fetchIntegration } from './integrations/fetch'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { honoIntegration } from './integrations/hono'; diff --git a/packages/cloudflare/src/integrations/httpServer.ts b/packages/cloudflare/src/integrations/httpServer.ts new file mode 100644 index 000000000000..31747e11a4d1 --- /dev/null +++ b/packages/cloudflare/src/integrations/httpServer.ts @@ -0,0 +1,106 @@ +import type { Client, IntegrationFn, MaxRequestBodySize } from '@sentry/core'; +import { captureBodyFromWinterCGRequest, defineIntegration, getIsolationScope } from '@sentry/core'; + +const INTEGRATION_NAME = 'HttpServer'; + +export interface HttpServerIntegrationOptions { + /** + * Controls the maximum size of incoming request bodies attached to events. + * + * Only applies to requests with textual content types (text/*, application/json, + * application/x-www-form-urlencoded, application/xml, application/graphql). + * Binary data is not captured. + * + * Available options: + * - `'none'`: No request bodies will be attached + * - `'small'`: Request bodies up to 1,000 bytes will be attached + * - `'medium'`: Request bodies up to 10,000 bytes will be attached (default) + * - `'always'`: Request bodies will always be attached (up to 1MB limit) + * + * @default 'medium' + */ + maxRequestBodySize?: MaxRequestBodySize; + + /** + * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * This can be useful for long running requests where the body is not needed, health check endpoints, + * or requests containing sensitive data that should not be captured. + * + * @param url The full URL of the incoming request, including query string, protocol, host, etc. + * @param request The incoming Request object. + * @returns `true` to skip body capture for this request, `false` to capture normally. + * + * @example + * ```ts + * Sentry.httpServerIntegration({ + * ignoreRequestBody: (url) => url.includes('/health') || url.includes('/upload'), + * }) + * ``` + */ + ignoreRequestBody?: (url: string, request: Request) => boolean; +} + +interface HttpServerIntegrationInstance { + name: string; + maxRequestBodySize: MaxRequestBodySize; + ignoreRequestBody?: (url: string, request: Request) => boolean; +} + +const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): HttpServerIntegrationInstance => { + return { + name: INTEGRATION_NAME, + maxRequestBodySize: options.maxRequestBodySize ?? 'medium', + ignoreRequestBody: options.ignoreRequestBody, + }; +}) satisfies IntegrationFn; + +/** + * Configures incoming HTTP request handling for Cloudflare Workers. + * + * This integration controls how incoming HTTP request data is captured, + * matching the API of `httpServerIntegration` in Node.js. + * + * @example + * ```ts + * Sentry.init({ + * integrations: [ + * Sentry.httpServerIntegration({ + * maxRequestBodySize: 'medium', + * ignoreRequestBody: (url) => url.includes('/health'), + * }), + * ], + * }); + * ``` + */ +export const httpServerIntegration = defineIntegration(_httpServerIntegration); + +/** + * Capture the request body based on the HttpServer integration config. + * Called internally by `wrapRequestHandler`. + */ +export async function captureIncomingRequestBody(client: Client, request: Request): Promise { + const integration = client.getIntegrationByName(INTEGRATION_NAME); + + if (!integration) { + return; + } + + const maxRequestBodySize = integration.maxRequestBodySize; + + if (maxRequestBodySize === 'none') { + return; + } + + // Skip GET and HEAD requests - they don't have bodies + // Also skip OPTIONS, even if they may have a body, they might not give a lot of extra value + if (request.method === 'GET' || request.method === 'HEAD' || request.method === 'OPTIONS') { + return; + } + + if (integration.ignoreRequestBody?.(request.url, request)) { + return; + } + + const isolationScope = getIsolationScope(); + await captureBodyFromWinterCGRequest(request, isolationScope, maxRequestBodySize); +} diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index 4fbdd9cf7fb4..f89e93924e1b 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -12,6 +12,7 @@ import { winterCGHeadersToDict, withIsolationScope, } from '@sentry/core'; +import { captureIncomingRequestBody } from './integrations/httpServer'; import type { CloudflareOptions } from './client'; import { flushAndDispose } from './flush'; import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils'; @@ -98,6 +99,10 @@ export function wrapRequestHandler( } } + if (client) { + await captureIncomingRequestBody(client, request); + } + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index a5eb7f4edcda..b957fabe1e70 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -15,6 +15,7 @@ import { import type { CloudflareClientOptions, CloudflareOptions } from './client'; import { CloudflareClient } from './client'; import { makeFlushLock } from './flush'; +import { httpServerIntegration } from './integrations/httpServer'; import { fetchIntegration } from './integrations/fetch'; import { honoIntegration } from './integrations/hono'; import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; @@ -36,6 +37,7 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[ linkedErrorsIntegration(), fetchIntegration(), honoIntegration(), + httpServerIntegration(), // TODO(v11): the `include` object should be defined directly in the integration based on `sendDefaultPii` requestDataIntegration(sendDefaultPii ? undefined : { include: { cookies: false } }), consoleIntegration(), diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 2f44b0097460..1e434b5de765 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -341,7 +341,7 @@ describe('instrumentWorkerEntrypoint', () => { expect(constructorEnv).toBe(mockEnv); }); - it('exposes instrumented DurableObjectNamespace via this.env when enableRpcTracePropagation is enabled', () => { + it('exposes instrumented DurableObjectNamespace via this.env when enableRpcTracePropagation is enabled', async () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -376,7 +376,7 @@ describe('instrumentWorkerEntrypoint', () => { TestClass as unknown as WorkerEntrypointConstructor, ); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); - obj.fetch(new Request('https://example.com')); + await obj.fetch(new Request('https://example.com')); expect(rpcMethod).toHaveBeenCalledWith('arg1', { __sentry_rpc_meta__: { @@ -386,7 +386,7 @@ describe('instrumentWorkerEntrypoint', () => { }); }); - it('returns original DurableObjectNamespace via this.env when enableRpcTracePropagation is disabled', () => { + it('returns original DurableObjectNamespace via this.env when enableRpcTracePropagation is disabled', async () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -421,12 +421,12 @@ describe('instrumentWorkerEntrypoint', () => { TestClass as unknown as WorkerEntrypointConstructor, ); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); - obj.fetch(new Request('https://example.com')); + await obj.fetch(new Request('https://example.com')); expect(rpcMethod).toHaveBeenCalledWith('arg1'); }); - it('injects Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is enabled', () => { + it('injects Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is enabled', async () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -460,7 +460,7 @@ describe('instrumentWorkerEntrypoint', () => { TestClass as unknown as WorkerEntrypointConstructor, ); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); - obj.fetch(new Request('https://example.com')); + await obj.fetch(new Request('https://example.com')); expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { __sentry_rpc_meta__: { @@ -470,7 +470,7 @@ describe('instrumentWorkerEntrypoint', () => { }); }); - it('does not inject Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is disabled', () => { + it('does not inject Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is disabled', async () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -504,12 +504,12 @@ describe('instrumentWorkerEntrypoint', () => { TestClass as unknown as WorkerEntrypointConstructor, ); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); - obj.fetch(new Request('https://example.com')); + await obj.fetch(new Request('https://example.com')); expect(rpcMethod).toHaveBeenCalledWith('arg1', 42); }); - it('caches instrumented bindings across multiple accesses via this.env', () => { + it('caches instrumented bindings across multiple accesses via this.env', async () => { const mockContext = createMockExecutionContext(); const doNamespace = { idFromName: vi.fn(), @@ -535,12 +535,12 @@ describe('instrumentWorkerEntrypoint', () => { TestClass as unknown as WorkerEntrypointConstructor, ); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); - obj.fetch(new Request('https://example.com')); + await obj.fetch(new Request('https://example.com')); expect(firstAccess).toBe(secondAccess); }); - it('primitive env values are returned unchanged', () => { + it('primitive env values are returned unchanged', async () => { const mockContext = createMockExecutionContext(); const mockEnv = { SENTRY_DSN: 'https://key@sentry.io/123', PORT: 8080, DEBUG: true }; @@ -562,7 +562,7 @@ describe('instrumentWorkerEntrypoint', () => { TestClass as unknown as WorkerEntrypointConstructor, ); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); - obj.fetch(new Request('https://example.com')); + await obj.fetch(new Request('https://example.com')); expect(capturedDsn).toBe('https://key@sentry.io/123'); expect(capturedPort).toBe(8080); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 28733ccfe651..2164989833b3 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -204,6 +204,142 @@ describe('withSentry', () => { expect(sentryEvent.contexts?.culture).toEqual({ timezone: 'UTC' }); }); + + test('captures request body with default integration (medium size)', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + // Default integrations include httpServerIntegration with 'medium' default + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'test', data: 'value' }), + }), + context, + }, + () => { + SentryCore.captureMessage('request body'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toEqual( + JSON.stringify({ username: 'test', data: 'value' }), + ); + }); + + test('does not capture request body for GET requests', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com'), + context, + }, + () => { + SentryCore.captureMessage('get request'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); + + test('does not capture request body for HEAD requests', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com', { method: 'HEAD' }), + context, + }, + () => { + SentryCore.captureMessage('head request'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); + + test('does not capture request body for OPTIONS requests', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com', { method: 'OPTIONS' }), + context, + }, + () => { + SentryCore.captureMessage('options request'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); + + test('does not capture request body for binary content types', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com', { + method: 'POST', + headers: { 'content-type': 'image/png' }, + body: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + }), + context, + }, + () => { + SentryCore.captureMessage('binary'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); }); describe('error instrumentation', () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4d80ea02ed33..19fc53365bb5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,11 +112,15 @@ export { shouldIgnoreSpan } from './utils/should-ignore-span'; export { winterCGHeadersToDict, winterCGRequestToRequestData, + captureBodyFromWinterCGRequest, httpRequestToRequestData, extractQueryParamsFromUrl, headersToDict, httpHeadersToSpanAttributes, + getMaxBodyByteLength, + MAX_BODY_BYTE_LENGTH, } from './utils/request'; +export type { MaxRequestBodySize } from './utils/request'; export { DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; export { functionToStringIntegration } from './integrations/functiontostring'; diff --git a/packages/core/src/types-hoist/webfetchapi.ts b/packages/core/src/types-hoist/webfetchapi.ts index 78b7d464ea71..a944356182b8 100644 --- a/packages/core/src/types-hoist/webfetchapi.ts +++ b/packages/core/src/types-hoist/webfetchapi.ts @@ -13,5 +13,7 @@ export interface WebFetchRequest { readonly headers: WebFetchHeaders; readonly method: string; readonly url: string; + readonly body?: unknown; clone(): WebFetchRequest; + text(): Promise; } diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 3f7477e6459b..700f0272f121 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -1,6 +1,42 @@ +/* eslint-disable max-lines-per-function */ +import { DEBUG_BUILD } from '../debug-build'; +import type { Scope } from '../scope'; import type { PolymorphicRequest } from '../types-hoist/polymorphics'; import type { RequestEventData } from '../types-hoist/request'; import type { WebFetchHeaders, WebFetchRequest } from '../types-hoist/webfetchapi'; +import { debug } from './debug-logger'; +import { safeUnref } from './timer'; + +/** + * Maximum size of incoming HTTP request bodies attached to events. + * + * - `'none'`: No request bodies will be attached + * - `'small'`: Request bodies up to 1,000 bytes will be attached + * - `'medium'`: Request bodies up to 10,000 bytes will be attached + * - `'always'`: Request bodies will always be attached (up to 1MB hard cap) + */ +export type MaxRequestBodySize = 'none' | 'small' | 'medium' | 'always'; + +/** Hard cap on captured body size, even when `maxRequestBodySize` is `'always'`. */ +export const MAX_BODY_BYTE_LENGTH = 1_024 * 1_024; + +/** Content types that are safe to capture as text. */ +const TEXT_CONTENT_TYPES = [ + 'text/', + 'application/json', + 'application/x-www-form-urlencoded', + 'application/xml', + 'application/graphql', +]; + +/** + * Convert a `maxRequestBodySize` setting to a maximum byte length. + */ +export function getMaxBodyByteLength(maxRequestBodySize: Exclude): number { + if (maxRequestBodySize === 'small') return 1_000; + if (maxRequestBodySize === 'medium') return 10_000; + return MAX_BODY_BYTE_LENGTH; +} /** * Transforms a `Headers` object that implements the `Web Fetch API` (https://developer.mozilla.org/en-US/docs/Web/API/Headers) into a simple key-value dict. @@ -56,6 +92,96 @@ export function winterCGRequestToRequestData(req: WebFetchRequest): RequestEvent }; } +/** + * Checks if the content type is textual and safe to capture. + */ +function isTextualContentType(contentType: string | null): boolean { + if (!contentType) { + return false; + } + const lowerContentType = contentType.toLowerCase(); + return TEXT_CONTENT_TYPES.some(type => lowerContentType.includes(type)); +} + +/** + * Captures the body from a Web Fetch API Request and adds it to the isolation scope. + * + * This function clones the request to read the body without affecting the original. + * Only textual content types are captured - binary data is skipped. + * + * This is used by WinterCG-compatible runtimes (Cloudflare Workers, Deno, Bun, Vercel Edge, etc.) + * that use the Web Fetch API Request object. + * + * @param request - The incoming Web Fetch API Request + * @param isolationScope - The isolation scope to add the body to + * @param maxRequestBodySize - The maximum size of the request body to capture ('small' = 1KB, 'medium' = 10KB, 'always' = 1MB) + */ +export async function captureBodyFromWinterCGRequest( + request: WebFetchRequest, + isolationScope: Scope, + maxRequestBodySize: Exclude, +): Promise { + try { + const contentType = request.headers.get('content-type'); + + if (!isTextualContentType(contentType)) { + DEBUG_BUILD && debug.log('Skipping body capture for non-textual content type:', contentType); + return; + } + + if (!request.body) { + return; + } + + const contentLength = request.headers.get('content-length'); + const maxBodySize = getMaxBodyByteLength(maxRequestBodySize); + + if (contentLength) { + const length = parseInt(contentLength, 10); + if (!isNaN(length) && length > MAX_BODY_BYTE_LENGTH) { + DEBUG_BUILD && debug.log('Skipping body capture: body too large', length); + return; + } + } + + const clonedRequest = request.clone(); + const bodyPromise = clonedRequest.text(); + const timeoutPromise = new Promise(resolve => { + safeUnref(setTimeout(() => resolve(null), 2000)); + }); + + const body = await Promise.race([bodyPromise, timeoutPromise]); + + if (body === null) { + DEBUG_BUILD && debug.log('Timeout reading request body'); + return; + } + + if (!body) { + return; + } + + // Using TextEncoder to get byte length for UTF-8 strings + const encoder = new TextEncoder(); + const bytes = encoder.encode(body); + const bodyByteLength = bytes.length; + + let truncatedBody: string; + if (bodyByteLength > maxBodySize) { + const decoder = new TextDecoder(); + truncatedBody = `${decoder.decode(bytes.slice(0, maxBodySize - 3))}...`; + } else { + truncatedBody = body; + } + + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } }); + + DEBUG_BUILD && debug.log('Captured request body:', bodyByteLength, 'bytes'); + } catch (error) { + DEBUG_BUILD && debug.error('Error capturing request body:', error); + } +} + /** * Convert a HTTP request object to RequestEventData to be passed as normalizedRequest. * Instead of allowing `PolymorphicRequest` to be passed, diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 250fcf8443c8..d6add13abc1b 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { + captureBodyFromWinterCGRequest, extractQueryParamsFromUrl, headersToDict, httpHeadersToSpanAttributes, @@ -7,6 +8,7 @@ import { winterCGHeadersToDict, winterCGRequestToRequestData, } from '../../../src/utils/request'; +import type { Scope } from '../../../src/scope'; describe('request utils', () => { describe('winterCGHeadersToDict', () => { @@ -851,4 +853,292 @@ describe('request utils', () => { }); }); }); + + describe('captureBodyFromWinterCGRequest', () => { + function createMockRequest(options: { + body?: string | null; + contentType?: string; + contentLength?: string; + }): Request { + const headers = new Headers(); + if (options.contentType) { + headers.set('content-type', options.contentType); + } + if (options.contentLength) { + headers.set('content-length', options.contentLength); + } + + return new Request('https://example.com/test', { + method: 'POST', + headers, + body: options.body ?? undefined, + }); + } + + function createMockScope(): Scope & { capturedData: unknown } { + const scope = { + capturedData: undefined as unknown, + setSDKProcessingMetadata(metadata: { normalizedRequest?: { data?: unknown } }) { + scope.capturedData = metadata.normalizedRequest?.data; + }, + }; + return scope as Scope & { capturedData: unknown }; + } + + it('captures JSON body', async () => { + const jsonBody = JSON.stringify({ userId: 42, email: 'user@example.com', action: 'update_profile' }); + const request = createMockRequest({ + body: jsonBody, + contentType: 'application/json', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe(jsonBody); + }); + + it('captures form-urlencoded body', async () => { + const request = createMockRequest({ + body: 'username=test&password=secret', + contentType: 'application/x-www-form-urlencoded', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('username=test&password=secret'); + }); + + it('captures text/plain body', async () => { + const request = createMockRequest({ + body: 'Hello, World!', + contentType: 'text/plain', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('Hello, World!'); + }); + + it('captures text/html body', async () => { + const request = createMockRequest({ + body: 'Test', + contentType: 'text/html', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('Test'); + }); + + it('captures application/xml body', async () => { + const request = createMockRequest({ + body: 'value', + contentType: 'application/xml', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('value'); + }); + + it('captures application/graphql body', async () => { + const request = createMockRequest({ + body: 'query { user { name } }', + contentType: 'application/graphql', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('query { user { name } }'); + }); + + it('skips non-textual content types', async () => { + const request = createMockRequest({ + body: 'binary data', + contentType: 'application/octet-stream', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('skips image content types', async () => { + const request = createMockRequest({ + body: 'image data', + contentType: 'image/png', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('skips when content-type is explicitly non-textual', async () => { + const request = { + headers: { + get: (name: string) => (name === 'content-type' ? null : null), + }, + body: {}, + clone: () => ({ + text: () => Promise.resolve('some data'), + }), + } as unknown as Request; + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('skips when body is null', async () => { + const request = createMockRequest({ + body: null, + contentType: 'application/json', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('skips when body is empty', async () => { + const request = createMockRequest({ + body: '', + contentType: 'application/json', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('truncates body when it exceeds small size limit (1000 bytes)', async () => { + const largeBody = 'x'.repeat(2000); + const request = createMockRequest({ + body: largeBody, + contentType: 'text/plain', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'small'); + + expect(scope.capturedData).toHaveLength(1000); + expect((scope.capturedData as string).endsWith('...')).toBe(true); + }); + + it('truncates body when it exceeds medium size limit (10000 bytes)', async () => { + const largeBody = 'x'.repeat(20000); + const request = createMockRequest({ + body: largeBody, + contentType: 'text/plain', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toHaveLength(10000); + expect((scope.capturedData as string).endsWith('...')).toBe(true); + }); + + it('does not truncate body within small size limit', async () => { + const smallBody = 'x'.repeat(500); + const request = createMockRequest({ + body: smallBody, + contentType: 'text/plain', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'small'); + + expect(scope.capturedData).toBe(smallBody); + }); + + it('skips when content-length exceeds 1MB limit', async () => { + const request = createMockRequest({ + body: 'small body', + contentType: 'application/json', + contentLength: '2000000', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'always'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('captures body with always size limit', async () => { + const largeBody = 'x'.repeat(50000); + const request = createMockRequest({ + body: largeBody, + contentType: 'text/plain', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'always'); + + expect(scope.capturedData).toBe(largeBody); + }); + + it('handles content-type with charset', async () => { + const request = createMockRequest({ + body: '{"test":"value"}', + contentType: 'application/json; charset=utf-8', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('{"test":"value"}'); + }); + + it('does not throw on errors', async () => { + const request = { + headers: { + get: () => { + throw new Error('Test error'); + }, + }, + body: 'test', + clone: () => request, + } as unknown as Request; + const scope = createMockScope(); + + await expect(captureBodyFromWinterCGRequest(request, scope, 'medium')).resolves.not.toThrow(); + expect(scope.capturedData).toBeUndefined(); + }); + + it('handles timeout gracefully', async () => { + vi.useFakeTimers(); + + const neverResolve = new Promise(() => {}); + const request = { + headers: new Headers({ 'content-type': 'application/json' }), + body: {}, + clone: () => ({ + text: () => neverResolve, + }), + } as unknown as Request; + const scope = createMockScope(); + + const promise = captureBodyFromWinterCGRequest(request, scope, 'medium'); + + await vi.advanceTimersByTimeAsync(2500); + await promise; + + expect(scope.capturedData).toBeUndefined(); + + vi.useRealTimers(); + }); + }); }); diff --git a/packages/node-core/src/integrations/http/constants.ts b/packages/node-core/src/integrations/http/constants.ts index 6ad7b4319758..35073ae0491d 100644 --- a/packages/node-core/src/integrations/http/constants.ts +++ b/packages/node-core/src/integrations/http/constants.ts @@ -1,4 +1 @@ export const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; - -/** We only want to capture request bodies up to 1mb. */ -export const MAX_BODY_BYTE_LENGTH = 1024 * 1024; diff --git a/packages/node-core/src/utils/captureRequestBody.ts b/packages/node-core/src/utils/captureRequestBody.ts index 023209223f82..7afb1e40c530 100644 --- a/packages/node-core/src/utils/captureRequestBody.ts +++ b/packages/node-core/src/utils/captureRequestBody.ts @@ -1,8 +1,7 @@ import type { IncomingMessage } from 'node:http'; import type { Scope } from '@sentry/core'; -import { debug } from '@sentry/core'; +import { debug, getMaxBodyByteLength, type MaxRequestBodySize } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; -import { MAX_BODY_BYTE_LENGTH } from '../integrations/http/constants'; /** * This method patches the request object to capture the body. @@ -13,7 +12,7 @@ import { MAX_BODY_BYTE_LENGTH } from '../integrations/http/constants'; export function patchRequestToCaptureBody( req: IncomingMessage, isolationScope: Scope, - maxIncomingRequestBodySize: 'small' | 'medium' | 'always', + maxIncomingRequestBodySize: Exclude, integrationName: string, ): void { let bodyByteLength = 0; @@ -28,12 +27,7 @@ export function patchRequestToCaptureBody( */ const callbackMap = new WeakMap(); - const maxBodySize = - maxIncomingRequestBodySize === 'small' - ? 1_000 - : maxIncomingRequestBodySize === 'medium' - ? 10_000 - : MAX_BODY_BYTE_LENGTH; + const maxBodySize = getMaxBodyByteLength(maxIncomingRequestBodySize); try { // eslint-disable-next-line @typescript-eslint/unbound-method From 80cb35a3a0ec372d259cee6c93071067df858f60 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 14:07:48 +0200 Subject: [PATCH 58/84] feat(browser): Add `ingest_settings` to v2 metrics envelope payload (#20454) Adds `version: 2` and `ingest_settings` to the metrics envelope payload so Relay can infer the end-user IP address and User-Agent from the incoming request ([link to spec](https://develop.sentry.dev/sdk/telemetry/logs/#wire-format)). This is only emitted by browser SDKs. Both settings are gated behind `sendDefaultPii` (modeled after how `event.sdk.settings.infer_ip` works today). Closes https://github.com/getsentry/sentry-javascript/issues/20276 --- .../suites/public-api/metrics/simple/test.ts | 2 + .../suites/light-mode/metrics/test.ts | 1 + .../suites/public-api/metrics/test.ts | 1 + .../metrics/server-address-option/test.ts | 1 + .../public-api/metrics/server-address/test.ts | 1 + .../suites/public-api/metrics/test.ts | 1 + packages/core/src/metrics/envelope.ts | 17 ++++- packages/core/src/metrics/internal.ts | 8 ++- packages/core/src/types-hoist/metric.ts | 5 ++ .../core/test/lib/metrics/envelope.test.ts | 68 +++++++++++++++++-- 10 files changed, 96 insertions(+), 9 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts index 66f44878ac86..a50d6c8b2b78 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts @@ -27,6 +27,8 @@ sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) content_type: 'application/vnd.sentry.items.trace-metric+json', }, { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts index c0c9d291de78..d2a67f8df890 100644 --- a/dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts +++ b/dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts @@ -11,6 +11,7 @@ describe('light mode metrics', () => { .unignore('trace_metric') .expect({ trace_metric: { + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts index 9494ce2a99ca..303eb22f3285 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts @@ -11,6 +11,7 @@ describe('metrics', () => { .unignore('trace_metric') .expect({ trace_metric: { + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts index 825d94f41624..dfb3094f1bb9 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts @@ -10,6 +10,7 @@ describe('metrics server.address', () => { const runner = createRunner(__dirname, 'scenario.ts') .expect({ trace_metric: { + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts index 048513da3c19..86eb295e0c5d 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -10,6 +10,7 @@ describe('metrics server.address', () => { const runner = createRunner(__dirname, 'scenario.ts') .expect({ trace_metric: { + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts index ff67b73e9ad3..9b266552b052 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts @@ -10,6 +10,7 @@ describe('metrics', () => { const runner = createRunner(__dirname, 'scenario.ts') .expect({ trace_metric: { + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/packages/core/src/metrics/envelope.ts b/packages/core/src/metrics/envelope.ts index 71ef0832667b..565b957c4f6d 100644 --- a/packages/core/src/metrics/envelope.ts +++ b/packages/core/src/metrics/envelope.ts @@ -4,14 +4,21 @@ import type { SerializedMetric } from '../types-hoist/metric'; import type { SdkMetadata } from '../types-hoist/sdkmetadata'; import { dsnToString } from '../utils/dsn'; import { createEnvelope } from '../utils/envelope'; +import { isBrowser } from '../utils/isBrowser'; /** * Creates a metric container envelope item for a list of metrics. * * @param items - The metrics to include in the envelope. + * @param inferUserData - If true, tells Relay to infer the end-user IP and User-Agent from the incoming request. + * Only emitted as `ingest_settings` in browser environments. * @returns The created metric container envelope item. */ -export function createMetricContainerEnvelopeItem(items: Array): MetricContainerItem { +export function createMetricContainerEnvelopeItem( + items: Array, + inferUserData?: boolean, +): MetricContainerItem { + const inferSetting = inferUserData ? 'auto' : 'never'; return [ { type: 'trace_metric', @@ -19,6 +26,10 @@ export function createMetricContainerEnvelopeItem(items: Array content_type: 'application/vnd.sentry.items.trace-metric+json', } as MetricContainerItem[0], { + version: 2, + ...(isBrowser() && { + ingest_settings: { infer_ip: inferSetting, infer_user_agent: inferSetting }, + }), items, }, ]; @@ -33,6 +44,7 @@ export function createMetricContainerEnvelopeItem(items: Array * @param metadata - The metadata to include in the envelope. * @param tunnel - The tunnel to include in the envelope. * @param dsn - The DSN to include in the envelope. + * @param inferUserData - If true, tells Relay to infer the end-user IP and User-Agent from the incoming request. * @returns The created envelope. */ export function createMetricEnvelope( @@ -40,6 +52,7 @@ export function createMetricEnvelope( metadata?: SdkMetadata, tunnel?: string, dsn?: DsnComponents, + inferUserData?: boolean, ): MetricEnvelope { const headers: MetricEnvelope[0] = {}; @@ -54,5 +67,5 @@ export function createMetricEnvelope( headers.dsn = dsnToString(dsn); } - return createEnvelope(headers, [createMetricContainerEnvelopeItem(metrics)]); + return createEnvelope(headers, [createMetricContainerEnvelopeItem(metrics, inferUserData)]); } diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 0545414654ef..26cc11fc0422 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -225,7 +225,13 @@ export function _INTERNAL_flushMetricsBuffer(client: Client, maybeMetricBuffer?: } const clientOptions = client.getOptions(); - const envelope = createMetricEnvelope(metricBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn()); + const envelope = createMetricEnvelope( + metricBuffer, + clientOptions._metadata, + clientOptions.tunnel, + client.getDsn(), + clientOptions.sendDefaultPii, + ); // Clear the metric buffer after envelopes have been constructed. _getBufferMap().set(client, []); diff --git a/packages/core/src/types-hoist/metric.ts b/packages/core/src/types-hoist/metric.ts index 976fc9fe863f..1b6380cbc471 100644 --- a/packages/core/src/types-hoist/metric.ts +++ b/packages/core/src/types-hoist/metric.ts @@ -77,5 +77,10 @@ export interface SerializedMetric { } export type SerializedMetricContainer = { + version?: number; + ingest_settings?: { + infer_ip?: 'auto' | 'never'; + infer_user_agent?: 'auto' | 'never'; + }; items: Array; }; diff --git a/packages/core/test/lib/metrics/envelope.test.ts b/packages/core/test/lib/metrics/envelope.test.ts index 87132e4bcaa0..25bf5e61923b 100644 --- a/packages/core/test/lib/metrics/envelope.test.ts +++ b/packages/core/test/lib/metrics/envelope.test.ts @@ -5,6 +5,7 @@ import type { SerializedMetric } from '../../../src/types-hoist/metric'; import type { SdkMetadata } from '../../../src/types-hoist/sdkmetadata'; import * as utilsDsn from '../../../src/utils/dsn'; import * as utilsEnvelope from '../../../src/utils/envelope'; +import { isBrowser } from '../../../src/utils/isBrowser'; vi.mock('../../../src/utils/dsn', () => ({ dsnToString: vi.fn(dsn => `https://${dsn.publicKey}@${dsn.host}/`), @@ -12,9 +13,16 @@ vi.mock('../../../src/utils/dsn', () => ({ vi.mock('../../../src/utils/envelope', () => ({ createEnvelope: vi.fn((_headers, items) => [_headers, items]), })); +vi.mock('../../../src/utils/isBrowser', () => ({ + isBrowser: vi.fn(() => false), +})); + +afterEach(() => { + vi.mocked(isBrowser).mockReturnValue(false); +}); describe('createMetricContainerEnvelopeItem', () => { - it('creates an envelope item with correct structure', () => { + it('emits version: 2 without ingest_settings when not in browser', () => { const mockMetric: SerializedMetric = { timestamp: 1713859200, trace_id: '3d9355f71e9c444b81161599adac6e29', @@ -26,15 +34,63 @@ describe('createMetricContainerEnvelopeItem', () => { attributes: {}, }; - const result = createMetricContainerEnvelopeItem([mockMetric, mockMetric]); + const result = createMetricContainerEnvelopeItem([mockMetric], true); - expect(result).toHaveLength(2); expect(result[0]).toEqual({ type: 'trace_metric', - item_count: 2, + item_count: 1, content_type: 'application/vnd.sentry.items.trace-metric+json', }); - expect(result[1]).toEqual({ items: [mockMetric, mockMetric] }); + expect(result[1]).toEqual({ + version: 2, + items: [mockMetric], + }); + }); + + it("includes ingest_settings with 'auto' values when in browser and inferUserData is true", () => { + vi.mocked(isBrowser).mockReturnValue(true); + + const mockMetric: SerializedMetric = { + timestamp: 1713859200, + trace_id: '3d9355f71e9c444b81161599adac6e29', + span_id: '8b5f5e5e5e5e5e5e', + name: 'test.metric', + type: 'counter', + value: 1, + unit: 'count', + attributes: {}, + }; + + const result = createMetricContainerEnvelopeItem([mockMetric], true); + + expect(result[1]).toEqual({ + version: 2, + ingest_settings: { infer_ip: 'auto', infer_user_agent: 'auto' }, + items: [mockMetric], + }); + }); + + it("includes ingest_settings with 'never' values when in browser and inferUserData is false", () => { + vi.mocked(isBrowser).mockReturnValue(true); + + const mockMetric: SerializedMetric = { + timestamp: 1713859200, + trace_id: '3d9355f71e9c444b81161599adac6e29', + span_id: '8b5f5e5e5e5e5e5e', + name: 'test.metric', + type: 'counter', + value: 1, + unit: 'count', + attributes: {}, + }; + + const result = createMetricContainerEnvelopeItem([mockMetric], false); + + expect(result[1]).toEqual({ + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, + items: [mockMetric], + }); }); }); @@ -165,7 +221,7 @@ describe('createMetricEnvelope', () => { expect.arrayContaining([ expect.arrayContaining([ { type: 'trace_metric', item_count: 2, content_type: 'application/vnd.sentry.items.trace-metric+json' }, - { items: mockMetrics }, + { version: 2, items: mockMetrics }, ]), ]), ); From e8f4c4269cfc12347312073c993f8d011d4f3d8b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 14:13:41 +0200 Subject: [PATCH 59/84] test(e2e): Add span streaming test app for next 16 (#20648) Duplicates our existing test suite on next-16 to span streaming. closes https://github.com/getsentry/sentry-javascript/issues/20668 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../nextjs-16-streaming/.gitignore | 44 +++++++ .../app/(nested-layout)/layout.tsx | 12 ++ .../nested-layout/[dynamic]/layout.tsx | 12 ++ .../nested-layout/[dynamic]/page.tsx | 15 +++ .../(nested-layout)/nested-layout/layout.tsx | 12 ++ .../(nested-layout)/nested-layout/page.tsx | 11 ++ .../app/ai-error-test/page.tsx | 50 ++++++++ .../nextjs-16-streaming/app/ai-test/page.tsx | 98 +++++++++++++++ .../app/api/cron-test-error/route.ts | 5 + .../app/api/cron-test/route.ts | 9 ++ .../api/endpoint-behind-middleware/route.ts | 3 + .../app/api/queue-send/route.ts | 14 +++ .../app/api/queues/process-order/route.ts | 10 ++ .../app/api/v3/topic/[...params]/route.ts | 112 ++++++++++++++++++ .../app/component-annotation/page.tsx | 18 +++ .../nextjs-16-streaming/app/favicon.ico | Bin 0 -> 25931 bytes .../nextjs-16-streaming/app/global-error.tsx | 23 ++++ .../app/isr-test/[product]/page.tsx | 17 +++ .../app/isr-test/static/page.tsx | 15 +++ .../nextjs-16-streaming/app/layout.tsx | 7 ++ .../nextjs-16-streaming/app/metrics/page.tsx | 34 ++++++ .../app/metrics/route-handler/route.ts | 23 ++++ .../app/nested-rsc-error/[param]/page.tsx | 17 +++ .../app/non-isr-test/[item]/page.tsx | 11 ++ .../nextjs-16-streaming/app/page.tsx | 3 + .../app/pageload-tracing/layout.tsx | 8 ++ .../app/pageload-tracing/page.tsx | 14 +++ .../parameterized/[one]/beep/[two]/page.tsx | 3 + .../app/parameterized/[one]/beep/page.tsx | 3 + .../app/parameterized/[one]/page.tsx | 3 + .../app/parameterized/static/page.tsx | 3 + .../app/prefetching/page.tsx | 9 ++ .../app/prefetching/to-be-prefetched/page.tsx | 5 + .../app/redirect/destination/page.tsx | 7 ++ .../app/redirect/origin/page.tsx | 18 +++ .../[xoxo]/capture-exception/route.ts | 9 ++ .../[xoxo]/capture-message/route.ts | 9 ++ .../app/route-handler/[xoxo]/edge/route.ts | 8 ++ .../app/route-handler/[xoxo]/error/route.ts | 5 + .../app/route-handler/[xoxo]/node/route.ts | 7 ++ .../[param]/client-page.tsx | 8 ++ .../app/streaming-rsc-error/[param]/page.tsx | 18 +++ .../app/suspense-error/page.tsx | 15 +++ .../app/third-party-filter/page.tsx | 24 ++++ .../nextjs-16-streaming/eslint.config.mjs | 19 +++ .../instrumentation-client.ts | 22 ++++ .../nextjs-16-streaming/instrumentation.ts | 13 ++ .../nextjs-16-streaming/lib/queue.ts | 12 ++ .../nextjs-16-streaming/next.config.ts | 18 +++ .../nextjs-16-streaming/package.json | 41 +++++++ .../nextjs-16-streaming/playwright.config.mjs | 29 +++++ .../nextjs-16-streaming/proxy.ts | 24 ++++ .../nextjs-16-streaming/public/file.svg | 1 + .../nextjs-16-streaming/public/globe.svg | 1 + .../nextjs-16-streaming/public/next.svg | 1 + .../nextjs-16-streaming/public/vercel.svg | 1 + .../nextjs-16-streaming/public/window.svg | 1 + .../nextjs-16-streaming/sentry.edge.config.ts | 11 ++ .../sentry.server.config.ts | 19 +++ .../nextjs-16-streaming/start-event-proxy.mjs | 14 +++ .../nextjs-16-streaming/tests/isDevMode.ts | 1 + .../tests/nested-rsc-error.test.ts | 37 ++++++ .../tests/pageload-tracing.test.ts | 19 +++ .../tests/parameterized-routes.test.ts | 60 ++++++++++ .../tests/route-handler.test.ts | 59 +++++++++ .../tests/server-components.test.ts | 63 ++++++++++ .../nextjs-16-streaming/tsconfig.json | 27 +++++ .../nextjs-16-streaming/vercel.json | 17 +++ 68 files changed, 1261 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-error-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test-error/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/endpoint-behind-middleware/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queue-send/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queues/process-order/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/v3/topic/[...params]/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/component-annotation/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/global-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/[product]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/route-handler/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/nested-rsc-error/[param]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/non-isr-test/[item]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/to-be-prefetched/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/destination/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/origin/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-exception/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-message/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/edge/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/error/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/node/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/client-page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/suspense-error/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/third-party-filter/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/eslint.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation-client.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/lib/queue.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/next.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/proxy.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/file.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/globe.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/next.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/vercel.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/window.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/isDevMode.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/nested-rsc-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/route-handler.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/vercel.json diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/.gitignore new file mode 100644 index 000000000000..dd146b53d966 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/layout.tsx new file mode 100644 index 000000000000..ace0c2f086b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

Layout

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx new file mode 100644 index 000000000000..dbdc60adadc2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

DynamicLayout

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/page.tsx new file mode 100644 index 000000000000..3eaddda2a1df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/page.tsx @@ -0,0 +1,15 @@ +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( +
+

Dynamic Page

+
+ ); +} + +export async function generateMetadata() { + return { + title: 'I am dynamic page generated metadata', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/layout.tsx new file mode 100644 index 000000000000..ace0c2f086b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

Layout

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/page.tsx new file mode 100644 index 000000000000..8077c14d23ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/page.tsx @@ -0,0 +1,11 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Hello World!

; +} + +export async function generateMetadata() { + return { + title: 'I am generated metadata', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-error-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-error-test/page.tsx new file mode 100644 index 000000000000..bd75c0062228 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-error-test/page.tsx @@ -0,0 +1,50 @@ +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +// Error trace handling in tool calls +async function runAITest() { + const result = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async args => { + throw new Error('Tool call failed'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); +} + +export default async function Page() { + await Sentry.startSpan({ op: 'function', name: 'ai-error-test' }, async () => { + return await runAITest(); + }); + + return ( +
+

AI Test Results

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-test/page.tsx new file mode 100644 index 000000000000..d28a147eb88d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-test/page.tsx @@ -0,0 +1,98 @@ +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +async function runAITest() { + // First span - telemetry should be enabled automatically but no input/output recorded when sendDefaultPii: true + const result1 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'First span here!', + }), + }), + prompt: 'Where is the first span?', + }); + + // Second span - explicitly enabled telemetry, should record inputs/outputs + const result2 = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Second span here!', + }), + }), + prompt: 'Where is the second span?', + }); + + // Third span - with tool calls and tool results + const result3 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async args => { + return `Weather in ${args.location}: Sunny, 72°F`; + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + + // Fourth span - explicitly disabled telemetry, should not be captured + const result4 = await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Third span here!', + }), + }), + prompt: 'Where is the third span?', + }); + + return { + result1: result1.text, + result2: result2.text, + result3: result3.text, + result4: result4.text, + }; +} + +export default async function Page() { + const results = await Sentry.startSpan({ op: 'function', name: 'ai-test' }, async () => { + return await runAITest(); + }); + + return ( +
+

AI Test Results

+
{JSON.stringify(results, null, 2)}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test-error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test-error/route.ts new file mode 100644 index 000000000000..4826ffa16b15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test-error/route.ts @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export async function GET() { + throw new Error('Cron job error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test/route.ts new file mode 100644 index 000000000000..e70938cbe491 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + // Simulate some work + await new Promise(resolve => setTimeout(resolve, 100)); + return NextResponse.json({ message: 'Cron job executed successfully' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/endpoint-behind-middleware/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/endpoint-behind-middleware/route.ts new file mode 100644 index 000000000000..2733cc918f44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/endpoint-behind-middleware/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ name: 'John Doe' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queue-send/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queue-send/route.ts new file mode 100644 index 000000000000..fe49a990921b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queue-send/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { send } from '../../../lib/queue'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request) { + const body = await request.json(); + const topic = body.topic ?? 'orders'; + const payload = body.payload ?? body; + + const { messageId } = await send(topic, payload); + + return NextResponse.json({ messageId }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queues/process-order/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queues/process-order/route.ts new file mode 100644 index 000000000000..41cec36d5d8a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queues/process-order/route.ts @@ -0,0 +1,10 @@ +import { handleCallback } from '../../../../lib/queue'; + +export const dynamic = 'force-dynamic'; + +// The @vercel/queue handleCallback return type (CallbackRequestInput) doesn't match +// Next.js's strict route handler type check with webpack builds, so we cast it. +export const POST = handleCallback(async (message, _metadata) => { + // Simulate some async work + await new Promise(resolve => setTimeout(resolve, 50)); +}) as unknown as (req: Request) => Promise; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/v3/topic/[...params]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/v3/topic/[...params]/route.ts new file mode 100644 index 000000000000..51dfa2e656db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/v3/topic/[...params]/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server'; + +/** + * Mock Vercel Queues API server. + * + * This route handler simulates the Vercel Queues HTTP API so that the real + * @vercel/queue SDK can be used in E2E tests without Vercel infrastructure. + * + * Handled endpoints: + * POST /api/v3/topic/{topic} → SendMessage + * POST /api/v3/topic/{topic}/consumer/{consumer}/id/{messageId} → ReceiveMessageById + * DELETE /api/v3/topic/{topic}/consumer/{consumer}/lease/{handle} → AcknowledgeMessage + * PATCH /api/v3/topic/{topic}/consumer/{consumer}/lease/{handle} → ExtendLease + */ + +export const dynamic = 'force-dynamic'; + +let messageCounter = 0; + +function generateMessageId(): string { + return `msg_test_${++messageCounter}_${Date.now()}`; +} + +function generateReceiptHandle(): string { + return `rh_test_${Date.now()}_${Math.random().toString(36).slice(2)}`; +} + +// Encode a file path into a consumer-group name, matching the SDK's algorithm. +function filePathToConsumerGroup(filePath: string): string { + let result = ''; + for (const char of filePath) { + if (char === '_') result += '__'; + else if (char === '/') result += '_S'; + else if (char === '.') result += '_D'; + else if (/[A-Za-z0-9-]/.test(char)) result += char; + else result += '_' + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'); + } + return result; +} + +// Topic → consumer route path (mirrors vercel.json experimentalTriggers). +const TOPIC_ROUTES: Record = { + orders: '/api/queues/process-order', +}; + +// The file path key used in vercel.json for each consumer route. +const ROUTE_FILE_PATHS: Record = { + '/api/queues/process-order': 'app/api/queues/process-order/route.ts', +}; + +export async function POST(request: Request, { params }: { params: Promise<{ params: string[] }> }) { + const { params: segments } = await params; + + // POST /api/v3/topic/{topic} → SendMessage + if (segments.length === 1) { + const topic = segments[0]; + const body = await request.arrayBuffer(); + const messageId = generateMessageId(); + const receiptHandle = generateReceiptHandle(); + const now = new Date(); + const createdAt = now.toISOString(); + const expiresAt = new Date(now.getTime() + 86_400_000).toISOString(); + const visibilityDeadline = new Date(now.getTime() + 300_000).toISOString(); + + const consumerRoute = TOPIC_ROUTES[topic]; + if (consumerRoute) { + const filePath = ROUTE_FILE_PATHS[consumerRoute] ?? consumerRoute; + const consumerGroup = filePathToConsumerGroup(filePath); + const port = process.env.PORT || 3030; + + // Simulate Vercel infrastructure pushing the message to the consumer. + // Fire-and-forget so the SendMessage response returns immediately. + void fetch(`http://localhost:${port}${consumerRoute}`, { + method: 'POST', + headers: { + 'ce-type': 'com.vercel.queue.v2beta', + 'ce-vqsqueuename': topic, + 'ce-vqsconsumergroup': consumerGroup, + 'ce-vqsmessageid': messageId, + 'ce-vqsreceipthandle': receiptHandle, + 'ce-vqsdeliverycount': '1', + 'ce-vqscreatedat': createdAt, + 'ce-vqsexpiresat': expiresAt, + 'ce-vqsregion': 'test1', + 'ce-vqsvisibilitydeadline': visibilityDeadline, + 'content-type': request.headers.get('content-type') || 'application/json', + }, + body: Buffer.from(body), + }).catch(err => console.error('[mock-queue] Failed to push to consumer:', err)); + } + + return NextResponse.json({ messageId }, { status: 201, headers: { 'Vqs-Message-Id': messageId } }); + } + + // POST /api/v3/topic/{topic}/consumer/{consumer}/id/{messageId} → ReceiveMessageById + // Not used in binary-mode push flow, but handled for completeness. + if (segments.length >= 4 && segments[1] === 'consumer') { + return new Response(null, { status: 204 }); + } + + return NextResponse.json({ error: 'Unknown endpoint' }, { status: 404 }); +} + +// DELETE /api/v3/topic/{topic}/consumer/{consumer}/lease/{receiptHandle} → AcknowledgeMessage +export async function DELETE() { + return new Response(null, { status: 204 }); +} + +// PATCH /api/v3/topic/{topic}/consumer/{consumer}/lease/{receiptHandle} → ExtendLease +export async function PATCH() { + return NextResponse.json({ success: true }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/component-annotation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/component-annotation/page.tsx new file mode 100644 index 000000000000..8ac6973dc5c8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/component-annotation/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +export default function ComponentAnnotationTestPage() { + return ( +
+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/global-error.tsx new file mode 100644 index 000000000000..20c175015b03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/global-error.tsx @@ -0,0 +1,23 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import NextError from 'next/error'; +import { useEffect } from 'react'; + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/[product]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/[product]/page.tsx new file mode 100644 index 000000000000..cd1e085e2763 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/[product]/page.tsx @@ -0,0 +1,17 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams + +export async function generateStaticParams(): Promise> { + return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }]; +} + +export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) { + const { product } = await params; + + return ( +
+

ISR Product: {product}

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

ISR Static Page

+
static-isr
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/page.tsx new file mode 100644 index 000000000000..fdb7bc0a40a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +export default function Page() { + const handleClick = async () => { + Sentry.metrics.count('test.page.count', 1, { + attributes: { + page: '/metrics', + 'random.attribute': 'Apples', + }, + }); + Sentry.metrics.distribution('test.page.distribution', 100, { + attributes: { + page: '/metrics', + 'random.attribute': 'Manzanas', + }, + }); + Sentry.metrics.gauge('test.page.gauge', 200, { + attributes: { + page: '/metrics', + 'random.attribute': 'Mele', + }, + }); + await fetch('/metrics/route-handler'); + }; + + return ( +
+

Metrics page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/route-handler/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/route-handler/route.ts new file mode 100644 index 000000000000..84e81960f9c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/route-handler/route.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/nextjs'; + +export const GET = async () => { + Sentry.metrics.count('test.route.handler.count', 1, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Potatoes', + }, + }); + Sentry.metrics.distribution('test.route.handler.distribution', 100, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Patatas', + }, + }); + Sentry.metrics.gauge('test.route.handler.gauge', 200, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Patate', + }, + }); + return Response.json({ message: 'Bueno' }); +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/nested-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/nested-rsc-error/[param]/page.tsx new file mode 100644 index 000000000000..675b248026be --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/nested-rsc-error/[param]/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( + Loading...

}> + {/* @ts-ignore */} + ; +
+ ); +} + +async function Crash() { + throw new Error('I am technically uncatchable'); + return

unreachable

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

Non-ISR Dynamic Page: {item}

+
{item}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/page.tsx new file mode 100644 index 000000000000..2bc0a407a355 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Next 16 test app

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/layout.tsx new file mode 100644 index 000000000000..1f0cbe478f88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/layout.tsx @@ -0,0 +1,8 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default async function Layout({ children }: PropsWithChildren) { + await new Promise(resolve => setTimeout(resolve, 500)); + return <>{children}; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/page.tsx new file mode 100644 index 000000000000..689735d61ddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/page.tsx @@ -0,0 +1,14 @@ +export const dynamic = 'force-dynamic'; + +export default async function Page() { + await new Promise(resolve => setTimeout(resolve, 1000)); + return

I am page 2

; +} + +export async function generateMetadata() { + (await fetch('https://example.com/', { cache: 'no-store' })).text(); + + return { + title: 'my title', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..16ef0482d53b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/static/page.tsx @@ -0,0 +1,3 @@ +export default function StaticPage() { + return
Static page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/page.tsx new file mode 100644 index 000000000000..4cb811ecf1b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/page.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link'; + +export default function Page() { + return ( + + link + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/to-be-prefetched/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/to-be-prefetched/page.tsx new file mode 100644 index 000000000000..83aac90d65cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/to-be-prefetched/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Hello

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/destination/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/destination/page.tsx new file mode 100644 index 000000000000..5583d36b04b0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/destination/page.tsx @@ -0,0 +1,7 @@ +export default function RedirectDestinationPage() { + return ( +
+

Redirect Destination

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/origin/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/origin/page.tsx new file mode 100644 index 000000000000..52615e0a054b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/origin/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from 'next/navigation'; + +async function redirectAction() { + 'use server'; + + redirect('/redirect/destination'); +} + +export default function RedirectOriginPage() { + return ( + <> + {/* @ts-ignore */} +
+ +
+ + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-exception/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-exception/route.ts new file mode 100644 index 000000000000..2f8a8b84d9e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-exception/route.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + Sentry.captureException(new Error('route-handler-capture-exception')); + return NextResponse.json({ message: 'Exception captured' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-message/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-message/route.ts new file mode 100644 index 000000000000..67015ec11b2f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-message/route.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + Sentry.captureMessage('route-handler-message'); + return NextResponse.json({ message: 'Message captured' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/edge/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/edge/route.ts new file mode 100644 index 000000000000..7cd1fc7e332c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/edge/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'edge'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Edge Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/error/route.ts new file mode 100644 index 000000000000..064b9df86854 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/error/route.ts @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export async function GET() { + throw new Error('route-handler-error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/node/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/node/route.ts new file mode 100644 index 000000000000..5bc418f077aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/node/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Node Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/client-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/client-page.tsx new file mode 100644 index 000000000000..7b66c3fbdeef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/client-page.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { use } from 'react'; + +export function RenderPromise({ stringPromise }: { stringPromise: Promise }) { + const s = use(stringPromise); + return <>{s}; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/page.tsx new file mode 100644 index 000000000000..9531f9a42139 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from 'react'; +import { RenderPromise } from './client-page'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + const crashingPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('I am a data streaming error')); + }, 100); + }); + + return ( + Loading...

}> + ; +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/suspense-error/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/suspense-error/page.tsx new file mode 100644 index 000000000000..ff49745d405b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/suspense-error/page.tsx @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/nextjs'; +import { use } from 'react'; +export const dynamic = 'force-dynamic'; + +export default async function Page() { + try { + use(fetch('https://example.com/')); + } catch (e) { + Sentry.captureException(e); // This error should not be reported + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for any async event processors to run + await Sentry.flush(); + } + + return

test

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/third-party-filter/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/third-party-filter/page.tsx new file mode 100644 index 000000000000..b6b4bea80def --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/third-party-filter/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +function throwFirstPartyError(): void { + throw new Error('first-party-error'); +} + +export default function Page() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/eslint.config.mjs @@ -0,0 +1,19 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'], + }, +]; + +export default eslintConfig; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation-client.ts new file mode 100644 index 000000000000..77e1e79967e4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation-client.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/nextjs'; +import type { Log } from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + integrations: [ + Sentry.thirdPartyErrorFilterIntegration({ + filterKeys: ['nextjs-16-streaming-e2e'], + behaviour: 'apply-tag-if-contains-third-party-frames', + }), + Sentry.spanStreamingIntegration(), + ], + beforeSendLog(log: Log) { + return log; + }, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/lib/queue.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/lib/queue.ts new file mode 100644 index 000000000000..8dc8ce0ad5ed --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/lib/queue.ts @@ -0,0 +1,12 @@ +import { QueueClient } from '@vercel/queue'; + +// For E2E testing, point the SDK at a local mock server running within Next.js. +// The mock API lives at app/api/v3/topic/[...params]/route.ts +const queue = new QueueClient({ + region: 'test1', + resolveBaseUrl: () => new URL(`http://localhost:${process.env.PORT || 3030}`), + token: 'mock-token', + deploymentId: null, +}); + +export const { send, handleCallback } = queue; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/next.config.ts new file mode 100644 index 000000000000..6067696c7d16 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/next.config.ts @@ -0,0 +1,18 @@ +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +// Simulate Vercel environment for cron monitoring tests +process.env.VERCEL = '1'; + +const nextConfig: NextConfig = {}; + +export default withSentryConfig(nextConfig, { + silent: true, + _experimental: { + vercelCronsMonitoring: true, + turbopackApplicationKey: 'nextjs-16-streaming-e2e', + turbopackReactComponentAnnotation: { + enabled: true, + }, + }, +}); 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 new file mode 100644 index 000000000000..8e254f4b4657 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json @@ -0,0 +1,41 @@ +{ + "name": "nextjs-16-streaming", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "start": "next start", + "lint": "eslint", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@vercel/queue": "^0.1.3", + "ai": "^3.0.0", + "import-in-the-middle": "^2", + "next": "16.2.4", + "react": "19.1.0", + "react-dom": "19.1.0", + "require-in-the-middle": "^8", + "zod": "^3.22.4" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "^16", + "typescript": "^5" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/playwright.config.mjs new file mode 100644 index 000000000000..797418b8cf7d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/playwright.config.mjs @@ -0,0 +1,29 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development-webpack') { + return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'development') { + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/proxy.ts new file mode 100644 index 000000000000..60722f329fa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/proxy.ts @@ -0,0 +1,24 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function proxy(request: NextRequest) { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + + if (request.headers.has('x-should-throw')) { + throw new Error('Middleware Error'); + } + + if (request.headers.has('x-should-make-request')) { + await fetch('http://localhost:3030/'); + } + + return NextResponse.next(); +} + +// See "Matching Paths" below to learn more +export const config = { + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/file.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/file.svg new file mode 100644 index 000000000000..004145cddf3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/globe.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/globe.svg new file mode 100644 index 000000000000..567f17b0d7c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/next.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/next.svg new file mode 100644 index 000000000000..5174b28c565c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/vercel.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/vercel.svg new file mode 100644 index 000000000000..77053960334e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/window.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/window.svg new file mode 100644 index 000000000000..b2b2a44f6ebc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.edge.config.ts new file mode 100644 index 000000000000..f2e946f81728 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.edge.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + traceLifecycle: 'stream', + integrations: [Sentry.spanStreamingIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.server.config.ts new file mode 100644 index 000000000000..d44e4da73818 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.server.config.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/nextjs'; +import { Log } from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + traceLifecycle: 'stream', + integrations: [ + Sentry.vercelAIIntegration(), + Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }), + Sentry.spanStreamingIntegration(), + ], + beforeSendLog(log: Log) { + return log; + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..9b3556d402af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-16-streaming', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-16-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/nested-rsc-error.test.ts new file mode 100644 index 000000000000..280f0ef4e33b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/nested-rsc-error.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ + page, +}) => { + const errorEventPromise = waitForError('nextjs-16-streaming', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable'); + }); + + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /nested-rsc-error/[param]' && span.is_segment; + }); + + await page.goto(`/nested-rsc-error/123`); + const errorEvent = await errorEventPromise; + const rootSpan = await rootSpanPromise; + + expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan.trace_id); + + expect(errorEvent.request).toMatchObject({ + headers: expect.any(Object), + method: 'GET', + }); + + expect(errorEvent.contexts?.nextjs).toEqual({ + route_type: 'render', + router_kind: 'App Router', + router_path: '/nested-rsc-error/[param]', + request_path: '/nested-rsc-error/123', + }); + + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.nextjs.on_request_error', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts new file mode 100644 index 000000000000..b64307ad9202 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('Server and client pageload spans should share the same trace', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/pageload-tracing`); + + const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); + + expect(pageloadSpan.trace_id).toBeTruthy(); + expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts new file mode 100644 index 000000000000..7990953bf7a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('should create a parameterized streamed span when the `app` directory is used', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/:one' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/cappuccino`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/:one'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('should create a static streamed span when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/static' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/static`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/static'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('url'); +}); + +test('should create a partially parameterized streamed span when the `app` directory is used', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/:one/beep' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/:one/beep'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('should create a nested parameterized streamed span when the `app` directory is used.', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/:one/beep/:two' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/:one/beep/:two'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/route-handler.test.ts new file mode 100644 index 000000000000..be6be4c220b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/route-handler.test.ts @@ -0,0 +1,59 @@ +import test, { expect } from '@playwright/test'; +import { waitForError, waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('Should create a streamed span for node route handlers', async ({ request }) => { + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /route-handler/[xoxo]/node' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + const response = await request.get('/route-handler/123/node', { headers: { 'x-charly': 'gomez' } }); + expect(await response.json()).toStrictEqual({ message: 'Hello Node Route Handler' }); + + const rootSpan = await rootSpanPromise; + + expect(rootSpan.status).toBe('ok'); + expect(getSpanOp(rootSpan)).toBe('http.server'); +}); + +test('Should report an error linked to the correct trace for a throwing route handler', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-16-streaming', errorEvent => { + return errorEvent?.exception?.values?.some(value => value.value === 'route-handler-error') ?? false; + }); + + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /route-handler/[xoxo]/error' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + request.get('/route-handler/456/error').catch(() => {}); + + const errorEvent = await errorEventPromise; + const rootSpan = await rootSpanPromise; + + expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan.trace_id); + expect(errorEvent.transaction).toBe('GET /route-handler/[xoxo]/error'); + expect(rootSpan.status).toBe('error'); +}); + +test('Should set a parameterized transaction name on a captureMessage event in a route handler', async ({ + request, +}) => { + const messageEventPromise = waitForError('nextjs-16-streaming', event => { + return event?.message === 'route-handler-message'; + }); + + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return ( + span.name === 'GET /route-handler/[xoxo]/capture-message' && getSpanOp(span) === 'http.server' && span.is_segment + ); + }); + + const response = await request.get('/route-handler/789/capture-message'); + expect(await response.json()).toStrictEqual({ message: 'Message captured' }); + + const messageEvent = await messageEventPromise; + const rootSpan = await rootSpanPromise; + + expect(messageEvent.contexts?.trace?.trace_id).toBe(rootSpan.trace_id); + expect(messageEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-message'); + expect(rootSpan.status).toBe('ok'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts new file mode 100644 index 000000000000..ba64953678b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, waitForStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +test('Sends a streamed span for a request to app router with URL', async ({ page }) => { + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /parameterized/[one]/beep/[two]' && span.is_segment; + }); + + await page.goto('/parameterized/1337/beep/42'); + + const rootSpan = await rootSpanPromise; + + expect(getSpanOp(rootSpan)).toBe('http.server'); + expect(rootSpan.status).toBe('ok'); +}); + +test('Will create streamed spans for every server component and metadata generation functions when visiting a page', async ({ + page, +}) => { + const spansPromise = waitForStreamedSpans('nextjs-16-streaming', spans => { + return spans.some(span => span.name === 'GET /nested-layout' && span.is_segment); + }); + + await page.goto('/nested-layout'); + + const spans = await spansPromise; + const spanNames = spans.map(span => span.name); + + expect(spanNames).toContainEqual('render route (app) /nested-layout'); + expect(spanNames).toContainEqual('build component tree'); + expect(spanNames).toContainEqual('resolve root layout server component'); + expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); + expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); + expect(spanNames).toContainEqual('resolve page server component "/nested-layout"'); + expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); + expect(spanNames).toContainEqual('start response'); + expect(spanNames).toContainEqual('NextNodeServer.clientComponentLoading'); +}); + +test('Will create streamed spans for every server component and metadata generation functions when visiting a dynamic page', async ({ + page, +}) => { + const spansPromise = waitForStreamedSpans('nextjs-16-streaming', spans => { + return spans.some(span => span.name === 'GET /nested-layout/[dynamic]' && span.is_segment); + }); + + await page.goto('/nested-layout/123'); + + const spans = await spansPromise; + const spanNames = spans.map(span => span.name); + + expect(spanNames).toContainEqual('resolve page components'); + expect(spanNames).toContainEqual('render route (app) /nested-layout/[dynamic]'); + expect(spanNames).toContainEqual('build component tree'); + expect(spanNames).toContainEqual('resolve root layout server component'); + expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); + expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); + expect(spanNames).toContainEqual('resolve layout server component "[dynamic]"'); + expect(spanNames).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); + expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); + expect(spanNames).toContainEqual('start response'); + expect(spanNames).toContainEqual('NextNodeServer.clientComponentLoading'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/vercel.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/vercel.json new file mode 100644 index 000000000000..58730a0978fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/vercel.json @@ -0,0 +1,17 @@ +{ + "crons": [ + { + "path": "/api/cron-test", + "schedule": "0 * * * *" + }, + { + "path": "/api/cron-test-error", + "schedule": "30 * * * *" + } + ], + "functions": { + "app/api/queues/process-order/route.ts": { + "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "orders" }] + } + } +} From a08fc2c53ec13f4b1718d360b2a7f54b421dd75e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 5 May 2026 14:27:14 +0200 Subject: [PATCH 60/84] test(cloudflare): Add integration tests for scheduled, D1, and workflow (#20609) closes #16894 closes [JS-677](https://linear.app/getsentry/issue/JS-677/add-cloudflare-integration-tests) This adds only basic integration tests for D1, `scheduled` of an exported handler and a Workflow. --- .../suites/tracing/d1/index.ts | 32 +++++++++ .../suites/tracing/d1/test.ts | 67 ++++++++++++++++++ .../suites/tracing/d1/wrangler.jsonc | 13 ++++ .../suites/tracing/scheduled/index.ts | 21 ++++++ .../suites/tracing/scheduled/test.ts | 45 ++++++++++++ .../suites/tracing/scheduled/wrangler.jsonc | 9 +++ .../suites/tracing/workflow/index.ts | 60 ++++++++++++++++ .../suites/tracing/workflow/test.ts | 69 +++++++++++++++++++ .../suites/tracing/workflow/wrangler.jsonc | 13 ++++ 9 files changed, 329 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/d1/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/workflow/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts new file mode 100644 index 000000000000..a0bc792645ec --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + DB: D1Database; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env, _ctx) { + const url = new URL(request.url); + const db = Sentry.instrumentD1WithSentry(env.DB); + + if (url.pathname === '/init') { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)'); + await db.prepare('INSERT INTO users (name) VALUES (?)').bind('Alice').run(); + return new Response('Initialized'); + } + + if (url.pathname === '/query') { + const result = await db.prepare('SELECT * FROM users WHERE name = ?').bind('Alice').first(); + return Response.json(result); + } + + return new Response('OK'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts new file mode 100644 index 000000000000..e921b23ce1a2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -0,0 +1,67 @@ +import { expect, it } from 'vitest'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('D1 database queries create spans with correct attributes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'GET /init', + spans: [ + { + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', + 'cloudflare.d1.query_type': 'run', + 'cloudflare.d1.duration': expect.any(Number), + 'cloudflare.d1.rows_read': expect.any(Number), + 'cloudflare.d1.rows_written': expect.any(Number), + }, + description: 'INSERT INTO users (name) VALUES (?)', + op: 'db.query', + origin: 'auto.db.cloudflare.d1', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }, + ], + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'GET /query', + spans: [ + { + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', + 'cloudflare.d1.query_type': 'first', + }, + description: 'SELECT * FROM users WHERE name = ?', + op: 'db.query', + origin: 'auto.db.cloudflare.d1', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }, + ], + }), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/init'); + await runner.makeRequest('get', '/query'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/wrangler.jsonc new file mode 100644 index 000000000000..0ae1692d6726 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "d1-worker", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "d1_databases": [ + { + "binding": "DB", + "database_name": "test-db", + "database_id": "local-test-db", + }, + ], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts new file mode 100644 index 000000000000..75341f09eeef --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(_request, _env, _ctx) { + return new Response('OK'); + }, + async scheduled(_controller, _env, _ctx) { + // Successful scheduled handler - just does some work + await new Promise(resolve => setTimeout(resolve, 10)); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts new file mode 100644 index 000000000000..462f4f046b78 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts @@ -0,0 +1,45 @@ +import { expect, it } from 'vitest'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, +} from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('Scheduled handler creates transaction with correct attributes', async ({ signal }) => { + const runner = createRunner(__dirname) + .withWranglerArgs('--test-scheduled') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: expect.stringMatching(/^Scheduled Cron/), + transaction_info: { source: 'task' }, + spans: [], + contexts: expect.objectContaining({ + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'faas.cron', + origin: 'auto.faas.cloudflare.scheduled', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'faas.cron', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.scheduled', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + 'faas.cron': expect.any(String), + 'faas.time': expect.any(String), + 'faas.trigger': 'timer', + }, + }, + }), + }), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/__scheduled'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/wrangler.jsonc new file mode 100644 index 000000000000..12630676aa01 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "name": "scheduled-worker", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "triggers": { + "crons": ["* * * * *"], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts new file mode 100644 index 000000000000..dce6c1d58ced --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts @@ -0,0 +1,60 @@ +import * as Sentry from '@sentry/cloudflare'; +import { WorkflowEntrypoint } from 'cloudflare:workers'; +import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_WORKFLOW: Workflow; +} + +class MyWorkflowBase extends WorkflowEntrypoint { + async run(_event: WorkflowEvent, step: WorkflowStep): Promise { + await step.do('step-one', async () => { + return 'Step one completed'; + }); + + await step.do('step-two', async () => { + return 'Step two completed'; + }); + } +} + +export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyWorkflowBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === '/workflow/trigger') { + const instance = await env.MY_WORKFLOW.create(); + for (let i = 0; i < 15; i++) { + try { + const s = await instance.status(); + if (s.status === 'complete' || s.status === 'errored') { + return new Response(JSON.stringify({ id: instance.id, ...s }), { + headers: { 'content-type': 'application/json' }, + }); + } + } catch { + // status() may not be available in local dev + } + await new Promise(r => setTimeout(r, 500)); + } + return new Response(JSON.stringify({ id: instance.id, status: 'timeout' }), { + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('OK'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts new file mode 100644 index 000000000000..568b744f9555 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts @@ -0,0 +1,69 @@ +import { expect, it } from 'vitest'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, +} from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('Workflow steps create transactions with correct attributes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'step-one', + transaction_info: { source: 'task' }, + spans: [], + contexts: expect.objectContaining({ + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'function.step.do', + origin: 'auto.faas.cloudflare.workflow', + status: 'ok', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.step.do', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + }, + }), + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'step-two', + transaction_info: { source: 'task' }, + spans: [], + contexts: expect.objectContaining({ + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'function.step.do', + origin: 'auto.faas.cloudflare.workflow', + status: 'ok', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.step.do', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + }, + }), + }), + ); + }) + .unordered() + .start(signal); + + await runner.makeRequest('get', '/workflow/trigger'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/wrangler.jsonc new file mode 100644 index 000000000000..b8d729d16591 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "workflow-worker", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "workflows": [ + { + "name": "my-workflow", + "binding": "MY_WORKFLOW", + "class_name": "MyWorkflow", + }, + ], +} From cea58ff686fa28ec193d1f1c9ed4526b8d69d77b Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 5 May 2026 15:12:19 +0200 Subject: [PATCH 61/84] test(browser): Make browser profiling test less flaky (#20664) Fixes https://github.com/getsentry/sentry-javascript/issues/20638 Hopefully this reduces flakiness, by increasing timeouts etc. and making sure things run longer. --- .../subject.js | 14 +++++++------- .../traceLifecycleMode_overlapping-spans/test.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js index 071afe1ed059..6b24c64541c3 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js @@ -3,7 +3,7 @@ import { browserProfilingIntegration } from '@sentry/browser'; window.Sentry = Sentry; -Sentry.init({ +const client = Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [browserProfilingIntegration()], tracesSampleRate: 1, @@ -11,7 +11,7 @@ Sentry.init({ profileLifecycle: 'trace', }); -function largeSum(amount = 1000000) { +function largeSum(amount) { let sum = 0; for (let i = 0; i < amount; i++) { sum += Math.sqrt(i) * Math.sin(i); @@ -28,7 +28,8 @@ function fibonacci(n) { let firstSpan; Sentry.startSpanManual({ name: 'root-largeSum-1', parentSpan: null, forceTransaction: true }, span => { - largeSum(); + // Enough iterations that largeSum stays on-stack across several profiler ticks (10ms interval); otherwise sampling can miss it entirely. + largeSum(2_500_000); firstSpan = span; }); @@ -39,14 +40,13 @@ await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, force console.log('child span'); }); - // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled - await new Promise(resolve => setTimeout(resolve, 21)); + // Profiler uses a 10ms sample interval — wait long enough for multiple ticks + await new Promise(resolve => setTimeout(resolve, 40)); span.end(); }); -await new Promise(r => setTimeout(r, 21)); +await new Promise(r => setTimeout(r, 40)); firstSpan.end(); -const client = Sentry.getClient(); await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts index de4bddd69f57..076c31cc7c39 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -43,7 +43,7 @@ sentryTest( const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( page, 1, - { url, envelopeType: 'profile_chunk', timeout: 5000 }, + { url, envelopeType: 'profile_chunk', timeout: 15_000 }, properFullEnvelopeRequestParser, ); From ad5fd21bc262fe8009fe15295a504091d916f61d Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 5 May 2026 15:48:50 +0200 Subject: [PATCH 62/84] chore: Remove `bundle-analyzer-scenarios` dev packages (#20680) This was unused for 2 years, so we may as well remove it. I also tried to run it locally and it did not really work at all. --- .oxlintrc.base.json | 8 +- .../bundle-analyzer-scenarios/README.md | 15 --- .../browser-basic/index.js | 5 - .../browser-basic/package.json | 4 - .../bundle-analyzer-scenarios/package.json | 25 ---- .../bundle-analyzer-scenarios/webpack.cjs | 88 ------------ package.json | 1 - yarn.lock | 125 +++--------------- 8 files changed, 16 insertions(+), 255 deletions(-) delete mode 100644 dev-packages/bundle-analyzer-scenarios/README.md delete mode 100644 dev-packages/bundle-analyzer-scenarios/browser-basic/index.js delete mode 100644 dev-packages/bundle-analyzer-scenarios/browser-basic/package.json delete mode 100644 dev-packages/bundle-analyzer-scenarios/package.json delete mode 100644 dev-packages/bundle-analyzer-scenarios/webpack.cjs diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index 87021fa59c58..da50021f4431 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -141,13 +141,7 @@ } }, { - "files": [ - "**/scenarios/**", - "**/rollup-utils/**", - "**/bundle-analyzer-scenarios/**", - "**/bundle-analyzer-scenarios/*.cjs", - "**/bundle-analyzer-scenarios/*.js" - ], + "files": ["**/scenarios/**", "**/rollup-utils/**"], "rules": { "no-console": "off" } diff --git a/dev-packages/bundle-analyzer-scenarios/README.md b/dev-packages/bundle-analyzer-scenarios/README.md deleted file mode 100644 index 97bd3033d1bb..000000000000 --- a/dev-packages/bundle-analyzer-scenarios/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Bundle Analyzer Scenarios - -This repository contains a set of scenarios to check the SDK against webpack bundle analyzer. - -You can run the scenarios by running `yarn analyze` and selecting the scenario you want to run. - -If you want to have more granular analysis of modules, you can build the SDK packages with with `preserveModules` set to -`true`. You can do this via the `SENTRY_BUILD_PRESERVE_MODULES`. - -```bash -SENTRY_BUILD_PRESERVE_MODULES=true yarn build -``` - -Please note that `preserveModules` has different behaviour with regards to tree-shaking, so you will get different total -bundle size results. diff --git a/dev-packages/bundle-analyzer-scenarios/browser-basic/index.js b/dev-packages/bundle-analyzer-scenarios/browser-basic/index.js deleted file mode 100644 index f3d47c97f7a2..000000000000 --- a/dev-packages/bundle-analyzer-scenarios/browser-basic/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import { init } from '@sentry/browser'; - -init({ - dsn: 'https://00000000000000000000000000000000@o000000.ingest.sentry.io/0000000', -}); diff --git a/dev-packages/bundle-analyzer-scenarios/browser-basic/package.json b/dev-packages/bundle-analyzer-scenarios/browser-basic/package.json deleted file mode 100644 index 07aec65d5a4f..000000000000 --- a/dev-packages/bundle-analyzer-scenarios/browser-basic/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "module", - "main": "index.js" -} diff --git a/dev-packages/bundle-analyzer-scenarios/package.json b/dev-packages/bundle-analyzer-scenarios/package.json deleted file mode 100644 index 492b9070fcab..000000000000 --- a/dev-packages/bundle-analyzer-scenarios/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@sentry-internal/bundle-analyzer-scenarios", - "version": "10.51.0", - "description": "Scenarios to test bundle analysis with", - "repository": "git://github.com/getsentry/sentry-javascript.git", - "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/dev-packages/bundle-analyzer-scenarios", - "author": "Sentry", - "license": "MIT", - "private": true, - "dependencies": { - "html-webpack-plugin": "^5.6.0", - "webpack": "^5.95.0", - "webpack-bundle-analyzer": "^4.10.2" - }, - "devDependencies": { - "eslint-plugin-regexp": "^1.15.0" - }, - "scripts": { - "analyze": "node webpack.cjs" - }, - "volta": { - "extends": "../../package.json" - }, - "type": "module" -} diff --git a/dev-packages/bundle-analyzer-scenarios/webpack.cjs b/dev-packages/bundle-analyzer-scenarios/webpack.cjs deleted file mode 100644 index f5874a607473..000000000000 --- a/dev-packages/bundle-analyzer-scenarios/webpack.cjs +++ /dev/null @@ -1,88 +0,0 @@ -const path = require('node:path'); -const { promises } = require('node:fs'); -const { parseArgs } = require('node:util'); - -const webpack = require('webpack'); -const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; -const HtmlWebpackPlugin = require('html-webpack-plugin'); - -async function init() { - const scenarios = await getScenariosFromDirectories(); - - const { values } = parseArgs({ - args: process.argv.slice(2), - options: { scenario: { type: 'string', short: 's' }, list: { type: 'boolean', short: 'l' } }, - }); - - if (values.list) { - console.log('Available scenarios:', scenarios); - process.exit(0); - } - - if (!scenarios.some(scenario => scenario === values.scenario)) { - console.error('Invalid scenario:', values.scenario); - console.error('Available scenarios:', scenarios); - process.exit(1); - } - - console.log(`Bundling scenario: ${values.scenario}`); - - await runWebpack(values.scenario); -} - -async function runWebpack(scenario) { - const alias = await generateAlias(); - - webpack( - { - mode: 'production', - entry: path.resolve(__dirname, scenario), - output: { - filename: 'main.js', - path: path.resolve(__dirname, 'dist', scenario), - }, - plugins: [new BundleAnalyzerPlugin({ analyzerMode: 'static' }), new HtmlWebpackPlugin()], - resolve: { - alias, - }, - }, - (err, stats) => { - if (err || stats.hasErrors()) { - console.log(err); - } - - // console.log('DONE', stats); - }, - ); -} - -const PACKAGE_PATH = '../../packages'; - -/** - * Generate webpack aliases based on packages in monorepo - * Example of an alias: '@sentry/serverless': 'path/to/sentry-javascript/packages/serverless', - */ -async function generateAlias() { - const dirents = await promises.readdir(PACKAGE_PATH); - - return Object.fromEntries( - await Promise.all( - dirents.map(async d => { - const packageJSON = JSON.parse(await promises.readFile(path.resolve(PACKAGE_PATH, d, 'package.json'))); - return [packageJSON['name'], path.resolve(PACKAGE_PATH, d)]; - }), - ), - ); -} - -/** - * Generates an array of available scenarios - */ -async function getScenariosFromDirectories() { - const exclude = ['node_modules', 'dist', '~', 'package.json', 'yarn.lock', 'README.md', '.DS_Store', 'webpack.cjs']; - - const dirents = await promises.readdir(path.join(__dirname), { withFileTypes: true }); - return dirents.map(dirent => dirent.name).filter(mape => !exclude.includes(mape)); -} - -init(); diff --git a/package.json b/package.json index 8abe6b6598ad..e71f94772bd0 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,6 @@ "packages/vue", "packages/wasm", "dev-packages/browser-integration-tests", - "dev-packages/bundle-analyzer-scenarios", "dev-packages/e2e-tests", "dev-packages/node-integration-tests", "dev-packages/bun-integration-tests", diff --git a/yarn.lock b/yarn.lock index 4be50683a09f..02db431ae28b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5436,17 +5436,6 @@ dependencies: "@tybys/wasm-util" "^0.10.1" -"@nestjs/common@11.1.19", "@nestjs/common@^11": - version "11.1.19" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.19.tgz#50ba93ae45ebaeda6163554b8e2ecec545a25c92" - integrity sha512-qeiTt2tv+e5QyDKqG8HlVZb2wx64FEaSGFJouqTSRs+kG44iTfl3xlz1XqVped+rihx4hmjWgL5gkhtdK3E6+Q== - dependencies: - uid "2.0.2" - file-type "21.3.4" - iterare "1.2.1" - load-esm "1.0.3" - tslib "2.8.1" - "@nestjs/common@^10.0.0": version "10.4.15" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70" @@ -5456,16 +5445,15 @@ iterare "1.2.1" tslib "2.8.1" -"@nestjs/core@11.1.19": +"@nestjs/common@^11": version "11.1.19" - resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.19.tgz#d724f1afc0caac29e005464f0f659425fc80235b" - integrity sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw== + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.19.tgz#50ba93ae45ebaeda6163554b8e2ecec545a25c92" + integrity sha512-qeiTt2tv+e5QyDKqG8HlVZb2wx64FEaSGFJouqTSRs+kG44iTfl3xlz1XqVped+rihx4hmjWgL5gkhtdK3E6+Q== dependencies: uid "2.0.2" - "@nuxt/opencollective" "0.4.1" - fast-safe-stringify "2.1.1" + file-type "21.3.4" iterare "1.2.1" - path-to-regexp "8.4.2" + load-esm "1.0.3" tslib "2.8.1" "@nestjs/core@^10.0.0": @@ -5492,17 +5480,6 @@ path-to-regexp "8.4.2" tslib "2.8.1" -"@nestjs/platform-express@11.1.19": - version "11.1.19" - resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.19.tgz#e55f5078396b2285344f95f2b530b648e844cd4c" - integrity sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg== - dependencies: - cors "2.8.6" - express "5.2.1" - multer "2.1.1" - path-to-regexp "8.4.2" - tslib "2.8.1" - "@nestjs/platform-express@^11": version "11.1.19" resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.19.tgz#e55f5078396b2285344f95f2b530b648e844cd4c" @@ -10001,7 +9978,7 @@ "@types/node" "*" "@types/webidl-conversions" "*" -"@types/ws@*", "@types/ws@^8.5.1", "@types/ws@^8.5.10": +"@types/ws@*", "@types/ws@^8.18.1", "@types/ws@^8.5.1", "@types/ws@^8.5.10": version "8.18.1" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== @@ -11009,7 +10986,7 @@ acorn-typescript@^1.4.3: resolved "https://registry.yarnpkg.com/acorn-typescript/-/acorn-typescript-1.4.13.tgz#5f851c8bdda0aa716ffdd5f6ac084df8acc6f5ea" integrity sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q== -acorn-walk@^8.0.0, acorn-walk@^8.0.2, acorn-walk@^8.1.1: +acorn-walk@^8.0.2, acorn-walk@^8.1.1: version "8.3.3" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.3.tgz#9caeac29eefaa0c41e3d4c65137de4d6f34df43e" integrity sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw== @@ -11021,7 +10998,7 @@ acorn@8.11.3: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== -acorn@^8.0.4, acorn@^8.1.0, acorn@^8.10.0, acorn@^8.11.0, acorn@^8.12.1, acorn@^8.14.0, acorn@^8.14.1, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.6.0, acorn@^8.7.0, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.1.0, acorn@^8.10.0, acorn@^8.11.0, acorn@^8.12.1, acorn@^8.14.0, acorn@^8.14.1, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.6.0, acorn@^8.7.0, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: version "8.16.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== @@ -14378,11 +14355,6 @@ db0@^0.3.4: resolved "https://registry.yarnpkg.com/db0/-/db0-0.3.4.tgz#fb109b0d9823ba1f787a4a3209fa1f3cf9ae9cf9" integrity sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw== -debounce@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" - integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== - debug@2, debug@2.6.9, debug@^2.1.0, debug@^2.1.1, debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -17418,16 +17390,6 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-type@21.3.2: - version "21.3.2" - resolved "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz" - integrity sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w== - dependencies: - "@tokenizer/inflate" "^0.4.1" - strtok3 "^10.3.4" - token-types "^6.1.1" - uint8array-extras "^1.4.0" - file-type@21.3.4: version "21.3.4" resolved "https://registry.yarnpkg.com/file-type/-/file-type-21.3.4.tgz#e3f902faee8ec4aa152909fc902a7a77f9c06725" @@ -18496,13 +18458,6 @@ gtoken@^7.0.0: gaxios "^6.0.0" jws "^4.0.0" -gzip-size@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" - integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q== - dependencies: - duplexer "^0.1.2" - gzip-size@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-7.0.0.tgz#9f9644251f15bc78460fccef4055ae5a5562ac60" @@ -18951,7 +18906,7 @@ html-entities@^2.3.2: resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.5.2.tgz#201a3cf95d3a15be7099521620d19dfb4f65359f" integrity sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA== -html-escaper@^2.0.0, html-escaper@^2.0.2: +html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== @@ -18989,7 +18944,7 @@ html-void-elements@^3.0.0: resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== -html-webpack-plugin@^5.5.0, html-webpack-plugin@^5.6.0: +html-webpack-plugin@^5.5.0: version "5.6.0" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz#50a8fa6709245608cb00e811eacecb8e0d7b7ea0" integrity sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw== @@ -22540,19 +22495,6 @@ msgpackr@^1.11.9: optionalDependencies: msgpackr-extract "^3.0.2" -multer@2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/multer/-/multer-2.0.2.tgz#08a8aa8255865388c387aaf041426b0c87bf58dd" - integrity sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw== - dependencies: - append-field "^1.0.0" - busboy "^1.6.0" - concat-stream "^2.0.0" - mkdirp "^0.5.6" - object-assign "^4.1.1" - type-is "^1.6.18" - xtend "^4.0.2" - multer@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/multer/-/multer-2.1.1.tgz#122d819244fbdfee1efddd9147426691014385b7" @@ -23735,11 +23677,6 @@ openai@^5.3.0: resolved "https://registry.yarnpkg.com/openai/-/openai-5.23.2.tgz#f13e2dc2ef6b88aab6a9b97cdc68d41a1d083c68" integrity sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg== -opener@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" - integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== - optional-require@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/optional-require/-/optional-require-1.1.8.tgz#16364d76261b75d964c482b2406cb824d8ec44b7" @@ -27604,15 +27541,6 @@ sinon@21.0.1: diff "^8.0.2" supports-color "^7.2.0" -sirv@^2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/sirv/-/sirv-2.0.4.tgz#5dd9a725c578e34e449f332703eb2a74e46a29b0" - integrity sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ== - dependencies: - "@polka/url" "^1.0.0-next.24" - mrmime "^2.0.0" - totalist "^3.0.0" - sirv@^3.0.0, sirv@^3.0.1, sirv@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.2.tgz#f775fccf10e22a40832684848d636346f41cd970" @@ -30685,24 +30613,6 @@ webidl-conversions@^7.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== -webpack-bundle-analyzer@^4.10.2: - version "4.10.2" - resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz#633af2862c213730be3dbdf40456db171b60d5bd" - integrity sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw== - dependencies: - "@discoveryjs/json-ext" "0.5.7" - acorn "^8.0.4" - acorn-walk "^8.0.0" - commander "^7.2.0" - debounce "^1.2.1" - escape-string-regexp "^4.0.0" - gzip-size "^6.0.0" - html-escaper "^2.0.2" - opener "^1.5.2" - picocolors "^1.0.0" - sirv "^2.0.3" - ws "^7.3.1" - webpack-dev-middleware@5.3.3: version "5.3.3" resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz#efae67c2793908e7311f1d9b06f2a08dcc97e51f" @@ -31266,15 +31176,10 @@ ws@8.18.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== -ws@^7.3.1: - version "7.5.10" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" - integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== - -ws@^8.13.0, ws@^8.18.0, ws@^8.18.3, ws@^8.4.2: - version "8.19.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.19.0.tgz#ddc2bdfa5b9ad860204f5a72a4863a8895fd8c8b" - integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== +ws@^8.13.0, ws@^8.18.0, ws@^8.18.3, ws@^8.20.0, ws@^8.4.2: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.0.tgz#4cd9532358eba60bc863aad1623dfb045a4d4af8" + integrity sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA== ws@~8.17.1: version "8.17.1" @@ -31303,7 +31208,7 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xtend@^4.0.0, xtend@^4.0.2: +xtend@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== From ab0356976dae7924c73b273620c3a60beb6851db Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 24 Mar 2026 15:54:08 -0700 Subject: [PATCH 63/84] core: add getDefaultExport method This was implemented for the portable Express integration, but others will need the same functionality, so make it a reusable util. --- .../core/src/integrations/express/index.ts | 8 ++---- .../core/src/integrations/express/utils.ts | 9 ------- packages/core/src/utils/get-default-export.ts | 27 +++++++++++++++++++ .../lib/integrations/express/utils.test.ts | 9 ------- .../test/lib/utils/get-default-export.test.ts | 27 +++++++++++++++++++ 5 files changed, 56 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/utils/get-default-export.ts create mode 100644 packages/core/test/lib/utils/get-default-export.test.ts diff --git a/packages/core/src/integrations/express/index.ts b/packages/core/src/integrations/express/index.ts index bbb1f8fe8a28..df616e7b7f32 100644 --- a/packages/core/src/integrations/express/index.ts +++ b/packages/core/src/integrations/express/index.ts @@ -33,7 +33,6 @@ import { DEBUG_BUILD } from '../../debug-build'; import type { ExpressApplication, ExpressErrorMiddleware, - ExpressExport, ExpressHandlerOptions, ExpressIntegrationOptions, ExpressLayer, @@ -49,16 +48,13 @@ import type { import { defaultShouldHandleError, getLayerPath, - hasDefaultProp, isExpressWithoutRouterPrototype, isExpressWithRouterPrototype, } from './utils'; import { wrapMethod } from '../../utils/object'; import { patchLayer } from './patch-layer'; import { setSDKProcessingMetadata } from './set-sdk-processing-metadata'; - -const getExpressExport = (express: ExpressModuleExport): ExpressExport => - hasDefaultProp(express) ? express.default : (express as ExpressExport); +import { getDefaultExport } from '../../utils/get-default-export'; function isLegacyOptions( options: ExpressModuleExport | (ExpressIntegrationOptions & { express: ExpressModuleExport }), @@ -119,7 +115,7 @@ export function patchExpressModule( } // pass in the require() or import() result of express - const express = getExpressExport(moduleExports); + const express = getDefaultExport(moduleExports); const routerProto: ExpressRouterv4 | ExpressRouterv5 | undefined = isExpressWithRouterPrototype(express) ? express.Router.prototype // Express v5 : isExpressWithoutRouterPrototype(express) diff --git a/packages/core/src/integrations/express/utils.ts b/packages/core/src/integrations/express/utils.ts index c3473bbab18a..af22a6ea1d97 100644 --- a/packages/core/src/integrations/express/utils.ts +++ b/packages/core/src/integrations/express/utils.ts @@ -30,7 +30,6 @@ import type { SpanAttributes } from '../../types-hoist/span'; import { getStoredLayers } from './request-layer-store'; import type { - ExpressExport, ExpressIntegrationOptions, ExpressLayer, ExpressLayerType, @@ -254,14 +253,6 @@ const isExpressRouterPrototype = (routerProto?: unknown): routerProto is Express export const isExpressWithoutRouterPrototype = (express: unknown): express is ExpressExportv4 => isExpressRouterPrototype((express as ExpressExportv4).Router) && !isExpressWithRouterPrototype(express); -// dynamic puts the default on .default, require or normal import are fine -export const hasDefaultProp = ( - express: unknown, -): express is { - [k: string]: unknown; - default: ExpressExport; -} => !!express && typeof express === 'object' && 'default' in express && typeof express.default === 'function'; - function getStatusCodeFromResponse(error: MiddlewareError): number { const statusCode = error.status || error.statusCode || error.status_code || error.output?.statusCode; return statusCode ? parseInt(statusCode as string, 10) : 500; diff --git a/packages/core/src/utils/get-default-export.ts b/packages/core/src/utils/get-default-export.ts new file mode 100644 index 000000000000..4e406219562f --- /dev/null +++ b/packages/core/src/utils/get-default-export.ts @@ -0,0 +1,27 @@ +/** + * Often we patch a module's default export, but we want to be able to do + * something like this: + * + * ```ts + * patchTheThing(await import('the-thing')); + * ``` + * + * Or like this: + * + * ```ts + * import theThing from 'the-thing'; + * patchTheThing(theThing); + * ``` + * + * Note: this does not support modules with a falsey default export. However, + * presumably in those cases, there's no default export to patch anyway. + */ +export function getDefaultExport(moduleExport: T | { default: T }): T { + return ( + (!!moduleExport && + typeof moduleExport === 'object' && + 'default' in moduleExport && + (moduleExport as { default: T }).default) || + (moduleExport as T) + ); +} diff --git a/packages/core/test/lib/integrations/express/utils.test.ts b/packages/core/test/lib/integrations/express/utils.test.ts index b41d89076900..a7ec32d96e8d 100644 --- a/packages/core/test/lib/integrations/express/utils.test.ts +++ b/packages/core/test/lib/integrations/express/utils.test.ts @@ -15,7 +15,6 @@ import { getLayerMetadata, getLayerPath, getRouterPath, - hasDefaultProp, isExpressWithoutRouterPrototype, isExpressWithRouterPrototype, isLayerIgnored, @@ -368,14 +367,6 @@ describe('getConstructedRoute', () => { }); }); -describe('hasDefaultProp', () => { - it('returns detects the presence of a default function prop', () => { - expect(hasDefaultProp({ default: function express() {} })).toBe(true); - expect(hasDefaultProp({ default: 'other thing' })).toBe(false); - expect(hasDefaultProp({})).toBe(false); - }); -}); - describe('isExpressWith(out)RouterPrototype', () => { it('detects what kind of express this is', () => { expect(isExpressWithoutRouterPrototype({})).toBe(false); diff --git a/packages/core/test/lib/utils/get-default-export.test.ts b/packages/core/test/lib/utils/get-default-export.test.ts new file mode 100644 index 000000000000..2a12e8d1d1a9 --- /dev/null +++ b/packages/core/test/lib/utils/get-default-export.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { getDefaultExport } from '../../../src/utils/get-default-export'; + +describe('getDefaultExport', () => { + it('returns the default export if there is one', () => { + const mod = { + default: () => {}, + }; + expect(getDefaultExport(mod)).toBe(mod.default); + }); + it('returns the module export if no default', () => { + const mod = {}; + expect(getDefaultExport(mod)).toBe(mod); + }); + it('returns the module if a function and not plain object', () => { + const mod = Object.assign(function () {}, { + default: () => {}, + }); + expect(getDefaultExport(mod)).toBe(mod); + }); + it('returns the module if a default is falsey', () => { + const mod = Object.assign(function () {}, { + default: false, + }); + expect(getDefaultExport(mod)).toBe(mod); + }); +}); From 83e6da124e556e960929a2fc0f07a640ea15b5b3 Mon Sep 17 00:00:00 2001 From: isaacs Date: Sun, 19 Apr 2026 18:05:29 -0700 Subject: [PATCH 64/84] feat(http): portable node:http client instrumentation (#20393) Refactor the `node:http` outgoing request instrumentation so that it can be applied to non-Node.js environments by patching the http module. Also, refactor so that the diagnostics_channel and monkeypatching paths can share code, and so that light and normal node-core instrumentations can share more of the functionality as well. To facilitate this, some portable minimal types are vendored in from the `node:http` module. --- .../aws-serverless-layer/tests/layer.test.ts | 4 +- .../aws-serverless/tests/npm.test.ts | 4 +- .../tests/request-instrumentation.test.ts | 2 +- .../tests/request-instrumentation.test.ts | 2 +- .../tests/request-instrumentation.test.ts | 2 +- .../tests/server.test.ts | 6 +- .../requests/http-no-tracing-no-spans/test.ts | 261 +++------- .../http-client-spans/http-basic/test.ts | 4 +- .../http-strip-query/test.ts | 4 +- .../requests/http-no-tracing-no-spans/test.ts | 261 +++------- packages/core/src/index.ts | 23 +- .../http/add-outgoing-request-breadcrumb.ts | 39 ++ .../src/integrations/http/client-patch.ts | 114 +++++ .../integrations/http/client-subscriptions.ts | 178 +++++++ .../core/src/integrations/http/constants.ts | 5 + .../http/get-outgoing-span-data.ts | 85 ++++ .../src/integrations/http/get-request-url.ts | 48 ++ packages/core/src/integrations/http/index.ts | 3 + .../http/inject-trace-propagation-headers.ts | 78 +++ packages/core/src/integrations/http/types.ts | 260 ++++++++++ packages/core/src/tracing/index.ts | 1 + packages/core/src/tracing/trace.ts | 2 +- packages/core/src/utils/baggage.ts | 72 +++ .../add-outgoing-request-breadcrumb.test.ts | 167 +++++++ .../integrations/http/client-patch.test.ts | 137 ++++++ .../http/client-subscriptions.test.ts | 112 +++++ .../lib/integrations/http/constants.test.ts | 20 + .../http/get-outgoing-span-data.test.ts | 177 +++++++ .../integrations/http/get-request-url.test.ts | 58 +++ .../inject-trace-propagation-headers.test.ts | 193 ++++++++ packages/core/test/lib/utils/baggage.test.ts | 196 +++++++- packages/node-core/src/common-exports.ts | 2 +- packages/node-core/src/index.ts | 3 + .../http/SentryHttpInstrumentation.ts | 464 ++++-------------- .../http/httpServerIntegration.ts | 4 +- .../http/httpServerSpansIntegration.ts | 45 +- .../node-core/src/integrations/http/index.ts | 5 +- .../integrations/http/outgoing-requests.ts | 6 - .../src/light/asyncLocalStorageStrategy.ts | 9 +- .../src/light/integrations/httpIntegration.ts | 173 +++---- packages/node-core/src/utils/baggage.ts | 62 --- packages/node-core/src/utils/getRequestUrl.ts | 19 - .../src/utils/outgoingFetchRequest.ts | 2 +- .../src/utils/outgoingHttpRequest.ts | 165 ------- .../SentryHttpInstrumentation.test.ts | 48 -- packages/node-core/test/utils/baggage.test.ts | 167 ------- .../test/utils/getRequestUrl.test.ts | 20 - packages/node/src/integrations/http.ts | 200 ++------ .../node/src/integrations/tracing/index.ts | 3 +- packages/node/test/integrations/http.test.ts | 30 -- 50 files changed, 2393 insertions(+), 1552 deletions(-) create mode 100644 packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts create mode 100644 packages/core/src/integrations/http/client-patch.ts create mode 100644 packages/core/src/integrations/http/client-subscriptions.ts create mode 100644 packages/core/src/integrations/http/constants.ts create mode 100644 packages/core/src/integrations/http/get-outgoing-span-data.ts create mode 100644 packages/core/src/integrations/http/get-request-url.ts create mode 100644 packages/core/src/integrations/http/index.ts create mode 100644 packages/core/src/integrations/http/inject-trace-propagation-headers.ts create mode 100644 packages/core/src/integrations/http/types.ts create mode 100644 packages/core/test/lib/integrations/http/add-outgoing-request-breadcrumb.test.ts create mode 100644 packages/core/test/lib/integrations/http/client-patch.test.ts create mode 100644 packages/core/test/lib/integrations/http/client-subscriptions.test.ts create mode 100644 packages/core/test/lib/integrations/http/constants.test.ts create mode 100644 packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts create mode 100644 packages/core/test/lib/integrations/http/get-request-url.test.ts create mode 100644 packages/core/test/lib/integrations/http/inject-trace-propagation-headers.test.ts delete mode 100644 packages/node-core/src/integrations/http/outgoing-requests.ts delete mode 100644 packages/node-core/src/utils/baggage.ts delete mode 100644 packages/node-core/src/utils/getRequestUrl.ts delete mode 100644 packages/node-core/src/utils/outgoingHttpRequest.ts delete mode 100644 packages/node-core/test/integrations/SentryHttpInstrumentation.test.ts delete mode 100644 packages/node-core/test/utils/baggage.test.ts delete mode 100644 packages/node-core/test/utils/getRequestUrl.test.ts delete mode 100644 packages/node/test/integrations/http.test.ts 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 560f676cfd07..35ed4b64428c 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 @@ -59,7 +59,7 @@ test.describe('Lambda layer', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', @@ -127,7 +127,7 @@ test.describe('Lambda layer', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', 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 943d5a2ab0f3..3f07fdd9b696 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 @@ -45,7 +45,7 @@ test.describe('NPM package', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', @@ -113,7 +113,7 @@ test.describe('NPM package', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts index 2446ffa68659..66752e7c2e41 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts @@ -28,7 +28,7 @@ test('Should send a transaction with a fetch span', async ({ page }) => { data: expect.objectContaining({ 'http.method': 'GET', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }), description: 'GET https://github.com/', }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts index f392a63d4086..939347da2a09 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts @@ -16,7 +16,7 @@ test.skip('Should send a transaction with a http span', async ({ request }) => { data: expect.objectContaining({ 'http.method': 'GET', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }), description: 'GET https://example.com/', }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts index c65ba88c39c3..65a6820a83da 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts @@ -16,7 +16,7 @@ test.skip('Should send a transaction with a http span', async ({ request }) => { data: expect.objectContaining({ 'http.method': 'GET', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }), description: 'GET https://example.com/', }), diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts index 937f2b7acc27..7e1b95e9e53f 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts @@ -133,7 +133,7 @@ test('Should record spans from http instrumentation', async ({ request }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), data: expect.objectContaining({ 'http.flavor': '1.1', - 'http.host': 'example.com:80', + 'http.host': 'example.com', 'http.method': 'GET', 'http.response.status_code': 200, 'http.status_code': 200, @@ -146,7 +146,7 @@ test('Should record spans from http instrumentation', async ({ request }) => { 'net.transport': 'ip_tcp', 'otel.kind': 'CLIENT', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', @@ -155,6 +155,6 @@ test('Should record spans from http instrumentation', async ({ request }) => { timestamp: expect.any(Number), status: 'ok', op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', }); }); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts index 17393f21a8a4..a1a9ce5d51dc 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -1,202 +1,101 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; -import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; describe('outgoing http requests with tracing & spans disabled', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - conditionalTest({ min: 22 })('node >=22', () => { - test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { - expect.assertions(11); + test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v1', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], - }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', - }, + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + type: 'Error', + value: 'foo', }, ], }, - }) - .start() - .completed(); - - closeTestServer(); - }); - }); - - // On older node versions, outgoing requests do not get trace-headers injected, sadly - // This is because the necessary diagnostics channel hook is not available yet - conditionalTest({ max: 21 })('node <22', () => { - test('outgoing http requests generate breadcrumbs correctly with tracing & spans disabled', async () => { - expect.assertions(9); - - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v1', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); - - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', }, - ], - }, - }) - .start() - .completed(); + timestamp: expect.any(Number), + type: 'http', + }, + ], + }, + }) + .start() + .completed(); - closeTestServer(); - }); + closeTestServer(); }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts index 0549d7e914c0..1fea661d33e5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts @@ -29,13 +29,13 @@ test('captures spans for outgoing http requests', async () => { expect.objectContaining({ description: expect.stringMatching(/GET .*\/api\/v0/), op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', status: 'ok', }), expect.objectContaining({ description: expect.stringMatching(/GET .*\/api\/v1/), op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', status: 'not_found', data: expect.objectContaining({ 'http.response.status_code': 404, diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts index 94ccd6c9702a..60add149deab 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts @@ -37,11 +37,11 @@ test('strips and handles query params in spans of outgoing http requests', async 'net.transport': 'ip_tcp', 'otel.kind': 'CLIENT', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }, description: `GET ${SERVER_URL}/api/v0/users`, op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', status: 'ok', parent_span_id: txn.contexts?.trace?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts index 17393f21a8a4..a1a9ce5d51dc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -1,202 +1,101 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; -import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; describe('outgoing http requests with tracing & spans disabled', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - conditionalTest({ min: 22 })('node >=22', () => { - test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { - expect.assertions(11); + test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v1', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], - }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', - }, + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + type: 'Error', + value: 'foo', }, ], }, - }) - .start() - .completed(); - - closeTestServer(); - }); - }); - - // On older node versions, outgoing requests do not get trace-headers injected, sadly - // This is because the necessary diagnostics channel hook is not available yet - conditionalTest({ max: 21 })('node <22', () => { - test('outgoing http requests generate breadcrumbs correctly with tracing & spans disabled', async () => { - expect.assertions(9); - - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v1', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); - - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', }, - ], - }, - }) - .start() - .completed(); + timestamp: expect.any(Number), + type: 'http', + }, + ], + }, + }) + .start() + .completed(); - closeTestServer(); - }); + closeTestServer(); }); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 19fc53365bb5..e20b2118adfc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -146,9 +146,27 @@ export { instrumentPostgresJsSql } from './integrations/postgresjs'; export { zodErrorsIntegration } from './integrations/zoderrors'; export { thirdPartyErrorFilterIntegration } from './integrations/third-party-errors-filter'; export { consoleIntegration } from './integrations/console'; -export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integrations/featureFlags'; +export type { FeatureFlagsIntegration } from './integrations/featureFlags'; +export { featureFlagsIntegration } from './integrations/featureFlags'; export { growthbookIntegration } from './integrations/featureFlags'; export { conversationIdIntegration } from './integrations/conversationId'; +export { patchHttpModuleClient, patchHttpsModuleClient } from './integrations/http/client-patch'; +export { getHttpClientSubscriptions } from './integrations/http/client-subscriptions'; +export { addOutgoingRequestBreadcrumb } from './integrations/http/add-outgoing-request-breadcrumb'; +export { + getRequestUrl, + getRequestUrlObject, + getRequestUrlFromClientRequest, + getRequestOptions, +} from './integrations/http/get-request-url'; +export { HTTP_ON_CLIENT_REQUEST, HTTP_ON_SERVER_REQUEST } from './integrations/http/constants'; +export type { + HttpInstrumentationOptions, + HttpClientRequest, + HttpIncomingMessage, + HttpServerResponse, + HttpModuleExport, +} from './integrations/http/types'; export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) @@ -343,6 +361,7 @@ export { dynamicSamplingContextToSentryBaggageHeader, parseBaggageHeader, objectToBaggageHeader, + mergeBaggageHeaders, } from './utils/baggage'; export { getSanitizedUrlString, @@ -575,9 +594,9 @@ export type { UnstableRollupPluginOptions, UnstableWebpackPluginOptions, } from './build-time-plugins/buildTimeOptionsBase'; +export type { RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner } from './utils/randomSafeContext'; export { withRandomSafeContext as _INTERNAL_withRandomSafeContext, - type RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, safeMathRandom as _INTERNAL_safeMathRandom, safeDateNow as _INTERNAL_safeDateNow, } from './utils/randomSafeContext'; diff --git a/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts b/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts new file mode 100644 index 000000000000..251dbfc540a6 --- /dev/null +++ b/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts @@ -0,0 +1,39 @@ +import { addBreadcrumb } from '../../breadcrumbs'; +import { getBreadcrumbLogLevelFromHttpStatusCode } from '../../utils/breadcrumb-log-level'; +import { getSanitizedUrlString, parseUrl } from '../../utils/url'; +import { getRequestUrlFromClientRequest } from './get-request-url'; +import type { HttpClientRequest, HttpIncomingMessage } from './types'; + +/** + * Create a breadcrumb for a finished outgoing HTTP request. + */ +export function addOutgoingRequestBreadcrumb( + request: HttpClientRequest, + response: HttpIncomingMessage | undefined, +): void { + const url = getRequestUrlFromClientRequest(request); + const parsedUrl = parseUrl(url); + + const statusCode = response?.statusCode; + const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); + + addBreadcrumb( + { + category: 'http', + data: { + status_code: statusCode, + url: getSanitizedUrlString(parsedUrl), + 'http.method': request.method || 'GET', + ...(parsedUrl.search ? { 'http.query': parsedUrl.search } : {}), + ...(parsedUrl.hash ? { 'http.fragment': parsedUrl.hash } : {}), + }, + type: 'http', + level, + }, + { + event: 'response', + request, + response, + }, + ); +} diff --git a/packages/core/src/integrations/http/client-patch.ts b/packages/core/src/integrations/http/client-patch.ts new file mode 100644 index 000000000000..3988336574fd --- /dev/null +++ b/packages/core/src/integrations/http/client-patch.ts @@ -0,0 +1,114 @@ +/** + * Platform-portable HTTP(S) outgoing-request patching integration + * + * Patches the `http` and `https` Node.js built-in module exports to create + * Sentry spans for outgoing requests and optionally inject distributed trace + * propagation headers. + * + * @module + * + * This Sentry integration is a derivative work based on the OpenTelemetry + * HTTP instrumentation. + * + * + * + * Extended under the terms of the Apache 2.0 license linked below: + * + * ---- + * + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getDefaultExport } from '../../utils/get-default-export'; +import { HTTP_ON_CLIENT_REQUEST } from './constants'; +import type { HttpExport, HttpModuleExport, HttpInstrumentationOptions, HttpClientRequest } from './types'; +import { getOriginalFunction, wrapMethod } from '../../utils/object'; +import { getHttpClientSubscriptions } from './client-subscriptions'; + +function patchHttpRequest(httpModule: HttpExport, options: HttpInstrumentationOptions): void { + // avoid double-wrap + if (!getOriginalFunction(httpModule.request)) { + const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = getHttpClientSubscriptions(options); + + const originalRequest = httpModule.request; + wrapMethod(httpModule, 'request', function patchedRequest(this: HttpExport, ...args: unknown[]) { + const request = originalRequest.apply(this, args) as HttpClientRequest; + onHttpClientRequestCreated({ request }, HTTP_ON_CLIENT_REQUEST); + return request; + }); + } +} + +// This simply ensures that http.get calls http.request, which we patched. +// Call it from the object each time, to ensure that any subsequent patches +// or other mutations are also respected. +function patchHttpGet(httpModule: HttpExport) { + if (!getOriginalFunction(httpModule.get)) { + // match node's normalization to exactly 3 arguments. + wrapMethod(httpModule, 'get', function patchedGet(this: HttpExport, input: unknown, options: unknown, cb: unknown) { + // http.get is like http.request but automatically calls .end() + const request = httpModule.request.call(this, input, options, cb) as HttpClientRequest; + request.end(); + return request; + }); + } +} + +function patchModule(httpModuleExport: HttpModuleExport, options: HttpInstrumentationOptions = {}): HttpModuleExport { + const httpDefault = getDefaultExport(httpModuleExport); + const httpModule = httpModuleExport as HttpExport; + // if we have a default, patch that, and copy to the import container + if (httpDefault !== httpModuleExport) { + patchModule(httpDefault, options); + // copy with defineProperty because these might be configured oddly + for (const method of ['get', 'request']) { + const desc = Object.getOwnPropertyDescriptor(httpDefault, method); + /* v8 ignore start - will always be set at this point */ + if (desc) { + Object.defineProperty(httpModule, method, desc); + } + /* v8 ignore stop */ + } + return httpModule; + } + patchHttpRequest(httpModule, options); + patchHttpGet(httpModule); + return httpModuleExport; +} + +/** + * Patch an `http`-module-shaped export so that every outgoing request is + * tracked as a Sentry span. + * + * @example + * ```javascript + * import http from 'http'; + * import { patchHttpModule } from '@sentry/core'; + * patchHttpModule(http, { propagateTrace: true }); + * ``` + */ +export const patchHttpModuleClient = ( + httpModuleExport: HttpModuleExport, + options: HttpInstrumentationOptions = {}, +): HttpModuleExport => patchModule(httpModuleExport, options); + +/** + * Patch an `https`-module-shaped export. Equivalent to `patchHttpModule` but + * sets default `protocol` / `port` for HTTPS when option objects are passed. + */ +export const patchHttpsModuleClient = ( + httpModuleExport: HttpModuleExport, + options: HttpInstrumentationOptions = {}, +): HttpModuleExport => patchModule(httpModuleExport, options); diff --git a/packages/core/src/integrations/http/client-subscriptions.ts b/packages/core/src/integrations/http/client-subscriptions.ts new file mode 100644 index 000000000000..756d2f576a78 --- /dev/null +++ b/packages/core/src/integrations/http/client-subscriptions.ts @@ -0,0 +1,178 @@ +/** + * Define the channels and subscription methods to subscribe to in order to + * instrument the `node:http` module. Note that this does *not* actually + * register the subscriptions, it simply returns a data object with the + * channel names and the subscription handlers. Attach these to diagnostic + * channels on Node versions where they are supported (ie, >=22.12.0). + * + * If any other platforms that do support diagnostic channels eventually add + * channel coverage for the `node:http` client, then these methods can be + * used on those platforms as well. + * + * This implementation is used in the client-patch strategy, by simply + * calling the handlers with the relevant data at the appropriate time. + */ + +import type { SpanStatus } from '../../types-hoist/spanStatus'; +import { addOutgoingRequestBreadcrumb } from './add-outgoing-request-breadcrumb'; +import { + getSpanStatusFromHttpCode, + SPAN_STATUS_ERROR, + SPAN_STATUS_UNSET, + startInactiveSpan, + SUPPRESS_TRACING_KEY, + withActiveSpan, +} from '../../tracing'; +import { debug } from '../../utils/debug-logger'; +import { LRUMap } from '../../utils/lru'; +import { getOutgoingRequestSpanData, setIncomingResponseSpanData } from './get-outgoing-span-data'; +import { getRequestUrlFromClientRequest } from './get-request-url'; +import { injectTracePropagationHeaders } from './inject-trace-propagation-headers'; +import type { HttpInstrumentationOptions, HttpClientRequest, HttpIncomingMessage } from './types'; +import { DEBUG_BUILD } from '../../debug-build'; +import { LOG_PREFIX, HTTP_ON_CLIENT_REQUEST } from './constants'; +import type { ClientSubscriptionName } from './constants'; +import { getClient, getCurrentScope } from '../../currentScopes'; +import { hasSpansEnabled } from '../../utils/hasSpansEnabled'; + +type ChannelListener = (message: unknown, name: string | symbol) => void; + +export type HttpClientSubscriptions = Record; + +export function getHttpClientSubscriptions(options: HttpInstrumentationOptions): HttpClientSubscriptions { + const propagationDecisionMap = new LRUMap(100); + const getConfig = () => getClient()?.getOptions(); + + const onHttpClientRequestCreated: ChannelListener = (data: unknown): void => { + // Skip all instrumentation if tracing is suppressed + // (e.g., Sentry's own transport uses this to avoid self-instrumentation) + if (getCurrentScope().getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY] === true) { + return; + } + + const clientOptions = getConfig(); + const { + errorMonitor = 'error', + spans: createSpans = clientOptions ? hasSpansEnabled(clientOptions) : true, + propagateTrace = false, + breadcrumbs = true, + } = options; + + const { request } = data as { request: HttpClientRequest }; + + // check if request is ignored. if so, we do nothing at all. + if (options.ignoreOutgoingRequests?.(getRequestUrlFromClientRequest(request), request)) { + return; + } + + // guard against adding breadcrumbs multiple times, or when not enabled + let addedBreadcrumbs = false; + function addBreadcrumbs(request: HttpClientRequest, response: HttpIncomingMessage | undefined) { + if (!addedBreadcrumbs) { + addedBreadcrumbs = true; + addOutgoingRequestBreadcrumb(request, response); + } + } + + // called if spans and/or trace propagation are disabled + function breadcrumbsOnly(request: HttpClientRequest) { + request.on(errorMonitor, () => addBreadcrumbs(request, undefined)); + request.prependListener('response', response => { + if (request.listenerCount('response') <= 1) { + response.resume(); + } + response.on('end', () => addBreadcrumbs(request, response)); + response.on(errorMonitor, () => addBreadcrumbs(request, response)); + }); + } + + if (!createSpans) { + // no spans, but maybe tracing and/or breadcrumbs + if (breadcrumbs) { + breadcrumbsOnly(request); + } + if (propagateTrace) { + injectTracePropagationHeaders(request, propagationDecisionMap); + } + return; + } + + // spans are enabled + const span = startInactiveSpan(getOutgoingRequestSpanData(request)); + options.outgoingRequestHook?.(span, request); + + // Inject trace headers after span creation so sentry-trace contains the + // outgoing span's ID (not the parent's), enabling downstream services to + // link to this span. + if (propagateTrace) { + if (span.isRecording()) { + withActiveSpan(span, () => { + injectTracePropagationHeaders(request, propagationDecisionMap); + }); + } else { + injectTracePropagationHeaders(request, propagationDecisionMap); + } + } + + let spanEnded = false; + function endSpan(status: SpanStatus): void { + if (!spanEnded) { + spanEnded = true; + span.setStatus(status); + span.end(); + } + } + + // Fallback: end span if the connection closes before any response. + // This is removed if we do get a response, because in that case + // we want to only end the span when the response is finished. + const requestOnClose = () => endSpan({ code: SPAN_STATUS_UNSET }); + request.on('close', requestOnClose); + + request.on(errorMonitor, error => { + DEBUG_BUILD && debug.log(LOG_PREFIX, 'outgoingRequest on request error()', error); + if (breadcrumbs) { + addBreadcrumbs(request, undefined); + } + endSpan({ code: SPAN_STATUS_ERROR }); + }); + + request.prependListener('response', response => { + // no longer need this, listen on response now. + // do not end the span until the response finishes + request.removeListener('close', requestOnClose); + if (request.listenerCount('response') <= 1) { + response.resume(); + } + setIncomingResponseSpanData(response, span); + options.outgoingResponseHook?.(span, response); + + let finished = false; + function finishWithResponse(error?: unknown): void { + if (!finished) { + finished = true; + if (error) { + DEBUG_BUILD && debug.log(LOG_PREFIX, 'outgoingRequest on response error()', error); + } + if (breadcrumbs) { + addBreadcrumbs(request, response); + } + const aborted = response.aborted && !response.complete; + const status: SpanStatus = + error || typeof response.statusCode !== 'number' || aborted + ? { code: SPAN_STATUS_ERROR } + : getSpanStatusFromHttpCode(response.statusCode); + options.applyCustomAttributesOnSpan?.(span, request, response); + endSpan(status); + } + } + + response.on('end', () => finishWithResponse()); + response.on(errorMonitor, finishWithResponse); + }); + }; + + return { + [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated, + }; +} diff --git a/packages/core/src/integrations/http/constants.ts b/packages/core/src/integrations/http/constants.ts new file mode 100644 index 000000000000..f2af12b00b62 --- /dev/null +++ b/packages/core/src/integrations/http/constants.ts @@ -0,0 +1,5 @@ +export const LOG_PREFIX = '@sentry/instrumentation-http'; +export const HTTP_ON_CLIENT_REQUEST = 'http.client.request.created'; +export const HTTP_ON_SERVER_REQUEST = 'http.server.request.start'; +export type ClientSubscriptionName = typeof HTTP_ON_CLIENT_REQUEST; +export type ServerSubscriptionName = typeof HTTP_ON_SERVER_REQUEST; diff --git a/packages/core/src/integrations/http/get-outgoing-span-data.ts b/packages/core/src/integrations/http/get-outgoing-span-data.ts new file mode 100644 index 000000000000..2d1d5bae37c2 --- /dev/null +++ b/packages/core/src/integrations/http/get-outgoing-span-data.ts @@ -0,0 +1,85 @@ +import type { Span, SpanAttributes } from '../../types-hoist/span'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../../semanticAttributes'; +import { getHttpSpanDetailsFromUrlObject, parseStringToURLObject } from '../../utils/url'; +import type { HttpClientRequest, HttpIncomingMessage } from './types'; +import { getRequestUrlFromClientRequest } from './get-request-url'; +import type { StartSpanOptions } from '../../types-hoist/startSpanOptions'; + +/** + * Build the initial span name and attributes for an outgoing HTTP request. + * This is called before the span is created, to get the initial details. + */ +export function getOutgoingRequestSpanData(request: HttpClientRequest): StartSpanOptions { + const url = getRequestUrlFromClientRequest(request); + const [name, attributes] = getHttpSpanDetailsFromUrlObject( + parseStringToURLObject(url), + 'client', + 'auto.http.client', + request, + ); + + const userAgent = request.getHeader('user-agent'); + + return { + name, + attributes: { + // TODO(v11): Update these to the Sentry semantic attributes for urls. + // https://getsentry.github.io/sentry-conventions/attributes/ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + 'otel.kind': 'CLIENT', + 'http.url': url, + 'http.method': request.method, + 'http.target': request.path || '/', + 'net.peer.name': request.host, + 'http.host': request.getHeader('host') as string | undefined, + ...(userAgent ? { 'user_agent.original': userAgent as string } : {}), + ...attributes, + }, + onlyIfParent: true, + }; +} + +/** + * Add span attributes once the response is received. + */ +export function setIncomingResponseSpanData(response: HttpIncomingMessage, span: Span): void { + const { statusCode, statusMessage, httpVersion, socket } = response; + const transport = httpVersion?.toUpperCase() !== 'QUIC' ? 'ip_tcp' : 'ip_udp'; + + span.setAttributes({ + 'http.response.status_code': statusCode, + 'network.protocol.version': httpVersion, + // TODO(v11): Update these to the Sentry semantic attributes for urls. + // https://getsentry.github.io/sentry-conventions/attributes/ + 'http.flavor': httpVersion, + 'network.transport': transport, + 'net.transport': transport, + 'http.status_text': statusMessage?.toUpperCase(), + 'http.status_code': statusCode, + ...getResponseContentLengthAttributes(response), + ...getSocketAttrs(socket), + }); +} + +function getSocketAttrs(socket: HttpIncomingMessage['socket']): SpanAttributes { + if (!socket) return {}; + const { remoteAddress, remotePort } = socket; + return { + 'network.peer.address': remoteAddress, + 'network.peer.port': remotePort, + 'net.peer.ip': remoteAddress, + 'net.peer.port': remotePort, + }; +} + +function getResponseContentLengthAttributes(response: HttpIncomingMessage): SpanAttributes { + const { headers } = response; + const contentLengthHeader = headers['content-length']; + const length = contentLengthHeader ? parseInt(String(contentLengthHeader), 10) : -1; + const encoding = headers['content-encoding']; + return length >= 0 + ? encoding && encoding !== 'identity' + ? { 'http.response_content_length': length } + : { 'http.response_content_length_uncompressed': length } + : {}; +} diff --git a/packages/core/src/integrations/http/get-request-url.ts b/packages/core/src/integrations/http/get-request-url.ts new file mode 100644 index 000000000000..024ac704acd1 --- /dev/null +++ b/packages/core/src/integrations/http/get-request-url.ts @@ -0,0 +1,48 @@ +import type { HttpClientRequest, HttpRequestOptions } from './types'; + +/** Convert an outgoing request to request options. */ +export function getRequestOptions(request: HttpClientRequest): HttpRequestOptions { + // request.host may be 'hostname:port' when the caller passed + // { host: 'hostname:port' } to http.request(). Split it so that + // `hostname` is always port-free (matching the http.RequestOptions contract) + // and the port is not lost when request.port is undefined. + const hostWithPort = request.host || ''; + const portInHost = /^(.*):(\d+)$/.exec(hostWithPort); + const hostname = portInHost ? portInHost[1] : hostWithPort; + const port = request.port ?? (portInHost ? Number(portInHost[2]) : undefined); + + return { + method: request.method, + port, + protocol: request.protocol, + host: request.host, + hostname, + path: request.path, + headers: request.getHeaders(), + }; +} + +export function getRequestUrl(requestOptions: HttpRequestOptions): string { + return String(getRequestUrlObject(requestOptions)); +} + +export function getRequestUrlObject(requestOptions: HttpRequestOptions): URL { + const protocol = requestOptions.protocol || 'http:'; + const hostHeader = requestOptions.headers?.host && String(requestOptions.headers?.host); + const hostname = hostHeader || requestOptions.hostname || requestOptions.host || ''; + // Don't log standard :80 (http) and :443 (https) ports to reduce the noise + // Also don't add port if the hostname already includes a port + const port = + !requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 || /^(.*):(\d+)$/.test(hostname) + ? '' + : `:${requestOptions.port}`; + const path = requestOptions.path ? requestOptions.path : '/'; + return new URL(path, `${protocol}//${hostname}${port}`); +} + +/** + * Build the full URL string from a Node.js ClientRequest. + */ +export function getRequestUrlFromClientRequest(request: HttpClientRequest): string { + return String(getRequestUrl(getRequestOptions(request))); +} diff --git a/packages/core/src/integrations/http/index.ts b/packages/core/src/integrations/http/index.ts new file mode 100644 index 000000000000..bbcaf3400c07 --- /dev/null +++ b/packages/core/src/integrations/http/index.ts @@ -0,0 +1,3 @@ +export type { HttpInstrumentationOptions } from './types'; +export * from './client-patch'; +export * from './client-subscriptions'; diff --git a/packages/core/src/integrations/http/inject-trace-propagation-headers.ts b/packages/core/src/integrations/http/inject-trace-propagation-headers.ts new file mode 100644 index 000000000000..0324cabb81b2 --- /dev/null +++ b/packages/core/src/integrations/http/inject-trace-propagation-headers.ts @@ -0,0 +1,78 @@ +import type { LRUMap } from '../../utils/lru'; +import { getClient } from '../../currentScopes'; +import { DEBUG_BUILD } from '../../debug-build'; +import { debug } from '../../utils/debug-logger'; +import { isError } from '../../utils/is'; +import { getTraceData } from '../../utils/traceData'; +import { shouldPropagateTraceForUrl } from '../../utils/tracePropagationTargets'; +import { LOG_PREFIX } from './constants'; +import { getRequestUrlFromClientRequest } from './get-request-url'; +import type { HttpClientRequest } from './types'; +import { mergeBaggageHeaders } from '../../utils/baggage'; + +/** + * Inject Sentry trace-propagation headers into an outgoing request if the + * target URL matches the configured `tracePropagationTargets`. + * + * Note: this must be called *before* calling `request.end()` (or firing the + * `http.client.request.start` diagnostics channel), because at that point, + * the headers have already been sent, and cannot be modified. + */ +export function injectTracePropagationHeaders( + request: HttpClientRequest, + propagationDecisionMap: LRUMap, +): void { + const url = getRequestUrlFromClientRequest(request); + const clientOptions = getClient()?.getOptions(); + const { tracePropagationTargets, propagateTraceparent } = clientOptions ?? {}; + + if (!shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap)) { + return; + } + + const hasExistingSentryTraceHeader = !!request.getHeader('sentry-trace'); + + if (hasExistingSentryTraceHeader) { + // add nothing if there's already a sentry-trace header, + // or else baggage can be sent twice. + return; + } + + const traceData = getTraceData({ propagateTraceparent }); + if (!traceData) return; + + const { 'sentry-trace': sentryTrace, baggage, traceparent } = traceData; + + if (sentryTrace) { + try { + request.setHeader('sentry-trace', sentryTrace); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added sentry-trace header'); + } catch (e) { + DEBUG_BUILD && + debug.error(LOG_PREFIX, 'Failed to set sentry-trace header:', isError(e) ? e.message : 'Unknown error'); + } + } + + if (traceparent && !request.getHeader('traceparent')) { + try { + request.setHeader('traceparent', traceparent); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added traceparent header'); + } catch (e) { + DEBUG_BUILD && + debug.error(LOG_PREFIX, 'Failed to set traceparent header:', isError(e) ? e.message : 'Unknown error'); + } + } + + if (baggage) { + const merged = mergeBaggageHeaders(request.getHeader('baggage'), baggage); + if (merged) { + try { + request.setHeader('baggage', merged); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added baggage header'); + } catch (e) { + DEBUG_BUILD && + debug.error(LOG_PREFIX, 'Failed to set baggage header:', isError(e) ? e.message : 'Unknown error'); + } + } + } +} diff --git a/packages/core/src/integrations/http/types.ts b/packages/core/src/integrations/http/types.ts new file mode 100644 index 000000000000..a7f56f76cbfb --- /dev/null +++ b/packages/core/src/integrations/http/types.ts @@ -0,0 +1,260 @@ +/** + * Platform-portable HTTP(S) outgoing-request integration – type definitions. + * + * @module + * + * This Sentry integration is a derivative work based on the OpenTelemetry + * HTTP instrumentation. + * + * + * + * Extended under the terms of the Apache 2.0 license linked below: + * + * ---- + * + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { RequestEventData } from '../../types-hoist/request'; +import type { Span } from '../../types-hoist/span'; + +/** Minimal interface for a Node.js http.ClientRequest */ +export interface HttpClientRequest { + method?: string; + path?: string; + host?: string; + protocol?: string; + port?: number; + end(): void; + getHeader(name: string): string | string[] | number | undefined; + getHeaders(): Record; + setHeader(name: string, value: string | string[] | number): void; + removeHeader(name: string): void; + prependListener(event: 'response', listener: (res: HttpIncomingMessage) => void): this; + prependListener(event: string | symbol, listener: (...args: unknown[]) => void): this; + on(event: string | symbol, listener: (...args: unknown[]) => void): this; + once(event: string | symbol, listener: (...args: unknown[]) => void): this; + listenerCount(event: string | symbol): number; + removeListener(event: string | symbol, listener: (...args: unknown[]) => void): this; +} + +/** Minimal interface for http client RequestOptions */ +export interface HttpRequestOptions { + method?: string; + protocol?: string | null; + hostname?: string | null; + host?: string | null; + port?: string | number | null; + path?: string | null; + headers?: Record; +} + +/** Minimal interface for a Node.js http.ServerResponse */ +export interface HttpServerResponse { + statusCode: number; + statusMessage?: string; + headers: Record; + once(ev: string, ...data: unknown[]): this; + once(ev: 'close'): this; + on(ev: string | symbol, handler: (...data: unknown[]) => void): this; +} + +export interface HttpServer { + emit(ev: string, ...data: unknown[]): this; + emit(ev: 'request', request: HttpIncomingMessage, response: HttpServerResponse): this; +} + +export interface HttpSocket { + remoteAddress?: string; + remotePort?: number; + localAddress?: string; + localPort?: number; +} + +/** Minimal interface for a Node.js http.IncomingMessage */ +export interface HttpIncomingMessage { + statusCode?: number; + statusMessage?: string; + httpVersion?: string; + url?: string; + method?: string; + headers: Record; + socket?: HttpSocket; + aborted?: boolean; + complete?: boolean; + resume(): void; + on(event: 'end', listener: () => void): this; + on(event: string | symbol, listener: (...args: unknown[]) => void): this; + addListener(event: 'end', listener: () => void): this; + addListener(event: string | symbol, listener: (...args: unknown[]) => void): this; + off(event: string | symbol, listener: (...args: unknown[]) => void): this; + removeListener(event: string | symbol, listener: (...args: unknown[]) => void): this; +} + +/** Minimal interface for a Node.js http / https module export */ +export interface HttpExport { + request: (...args: unknown[]) => HttpClientRequest; + get: (...args: unknown[]) => HttpClientRequest; + [key: string]: unknown; +} + +export type HttpModuleExport = HttpExport | (HttpExport & { default: HttpExport }); + +export interface HttpInstrumentationOptions { + /** + * Whether to create spans for outgoing HTTP requests. + * @default true + */ + spans?: boolean; + + /** + * Whether to inject distributed trace propagation headers + * (`sentry-trace`, `baggage`, `traceparent`) into outgoing requests. + * @default false + */ + propagateTrace?: boolean; + + /** + * Skip span / breadcrumb creation for requests to matching URLs. + * Receives the full URL string and the outgoing request object. + */ + ignoreOutgoingRequests?: (url: string, request: HttpClientRequest) => boolean; + + /** + * Whether breadcrumbs should be recorded for outgoing requests. + * @default true + */ + breadcrumbs?: boolean; + + /** + * Called after the outgoing-request span is created by the client. + * Use this to add custom attributes to the span. + */ + outgoingRequestHook?: (span: Span, request: HttpClientRequest) => void; + + /** + * Called when the response is received by the client. + */ + outgoingResponseHook?: (span: Span, response: HttpIncomingMessage) => void; + + /** + * Called when both the request and response are available (after the + * response ends). Useful for adding attributes based on both objects. + */ + applyCustomAttributesOnSpan?: (span: Span, request: HttpClientRequest, response: HttpIncomingMessage) => void; + + /** + * Symbol to use for observing errors on EventEmitters without consuming + * them. Pass `EventEmitter.errorMonitor` from Node.js `events` module. + * Falls back to the plain `'error'` event string when not provided. + * + * Using the real `errorMonitor` symbol ensures that Sentry does not + * swallow errors before they reach user-supplied `'error'` handlers. + */ + errorMonitor?: symbol | string; + + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * Available options: + * - 'none': No request bodies will be attached + * - 'small': Request bodies up to 1,000 bytes will be attached + * - 'medium': Request bodies up to 10,000 bytes will be attached (default) + * - 'always': Request bodies will always be attached + * + * Note that even with 'always' setting, bodies exceeding 1MB will never be attached + * for performance and security reasons. + * + * @default 'medium' + */ + maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + + /** + * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * 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. + */ + ignoreRequestBody?: (url: string, request: HttpIncomingMessage) => boolean; + + /** + * 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. + * Read more about Release Health: https://docs.sentry.io/product/releases/health/ + * + * Defaults to `true`. + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions tracked with `trackIncomingRequestsAsSessions` will be flushed as a session aggregate. + * + * Defaults to `60000` (60s). + */ + sessionFlushingDelayMS?: number; + + /** + * Optional callback that can be used by integrations to emit the 'request' + * event within a given Sentry or OTEL context, possibly after creating a + * span, as in the HttpServerSpansIntegration. + */ + wrapServerEmitRequest?: ( + request: HttpIncomingMessage, + response: HttpServerResponse, + normalizedRequest: RequestEventData, + next: () => void, + ) => void; + + /** + * Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`. + * Spans will be non recording if tracing is disabled. + * + * The `urlPath` param consists of the URL path and query string (if any) of the incoming request. + * For example: `'/users/details?id=123'` + * + * The `request` param contains the original {@type IncomingMessage} object of the incoming request. + * You can use it to filter on additional properties like method, headers, etc. + */ + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; + + /** + * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. + * This helps reduce noise in your transactions. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Do not capture spans for incoming HTTP requests with the given status codes. + * By default, spans with some 3xx and 4xx status codes are ignored (see @default). + * Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes. + * + * @default `[[401, 404], [301, 303], [305, 399]]` + */ + ignoreStatusCodes?: (number | [number, number])[]; + + /** + * A hook that can be used to mutate the span for incoming requests. + * 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, eg to update the transaction name based on + * the RPC metadata. + */ + onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; +} diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 3d3736876015..9b56045b37f3 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -13,6 +13,7 @@ export { withActiveSpan, suppressTracing, startNewTrace, + SUPPRESS_TRACING_KEY, } from './trace'; export { getDynamicSamplingContextFromClient, diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 45379866d56e..297687d40dcc 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -36,7 +36,7 @@ import { SPAN_STATUS_ERROR } from './spanstatus'; import { setCapturedScopesOnSpan } from './utils'; import type { Client } from '../client'; -const SUPPRESS_TRACING_KEY = '__SENTRY_SUPPRESS_TRACING__'; +export const SUPPRESS_TRACING_KEY = '__SENTRY_SUPPRESS_TRACING__'; /** * Wraps a function with a transaction/span and finishes the span after the function is done. diff --git a/packages/core/src/utils/baggage.ts b/packages/core/src/utils/baggage.ts index 9f4f85313951..20b3aa65a81c 100644 --- a/packages/core/src/utils/baggage.ts +++ b/packages/core/src/utils/baggage.ts @@ -166,3 +166,75 @@ export function objectToBaggageHeader(object: Record): string | } }, ''); } + +/** + * Merge two baggage headers into one. + * - Sentry-specific entries (keys starting with "sentry-") from the new + * baggage take precedence + * - Non-Sentry entries from existing baggage take precedence + * + * The order of the existing baggage will be preserved, and new entries will + * be added to the end. + * + * This matches the behavior of OTEL's propagation.inject() which uses + * `baggage.setEntry()` to overwrite existing entries with the same key. + */ +export function mergeBaggageHeaders( + existing: Existing, + incoming: string, +): string | undefined | Existing { + if (!existing) { + return incoming; + } + + const existingEntries = parseBaggageHeader(existing); + const incomingEntries = parseBaggageHeader(incoming); + + if (!incomingEntries) { + return existing; + } + + // 1. All non-sentry entries from existing are kept + // 2. All sentry- entries from the new baggage are retained + // 3. If sentry- entries present in new, ignore from old, else keep from old. + // 4. Non-sentry entries from new are only kept if not in existing. + + const merged: Record = {}; + + // partition incoming entries into sentry and non-sentry prefixed + let hasNewSentryEntries = false; + const newSentryEntries: Record = {}; + const newNonSentryEntries: Record = {}; + for (const [key, value] of Object.entries(incomingEntries)) { + if (key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { + newSentryEntries[key] = value; + hasNewSentryEntries = true; + } else { + newNonSentryEntries[key] = value; + } + } + + // If new baggage contains at least one sentry- value, we remove all old + // sentry- values otherwise, we keep old sentry- values. If we don't remove + // old sentry- values, we end up with an inconsistent dynamic sampling + // context propagation. + if (existingEntries) { + for (const [key, value] of Object.entries(existingEntries)) { + if (!hasNewSentryEntries || !key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { + merged[key] = value; + } + } + } + + // Assign new sentry fields. + if (hasNewSentryEntries) { + Object.assign(merged, newSentryEntries); + } + + // assign new non-sentry fields not found on existing object. + for (const [key, value] of Object.entries(newNonSentryEntries)) { + merged[key] ??= value; + } + + return objectToBaggageHeader(merged); +} diff --git a/packages/core/test/lib/integrations/http/add-outgoing-request-breadcrumb.test.ts b/packages/core/test/lib/integrations/http/add-outgoing-request-breadcrumb.test.ts new file mode 100644 index 000000000000..8ed6a1f3d660 --- /dev/null +++ b/packages/core/test/lib/integrations/http/add-outgoing-request-breadcrumb.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as breadcrumbsModule from '../../../../src/breadcrumbs'; +import { addOutgoingRequestBreadcrumb } from '../../../../src/integrations/http/add-outgoing-request-breadcrumb'; +import type { HttpClientRequest, HttpIncomingMessage } from '../../../../src/integrations/http/types'; + +function makeMockRequest(overrides: Partial> = {}): HttpClientRequest { + return { + method: 'GET', + path: '/api/test', + host: 'example.com', + protocol: 'http:', + port: 80, + getHeader: vi.fn(() => undefined), + getHeaders: vi.fn(() => ({})), + setHeader: vi.fn(), + removeHeader: vi.fn(), + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + prependListener: vi.fn(), + listenerCount: vi.fn(() => 0), + removeListener: vi.fn(), + ...overrides, + } as unknown as HttpClientRequest; +} + +function makeMockResponse(overrides: Partial = {}): HttpIncomingMessage { + return { + statusCode: 200, + statusMessage: 'OK', + httpVersion: '1.1', + headers: {}, + resume: vi.fn(), + on: vi.fn(), + addListener: vi.fn(), + off: vi.fn(), + removeListener: vi.fn(), + ...overrides, + } as unknown as HttpIncomingMessage; +} + +describe('addOutgoingRequestBreadcrumb', () => { + beforeEach(() => { + vi.spyOn(breadcrumbsModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('adds a breadcrumb with category "http" and type "http"', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse()); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledOnce(); + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ category: 'http', type: 'http' }), + expect.anything(), + ); + }); + + it('includes sanitized URL, method, and status_code in data', () => { + const request = makeMockRequest({ method: 'POST' }); + const response = makeMockResponse({ statusCode: 201 }); + + addOutgoingRequestBreadcrumb(request, response); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + url: 'http://example.com/api/test', + 'http.method': 'POST', + status_code: 201, + }), + }), + expect.anything(), + ); + }); + + it('includes http.query when the URL has a query string', () => { + addOutgoingRequestBreadcrumb(makeMockRequest({ path: '/api/test?foo=bar' }), makeMockResponse()); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ 'http.query': '?foo=bar' }), + }), + expect.anything(), + ); + // The main URL in data.url should not contain the query string + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.data?.url).not.toContain('foo=bar'); + }); + + it('does not include http.query when the URL has no query string', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse()); + + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.data).not.toHaveProperty('http.query'); + }); + + it('does not include http.fragment by default', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse()); + + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.data).not.toHaveProperty('http.fragment'); + }); + + it('sets level to "warning" for 4xx status codes', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse({ statusCode: 404 })); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ level: 'warning' }), + expect.anything(), + ); + }); + + it('sets level to "error" for 5xx status codes', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse({ statusCode: 500 })); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ level: 'error' }), + expect.anything(), + ); + }); + + it('does not set level for 2xx status codes', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse({ statusCode: 200 })); + + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.level).toBeUndefined(); + }); + + it('passes hint with event, request, and response', () => { + const request = makeMockRequest(); + const response = makeMockResponse(); + + addOutgoingRequestBreadcrumb(request, response); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith(expect.anything(), { + event: 'response', + request, + response, + }); + }); + + it('handles undefined response (network error)', () => { + const request = makeMockRequest(); + + addOutgoingRequestBreadcrumb(request, undefined); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledOnce(); + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.data?.status_code).toBeUndefined(); + expect(callArg.level).toBeUndefined(); + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith(expect.anything(), { + event: 'response', + request, + response: undefined, + }); + }); + + it('defaults method to "GET" when request.method is undefined', () => { + addOutgoingRequestBreadcrumb(makeMockRequest({ method: undefined }), makeMockResponse()); + + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.data?.['http.method']).toBe('GET'); + }); +}); diff --git a/packages/core/test/lib/integrations/http/client-patch.test.ts b/packages/core/test/lib/integrations/http/client-patch.test.ts new file mode 100644 index 000000000000..4c50c46b61c2 --- /dev/null +++ b/packages/core/test/lib/integrations/http/client-patch.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HTTP_ON_CLIENT_REQUEST } from '../../../../src/integrations/http/constants'; +import { patchHttpModuleClient } from '../../../../src/integrations/http/client-patch'; +import type { HttpClientRequest, HttpExport } from '../../../../src/integrations/http/types'; +import { getOriginalFunction } from '../../../../src/utils/object'; + +const mockClientRequestHandler = vi.fn(); + +vi.mock('../../../../src/integrations/http/client-subscriptions', () => ({ + getHttpClientSubscriptions: vi.fn(() => ({ + [HTTP_ON_CLIENT_REQUEST]: mockClientRequestHandler, + })), +})); + +function makeMockClientRequest(): HttpClientRequest { + return { + method: 'GET', + path: '/api/test', + host: 'example.com', + protocol: 'http:', + port: 80, + end: vi.fn(), + getHeader: vi.fn(() => undefined), + getHeaders: vi.fn(() => ({})), + setHeader: vi.fn(), + removeHeader: vi.fn(), + on: vi.fn(), + once: vi.fn(), + prependListener: vi.fn(), + listenerCount: vi.fn(() => 0), + removeListener: vi.fn(), + } as unknown as HttpClientRequest; +} + +function makeMockHttpModule(): HttpExport & { + request: ReturnType; + get: ReturnType; +} { + const mockClientReq = makeMockClientRequest(); + const request = vi.fn(() => mockClientReq); + const get = vi.fn(() => mockClientReq); + return { request, get }; +} + +describe('patchHttpModuleClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('replaces request with a wrapped version', () => { + const httpModule = makeMockHttpModule(); + const originalRequest = httpModule.request; + + patchHttpModuleClient(httpModule); + + expect(httpModule.request).not.toBe(originalRequest); + }); + + it('preserves the original function via __sentry_original__', () => { + const httpModule = makeMockHttpModule(); + const originalRequest = httpModule.request; + + patchHttpModuleClient(httpModule); + + expect(getOriginalFunction(httpModule.request)).toBe(originalRequest); + }); + + it('still calls the original request when the patched one is invoked', () => { + const httpModule = makeMockHttpModule(); + const originalRequest = httpModule.request; + + patchHttpModuleClient(httpModule); + httpModule.request('http://example.com/'); + + expect(originalRequest).toHaveBeenCalledOnce(); + }); + + it('returns the result of the original request', () => { + const httpModule = makeMockHttpModule(); + + patchHttpModuleClient(httpModule); + const result = httpModule.request('http://example.com/'); + + expect(result).toBeDefined(); + }); + + it('invokes the subscription handler after each request', () => { + const httpModule = makeMockHttpModule(); + + patchHttpModuleClient(httpModule); + httpModule.request('http://example.com/'); + + expect(mockClientRequestHandler).toHaveBeenCalledOnce(); + expect(mockClientRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ request: expect.any(Object) }), + HTTP_ON_CLIENT_REQUEST, + ); + }); + + it('wraps get to call .end() on the returned request automatically', () => { + const httpModule = makeMockHttpModule(); + const mockReq = makeMockClientRequest(); + httpModule.request = vi.fn(() => mockReq); + + patchHttpModuleClient(httpModule); + httpModule.get('http://example.com/'); + + expect(mockReq.end).toHaveBeenCalledOnce(); + }); + + it('is idempotent — patching a second time does not re-wrap', () => { + const httpModule = makeMockHttpModule(); + + patchHttpModuleClient(httpModule); + const wrappedRequest = httpModule.request; + + patchHttpModuleClient(httpModule); + + expect(httpModule.request).toBe(wrappedRequest); + }); + + it('handles CJS default export — patches the default and copies back to the container', () => { + const httpDefault = makeMockHttpModule(); + const httpModule: HttpExport & { default: HttpExport } = { + ...httpDefault, + default: httpDefault, + }; + const originalRequest = httpDefault.request; + + patchHttpModuleClient(httpModule); + + // The default export's request is now wrapped + expect(getOriginalFunction(httpDefault.request)).toBe(originalRequest); + // The module container's request descriptor was copied from the default + expect(httpModule.request).toBe(httpDefault.request); + }); +}); diff --git a/packages/core/test/lib/integrations/http/client-subscriptions.test.ts b/packages/core/test/lib/integrations/http/client-subscriptions.test.ts new file mode 100644 index 000000000000..46b486368cdc --- /dev/null +++ b/packages/core/test/lib/integrations/http/client-subscriptions.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as breadcrumbModule from '../../../../src/integrations/http/add-outgoing-request-breadcrumb'; +import { HTTP_ON_CLIENT_REQUEST } from '../../../../src/integrations/http/constants'; +import { getHttpClientSubscriptions } from '../../../../src/integrations/http/client-subscriptions'; +import type { HttpClientRequest, HttpIncomingMessage } from '../../../../src/integrations/http/types'; +import { SUPPRESS_TRACING_KEY } from '../../../../src/tracing'; +import { getCurrentScope, withScope } from '../../../../src/currentScopes'; + +function makeMockRequest(): HttpClientRequest & { + _responseListeners: ((res: HttpIncomingMessage) => void)[]; +} { + const responseListeners: ((res: HttpIncomingMessage) => void)[] = []; + return { + method: 'GET', + path: '/test', + host: 'example.com', + protocol: 'http:', + port: 80, + getHeader: () => undefined, + getHeaders: () => ({}), + setHeader: vi.fn(), + removeHeader: vi.fn(), + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + prependListener: vi.fn((_event: string, fn: (...args: unknown[]) => void) => { + responseListeners.push(fn as (res: HttpIncomingMessage) => void); + }), + listenerCount: () => 0, + removeListener: vi.fn(), + _responseListeners: responseListeners, + } as unknown as HttpClientRequest & { _responseListeners: ((res: HttpIncomingMessage) => void)[] }; +} + +function makeMockResponse(): HttpIncomingMessage & { _endListeners: (() => void)[] } { + const endListeners: (() => void)[] = []; + return { + statusCode: 200, + statusMessage: 'OK', + httpVersion: '1.1', + headers: {}, + resume: vi.fn(), + on: vi.fn((_event: string, fn: (...args: unknown[]) => void) => { + if (_event === 'end') endListeners.push(fn as () => void); + }), + addListener: vi.fn(), + off: vi.fn(), + removeListener: vi.fn(), + _endListeners: endListeners, + } as unknown as HttpIncomingMessage & { _endListeners: (() => void)[] }; +} + +describe('getHttpClientSubscriptions', () => { + afterEach(() => { + vi.restoreAllMocks(); + getCurrentScope().setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: undefined }); + }); + + describe('suppressTracing', () => { + it('does not add breadcrumbs when suppressTracing is active', () => { + const spy = vi.spyOn(breadcrumbModule, 'addOutgoingRequestBreadcrumb'); + const subscriptions = getHttpClientSubscriptions({ breadcrumbs: true, spans: false }); + const handler = subscriptions[HTTP_ON_CLIENT_REQUEST]; + + withScope(scope => { + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); + + const request = makeMockRequest(); + handler({ request }, HTTP_ON_CLIENT_REQUEST); + + // no response listeners should have been registered + expect(request._responseListeners).toHaveLength(0); + + // simulate a response completing anyway — breadcrumb must still not fire + const response = makeMockResponse(); + request._responseListeners.forEach(fn => fn(response)); + response._endListeners.forEach(fn => fn()); + }); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('does not propagate trace headers when suppressTracing is active', () => { + const subscriptions = getHttpClientSubscriptions({ breadcrumbs: false, spans: false, propagateTrace: true }); + const handler = subscriptions[HTTP_ON_CLIENT_REQUEST]; + + withScope(scope => { + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); + + const request = makeMockRequest(); + handler({ request }, HTTP_ON_CLIENT_REQUEST); + + expect(request.setHeader).not.toHaveBeenCalled(); + }); + }); + + it('still adds breadcrumbs when suppressTracing is NOT active', () => { + const spy = vi.spyOn(breadcrumbModule, 'addOutgoingRequestBreadcrumb'); + const subscriptions = getHttpClientSubscriptions({ breadcrumbs: true, spans: false }); + const handler = subscriptions[HTTP_ON_CLIENT_REQUEST]; + + const request = makeMockRequest(); + handler({ request }, HTTP_ON_CLIENT_REQUEST); + + const response = makeMockResponse(); + request._responseListeners.forEach(fn => fn(response)); + response._endListeners.forEach(fn => fn()); + + expect(spy).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/http/constants.test.ts b/packages/core/test/lib/integrations/http/constants.test.ts new file mode 100644 index 000000000000..6a63e74ba5c6 --- /dev/null +++ b/packages/core/test/lib/integrations/http/constants.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { + HTTP_ON_CLIENT_REQUEST, + HTTP_ON_SERVER_REQUEST, + LOG_PREFIX, +} from '../../../../src/integrations/http/constants'; + +describe('http constants', () => { + it('LOG_PREFIX is the expected string', () => { + expect(LOG_PREFIX).toBe('@sentry/instrumentation-http'); + }); + + it('HTTP_ON_CLIENT_REQUEST is the diagnostics-channel name', () => { + expect(HTTP_ON_CLIENT_REQUEST).toBe('http.client.request.created'); + }); + + it('HTTP_ON_SERVER_REQUEST is the diagnostics-channel name', () => { + expect(HTTP_ON_SERVER_REQUEST).toBe('http.server.request.start'); + }); +}); diff --git a/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts b/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts new file mode 100644 index 000000000000..fa5eddc88f65 --- /dev/null +++ b/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + getOutgoingRequestSpanData, + setIncomingResponseSpanData, +} from '../../../../src/integrations/http/get-outgoing-span-data'; +import type { HttpClientRequest, HttpIncomingMessage } from '../../../../src/integrations/http/types'; +import type { Span } from '../../../../src/types-hoist/span'; + +function makeMockRequest(overrides: Partial> = {}): HttpClientRequest { + return { + method: 'GET', + path: '/api/test', + host: 'example.com', + protocol: 'http:', + port: 80, + getHeader: vi.fn(() => undefined), + getHeaders: vi.fn(() => ({})), + setHeader: vi.fn(), + removeHeader: vi.fn(), + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + prependListener: vi.fn(), + listenerCount: vi.fn(() => 0), + removeListener: vi.fn(), + ...overrides, + } as unknown as HttpClientRequest; +} + +function makeMockResponse(overrides: Partial = {}): HttpIncomingMessage { + return { + statusCode: 200, + statusMessage: 'OK', + httpVersion: '1.1', + headers: {}, + socket: undefined, + resume: vi.fn(), + on: vi.fn(), + addListener: vi.fn(), + off: vi.fn(), + removeListener: vi.fn(), + ...overrides, + } as unknown as HttpIncomingMessage; +} + +describe('getOutgoingRequestSpanData', () => { + it('returns onlyIfParent: true', () => { + const result = getOutgoingRequestSpanData(makeMockRequest()); + expect(result.onlyIfParent).toBe(true); + }); + + it('sets sentry.op to "http.client"', () => { + const result = getOutgoingRequestSpanData(makeMockRequest()); + expect(result.attributes!['sentry.op']).toBe('http.client'); + }); + + it('sets otel.kind to "CLIENT"', () => { + const result = getOutgoingRequestSpanData(makeMockRequest()); + expect(result.attributes!['otel.kind']).toBe('CLIENT'); + }); + + it('builds the span name from method and URL', () => { + const result = getOutgoingRequestSpanData(makeMockRequest({ method: 'POST' })); + expect(result.name).toMatch(/^POST /); + }); + + it('includes http.url, http.method, http.target, net.peer.name', () => { + const result = getOutgoingRequestSpanData(makeMockRequest()); + expect(result.attributes).toMatchObject({ + 'http.url': 'http://example.com/api/test', + 'http.method': 'GET', + 'http.target': '/api/test', + 'net.peer.name': 'example.com', + }); + }); + + it('falls back to "/" for http.target when path is not set', () => { + const result = getOutgoingRequestSpanData(makeMockRequest({ path: undefined })); + expect(result.attributes!['http.target']).toBe('/'); + }); + + it('includes user_agent.original when user-agent header is set', () => { + const request = makeMockRequest({ + getHeader: (name: string) => (name === 'user-agent' ? 'Mozilla/5.0' : undefined), + }); + const result = getOutgoingRequestSpanData(request); + expect(result.attributes!['user_agent.original']).toBe('Mozilla/5.0'); + }); + + it('omits user_agent.original when user-agent header is absent', () => { + const result = getOutgoingRequestSpanData(makeMockRequest()); + expect(result.attributes).not.toHaveProperty('user_agent.original'); + }); + + it('includes non-standard port in the URL', () => { + const result = getOutgoingRequestSpanData(makeMockRequest({ port: 3000 })); + expect(result.attributes!['http.url']).toContain(':3000'); + }); +}); + +describe('setIncomingResponseSpanData', () => { + function makeMockSpan(): Span & { setAttributes: ReturnType } { + return { setAttributes: vi.fn() } as unknown as Span & { setAttributes: ReturnType }; + } + + it('sets http.response.status_code from statusCode', () => { + const span = makeMockSpan(); + setIncomingResponseSpanData(makeMockResponse({ statusCode: 201 }), span); + expect(span.setAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'http.response.status_code': 201 })); + }); + + it('sets network.protocol.version and http.flavor from httpVersion', () => { + const span = makeMockSpan(); + setIncomingResponseSpanData(makeMockResponse({ httpVersion: '2.0' }), span); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ 'network.protocol.version': '2.0', 'http.flavor': '2.0' }), + ); + }); + + it('sets http.status_text from statusMessage', () => { + const span = makeMockSpan(); + setIncomingResponseSpanData(makeMockResponse({ statusMessage: 'Created' }), span); + expect(span.setAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'http.status_text': 'CREATED' })); + }); + + it('uses ip_tcp transport for non-QUIC connections', () => { + const span = makeMockSpan(); + setIncomingResponseSpanData(makeMockResponse({ httpVersion: '1.1' }), span); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ 'network.transport': 'ip_tcp', 'net.transport': 'ip_tcp' }), + ); + }); + + it('uses ip_udp transport for QUIC connections', () => { + const span = makeMockSpan(); + setIncomingResponseSpanData(makeMockResponse({ httpVersion: 'QUIC' }), span); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ 'network.transport': 'ip_udp', 'net.transport': 'ip_udp' }), + ); + }); + + it('includes socket address and port attributes when socket is present', () => { + const span = makeMockSpan(); + const response = makeMockResponse({ + socket: { remoteAddress: '1.2.3.4', remotePort: 12345 }, + }); + setIncomingResponseSpanData(response, span); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'network.peer.address': '1.2.3.4', + 'network.peer.port': 12345, + 'net.peer.ip': '1.2.3.4', + 'net.peer.port': 12345, + }), + ); + }); + + it('includes uncompressed content-length when content-encoding is identity', () => { + const span = makeMockSpan(); + const response = makeMockResponse({ + headers: { 'content-length': '42', 'content-encoding': 'identity' }, + }); + setIncomingResponseSpanData(response, span); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ 'http.response_content_length_uncompressed': 42 }), + ); + }); + + it('includes compressed content-length when content-encoding is gzip', () => { + const span = makeMockSpan(); + const response = makeMockResponse({ + headers: { 'content-length': '100', 'content-encoding': 'gzip' }, + }); + setIncomingResponseSpanData(response, span); + expect(span.setAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'http.response_content_length': 100 })); + }); +}); diff --git a/packages/core/test/lib/integrations/http/get-request-url.test.ts b/packages/core/test/lib/integrations/http/get-request-url.test.ts new file mode 100644 index 000000000000..55bc1ba90f3a --- /dev/null +++ b/packages/core/test/lib/integrations/http/get-request-url.test.ts @@ -0,0 +1,58 @@ +import { it, describe, expect } from 'vitest'; +import { getRequestUrlFromClientRequest, getRequestUrl } from '../../../../src/integrations/http/get-request-url'; +import type { HttpClientRequest, HttpRequestOptions } from '../../../../src/integrations/http/types'; + +describe('getRequestUrl', () => { + it.each([ + [{ protocol: 'http:', hostname: 'localhost', port: 80 }, 'http://localhost/'], + [{ protocol: 'http:', hostname: 'localhost', host: 'localhost:80', port: 80 }, 'http://localhost/'], + [{ protocol: 'http:', hostname: 'localhost', port: 3000 }, 'http://localhost:3000/'], + [{ protocol: 'http:', host: 'localhost:3000', port: 3000 }, 'http://localhost:3000/'], + [{ protocol: 'https:', hostname: 'localhost', port: 443 }, 'https://localhost/'], + [{ protocol: 'https:', hostname: 'localhost', port: 443, path: '/my-path' }, 'https://localhost/my-path'], + [ + { protocol: 'https:', hostname: 'www.example.com', port: 443, path: '/my-path' }, + 'https://www.example.com/my-path', + ], + [{ protocol: 'https:', host: 'www.example.com:443', path: '/my-path' }, 'https://www.example.com/my-path'], + [ + { + protocol: 'https:', + headers: { + host: 'proxy.local', + }, + path: 'http://www.example.com:80/', + }, + 'http://www.example.com/', + ], + [ + { + host: 'proxy.local', + port: 3128, + method: 'GET', + path: 'http://target.example/foo', + }, + 'http://target.example/foo', + ], + [ + { + protocol: 'data:', + host: null, + method: 'GET', + path: 'data:text/plain;hello, world!', + }, + 'data:text/plain;hello, world!', + ], + ])('works with %s', (input: HttpRequestOptions, expected: string | undefined) => { + // pretend to be a client request that option-ifies to this value + const clientRequest = { + ...input, + hostname: undefined, + host: input.hostname ?? input.host, + headers: undefined, + getHeaders: () => input.headers ?? {}, + } as unknown as HttpClientRequest; + expect(String(getRequestUrl(input))).toBe(expected); + expect(getRequestUrlFromClientRequest(clientRequest)).toBe(expected); + }); +}); diff --git a/packages/core/test/lib/integrations/http/inject-trace-propagation-headers.test.ts b/packages/core/test/lib/integrations/http/inject-trace-propagation-headers.test.ts new file mode 100644 index 000000000000..3338d066328f --- /dev/null +++ b/packages/core/test/lib/integrations/http/inject-trace-propagation-headers.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { injectTracePropagationHeaders } from '../../../../src/integrations/http/inject-trace-propagation-headers'; +import type { HttpClientRequest } from '../../../../src/integrations/http/types'; +import { LRUMap } from '../../../../src/utils/lru'; + +const DEFAULT_SENTRY_TRACE = 'aabbccdd-aabbccdd-1'; +const DEFAULT_TRACEPARENT = '00-aabbccdd-aabbccdd-01'; +const DEFAULT_BAGGAGE = 'sentry-trace_id=aabbccdd,sentry-sampled=true'; + +vi.mock('../../../../src/utils/traceData', () => ({ + getTraceData: vi.fn(() => ({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + traceparent: DEFAULT_TRACEPARENT, + baggage: DEFAULT_BAGGAGE, + })), +})); + +vi.mock('../../../../src/currentScopes', () => ({ + getClient: vi.fn(() => ({ + getOptions: vi.fn(() => ({ + tracePropagationTargets: undefined, + })), + })), +})); + +function makeMockRequest(existingHeaders: Record = {}): HttpClientRequest & { + setHeader: ReturnType; +} { + return { + method: 'GET', + path: '/api/test', + host: 'example.com', + protocol: 'http:', + port: 80, + getHeader: vi.fn((name: string) => existingHeaders[name]), + getHeaders: vi.fn(() => existingHeaders), + setHeader: vi.fn((key: string, value: string) => { + existingHeaders[key] = value; + }), + removeHeader: vi.fn(), + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + prependListener: vi.fn(), + listenerCount: vi.fn(() => 0), + removeListener: vi.fn(), + } as unknown as HttpClientRequest & { setHeader: ReturnType }; +} + +describe('injectTracePropagationHeaders', () => { + let propagationDecisionMap: LRUMap; + + beforeEach(() => { + propagationDecisionMap = new LRUMap(100); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('injects sentry-trace, traceparent, and baggage headers', () => { + const request = makeMockRequest(); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).toHaveBeenCalledWith('sentry-trace', DEFAULT_SENTRY_TRACE); + expect(request.setHeader).toHaveBeenCalledWith('traceparent', DEFAULT_TRACEPARENT); + expect(request.setHeader).toHaveBeenCalledWith('baggage', DEFAULT_BAGGAGE); + }); + + it('does not overwrite an existing sentry-trace header', () => { + const request = makeMockRequest({ 'sentry-trace': 'existing-value' }); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).not.toHaveBeenCalledWith('sentry-trace', expect.anything()); + }); + + it('does not overwrite an existing traceparent header', () => { + const request = makeMockRequest({ traceparent: 'existing-parent' }); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).not.toHaveBeenCalledWith('traceparent', expect.anything()); + }); + + it('merges baggage with existing baggage header', () => { + const request = makeMockRequest({ baggage: 'custom=value' }); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + const baggageCall = vi.mocked(request.setHeader).mock.calls.find(c => c[0] === 'baggage'); + expect(baggageCall).toBeDefined(); + const merged = baggageCall![1] as string; + expect(merged).toContain('custom=value'); + expect(merged).toContain(DEFAULT_BAGGAGE); + expect(request.getHeaders()).toStrictEqual({ + baggage: `custom=value,${DEFAULT_BAGGAGE}`, + 'sentry-trace': DEFAULT_SENTRY_TRACE, + traceparent: DEFAULT_TRACEPARENT, + }); + }); + + it('does not inject trace propagation headers when sentry-trace is already present', () => { + const request = makeMockRequest({ + baggage: 'original=value', + 'sentry-trace': 'yyyyyyyy-xxxxxxxx-1', + }); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + const baggageCall = vi.mocked(request.setHeader).mock.calls.find(c => c[0] === 'baggage'); + expect(baggageCall).toBe(undefined); + expect(request.getHeaders()).toStrictEqual({ + baggage: 'original=value', + 'sentry-trace': 'yyyyyyyy-xxxxxxxx-1', + }); + }); + + it('does not inject headers when URL does not match tracePropagationTargets', async () => { + const { getClient } = await import('../../../../src/currentScopes'); + vi.mocked(getClient).mockReturnValue({ + getOptions: vi.fn(() => ({ + tracePropagationTargets: [/^https:\/\/api\.example\.com(?:\/|$)/], + })), + } as any); + + const request = makeMockRequest(); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).not.toHaveBeenCalled(); + }); + + it('does not inject headers when getTraceData returns null', async () => { + const { getTraceData } = await import('../../../../src/utils/traceData'); + vi.mocked(getTraceData).mockReturnValueOnce(null as any); + + const request = makeMockRequest(); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).not.toHaveBeenCalled(); + }); + + it('does not inject headers when getClient returns undefined', async () => { + const { getClient } = await import('../../../../src/currentScopes'); + vi.mocked(getClient).mockReturnValueOnce(undefined); + + const request = makeMockRequest(); + + // tracePropagationTargets is undefined → propagation is allowed + // but there is no client, so clientOptions is undefined + // In this case, tracePropagationTargets is undefined → shouldPropagateTraceForUrl returns true + // So headers should still be injected + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).toHaveBeenCalledWith('sentry-trace', DEFAULT_SENTRY_TRACE); + }); + + it('caches propagation decisions in the decision map', async () => { + // tracePropagationTargets must be defined for the decision to be cached + const { getClient } = await import('../../../../src/currentScopes'); + vi.mocked(getClient).mockReturnValue({ + getOptions: vi.fn(() => ({ + tracePropagationTargets: ['example.com'], + })), + } as any); + + const request1 = makeMockRequest(); + const request2 = makeMockRequest(); + + injectTracePropagationHeaders(request1, propagationDecisionMap); + injectTracePropagationHeaders(request2, propagationDecisionMap); + + // Both requests should have had headers injected (URL matches the target) + expect(request1.setHeader).toHaveBeenCalledWith('sentry-trace', DEFAULT_SENTRY_TRACE); + expect(request2.setHeader).toHaveBeenCalledWith('sentry-trace', DEFAULT_SENTRY_TRACE); + + // The decision map should contain a cached entry for the URL + expect(propagationDecisionMap.size).toBe(1); + }); + + it('handles setHeader exceptions gracefully', () => { + const request = makeMockRequest(); + vi.mocked(request.setHeader).mockImplementation(() => { + throw new Error('Headers already sent'); + }); + + // Should not throw + expect(() => injectTracePropagationHeaders(request, propagationDecisionMap)).not.toThrow(); + }); +}); diff --git a/packages/core/test/lib/utils/baggage.test.ts b/packages/core/test/lib/utils/baggage.test.ts index f3717a524bf8..dcc94fa1a839 100644 --- a/packages/core/test/lib/utils/baggage.test.ts +++ b/packages/core/test/lib/utils/baggage.test.ts @@ -1,10 +1,23 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, it, vi } from 'vitest'; import { baggageHeaderToDynamicSamplingContext, dynamicSamplingContextToSentryBaggageHeader, + mergeBaggageHeaders, parseBaggageHeader, + objectToBaggageHeader, } from '../../../src/utils/baggage'; +const DEBUG_WARNS: string[] = []; + +vi.mock(import('../../../src/debug-build'), () => ({ DEBUG_BUILD: true })); +vi.mock('../../../src/utils/debug-logger', () => ({ + debug: { + warn(msg: string) { + DEBUG_WARNS.push(msg); + }, + }, +})); + test.each([ ['', undefined], [' ', undefined], @@ -84,3 +97,184 @@ describe('parseBaggageHeader', () => { }); }); }); + +describe('objectToBaggageHeader', () => { + it('does not create a baggage header that is too big', () => { + const obj: Record = {}; + // it takes this many to exceed the limit + for (let i = 0; i < 765; i++) { + obj[`foo${i}`] = String(i); + } + const header = objectToBaggageHeader(obj); + expect(header?.endsWith(',foo764=764')).toBe(false); + expect(Number(header?.length) < 8192).toBe(true); + expect(DEBUG_WARNS).toStrictEqual([ + 'Not adding key: foo764 with val: 764 to baggage header due to exceeding baggage size limits.', + ]); + }); +}); + +describe('mergeBaggageHeaders', () => { + it('returns new baggage when existing is undefined', () => { + const result = mergeBaggageHeaders(undefined, 'foo=bar'); + expect(result).toBe('foo=bar'); + }); + + it('returns existing baggage when new baggage is empty', () => { + const result = mergeBaggageHeaders('foo=bar', ''); + expect(result).toBe('foo=bar'); + }); + + it('returns existing baggage when new baggage is invalid', () => { + const result = mergeBaggageHeaders('foo=bar', 'invalid'); + expect(result).toBe('foo=bar'); + }); + + it('handles empty existing baggage', () => { + const result = mergeBaggageHeaders('', 'foo=bar,sentry-release=1.0.0'); + expect(result).toBe('foo=bar,sentry-release=1.0.0'); + }); + + it('preserves existing non-Sentry entries', () => { + const result = mergeBaggageHeaders('foo=bar,other=vendor', 'foo=newvalue,third=party'); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('third=party'); + expect(entries).not.toContain('foo=newvalue'); + }); + + it('overwrites existing Sentry entries with new ones', () => { + const result = mergeBaggageHeaders( + 'sentry-release=1.0.0,sentry-environment=prod', + 'sentry-release=2.0.0,sentry-environment=staging', + ); + + const entries = result?.split(','); + expect(entries).toContain('sentry-release=2.0.0'); + expect(entries).toContain('sentry-environment=staging'); + expect(entries).not.toContain('sentry-release=1.0.0'); + expect(entries).not.toContain('sentry-environment=prod'); + }); + + it('merges Sentry and non-Sentry entries correctly', () => { + const result = mergeBaggageHeaders('foo=bar,sentry-release=1.0.0,other=vendor', 'sentry-release=2.0.0,third=party'); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('third=party'); + expect(entries).toContain('sentry-release=2.0.0'); + expect(entries).not.toContain('sentry-release=1.0.0'); + }); + + it('handles third-party baggage with Sentry entries', () => { + const result = mergeBaggageHeaders( + 'other=vendor,foo=bar,third=party,sentry-release=9.9.9,sentry-environment=staging,sentry-sample_rate=0.54,last=item', + 'sentry-release=2.1.0,sentry-environment=myEnv', + ); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('last=item'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('third=party'); + expect(entries).toContain('sentry-environment=myEnv'); + expect(entries).toContain('sentry-release=2.1.0'); + expect(entries).not.toContain('sentry-environment=staging'); + expect(entries).not.toContain('sentry-release=9.9.9'); + }); + + it('adds new Sentry entries when they do not exist', () => { + const result = mergeBaggageHeaders('foo=bar,other=vendor', 'sentry-release=1.0.0,sentry-environment=prod'); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('sentry-release=1.0.0'); + expect(entries).toContain('sentry-environment=prod'); + }); + + it('handles array-type existing baggage', () => { + const result = mergeBaggageHeaders(['foo=bar', 'other=vendor'], 'sentry-release=1.0.0'); + + const entries = (result as string)?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('sentry-release=1.0.0'); + }); + + it('preserves order of existing entries', () => { + const result = mergeBaggageHeaders('first=1,second=2,third=3', 'fourth=4'); + expect(result).toBe('first=1,second=2,third=3,fourth=4'); + }); + + it('handles complex scenario with multiple Sentry keys', () => { + const result = mergeBaggageHeaders( + 'foo=bar,sentry-release=old,sentry-environment=old,other=vendor', + 'sentry-release=new,sentry-environment=new,sentry-transaction=test,new=entry', + ); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('sentry-release=new'); + expect(entries).toContain('sentry-environment=new'); + expect(entries).toContain('sentry-transaction=test'); + expect(entries).toContain('new=entry'); + expect(entries).not.toContain('sentry-release=old'); + expect(entries).not.toContain('sentry-environment=old'); + }); + + it('overwrites existing Sentry entries with new SDK values', () => { + const result = mergeBaggageHeaders( + 'sentry-trace_id=abc123,sentry-sampled=false,non-sentry=keep', + 'sentry-trace_id=xyz789,sentry-sampled=true', + ); + + const entries = result?.split(','); + expect(entries).toContain('sentry-trace_id=xyz789'); + expect(entries).toContain('sentry-sampled=true'); + expect(entries).toContain('non-sentry=keep'); + expect(entries).not.toContain('sentry-trace_id=abc123'); + expect(entries).not.toContain('sentry-sampled=false'); + }); + + it('merges non-conflicting baggage entries', () => { + const existing = 'custom-key=value'; + const newBaggage = 'sentry-environment=production'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('custom-key=value,sentry-environment=production'); + }); + + it('overwrites existing Sentry entries when keys conflict', () => { + const existing = 'sentry-environment=staging'; + const newBaggage = 'sentry-environment=production'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('sentry-environment=production'); + }); + + it('handles multiple entries with Sentry conflicts', () => { + const existing = 'custom-key=value1,sentry-environment=staging'; + const newBaggage = 'sentry-environment=production,sentry-trace_id=123'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toContain('custom-key=value1'); + expect(result).toContain('sentry-environment=production'); + expect(result).toContain('sentry-trace_id=123'); + expect(result).not.toContain('sentry-environment=staging'); + }); + + it('removes all sentry- values from old baggage and only adds new ones (if at least one new sentry- value is present)', () => { + const existing = 'sentry-trace_id=old,sentry-sampled=false,non-sentry=keep'; + const newBaggage = 'sentry-trace_id=new,sentry-environment=new'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('non-sentry=keep,sentry-trace_id=new,sentry-environment=new'); + }); + + it('preserves existing sentry entries when new baggage has no sentry entries', () => { + const result = mergeBaggageHeaders('sentry-release=1.0.0,foo=bar', 'baz=qux'); + + expect(result).toBe('sentry-release=1.0.0,foo=bar,baz=qux'); + }); +}); diff --git a/packages/node-core/src/common-exports.ts b/packages/node-core/src/common-exports.ts index b2f1deee7f4a..ddedd5c171eb 100644 --- a/packages/node-core/src/common-exports.ts +++ b/packages/node-core/src/common-exports.ts @@ -33,7 +33,6 @@ export { consoleIntegration } from './integrations/console'; export { getSentryRelease, defaultStackParser } from './sdk/api'; export { createGetModuleFromFilename } from './utils/module'; export { addOriginToSpan } from './utils/addOriginToSpan'; -export { getRequestUrl } from './utils/getRequestUrl'; export { initializeEsmLoader } from './sdk/esmLoader'; export { isCjs } from './utils/detection'; export { createMissingInstrumentationContext } from './utils/createMissingInstrumentationContext'; @@ -124,6 +123,7 @@ export { withStreamedSpan, metrics, envToBool, + getRequestUrl, } from '@sentry/core'; export type { diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index a9633b94c25d..8ce1cade960d 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -3,6 +3,9 @@ export { httpIntegration } from './integrations/http'; export { httpServerSpansIntegration } from './integrations/http/httpServerSpansIntegration'; export { httpServerIntegration } from './integrations/http/httpServerIntegration'; +export type { HttpServerIntegrationOptions } from './integrations/http/httpServerIntegration'; +export type { HttpServerSpansIntegrationOptions } from './integrations/http/httpServerSpansIntegration'; + export { SentryHttpInstrumentation, type SentryHttpInstrumentationOptions, diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index 60cf7bbae9aa..808d2aee1018 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,48 +1,33 @@ -/* eslint-disable max-lines */ -import type { ChannelListener } from 'node:diagnostics_channel'; -import { subscribe, unsubscribe } from 'node:diagnostics_channel'; -import { errorMonitor } from 'node:events'; +import { subscribe } from 'node:diagnostics_channel'; import type * as http from 'node:http'; -import type * as https from 'node:https'; -import { context, SpanStatusCode, trace } from '@opentelemetry/api'; +import { context, trace } from '@opentelemetry/api'; import { isTracingSuppressed } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import type { ClientRequest, IncomingMessage, ServerResponse } from 'node:http'; +import type { + HttpClientRequest, + HttpIncomingMessage, + HttpInstrumentationOptions, + HttpModuleExport, + Span, +} from '@sentry/core'; import { - ATTR_HTTP_RESPONSE_STATUS_CODE, - ATTR_NETWORK_PEER_ADDRESS, - ATTR_NETWORK_PEER_PORT, - ATTR_NETWORK_PROTOCOL_VERSION, - ATTR_NETWORK_TRANSPORT, - ATTR_URL_FULL, - ATTR_USER_AGENT_ORIGINAL, - SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH, - SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED, -} from '@opentelemetry/semantic-conventions'; -import type { Span, SpanAttributes, SpanStatus } from '@sentry/core'; -import { - debug, - getHttpSpanDetailsFromUrlObject, - getSpanStatusFromHttpCode, - LRUMap, - parseStringToURLObject, + getHttpClientSubscriptions, + patchHttpModuleClient, + patchHttpsModuleClient, SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - startInactiveSpan, + getRequestOptions, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { INSTRUMENTATION_NAME } from './constants'; -import { - addRequestBreadcrumb, - addTracePropagationHeadersToOutgoingRequest, - getClientRequestUrl, - getRequestOptions, -} from './outgoing-requests'; +import { HTTP_ON_CLIENT_REQUEST } from '@sentry/core'; +import { NODE_VERSION } from '../../nodeVersion'; +import { errorMonitor } from 'node:events'; -type Http = typeof http; -type Https = typeof https; -type IncomingHttpHeaders = http.IncomingHttpHeaders; -type OutgoingHttpHeaders = http.OutgoingHttpHeaders; +const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = + (NODE_VERSION.major === 22 && NODE_VERSION.minor >= 12) || + (NODE_VERSION.major === 23 && NODE_VERSION.minor >= 2) || + NODE_VERSION.major >= 24; export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** @@ -96,19 +81,19 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * Hooks for outgoing request spans, called when `createSpansForOutgoingRequests` is enabled. * These mirror the OTEL HttpInstrumentation hooks for backwards compatibility. */ - outgoingRequestHook?: (span: Span, request: http.ClientRequest) => void; - outgoingResponseHook?: (span: Span, response: http.IncomingMessage) => void; + outgoingRequestHook?: (span: Span, request: ClientRequest | HttpClientRequest) => void; + outgoingResponseHook?: (span: Span, response: IncomingMessage | HttpIncomingMessage) => void; outgoingRequestApplyCustomAttributes?: ( span: Span, - request: http.ClientRequest, - response: http.IncomingMessage, + request: HttpClientRequest, + response: HttpIncomingMessage, ) => void; // All options below do not do anything anymore in this instrumentation, and will be removed in the future. // They are only kept here for backwards compatibility - the respective functionality is now handled by the httpServerIntegration/httpServerSpansIntegration. /** - * @depreacted This no longer does anything. + * @deprecated This no longer does anything. */ extractIncomingTraceFromHeader?: boolean; @@ -125,7 +110,7 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** * @deprecated This no longer does anything. */ - ignoreSpansForIncomingRequests?: (urlPath: string, request: http.IncomingMessage) => boolean; + ignoreSpansForIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; /** * @deprecated This no longer does anything. @@ -146,12 +131,12 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * @deprecated This no longer does anything. */ instrumentation?: { - requestHook?: (span: Span, req: http.ClientRequest | http.IncomingMessage) => void; - responseHook?: (span: Span, response: http.IncomingMessage | http.ServerResponse) => void; + requestHook?: (span: Span, req: ClientRequest | IncomingMessage) => void; + responseHook?: (span: Span, response: IncomingMessage | ServerResponse) => void; applyCustomAttributesOnSpan?: ( span: Span, - request: http.ClientRequest | http.IncomingMessage, - response: http.IncomingMessage | http.ServerResponse, + request: ClientRequest | IncomingMessage, + response: IncomingMessage | ServerResponse, ) => void; }; @@ -178,64 +163,77 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts */ export class SentryHttpInstrumentation extends InstrumentationBase { - private _propagationDecisionMap: LRUMap; - private _ignoreOutgoingRequestsMap: WeakMap; - public constructor(config: SentryHttpInstrumentationOptions = {}) { super(INSTRUMENTATION_NAME, SDK_VERSION, config); - - this._propagationDecisionMap = new LRUMap(100); - this._ignoreOutgoingRequestsMap = new WeakMap(); } /** @inheritdoc */ public init(): [InstrumentationNodeModuleDefinition, InstrumentationNodeModuleDefinition] { - // We register handlers when either http or https is instrumented - // but we only want to register them once, whichever is loaded first - let hasRegisteredHandlers = false; - - const onHttpClientResponseFinish = ((_data: unknown) => { - const data = _data as { request: http.ClientRequest; response: http.IncomingMessage }; - this._onOutgoingRequestFinish(data.request, data.response); - }) satisfies ChannelListener; - - const onHttpClientRequestError = ((_data: unknown) => { - const data = _data as { request: http.ClientRequest }; - this._onOutgoingRequestFinish(data.request, undefined); - }) satisfies ChannelListener; - - const onHttpClientRequestCreated = ((_data: unknown) => { - const data = _data as { request: http.ClientRequest }; - this._onOutgoingRequestCreated(data.request); - }) satisfies ChannelListener; - - const wrap = (moduleExports: T): T => { - if (hasRegisteredHandlers) { - return moduleExports; - } + const { outgoingRequestApplyCustomAttributes: applyCustomAttributesOnSpan, ...options } = this.getConfig(); + const patchOptions: HttpInstrumentationOptions = { + propagateTrace: options.propagateTraceInOutgoingRequests, + applyCustomAttributesOnSpan, + ...options, + spans: options.createSpansForOutgoingRequests && (options.spans ?? true), + ignoreOutgoingRequests(url, request) { + return ( + isTracingSuppressed(context.active()) || + !!options.ignoreOutgoingRequests?.(url, getRequestOptions(request as ClientRequest)) + ); + }, + outgoingRequestHook(span, request) { + options.outgoingRequestHook?.(span, request); + // We monkey-patch `req.once('response'), which is used to trigger + // the callback of the request, so that it runs in the active context + // eslint-disable-next-line @typescript-eslint/unbound-method, deprecation/deprecation + const originalOnce = request.once; + + const newOnce = new Proxy(originalOnce, { + apply(target, thisArg, args: Parameters) { + const [event] = args; + if (event !== 'response') { + return target.apply(thisArg, args); + } + + const parentContext = context.active(); + const requestContext = trace.setSpan(parentContext, span); + + return context.with(requestContext, () => { + return target.apply(thisArg, args); + }); + }, + }); - hasRegisteredHandlers = true; + // eslint-disable-next-line deprecation/deprecation + request.once = newOnce; + }, + outgoingResponseHook(span, response) { + options.outgoingResponseHook?.(span, response); + context.bind(context.active(), response); + }, + errorMonitor, + }; - subscribe('http.client.response.finish', onHttpClientResponseFinish); + // only generate the subscriber function if we'll actually use it + const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL + ? getHttpClientSubscriptions(patchOptions) + : {}; - // When an error happens, we still want to have a breadcrumb - // In this case, `http.client.response.finish` is not triggered - subscribe('http.client.request.error', onHttpClientRequestError); + // guard because we cover both http and https with the same subscribers + let hasRegisteredHandlers = false; + const sub = onHttpClientRequestCreated + ? (moduleExports: T): T => { + if (!hasRegisteredHandlers && onHttpClientRequestCreated) { + hasRegisteredHandlers = true; + subscribe(HTTP_ON_CLIENT_REQUEST, onHttpClientRequestCreated); + } + return moduleExports; + } + : undefined; - // NOTE: This channel only exists since Node 22.12+ - // Before that, outgoing requests are not patched - // and trace headers are not propagated, sadly. - if (this.getConfig().propagateTraceInOutgoingRequests || this.getConfig().createSpansForOutgoingRequests) { - subscribe('http.client.request.created', onHttpClientRequestCreated); - } - return moduleExports; - }; + const wrapHttp = sub ?? ((moduleExports: HttpModuleExport) => patchHttpModuleClient(moduleExports, patchOptions)); - const unwrap = (): void => { - unsubscribe('http.client.response.finish', onHttpClientResponseFinish); - unsubscribe('http.client.request.error', onHttpClientRequestError); - unsubscribe('http.client.request.created', onHttpClientRequestCreated); - }; + const wrapHttps = sub ?? ((moduleExports: HttpModuleExport) => patchHttpsModuleClient(moduleExports, patchOptions)); /** * You may be wondering why we register these diagnostics-channel listeners @@ -246,284 +244,8 @@ export class SentryHttpInstrumentation extends InstrumentationBase) { - const [event] = args; - if (event !== 'response') { - return target.apply(thisArg, args); - } - - const parentContext = context.active(); - const requestContext = trace.setSpan(parentContext, span); - - return context.with(requestContext, () => { - return target.apply(thisArg, args); - }); - }, - }); - - // eslint-disable-next-line deprecation/deprecation - request.once = newOnce; - - /** - * Determines if the request has errored or the response has ended/errored. - */ - let responseFinished = false; - - const endSpan = (status: SpanStatus): void => { - if (responseFinished) { - return; - } - responseFinished = true; - - span.setStatus(status); - span.end(); - }; - - request.prependListener('response', response => { - if (request.listenerCount('response') <= 1) { - response.resume(); - } - - context.bind(context.active(), response); - - const additionalAttributes = _getOutgoingRequestEndedSpanData(response); - span.setAttributes(additionalAttributes); - - this.getConfig().outgoingResponseHook?.(span, response); - this.getConfig().outgoingRequestApplyCustomAttributes?.(span, request, response); - - const endHandler = (forceError: boolean = false): void => { - this._diag.debug('outgoingRequest on end()'); - - const status = - // eslint-disable-next-line deprecation/deprecation - forceError || typeof response.statusCode !== 'number' || (response.aborted && !response.complete) - ? { code: SpanStatusCode.ERROR } - : getSpanStatusFromHttpCode(response.statusCode); - - endSpan(status); - }; - - response.on('end', () => { - endHandler(); - }); - response.on(errorMonitor, error => { - this._diag.debug('outgoingRequest on response error()', error); - endHandler(true); - }); - }); - - // Fallback if proper response end handling above fails - request.on('close', () => { - endSpan({ code: SpanStatusCode.UNSET }); - }); - request.on(errorMonitor, error => { - this._diag.debug('outgoingRequest on request error()', error); - endSpan({ code: SpanStatusCode.ERROR }); - }); - - return span; - } - - /** - * This is triggered when an outgoing request finishes. - * It has access to the final request and response objects. - */ - private _onOutgoingRequestFinish(request: http.ClientRequest, response?: http.IncomingMessage): void { - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Handling finished outgoing request'); - - const _breadcrumbs = this.getConfig().breadcrumbs; - const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; - - // Note: We cannot rely on the map being set by `_onOutgoingRequestCreated`, because that is not run in Node <22 - const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request) ?? this._shouldIgnoreOutgoingRequest(request); - this._ignoreOutgoingRequestsMap.set(request, shouldIgnore); - - if (breadCrumbsEnabled && !shouldIgnore) { - addRequestBreadcrumb(request, response); - } - } - - /** - * This is triggered when an outgoing request is created. - * It creates a span (if enabled) and propagates trace headers within the span's context, - * so downstream services link to the outgoing HTTP span rather than its parent. - */ - private _onOutgoingRequestCreated(request: http.ClientRequest): void { - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Handling outgoing request created'); - - const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request) ?? this._shouldIgnoreOutgoingRequest(request); - this._ignoreOutgoingRequestsMap.set(request, shouldIgnore); - - if (shouldIgnore) { - return; - } - - const shouldCreateSpan = this.getConfig().createSpansForOutgoingRequests && (this.getConfig().spans ?? true); - const shouldPropagate = this.getConfig().propagateTraceInOutgoingRequests; - - if (shouldCreateSpan) { - const span = this._startSpanForOutgoingRequest(request); - - // Propagate headers within the span's context so the sentry-trace header - // contains the outgoing span's ID, not the parent span's ID. - // Only do this if the span is recording (has a parent) - otherwise the non-recording - // span would produce all-zero trace IDs instead of using the scope's propagation context. - if (shouldPropagate && span.isRecording()) { - const requestContext = trace.setSpan(context.active(), span); - context.with(requestContext, () => { - addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); - }); - } else if (shouldPropagate) { - addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); - } - } else if (shouldPropagate) { - addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); - } - } - - /** - * Check if the given outgoing request should be ignored. - */ - private _shouldIgnoreOutgoingRequest(request: http.ClientRequest): boolean { - if (isTracingSuppressed(context.active())) { - return true; - } - - const ignoreOutgoingRequests = this.getConfig().ignoreOutgoingRequests; - - if (!ignoreOutgoingRequests) { - return false; - } - - const options = getRequestOptions(request); - const url = getClientRequestUrl(request); - return ignoreOutgoingRequests(url, options); - } -} - -function _getOutgoingRequestSpanData(request: http.ClientRequest): [string, SpanAttributes] { - const url = getClientRequestUrl(request); - - const [name, attributes] = getHttpSpanDetailsFromUrlObject( - parseStringToURLObject(url), - 'client', - 'auto.http.otel.http', - request, - ); - - const userAgent = request.getHeader('user-agent'); - - return [ - name, - { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', - 'otel.kind': 'CLIENT', - [ATTR_USER_AGENT_ORIGINAL]: userAgent, - [ATTR_URL_FULL]: url, - 'http.url': url, - 'http.method': request.method, - 'http.target': request.path || '/', - 'net.peer.name': request.host, - 'http.host': request.getHeader('host'), - ...attributes, - }, - ]; -} - -/** - * Exported for testing purposes. - */ -export function _getOutgoingRequestEndedSpanData(response: http.IncomingMessage): SpanAttributes { - const { statusCode, statusMessage, httpVersion, socket } = response; - - // httpVersion can be undefined in some cases and we seem to have encountered this before: - // https://github.com/getsentry/sentry-javascript/blob/ec8c8c64cde6001123db0199a8ca017b8863eac8/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts#L158 - // see: #20415 - const transport = httpVersion?.toUpperCase() !== 'QUIC' ? 'ip_tcp' : 'ip_udp'; - - const additionalAttributes: SpanAttributes = { - [ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode, - [ATTR_NETWORK_PROTOCOL_VERSION]: httpVersion, - 'http.flavor': httpVersion, - [ATTR_NETWORK_TRANSPORT]: transport, - 'net.transport': transport, - ['http.status_text']: statusMessage?.toUpperCase(), - 'http.status_code': statusCode, - ...getResponseContentLengthAttributes(response), - }; - - if (socket) { - const { remoteAddress, remotePort } = socket; - - additionalAttributes[ATTR_NETWORK_PEER_ADDRESS] = remoteAddress; - additionalAttributes[ATTR_NETWORK_PEER_PORT] = remotePort; - additionalAttributes['net.peer.ip'] = remoteAddress; - additionalAttributes['net.peer.port'] = remotePort; - } - - return additionalAttributes; -} - -function getResponseContentLengthAttributes(response: http.IncomingMessage): SpanAttributes { - const length = getContentLength(response.headers); - if (length == null) { - return {}; - } - - if (isCompressed(response.headers)) { - // eslint-disable-next-line deprecation/deprecation - return { [SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH]: length }; - } else { - // eslint-disable-next-line deprecation/deprecation - return { [SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED]: length }; - } -} - -function getContentLength(headers: http.OutgoingHttpHeaders | http.IncomingHttpHeaders): number | undefined { - const contentLengthHeader = headers['content-length']; - if (typeof contentLengthHeader === 'number') { - return contentLengthHeader; - } - if (typeof contentLengthHeader !== 'string') { - return undefined; - } - - const contentLength = parseInt(contentLengthHeader, 10); - if (isNaN(contentLength)) { - return undefined; - } - - return contentLength; -} - -function isCompressed(headers: OutgoingHttpHeaders | IncomingHttpHeaders): boolean { - const encoding = headers['content-encoding']; - - return !!encoding && encoding !== 'identity'; } diff --git a/packages/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts index 986be8d4c8ff..1c08e9f4f16e 100644 --- a/packages/node-core/src/integrations/http/httpServerIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -4,7 +4,7 @@ import type { EventEmitter } from 'node:events'; import type { IncomingMessage, RequestOptions, Server, ServerResponse } from 'node:http'; import type { Socket } from 'node:net'; import { context, createContextKey, propagation } from '@opentelemetry/api'; -import type { AggregationCounts, Client, Integration, IntegrationFn, Scope } from '@sentry/core'; +import type { AggregationCounts, Client, HttpIncomingMessage, Integration, IntegrationFn, Scope } from '@sentry/core'; import { _INTERNAL_safeMathRandom, addNonEnumerableProperty, @@ -30,7 +30,7 @@ interface WeakRefImpl { } type StartSpanCallback = (next: () => boolean) => boolean; -type RequestWithOptionalStartSpanCallback = IncomingMessage & { +type RequestWithOptionalStartSpanCallback = HttpIncomingMessage & { _startSpanCallback?: WeakRefImpl; }; diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index 7909482a5923..3d70387df415 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -1,5 +1,5 @@ import { errorMonitor } from 'node:events'; -import type { ClientRequest, IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'; +import type { IncomingHttpHeaders } from 'node:http'; import { context, SpanKind, trace } from '@opentelemetry/api'; import type { RPCMetadata } from '@opentelemetry/core'; import { getRPCMetadata, isTracingSuppressed, RPCType, setRPCMetadata } from '@opentelemetry/core'; @@ -11,7 +11,17 @@ import { SEMATTRS_NET_HOST_PORT, SEMATTRS_NET_PEER_IP, } from '@opentelemetry/semantic-conventions'; -import type { Event, Integration, IntegrationFn, Span, SpanAttributes, SpanStatus } from '@sentry/core'; +import type { + Event, + HttpClientRequest, + HttpIncomingMessage, + HttpServerResponse, + Integration, + IntegrationFn, + Span, + SpanAttributes, + SpanStatus, +} from '@sentry/core'; import { debug, getIsolationScope, @@ -43,7 +53,7 @@ export interface HttpServerSpansIntegrationOptions { * The `request` param contains the original {@type IncomingMessage} object of the incoming request. * You can use it to filter on additional properties like method, headers, etc. */ - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; /** * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. @@ -66,12 +76,12 @@ export interface HttpServerSpansIntegrationOptions { * @deprecated This is deprecated in favor of `incomingRequestSpanHook`. */ instrumentation?: { - requestHook?: (span: Span, req: ClientRequest | IncomingMessage) => void; - responseHook?: (span: Span, response: IncomingMessage | ServerResponse) => void; + requestHook?: (span: Span, req: HttpClientRequest | HttpIncomingMessage) => void; + responseHook?: (span: Span, response: HttpIncomingMessage | HttpServerResponse) => void; applyCustomAttributesOnSpan?: ( span: Span, - request: ClientRequest | IncomingMessage, - response: IncomingMessage | ServerResponse, + request: HttpClientRequest | HttpIncomingMessage, + response: HttpIncomingMessage | HttpServerResponse, ) => void; }; @@ -79,7 +89,7 @@ export interface HttpServerSpansIntegrationOptions { * A hook that can be used to mutate the span for incoming requests. * This is triggered after the span is created, but before it is recorded. */ - onSpanCreated?: (span: Span, request: IncomingMessage, response: ServerResponse) => void; + onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions = {}) => { @@ -106,8 +116,8 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions client.on('httpServerRequest', (_request, _response, normalizedRequest) => { // Type-casting this here because we do not want to put the node types into core - const request = _request as IncomingMessage; - const response = _response as ServerResponse; + const request = _request as HttpIncomingMessage; + const response = _response as HttpServerResponse; const startSpan = (next: () => boolean): boolean => { if ( @@ -127,7 +137,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions const userAgent = headers['user-agent']; const ips = headers['x-forwarded-for']; const httpVersion = request.httpVersion; - const host = headers.host; + const host = headers.host as string | undefined; const hostname = host?.replace(/^(.*)(:[0-9]{1,5})/, '$1') || 'localhost'; const tracer = client.tracer; @@ -264,7 +274,7 @@ export const httpServerSpansIntegration = _httpServerSpansIntegration as ( processEvent: (event: Event) => Event | null; }; -function isKnownPrefetchRequest(req: IncomingMessage): boolean { +function isKnownPrefetchRequest(req: HttpIncomingMessage): boolean { // Currently only handles Next.js prefetch requests but may check other frameworks in the future. return req.headers['next-router-prefetch'] === '1'; } @@ -290,13 +300,13 @@ export function isStaticAssetRequest(urlPath: string): boolean { } function shouldIgnoreSpansForIncomingRequest( - request: IncomingMessage, + request: HttpIncomingMessage, { ignoreStaticAssets, ignoreIncomingRequests, }: { ignoreStaticAssets?: boolean; - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; }, ): boolean { if (isTracingSuppressed(context.active())) { @@ -325,7 +335,7 @@ function shouldIgnoreSpansForIncomingRequest( return false; } -function getRequestContentLengthAttribute(request: IncomingMessage): SpanAttributes { +function getRequestContentLengthAttribute(request: HttpIncomingMessage): SpanAttributes { const length = getContentLength(request.headers); if (length == null) { return {}; @@ -358,7 +368,10 @@ function isCompressed(headers: IncomingHttpHeaders): boolean { return !!encoding && encoding !== 'identity'; } -function getIncomingRequestAttributesOnResponse(request: IncomingMessage, response: ServerResponse): SpanAttributes { +function getIncomingRequestAttributesOnResponse( + request: HttpIncomingMessage, + response: HttpServerResponse, +): SpanAttributes { // take socket from the request, // since it may be detached from the response object in keep-alive mode const { socket } = request; diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts index 34cb86704415..a59186875c76 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -1,4 +1,5 @@ -import type { IncomingMessage, RequestOptions } from 'node:http'; +import type { RequestOptions } from 'node:http'; +import type { HttpIncomingMessage } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '../../otel/instrument'; import type { NodeClient } from '../../sdk/client'; @@ -73,7 +74,7 @@ interface HttpOptions { * The `request` param contains the original {@type IncomingMessage} object of the incoming request. * You can use it to filter on additional properties like method, headers, etc. */ - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; /** * Do not capture spans for incoming HTTP requests with the given status codes. diff --git a/packages/node-core/src/integrations/http/outgoing-requests.ts b/packages/node-core/src/integrations/http/outgoing-requests.ts deleted file mode 100644 index b505904906fc..000000000000 --- a/packages/node-core/src/integrations/http/outgoing-requests.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - addRequestBreadcrumb, - addTracePropagationHeadersToOutgoingRequest, - getClientRequestUrl, - getRequestOptions, -} from '../../utils/outgoingHttpRequest'; diff --git a/packages/node-core/src/light/asyncLocalStorageStrategy.ts b/packages/node-core/src/light/asyncLocalStorageStrategy.ts index 1d6f3f413e59..00a7939d664f 100644 --- a/packages/node-core/src/light/asyncLocalStorageStrategy.ts +++ b/packages/node-core/src/light/asyncLocalStorageStrategy.ts @@ -1,6 +1,11 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { Scope } from '@sentry/core'; -import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; +import { + getDefaultCurrentScope, + getDefaultIsolationScope, + setAsyncContextStrategy, + SUPPRESS_TRACING_KEY, +} from '@sentry/core'; /** * Sets the async context strategy to use AsyncLocalStorage. @@ -62,7 +67,7 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { // In contrast to the browser, we can rely on async context isolation here function suppressTracing(callback: () => T): T { return withScope(scope => { - scope.setSDKProcessingMetadata({ __SENTRY_SUPPRESS_TRACING__: true }); + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); return callback(); }); } diff --git a/packages/node-core/src/light/integrations/httpIntegration.ts b/packages/node-core/src/light/integrations/httpIntegration.ts index 0f3d6f5c5cc4..d7dbfbba9fdf 100644 --- a/packages/node-core/src/light/integrations/httpIntegration.ts +++ b/packages/node-core/src/light/integrations/httpIntegration.ts @@ -1,30 +1,36 @@ -import type { ChannelListener } from 'node:diagnostics_channel'; import { subscribe } from 'node:diagnostics_channel'; -import type { ClientRequest, IncomingMessage, RequestOptions, Server } from 'node:http'; -import type { Integration, IntegrationFn } from '@sentry/core'; +import type { RequestOptions } from 'node:http'; +import type { HttpClientRequest, HttpIncomingMessage, Integration, IntegrationFn } from '@sentry/core'; import { + addOutgoingRequestBreadcrumb, continueTrace, debug, generateSpanId, getCurrentScope, + getHttpClientSubscriptions, getIsolationScope, + HTTP_ON_CLIENT_REQUEST, httpRequestToRequestData, - LRUMap, stripUrlQueryAndFragment, + SUPPRESS_TRACING_KEY, withIsolationScope, + getRequestOptions, + getRequestUrlFromClientRequest, } from '@sentry/core'; +import type { ClientRequest, IncomingMessage, Server } from 'node:http'; import { DEBUG_BUILD } from '../../debug-build'; import { patchRequestToCaptureBody } from '../../utils/captureRequestBody'; -import { - addRequestBreadcrumb, - addTracePropagationHeadersToOutgoingRequest, - getClientRequestUrl, - getRequestOptions, -} from '../../utils/outgoingHttpRequest'; import type { LightNodeClient } from '../client'; +import { errorMonitor } from 'node:events'; +import { NODE_VERSION } from '../../nodeVersion'; const INTEGRATION_NAME = 'Http'; +const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = + (NODE_VERSION.major === 22 && NODE_VERSION.minor >= 12) || + (NODE_VERSION.major === 23 && NODE_VERSION.minor >= 2) || + NODE_VERSION.major >= 24; + // We keep track of emit functions we wrapped, to avoid double wrapping const wrappedEmitFns = new WeakSet(); @@ -83,6 +89,8 @@ export interface HttpIntegrationOptions { const _httpIntegration = ((options: HttpIntegrationOptions = {}) => { const _options = { + ...options, + sessions: false, maxRequestBodySize: options.maxRequestBodySize ?? 'medium', ignoreRequestBody: options.ignoreRequestBody, breadcrumbs: options.breadcrumbs ?? true, @@ -90,40 +98,77 @@ const _httpIntegration = ((options: HttpIntegrationOptions = {}) => { ignoreOutgoingRequests: options.ignoreOutgoingRequests, }; - const propagationDecisionMap = new LRUMap(100); - const ignoreOutgoingRequestsMap = new WeakMap(); - return { name: INTEGRATION_NAME, setupOnce() { - const onHttpServerRequestStart = ((_data: unknown) => { + const onHttpServerRequestStart = (_data: unknown) => { const data = _data as { server: Server }; instrumentServer(data.server, _options); - }) satisfies ChannelListener; - - const onHttpClientRequestCreated = ((_data: unknown) => { - const data = _data as { request: ClientRequest }; - onOutgoingRequestCreated(data.request, _options, propagationDecisionMap, ignoreOutgoingRequestsMap); - }) satisfies ChannelListener; - - const onHttpClientResponseFinish = ((_data: unknown) => { - const data = _data as { request: ClientRequest; response: IncomingMessage }; - onOutgoingRequestFinish(data.request, data.response, _options, ignoreOutgoingRequestsMap); - }) satisfies ChannelListener; - - const onHttpClientRequestError = ((_data: unknown) => { - const data = _data as { request: ClientRequest }; - onOutgoingRequestFinish(data.request, undefined, _options, ignoreOutgoingRequestsMap); - }) satisfies ChannelListener; + }; + + const { ignoreOutgoingRequests } = _options; + + const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = getHttpClientSubscriptions({ + breadcrumbs: _options.breadcrumbs, + propagateTrace: _options.tracePropagation, + ignoreOutgoingRequests: ignoreOutgoingRequests + ? (url, request) => ignoreOutgoingRequests(url, getRequestOptions(request as ClientRequest)) + : undefined, + // No spans in light mode + spans: false, + errorMonitor, + }); subscribe('http.server.request.start', onHttpServerRequestStart); - subscribe('http.client.request.created', onHttpClientRequestCreated); - subscribe('http.client.response.finish', onHttpClientResponseFinish); - subscribe('http.client.request.error', onHttpClientRequestError); + + // Subscribe on the request creation in node versions that support it + subscribe(HTTP_ON_CLIENT_REQUEST, onHttpClientRequestCreated); + + // fall back to just doing breadcrumbs on the request.end() channel + // if we do not have earlier access to the request object at creation + // time. The http.client.request.error channel is only available on + // the same node versions as client.request.created, so no help. + if (_options.breadcrumbs && !FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL) { + subscribe('http.client.request.start', (data: unknown) => { + const { request } = data as { request: HttpClientRequest }; + request.on(errorMonitor, () => onOutgoingResponseFinish(request, undefined, _options)); + request.prependListener('response', response => { + if (request.listenerCount('response') <= 1) { + response.resume(); + } + onOutgoingResponseFinish(request, response, _options); + }); + }); + } }, }; }) satisfies IntegrationFn; +function onOutgoingResponseFinish( + request: HttpClientRequest, + response: HttpIncomingMessage | undefined, + options: { + breadcrumbs: boolean; + ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; + }, +): void { + if (!options.breadcrumbs) { + return; + } + // Check if tracing is suppressed (e.g. for Sentry's own transport requests) + if (getCurrentScope().getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY]) { + return; + } + const { ignoreOutgoingRequests } = options; + if (ignoreOutgoingRequests) { + const url = getRequestUrlFromClientRequest(request as ClientRequest); + if (ignoreOutgoingRequests(url, getRequestOptions(request as ClientRequest))) { + return; + } + } + addOutgoingRequestBreadcrumb(request, response); +} + /** * This integration handles incoming and outgoing HTTP requests in light mode (without OpenTelemetry). * @@ -221,65 +266,3 @@ function instrumentServer( wrappedEmitFns.add(newEmit); server.emit = newEmit; } - -function onOutgoingRequestCreated( - request: ClientRequest, - options: { tracePropagation: boolean; ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean }, - propagationDecisionMap: LRUMap, - ignoreOutgoingRequestsMap: WeakMap, -): void { - const shouldIgnore = shouldIgnoreOutgoingRequest(request, options); - ignoreOutgoingRequestsMap.set(request, shouldIgnore); - - if (shouldIgnore) { - return; - } - - if (options.tracePropagation) { - addTracePropagationHeadersToOutgoingRequest(request, propagationDecisionMap); - } -} - -function onOutgoingRequestFinish( - request: ClientRequest, - response: IncomingMessage | undefined, - options: { - breadcrumbs: boolean; - ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; - }, - ignoreOutgoingRequestsMap: WeakMap, -): void { - if (!options.breadcrumbs) { - return; - } - - // Note: We cannot rely on the map being set by `onOutgoingRequestCreated`, because that channel - // only exists since Node 22 - const shouldIgnore = ignoreOutgoingRequestsMap.get(request) ?? shouldIgnoreOutgoingRequest(request, options); - - if (shouldIgnore) { - return; - } - - addRequestBreadcrumb(request, response); -} - -/** Check if the given outgoing request should be ignored. */ -function shouldIgnoreOutgoingRequest( - request: ClientRequest, - options: { ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean }, -): boolean { - // Check if tracing is suppressed (e.g. for Sentry's own transport requests) - if (getCurrentScope().getScopeData().sdkProcessingMetadata.__SENTRY_SUPPRESS_TRACING__) { - return true; - } - - const { ignoreOutgoingRequests } = options; - - if (!ignoreOutgoingRequests) { - return false; - } - - const url = getClientRequestUrl(request); - return ignoreOutgoingRequests(url, getRequestOptions(request)); -} diff --git a/packages/node-core/src/utils/baggage.ts b/packages/node-core/src/utils/baggage.ts deleted file mode 100644 index 496c834d5c23..000000000000 --- a/packages/node-core/src/utils/baggage.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { objectToBaggageHeader, parseBaggageHeader, SENTRY_BAGGAGE_KEY_PREFIX } from '@sentry/core'; - -/** - * Merge two baggage headers into one. - * - Sentry-specific entries (keys starting with "sentry-") from the new baggage take precedence - * - Non-Sentry entries from existing baggage take precedence - * The order of the existing baggage will be preserved, and new entries will be added to the end. - * - * This matches the behavior of OTEL's propagation.inject() which uses baggage.setEntry() - * to overwrite existing entries with the same key. - */ -export function mergeBaggageHeaders( - existing: Existing, - baggage: string, -): string | undefined | Existing { - if (!existing) { - return baggage; - } - - const existingBaggageEntries = parseBaggageHeader(existing); - const newBaggageEntries = parseBaggageHeader(baggage); - - if (!newBaggageEntries) { - return existing; - } - - // Single pass over new entries to partition sentry vs non-sentry - const newSentryEntries: Record = {}; - const newNonSentryEntries: Record = {}; - for (const [key, value] of Object.entries(newBaggageEntries)) { - if (key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { - newSentryEntries[key] = value; - } else { - newNonSentryEntries[key] = value; - } - } - - const hasNewSentryEntries = Object.keys(newSentryEntries).length > 0; - - // If new baggage contains at least one sentry- value, we remove all old sentry- values - // otherwise, we keep old sentry- values. If we don't remove old sentry- values, we end - // up with an inconsistent dynamic sampling context propagation. - const mergedBaggageEntries: Record = {}; - if (existingBaggageEntries) { - for (const [key, value] of Object.entries(existingBaggageEntries)) { - if (hasNewSentryEntries && key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { - continue; - } - mergedBaggageEntries[key] = value; - } - } - - // Sentry entries from new baggage always overwrite; non-sentry only if not already present - Object.assign(mergedBaggageEntries, newSentryEntries); - for (const [key, value] of Object.entries(newNonSentryEntries)) { - if (!mergedBaggageEntries[key]) { - mergedBaggageEntries[key] = value; - } - } - - return objectToBaggageHeader(mergedBaggageEntries); -} diff --git a/packages/node-core/src/utils/getRequestUrl.ts b/packages/node-core/src/utils/getRequestUrl.ts deleted file mode 100644 index 73ddd33b447b..000000000000 --- a/packages/node-core/src/utils/getRequestUrl.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** Build a full URL from request options or a ClientRequest. */ -export function getRequestUrl(requestOptions: { - protocol?: string | null; - hostname?: string | null; - host?: string | null; - port?: string | number | null; - path?: string | null; -}): string { - const protocol = requestOptions.protocol || ''; - const hostname = requestOptions.hostname || requestOptions.host || ''; - // Don't log standard :80 (http) and :443 (https) ports to reduce the noise - // Also don't add port if the hostname already includes a port - const port = - !requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 || /^(.*):(\d+)$/.test(hostname) - ? '' - : `:${requestOptions.port}`; - const path = requestOptions.path ? requestOptions.path : '/'; - return `${protocol}//${hostname}${port}${path}`; -} diff --git a/packages/node-core/src/utils/outgoingFetchRequest.ts b/packages/node-core/src/utils/outgoingFetchRequest.ts index 85edd6a73b58..ea5a67ab1fde 100644 --- a/packages/node-core/src/utils/outgoingFetchRequest.ts +++ b/packages/node-core/src/utils/outgoingFetchRequest.ts @@ -7,9 +7,9 @@ import { getTraceData, parseUrl, shouldPropagateTraceForUrl, + mergeBaggageHeaders, } from '@sentry/core'; import type { UndiciRequest, UndiciResponse } from '../integrations/node-fetch/types'; -import { mergeBaggageHeaders } from './baggage'; import { debug } from '@sentry/core'; const SENTRY_TRACE_HEADER = 'sentry-trace'; const SENTRY_BAGGAGE_HEADER = 'baggage'; diff --git a/packages/node-core/src/utils/outgoingHttpRequest.ts b/packages/node-core/src/utils/outgoingHttpRequest.ts deleted file mode 100644 index 34624900b472..000000000000 --- a/packages/node-core/src/utils/outgoingHttpRequest.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { LRUMap, SanitizedRequestData } from '@sentry/core'; -import { - addBreadcrumb, - debug, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - getSanitizedUrlString, - getTraceData, - isError, - parseUrl, - shouldPropagateTraceForUrl, -} from '@sentry/core'; -import type { ClientRequest, IncomingMessage, RequestOptions } from 'http'; -import { DEBUG_BUILD } from '../debug-build'; -import { mergeBaggageHeaders } from './baggage'; - -const LOG_PREFIX = '@sentry/instrumentation-http'; - -/** Add a breadcrumb for outgoing requests. */ -export function addRequestBreadcrumb(request: ClientRequest, response: IncomingMessage | undefined): void { - const data = getBreadcrumbData(request); - - const statusCode = response?.statusCode; - const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); - - addBreadcrumb( - { - category: 'http', - data: { - status_code: statusCode, - ...data, - }, - type: 'http', - level, - }, - { - event: 'response', - request, - response, - }, - ); -} - -/** - * Add trace propagation headers to an outgoing request. - * This must be called _before_ the request is sent! - */ -// eslint-disable-next-line complexity -export function addTracePropagationHeadersToOutgoingRequest( - request: ClientRequest, - propagationDecisionMap: LRUMap, -): void { - const url = getClientRequestUrl(request); - - const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() || {}; - const headersToAdd = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap) - ? getTraceData({ propagateTraceparent }) - : undefined; - - if (!headersToAdd) { - return; - } - - const { 'sentry-trace': sentryTrace, baggage, traceparent } = headersToAdd; - - const hasExistingSentryTraceHeader = !!request.getHeader('sentry-trace'); - - if (hasExistingSentryTraceHeader) { - return; - } - - if (sentryTrace) { - try { - request.setHeader('sentry-trace', sentryTrace); - DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added sentry-trace header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - LOG_PREFIX, - 'Failed to add sentry-trace header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - - if (traceparent && !request.getHeader('traceparent')) { - try { - request.setHeader('traceparent', traceparent); - DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added traceparent header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - LOG_PREFIX, - 'Failed to add traceparent header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - - if (baggage) { - const existingBaggage = request.getHeader('baggage'); - const newBaggage = mergeBaggageHeaders(existingBaggage, baggage); - if (newBaggage) { - try { - request.setHeader('baggage', newBaggage); - DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added baggage header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - LOG_PREFIX, - 'Failed to add baggage header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - } -} - -function getBreadcrumbData(request: ClientRequest): Partial { - try { - // `request.host` does not contain the port, but the host header does - const host = request.getHeader('host') || request.host; - const url = new URL(request.path, `${request.protocol}//${host}`); - const parsedUrl = parseUrl(url.toString()); - - const data: Partial = { - url: getSanitizedUrlString(parsedUrl), - 'http.method': request.method || 'GET', - }; - - if (parsedUrl.search) { - data['http.query'] = parsedUrl.search; - } - if (parsedUrl.hash) { - data['http.fragment'] = parsedUrl.hash; - } - - return data; - } catch { - return {}; - } -} - -/** Convert an outgoing request to request options. */ -export function getRequestOptions(request: ClientRequest): RequestOptions { - return { - method: request.method, - protocol: request.protocol, - host: request.host, - hostname: request.host, - path: request.path, - headers: request.getHeaders(), - }; -} - -/** - * - */ -export function getClientRequestUrl(request: ClientRequest): string { - const hostname = request.getHeader('host') || request.host; - const protocol = request.protocol; - const path = request.path; - - return `${protocol}//${hostname}${path}`; -} diff --git a/packages/node-core/test/integrations/SentryHttpInstrumentation.test.ts b/packages/node-core/test/integrations/SentryHttpInstrumentation.test.ts deleted file mode 100644 index 182abaa3663f..000000000000 --- a/packages/node-core/test/integrations/SentryHttpInstrumentation.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type * as http from 'node:http'; -import { describe, expect, it } from 'vitest'; -import { _getOutgoingRequestEndedSpanData } from '../../src/integrations/http/SentryHttpInstrumentation'; - -function createResponse(overrides: Partial): http.IncomingMessage { - return { - statusCode: 200, - statusMessage: 'OK', - httpVersion: '1.1', - headers: {}, - socket: undefined, - ...overrides, - } as unknown as http.IncomingMessage; -} - -describe('_getOutgoingRequestEndedSpanData', () => { - it('sets ip_tcp transport for HTTP/1.1', () => { - const attributes = _getOutgoingRequestEndedSpanData(createResponse({ httpVersion: '1.1' })); - - expect(attributes['network.transport']).toBe('ip_tcp'); - expect(attributes['net.transport']).toBe('ip_tcp'); - expect(attributes['network.protocol.version']).toBe('1.1'); - expect(attributes['http.flavor']).toBe('1.1'); - }); - - it('sets ip_udp transport for QUIC', () => { - const attributes = _getOutgoingRequestEndedSpanData(createResponse({ httpVersion: 'QUIC' })); - - expect(attributes['network.transport']).toBe('ip_udp'); - expect(attributes['net.transport']).toBe('ip_udp'); - }); - - it('does not throw when httpVersion is null', () => { - expect(() => - _getOutgoingRequestEndedSpanData(createResponse({ httpVersion: null as unknown as string })), - ).not.toThrow(); - - const attributes = _getOutgoingRequestEndedSpanData(createResponse({ httpVersion: null as unknown as string })); - expect(attributes['network.transport']).toBe('ip_tcp'); - expect(attributes['net.transport']).toBe('ip_tcp'); - }); - - it('does not throw when httpVersion is undefined', () => { - expect(() => - _getOutgoingRequestEndedSpanData(createResponse({ httpVersion: undefined as unknown as string })), - ).not.toThrow(); - }); -}); diff --git a/packages/node-core/test/utils/baggage.test.ts b/packages/node-core/test/utils/baggage.test.ts deleted file mode 100644 index 0d7ff5f757d5..000000000000 --- a/packages/node-core/test/utils/baggage.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { mergeBaggageHeaders } from '../../src/utils/baggage'; - -describe('mergeBaggageHeaders', () => { - it('returns new baggage when existing is undefined', () => { - const result = mergeBaggageHeaders(undefined, 'foo=bar'); - expect(result).toBe('foo=bar'); - }); - - it('returns existing baggage when new baggage is empty', () => { - const result = mergeBaggageHeaders('foo=bar', ''); - expect(result).toBe('foo=bar'); - }); - - it('returns existing baggage when new baggage is invalid', () => { - const result = mergeBaggageHeaders('foo=bar', 'invalid'); - expect(result).toBe('foo=bar'); - }); - - it('handles empty existing baggage', () => { - const result = mergeBaggageHeaders('', 'foo=bar,sentry-release=1.0.0'); - expect(result).toBe('foo=bar,sentry-release=1.0.0'); - }); - - it('preserves existing non-Sentry entries', () => { - const result = mergeBaggageHeaders('foo=bar,other=vendor', 'foo=newvalue,third=party'); - - const entries = result?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('third=party'); - expect(entries).not.toContain('foo=newvalue'); - }); - - it('overwrites existing Sentry entries with new ones', () => { - const result = mergeBaggageHeaders( - 'sentry-release=1.0.0,sentry-environment=prod', - 'sentry-release=2.0.0,sentry-environment=staging', - ); - - const entries = result?.split(','); - expect(entries).toContain('sentry-release=2.0.0'); - expect(entries).toContain('sentry-environment=staging'); - expect(entries).not.toContain('sentry-release=1.0.0'); - expect(entries).not.toContain('sentry-environment=prod'); - }); - - it('merges Sentry and non-Sentry entries correctly', () => { - const result = mergeBaggageHeaders('foo=bar,sentry-release=1.0.0,other=vendor', 'sentry-release=2.0.0,third=party'); - - const entries = result?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('third=party'); - expect(entries).toContain('sentry-release=2.0.0'); - expect(entries).not.toContain('sentry-release=1.0.0'); - }); - - it('handles third-party baggage with Sentry entries', () => { - const result = mergeBaggageHeaders( - 'other=vendor,foo=bar,third=party,sentry-release=9.9.9,sentry-environment=staging,sentry-sample_rate=0.54,last=item', - 'sentry-release=2.1.0,sentry-environment=myEnv', - ); - - const entries = result?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('last=item'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('third=party'); - expect(entries).toContain('sentry-environment=myEnv'); - expect(entries).toContain('sentry-release=2.1.0'); - expect(entries).not.toContain('sentry-environment=staging'); - expect(entries).not.toContain('sentry-release=9.9.9'); - }); - - it('adds new Sentry entries when they do not exist', () => { - const result = mergeBaggageHeaders('foo=bar,other=vendor', 'sentry-release=1.0.0,sentry-environment=prod'); - - const entries = result?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('sentry-release=1.0.0'); - expect(entries).toContain('sentry-environment=prod'); - }); - - it('handles array-type existing baggage', () => { - const result = mergeBaggageHeaders(['foo=bar', 'other=vendor'], 'sentry-release=1.0.0'); - - const entries = (result as string)?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('sentry-release=1.0.0'); - }); - - it('preserves order of existing entries', () => { - const result = mergeBaggageHeaders('first=1,second=2,third=3', 'fourth=4'); - expect(result).toBe('first=1,second=2,third=3,fourth=4'); - }); - - it('handles complex scenario with multiple Sentry keys', () => { - const result = mergeBaggageHeaders( - 'foo=bar,sentry-release=old,sentry-environment=old,other=vendor', - 'sentry-release=new,sentry-environment=new,sentry-transaction=test,new=entry', - ); - - const entries = result?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('sentry-release=new'); - expect(entries).toContain('sentry-environment=new'); - expect(entries).toContain('sentry-transaction=test'); - expect(entries).toContain('new=entry'); - expect(entries).not.toContain('sentry-release=old'); - expect(entries).not.toContain('sentry-environment=old'); - }); - - it('overwrites existing Sentry entries with new SDK values', () => { - const result = mergeBaggageHeaders( - 'sentry-trace_id=abc123,sentry-sampled=false,non-sentry=keep', - 'sentry-trace_id=xyz789,sentry-sampled=true', - ); - - const entries = result?.split(','); - expect(entries).toContain('sentry-trace_id=xyz789'); - expect(entries).toContain('sentry-sampled=true'); - expect(entries).toContain('non-sentry=keep'); - expect(entries).not.toContain('sentry-trace_id=abc123'); - expect(entries).not.toContain('sentry-sampled=false'); - }); - - it('merges non-conflicting baggage entries', () => { - const existing = 'custom-key=value'; - const newBaggage = 'sentry-environment=production'; - const result = mergeBaggageHeaders(existing, newBaggage); - expect(result).toBe('custom-key=value,sentry-environment=production'); - }); - - it('overwrites existing Sentry entries when keys conflict', () => { - const existing = 'sentry-environment=staging'; - const newBaggage = 'sentry-environment=production'; - const result = mergeBaggageHeaders(existing, newBaggage); - expect(result).toBe('sentry-environment=production'); - }); - - it('handles multiple entries with Sentry conflicts', () => { - const existing = 'custom-key=value1,sentry-environment=staging'; - const newBaggage = 'sentry-environment=production,sentry-trace_id=123'; - const result = mergeBaggageHeaders(existing, newBaggage); - expect(result).toContain('custom-key=value1'); - expect(result).toContain('sentry-environment=production'); - expect(result).toContain('sentry-trace_id=123'); - expect(result).not.toContain('sentry-environment=staging'); - }); - - it('removes all sentry- values from old baggage and only adds new ones (if at least one new sentry- value is present)', () => { - const existing = 'sentry-trace_id=old,sentry-sampled=false,non-sentry=keep'; - const newBaggage = 'sentry-trace_id=new,sentry-environment=new'; - const result = mergeBaggageHeaders(existing, newBaggage); - expect(result).toBe('non-sentry=keep,sentry-trace_id=new,sentry-environment=new'); - }); - - it('preserves existing sentry entries when new baggage has no sentry entries', () => { - const result = mergeBaggageHeaders('sentry-release=1.0.0,foo=bar', 'baz=qux'); - - expect(result).toBe('sentry-release=1.0.0,foo=bar,baz=qux'); - }); -}); diff --git a/packages/node-core/test/utils/getRequestUrl.test.ts b/packages/node-core/test/utils/getRequestUrl.test.ts deleted file mode 100644 index a96514380481..000000000000 --- a/packages/node-core/test/utils/getRequestUrl.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RequestOptions } from 'http'; -import { describe, expect, it } from 'vitest'; -import { getRequestUrl } from '../../src/utils/getRequestUrl'; - -describe('getRequestUrl', () => { - it.each([ - [{ protocol: 'http:', hostname: 'localhost', port: 80 }, 'http://localhost/'], - [{ protocol: 'http:', hostname: 'localhost', host: 'localhost:80', port: 80 }, 'http://localhost/'], - [{ protocol: 'http:', hostname: 'localhost', port: 3000 }, 'http://localhost:3000/'], - [{ protocol: 'http:', host: 'localhost:3000', port: 3000 }, 'http://localhost:3000/'], - [{ protocol: 'https:', hostname: 'localhost', port: 443 }, 'https://localhost/'], - [{ protocol: 'https:', hostname: 'localhost', port: 443, path: '/my-path' }, 'https://localhost/my-path'], - [ - { protocol: 'https:', hostname: 'www.example.com', port: 443, path: '/my-path' }, - 'https://www.example.com/my-path', - ], - ])('works with %s', (input: RequestOptions, expected: string | undefined) => { - expect(getRequestUrl(input)).toBe(expected); - }); -}); diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 3e38c12f0c4b..7786747dc9ee 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -1,38 +1,29 @@ -import type { ClientRequest, IncomingMessage, RequestOptions, ServerResponse } from 'node:http'; -import { diag } from '@opentelemetry/api'; -import type { HttpInstrumentationConfig } from '@opentelemetry/instrumentation-http'; -import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import type { Span } from '@sentry/core'; +import type { RequestOptions } from 'node:http'; +import type { HttpClientRequest, HttpIncomingMessage, HttpServerResponse, Span } from '@sentry/core'; import { defineIntegration, - getClient, hasSpansEnabled, SEMANTIC_ATTRIBUTE_URL_FULL, stripDataUrlContent, + getRequestUrlFromClientRequest, } from '@sentry/core'; -import type { HTTPModuleRequestIncomingMessage, NodeClient, SentryHttpInstrumentationOptions } from '@sentry/node-core'; +import type { + NodeClient, + SentryHttpInstrumentationOptions, + HttpServerIntegrationOptions, + HttpServerSpansIntegrationOptions, +} from '@sentry/node-core'; import { - addOriginToSpan, generateInstrumentOnce, - getRequestUrl, httpServerIntegration, httpServerSpansIntegration, - NODE_VERSION, SentryHttpInstrumentation, } from '@sentry/node-core'; -import type { NodeClientOptions } from '../types'; const INTEGRATION_NAME = 'Http'; -const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http'; - -// The `http.client.request.created` diagnostics channel, needed for trace propagation, -// was added in Node 22.12.0 (backported from 23.2.0). Earlier 22.x versions don't have it. -const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = - (NODE_VERSION.major === 22 && NODE_VERSION.minor >= 12) || - (NODE_VERSION.major === 23 && NODE_VERSION.minor >= 2) || - NODE_VERSION.major >= 24; - +// TODO(v11): Consolidate all the various HTTP integration options into one, +// and deprecate the duplicated and aliased options. interface HttpOptions { /** * Whether breadcrumbs should be recorded for outgoing requests. @@ -97,13 +88,13 @@ interface HttpOptions { * The `request` param contains the original {@type IncomingMessage} object of the incoming request. * You can use it to filter on additional properties like method, headers, etc. */ - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; /** * A hook that can be used to mutate the span for incoming requests. * This is triggered after the span is created, but before it is recorded. */ - incomingRequestSpanHook?: (span: Span, request: IncomingMessage, response: ServerResponse) => void; + incomingRequestSpanHook?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; /** * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. @@ -157,12 +148,12 @@ interface HttpOptions { * Additional instrumentation options that are passed to the underlying HttpInstrumentation. */ instrumentation?: { - requestHook?: (span: Span, req: ClientRequest | HTTPModuleRequestIncomingMessage) => void; - responseHook?: (span: Span, response: HTTPModuleRequestIncomingMessage | ServerResponse) => void; + requestHook?: (span: Span, req: HttpIncomingMessage | HttpClientRequest) => void; + responseHook?: (span: Span, response: HttpIncomingMessage | HttpServerResponse) => void; applyCustomAttributesOnSpan?: ( span: Span, - request: ClientRequest | HTTPModuleRequestIncomingMessage, - response: HTTPModuleRequestIncomingMessage | ServerResponse, + request: HttpIncomingMessage | HttpClientRequest, + response: HttpIncomingMessage | HttpServerResponse, ) => void; }; } @@ -174,65 +165,6 @@ export const instrumentSentryHttp = generateInstrumentOnce(INTEGRATION_NAME, config => { - const instrumentation = new HttpInstrumentation({ - ...config, - // This is hard-coded and can never be overridden by the user - disableIncomingRequestInstrumentation: true, - }); - - // We want to update the logger namespace so we can better identify what is happening here - try { - instrumentation['_diag'] = diag.createComponentLogger({ - namespace: INSTRUMENTATION_NAME, - }); - // @ts-expect-error We are writing a read-only property here... - instrumentation.instrumentationName = INSTRUMENTATION_NAME; - } catch { - // ignore errors here... - } - - // The OTel HttpInstrumentation (>=0.213.0) has a guard (`_httpPatched`/`_httpsPatched`) - // that prevents patching `http`/`https` when loaded by both CJS `require()` and ESM `import`. - // In environments like AWS Lambda, the runtime loads `http` via CJS first (for the Runtime API), - // and then the user's ESM handler imports `node:http`. The guard blocks ESM patching after CJS, - // which breaks HTTP spans for ESM handlers. We disable this guard to allow both to be patched. - // TODO(andrei): Remove once https://github.com/open-telemetry/opentelemetry-js/issues/6489 is fixed. - try { - const noopDescriptor = { get: () => false, set: () => {} }; - Object.defineProperty(instrumentation, '_httpPatched', noopDescriptor); - Object.defineProperty(instrumentation, '_httpsPatched', noopDescriptor); - } catch { - // ignore errors here... - } - - return instrumentation; -}); - -/** Exported only for tests. */ -export function _shouldUseOtelHttpInstrumentation( - options: HttpOptions, - clientOptions: Partial = {}, -): boolean { - // If `spans` is passed in, it takes precedence - // Else, we by default emit spans, unless `skipOpenTelemetrySetup` is set to `true` or spans are not enabled - if (typeof options.spans === 'boolean') { - return options.spans; - } - - if (clientOptions.skipOpenTelemetrySetup) { - return false; - } - - // IMPORTANT: We only disable span instrumentation when spans are not enabled _and_ we are on a Node version - // that fully supports the necessary diagnostics channels for trace propagation - if (!hasSpansEnabled(clientOptions) && FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL) { - return false; - } - - return true; -} - /** * 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. @@ -240,27 +172,26 @@ export function _shouldUseOtelHttpInstrumentation( export const httpIntegration = defineIntegration((options: HttpOptions = {}) => { const spans = options.spans ?? true; const disableIncomingRequestSpans = options.disableIncomingRequestSpans; + const enableServerSpans = spans && !disableIncomingRequestSpans; const serverOptions = { sessions: options.trackIncomingRequestsAsSessions, sessionFlushingDelayMS: options.sessionFlushingDelayMS, ignoreRequestBody: options.ignoreIncomingRequestBody, maxRequestBodySize: options.maxIncomingRequestBodySize, - } satisfies Parameters[0]; + } satisfies HttpServerIntegrationOptions; - const serverSpansOptions = { + const serverSpansOptions: HttpServerSpansIntegrationOptions = { ignoreIncomingRequests: options.ignoreIncomingRequests, ignoreStaticAssets: options.ignoreStaticAssets, ignoreStatusCodes: options.dropSpansForIncomingRequestStatusCodes, instrumentation: options.instrumentation, onSpanCreated: options.incomingRequestSpanHook, - } satisfies Parameters[0]; + }; const server = httpServerIntegration(serverOptions); const serverSpans = httpServerSpansIntegration(serverSpansOptions); - const enableServerSpans = spans && !disableIncomingRequestSpans; - return { name: INTEGRATION_NAME, setup(client: NodeClient) { @@ -271,99 +202,42 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => } }, setupOnce() { - const clientOptions = (getClient()?.getOptions() || {}) satisfies Partial; - const useOtelHttpInstrumentation = _shouldUseOtelHttpInstrumentation(options, clientOptions); - server.setupOnce(); - const sentryHttpInstrumentationOptions = { + const sentryHttpInstrumentationOptions: SentryHttpInstrumentationOptions = { breadcrumbs: options.breadcrumbs, - propagateTraceInOutgoingRequests: - typeof options.tracePropagation === 'boolean' - ? options.tracePropagation - : FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL || !useOtelHttpInstrumentation, - createSpansForOutgoingRequests: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL, - spans: options.spans, + spans, + propagateTraceInOutgoingRequests: options.tracePropagation ?? true, + createSpansForOutgoingRequests: spans, ignoreOutgoingRequests: options.ignoreOutgoingRequests, - outgoingRequestHook: (span: Span, request: ClientRequest) => { + outgoingRequestHook: (span: Span, request: HttpClientRequest) => { // Sanitize data URLs to prevent long base64 strings in span attributes - const url = getRequestUrl(request); + const url = getRequestUrlFromClientRequest(request); if (url.startsWith('data:')) { const sanitizedUrl = stripDataUrlContent(url); + // TODO(v11): Update these to the Sentry semantic attributes. + // https://getsentry.github.io/sentry-conventions/attributes/ span.setAttribute('http.url', sanitizedUrl); span.setAttribute(SEMANTIC_ATTRIBUTE_URL_FULL, sanitizedUrl); span.updateName(`${request.method || 'GET'} ${sanitizedUrl}`); } - options.instrumentation?.requestHook?.(span, request); }, outgoingResponseHook: options.instrumentation?.responseHook, outgoingRequestApplyCustomAttributes: options.instrumentation?.applyCustomAttributesOnSpan, - } satisfies SentryHttpInstrumentationOptions; + }; - // This is Sentry-specific instrumentation for outgoing request breadcrumbs & trace propagation + // This is Sentry-specific instrumentation for outgoing request + // breadcrumbs & trace propagation. It uses the diagnostic channels on + // node versions that support it, falling back to monkey-patching when + // needed. instrumentSentryHttp(sentryHttpInstrumentationOptions); - - // This is the "regular" OTEL instrumentation that emits outgoing request spans - if (useOtelHttpInstrumentation) { - const instrumentationConfig = getConfigWithDefaults(options); - instrumentOtelHttp(instrumentationConfig); - } }, processEvent(event) { - // Note: We always run this, even if spans are disabled - // The reason being that e.g. the remix integration disables span creation here but still wants to use the ignore status codes option + // Always run this, even if spans are disabled + // The reason being that e.g. the remix integration disables span + // creation here but still wants to use the ignore status codes option return serverSpans.processEvent(event); }, }; }); - -function getConfigWithDefaults(options: Partial = {}): HttpInstrumentationConfig { - const instrumentationConfig = { - // This is handled by the SentryHttpInstrumentation on Node 22+ - disableOutgoingRequestInstrumentation: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL, - - ignoreOutgoingRequestHook: request => { - const url = getRequestUrl(request); - - if (!url) { - return false; - } - - const _ignoreOutgoingRequests = options.ignoreOutgoingRequests; - if (_ignoreOutgoingRequests?.(url, request)) { - return true; - } - - return false; - }, - - requireParentforOutgoingSpans: false, - requestHook: (span, req) => { - addOriginToSpan(span, 'auto.http.otel.http'); - - // Sanitize data URLs to prevent long base64 strings in span attributes - const url = getRequestUrl(req as ClientRequest); - if (url.startsWith('data:')) { - const sanitizedUrl = stripDataUrlContent(url); - span.setAttribute('http.url', sanitizedUrl); - span.setAttribute(SEMANTIC_ATTRIBUTE_URL_FULL, sanitizedUrl); - span.updateName(`${(req as ClientRequest).method || 'GET'} ${sanitizedUrl}`); - } - - options.instrumentation?.requestHook?.(span, req); - }, - responseHook: (span, res) => { - options.instrumentation?.responseHook?.(span, res); - }, - applyCustomAttributesOnSpan: ( - span: Span, - request: ClientRequest | HTTPModuleRequestIncomingMessage, - response: HTTPModuleRequestIncomingMessage | ServerResponse, - ) => { - options.instrumentation?.applyCustomAttributesOnSpan?.(span, request, response); - }, - } satisfies HttpInstrumentationConfig; - - return instrumentationConfig; -} diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index dcd2efa5595c..944d762f26b4 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { instrumentOtelHttp, instrumentSentryHttp } from '../http'; +import { instrumentSentryHttp } from '../http'; import { amqplibIntegration, instrumentAmqplib } from './amqplib'; import { anthropicAIIntegration, instrumentAnthropicAi } from './anthropic-ai'; import { connectIntegration, instrumentConnect } from './connect'; @@ -72,7 +72,6 @@ export function getAutoPerformanceIntegrations(): Integration[] { export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => void) & { id: string })[] { return [ instrumentSentryHttp, - instrumentOtelHttp, instrumentExpress, instrumentConnect, instrumentFastify, diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts deleted file mode 100644 index d02bc12393c6..000000000000 --- a/packages/node/test/integrations/http.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { _shouldUseOtelHttpInstrumentation } from '../../src/integrations/http'; -import { conditionalTest } from '../helpers/conditional'; - -describe('httpIntegration', () => { - describe('_shouldInstrumentSpans', () => { - it.each([ - [{ spans: true }, {}, true], - [{ spans: false }, {}, false], - [{ spans: true }, { skipOpenTelemetrySetup: true }, true], - [{ spans: false }, { skipOpenTelemetrySetup: true }, false], - [{}, { skipOpenTelemetrySetup: true }, false], - [{}, { tracesSampleRate: 0, skipOpenTelemetrySetup: true }, false], - [{}, { tracesSampleRate: 0 }, true], - ])('returns the correct value for options=%j and clientOptions=%j', (options, clientOptions, expected) => { - const actual = _shouldUseOtelHttpInstrumentation(options, clientOptions); - expect(actual).toBe(expected); - }); - - conditionalTest({ min: 22 })('returns false without tracesSampleRate on Node >=22.12', () => { - const actual = _shouldUseOtelHttpInstrumentation({}, {}); - expect(actual).toBe(false); - }); - - conditionalTest({ max: 21 })('returns true without tracesSampleRate on Node <22', () => { - const actual = _shouldUseOtelHttpInstrumentation({}, {}); - expect(actual).toBe(true); - }); - }); -}); From a152b251937d0c5896d67ac19435aca2b36d04b0 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 28 Apr 2026 12:33:04 -0700 Subject: [PATCH 65/84] test(node): add integration test for http double-instrumentation --- .../scenario-mitigation.ts | 40 +++++++++ .../scenario.ts | 35 ++++++++ .../http-otel-double-instrumentation/test.ts | 86 +++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario-mitigation.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario-mitigation.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario-mitigation.ts new file mode 100644 index 000000000000..decdf15a413e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario-mitigation.ts @@ -0,0 +1,40 @@ +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, + integrations: [ + // Disable Sentry's span creation so that OTel HttpInstrumentation + // is the only source of http.client spans. Breadcrumbs and + // trace-propagation headers are still injected; only span creation + // is suppressed. + Sentry.httpIntegration({ spans: false }), + ], +}); + +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; + +registerInstrumentations({ + instrumentations: [new HttpInstrumentation()], +}); + +import * as http from 'http'; + +void Sentry.startSpan({ name: 'test_transaction' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); +}); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => {}); + httpRes.on('end', resolve); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario.ts new file mode 100644 index 000000000000..b80ceb834d1a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario.ts @@ -0,0 +1,35 @@ +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, +}); + +// Simulate a user who independently sets up OTel HttpInstrumentation +// alongside the Sentry SDK, as when adopting Sentry into existing OTel app +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; + +registerInstrumentations({ + instrumentations: [new HttpInstrumentation()], +}); + +import * as http from 'http'; + +void Sentry.startSpan({ name: 'test_transaction' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); +}); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => {}); + httpRes.on('end', resolve); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/test.ts new file mode 100644 index 000000000000..2e5328f062b8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/test.ts @@ -0,0 +1,86 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('registers double spans when OTel HttpInstrumentation is also active — documents known issue', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', () => {}) + .start(); + + await createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: txn => { + expect(txn.transaction).toBe('test_transaction'); + + const httpClientSpans = (txn.spans ?? []).filter(s => s.op === 'http.client'); + + // PROBLEM: two http.client spans are produced for a single + // outgoing request when @opentelemetry/instrumentation-http runs + // alongside Sentry. + // + // - OTel's HttpInstrumentation monkey-patches http.request and + // creates an OTel span; Sentry's SentrySpanProcessor converts that + // to a Sentry span. + // - On Node >=22.12 Sentry's SentryHttpInstrumentation additionally + // subscribes to the http.client.request.created diagnostic channel. + // The channel fires inside OTel's already-patched http.request, + // triggering Sentry to create a *second* http.client span as a + // child of the first. + // - On Node <22.12 both instrumentations monkey-patch http.request, + // so both wrappers fire and each creates its own span. + // + // MITIGATION: pass `spans: false` to httpIntegration() so Sentry + // defers all outgoing span creation to OTel's HttpInstrumentation + // (whose spans Sentry already captures via SentrySpanProcessor). + // + // See the 'mitigation' scenario alongside this test. + expect(httpClientSpans).toHaveLength(2); + + // The outer span comes from OTel HttpInstrumentation (no Sentry + // origin). The inner span is the one Sentry's own handler creates; + // it is a *child* of the outer span with origin 'auto.http.client'. + const sentrySpan = httpClientSpans.find(s => s.data?.['sentry.origin'] === 'auto.http.client'); + const otelSpan = httpClientSpans.find(s => s.data?.['sentry.origin'] !== 'auto.http.client'); + + expect(sentrySpan).toBeDefined(); + expect(otelSpan).toBeDefined(); + + // the sentry-created span is nested inside the otel-created span. + expect(sentrySpan!.parent_span_id).toBe(otelSpan!.span_id); + }, + }) + .start() + .completed(); + + closeTestServer(); +}); + +test('mitigation: spans: false on httpIntegration prevents double-instrumentation', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', () => {}) + .start(); + + await createRunner(__dirname, 'scenario-mitigation.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: txn => { + expect(txn.transaction).toBe('test_transaction'); + + const httpClientSpans = (txn.spans ?? []).filter(s => s.op === 'http.client'); + // With spans: false in httpIntegration(), Sentry does not create its + // own span. OTel's HttpInstrumentation still creates one, which + // flows through SentrySpanProcessor, so there is exactly one + // http.client span. + expect(httpClientSpans).toHaveLength(1); + expect(httpClientSpans[0]).toMatchObject({ + description: expect.stringMatching(/GET .*\/api\/v0/), + status: 'ok', + }); + }, + }) + .start() + .completed(); + + closeTestServer(); +}); From 4ab98ed58aa7a520e0a5b2849954864d3271e302 Mon Sep 17 00:00:00 2001 From: isaacs Date: Sat, 2 May 2026 15:20:08 -0700 Subject: [PATCH 66/84] core: detect OTel http client wrapping and print warning for the user --- .../scenario.ts | 1 + .../http-otel-double-instrumentation/test.ts | 12 +++- packages/core/src/index.ts | 2 +- .../src/integrations/http/client-patch.ts | 18 ++---- .../integrations/http/client-subscriptions.ts | 11 ++++ .../integrations/http/double-wrap-warning.ts | 24 ++++++++ packages/core/src/integrations/http/types.ts | 25 +++++++- .../http/double-wrap-warning.test.ts | 59 +++++++++++++++++++ .../http/SentryHttpInstrumentation.ts | 16 +++-- .../src/light/integrations/httpIntegration.ts | 1 + 10 files changed, 142 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/integrations/http/double-wrap-warning.ts create mode 100644 packages/core/test/lib/integrations/http/double-wrap-warning.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario.ts index b80ceb834d1a..69edce226157 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario.ts @@ -6,6 +6,7 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, + debug: true, }); // Simulate a user who independently sets up OTel HttpInstrumentation diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/test.ts index 2e5328f062b8..495b6a8a17f6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/test.ts @@ -7,7 +7,7 @@ test('registers double spans when OTel HttpInstrumentation is also active — do .get('/api/v0', () => {}) .start(); - await createRunner(__dirname, 'scenario.ts') + const runner = createRunner(__dirname, 'scenario.ts') .withEnv({ SERVER_URL }) .expect({ transaction: txn => { @@ -50,8 +50,14 @@ test('registers double spans when OTel HttpInstrumentation is also active — do expect(sentrySpan!.parent_span_id).toBe(otelSpan!.span_id); }, }) - .start() - .completed(); + .start(); + + await runner.completed(); + + // The double-wrap warning should have been logged to stderr (via debug.warn) + // since scenario.ts initialises Sentry with debug: true. + const logs = runner.getLogs(); + expect(logs.some(l => l.includes('Double-wrapped http.client detected'))).toBe(true); closeTestServer(); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e20b2118adfc..1751192d13dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -150,7 +150,7 @@ export type { FeatureFlagsIntegration } from './integrations/featureFlags'; export { featureFlagsIntegration } from './integrations/featureFlags'; export { growthbookIntegration } from './integrations/featureFlags'; export { conversationIdIntegration } from './integrations/conversationId'; -export { patchHttpModuleClient, patchHttpsModuleClient } from './integrations/http/client-patch'; +export { patchHttpModuleClient } from './integrations/http/client-patch'; export { getHttpClientSubscriptions } from './integrations/http/client-subscriptions'; export { addOutgoingRequestBreadcrumb } from './integrations/http/add-outgoing-request-breadcrumb'; export { diff --git a/packages/core/src/integrations/http/client-patch.ts b/packages/core/src/integrations/http/client-patch.ts index 3988336574fd..fba60fd9c198 100644 --- a/packages/core/src/integrations/http/client-patch.ts +++ b/packages/core/src/integrations/http/client-patch.ts @@ -40,7 +40,10 @@ import { getHttpClientSubscriptions } from './client-subscriptions'; function patchHttpRequest(httpModule: HttpExport, options: HttpInstrumentationOptions): void { // avoid double-wrap if (!getOriginalFunction(httpModule.request)) { - const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = getHttpClientSubscriptions(options); + const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = getHttpClientSubscriptions({ + ...options, + http: httpModule, + }); const originalRequest = httpModule.request; wrapMethod(httpModule, 'request', function patchedRequest(this: HttpExport, ...args: unknown[]) { @@ -89,8 +92,8 @@ function patchModule(httpModuleExport: HttpModuleExport, options: HttpInstrument } /** - * Patch an `http`-module-shaped export so that every outgoing request is - * tracked as a Sentry span. + * Patch an `node:http` or `node:https` module-shaped export so that every + * outgoing request is tracked by Sentry. * * @example * ```javascript @@ -103,12 +106,3 @@ export const patchHttpModuleClient = ( httpModuleExport: HttpModuleExport, options: HttpInstrumentationOptions = {}, ): HttpModuleExport => patchModule(httpModuleExport, options); - -/** - * Patch an `https`-module-shaped export. Equivalent to `patchHttpModule` but - * sets default `protocol` / `port` for HTTPS when option objects are passed. - */ -export const patchHttpsModuleClient = ( - httpModuleExport: HttpModuleExport, - options: HttpInstrumentationOptions = {}, -): HttpModuleExport => patchModule(httpModuleExport, options); diff --git a/packages/core/src/integrations/http/client-subscriptions.ts b/packages/core/src/integrations/http/client-subscriptions.ts index 756d2f576a78..0bece51e441f 100644 --- a/packages/core/src/integrations/http/client-subscriptions.ts +++ b/packages/core/src/integrations/http/client-subscriptions.ts @@ -34,6 +34,7 @@ import { LOG_PREFIX, HTTP_ON_CLIENT_REQUEST } from './constants'; import type { ClientSubscriptionName } from './constants'; import { getClient, getCurrentScope } from '../../currentScopes'; import { hasSpansEnabled } from '../../utils/hasSpansEnabled'; +import { doubleWrapWarning } from './double-wrap-warning'; type ChannelListener = (message: unknown, name: string | symbol) => void; @@ -56,6 +57,9 @@ export function getHttpClientSubscriptions(options: HttpInstrumentationOptions): spans: createSpans = clientOptions ? hasSpansEnabled(clientOptions) : true, propagateTrace = false, breadcrumbs = true, + http, + https, + suppressOtelWarning = false, } = options; const { request } = data as { request: HttpClientRequest }; @@ -97,6 +101,13 @@ export function getHttpClientSubscriptions(options: HttpInstrumentationOptions): return; } + // guard against OTel wrapping the same module and emitting double-spans + // this doesn't prevent it, just prints a debug warning for the user. + if (!suppressOtelWarning) { + if (http) doubleWrapWarning(http); + if (https) doubleWrapWarning(https); + } + // spans are enabled const span = startInactiveSpan(getOutgoingRequestSpanData(request)); options.outgoingRequestHook?.(span, request); diff --git a/packages/core/src/integrations/http/double-wrap-warning.ts b/packages/core/src/integrations/http/double-wrap-warning.ts new file mode 100644 index 000000000000..27dea32dd9b3 --- /dev/null +++ b/packages/core/src/integrations/http/double-wrap-warning.ts @@ -0,0 +1,24 @@ +import { DEBUG_BUILD } from '../../debug-build'; +import { debug } from '../../utils/debug-logger'; +import type { HttpModuleExport } from './types'; + +const isOtelWrapped = (fn: Function & { __unwrap?: Function }): fn is Function & { __unwrap: Function } => + typeof fn.__unwrap === 'function'; + +// exported for tess +export const warning = + 'Double-wrapped http.client detected. Either disable spans in Sentry.httpIntegration, or disable the OpenTelemetry HTTP instrumentation.'; + +let didDoubleWrapWarning = false; +// no-op in non-debug builds +export const doubleWrapWarning = DEBUG_BUILD + ? (http: HttpModuleExport) => { + if (!didDoubleWrapWarning) { + if (isOtelWrapped(http.request) || isOtelWrapped(http.get)) { + // TODO: add link to documentation + didDoubleWrapWarning = true; + debug.warn(warning); + } + } + } + : () => {}; diff --git a/packages/core/src/integrations/http/types.ts b/packages/core/src/integrations/http/types.ts index a7f56f76cbfb..fb9a132a1e3b 100644 --- a/packages/core/src/integrations/http/types.ts +++ b/packages/core/src/integrations/http/types.ts @@ -104,8 +104,10 @@ export interface HttpIncomingMessage { /** Minimal interface for a Node.js http / https module export */ export interface HttpExport { - request: (...args: unknown[]) => HttpClientRequest; - get: (...args: unknown[]) => HttpClientRequest; + //oxlint-disable typescript/no-explicit-any + request: (...args: any[]) => HttpClientRequest; + //oxlint-disable typescript/no-explicit-any + get: (...args: any[]) => HttpClientRequest; [key: string]: unknown; } @@ -257,4 +259,23 @@ export interface HttpInstrumentationOptions { * the RPC metadata. */ onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; + + /** + * Optional: pass in the `http` and `https` modules in order to detect + * whether a standalone OTel instrumentation is attempting to wrap them + * as well. This is fine, as long as `spans` option is disabled, but will + * result in double-emitting spans otherwise. + * + * Since this cannot be fully prevented due to module load timing, and isn't + * necessarily harmful per se (just noisy/annoying), and there are a number + * of reasonable approaches to fix it (disable the OTel instrumentation, + * disable this instrumentation, or keep both and disable spans in one or the + * other), we simply print a warning so the user can hopefully make an + * informed decision about how to address it (if at all). + */ + http?: HttpModuleExport; + https?: HttpModuleExport; + + /** suppress the warning about double-wrapping with OTel */ + suppressOtelWarning?: boolean; } diff --git a/packages/core/test/lib/integrations/http/double-wrap-warning.test.ts b/packages/core/test/lib/integrations/http/double-wrap-warning.test.ts new file mode 100644 index 000000000000..34108bbe5111 --- /dev/null +++ b/packages/core/test/lib/integrations/http/double-wrap-warning.test.ts @@ -0,0 +1,59 @@ +import { it, expect, describe, vi } from 'vitest'; +import { doubleWrapWarning, warning } from '../../../../src/integrations/http/double-wrap-warning'; +import type { HttpModuleExport } from '../../../../src/integrations/http/types'; + +const DEBUG_WARNS: string[] = []; +vi.mock('../../../../src/utils/debug-logger', () => ({ + debug: { + warn: (msg: string) => { + DEBUG_WARNS.push(msg); + }, + }, +})); + +// must be var, because vi.mock hoists +var debugBuild: boolean = true; +vi.mock(import('../../../../src/debug-build'), () => ({ + get DEBUG_BUILD() { + return debugBuild ?? true; + }, +})); + +describe('doubleWrapWarning', () => { + it('prints no warning if http.request/get not wrapped', () => { + doubleWrapWarning({ + request() {}, + get() {}, + } as unknown as HttpModuleExport); + expect(DEBUG_WARNS).toStrictEqual([]); + }); + + it('prints exactly one warning if http.request/get are wrapped', () => { + doubleWrapWarning({ + request: Object.assign(() => {}, { __unwrap() {} }), + get: Object.assign(() => {}, { __unwrap() {} }), + } as unknown as HttpModuleExport); + doubleWrapWarning({ + request: Object.assign(() => {}, { __unwrap() {} }), + get: Object.assign(() => {}, { __unwrap() {} }), + } as unknown as HttpModuleExport); + doubleWrapWarning({ + request: Object.assign(() => {}, { __unwrap() {} }), + get: Object.assign(() => {}, { __unwrap() {} }), + } as unknown as HttpModuleExport); + expect(DEBUG_WARNS).toStrictEqual([warning]); + DEBUG_WARNS.length = 0; + }); + + it('is a no-op if not in debug mode', async () => { + vi.resetModules(); + debugBuild = false; + const { doubleWrapWarning } = await import('../../../../src/integrations/http/double-wrap-warning'); + doubleWrapWarning({ + request: Object.assign(() => {}, { __unwrap() {} }), + get: Object.assign(() => {}, { __unwrap() {} }), + } as unknown as HttpModuleExport); + expect(DEBUG_WARNS).toStrictEqual([]); + DEBUG_WARNS.length = 0; + }); +}); diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index 808d2aee1018..9a53f4ed926e 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,5 +1,4 @@ import { subscribe } from 'node:diagnostics_channel'; -import type * as http from 'node:http'; import { context, trace } from '@opentelemetry/api'; import { isTracingSuppressed } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; @@ -12,17 +11,13 @@ import type { HttpModuleExport, Span, } from '@sentry/core'; -import { - getHttpClientSubscriptions, - patchHttpModuleClient, - patchHttpsModuleClient, - SDK_VERSION, - getRequestOptions, -} from '@sentry/core'; +import { getHttpClientSubscriptions, patchHttpModuleClient, SDK_VERSION, getRequestOptions } from '@sentry/core'; import { INSTRUMENTATION_NAME } from './constants'; import { HTTP_ON_CLIENT_REQUEST } from '@sentry/core'; import { NODE_VERSION } from '../../nodeVersion'; import { errorMonitor } from 'node:events'; +import * as http from 'node:http'; +import * as https from 'node:https'; const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = (NODE_VERSION.major === 22 && NODE_VERSION.minor >= 12) || @@ -212,6 +207,9 @@ export class SentryHttpInstrumentation extends InstrumentationBase patchHttpModuleClient(moduleExports, patchOptions)); - const wrapHttps = sub ?? ((moduleExports: HttpModuleExport) => patchHttpsModuleClient(moduleExports, patchOptions)); + const wrapHttps = sub ?? ((moduleExports: HttpModuleExport) => patchHttpModuleClient(moduleExports, patchOptions)); /** * You may be wondering why we register these diagnostics-channel listeners diff --git a/packages/node-core/src/light/integrations/httpIntegration.ts b/packages/node-core/src/light/integrations/httpIntegration.ts index d7dbfbba9fdf..e5a13fd6782d 100644 --- a/packages/node-core/src/light/integrations/httpIntegration.ts +++ b/packages/node-core/src/light/integrations/httpIntegration.ts @@ -115,6 +115,7 @@ const _httpIntegration = ((options: HttpIntegrationOptions = {}) => { ? (url, request) => ignoreOutgoingRequests(url, getRequestOptions(request as ClientRequest)) : undefined, // No spans in light mode + // means we don't have pass modules to detect OTel double-wrap spans: false, errorMonitor, }); From d08f452dad73bf11d5babdd6bde6e0f992788603 Mon Sep 17 00:00:00 2001 From: isaacs Date: Sat, 2 May 2026 18:34:07 -0700 Subject: [PATCH 67/84] fix(test): update flaky test for vi.mock hoisting behavior --- .../core/test/lib/integrations/express/patch-layer.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/test/lib/integrations/express/patch-layer.test.ts b/packages/core/test/lib/integrations/express/patch-layer.test.ts index 8953955ee373..5b9ba443aa84 100644 --- a/packages/core/test/lib/integrations/express/patch-layer.test.ts +++ b/packages/core/test/lib/integrations/express/patch-layer.test.ts @@ -11,11 +11,12 @@ import { type Span } from '../../../../src/types-hoist/span'; import { EventEmitter } from 'node:events'; import { getOriginalFunction, markFunctionWrapped } from '../../../../src'; -let DEBUG_BUILD = false; +// must be var to hoist above vi.mock +var DEBUG_BUILD = true; beforeEach(() => (DEBUG_BUILD = true)); vi.mock('../../../../src/debug-build', () => ({ get DEBUG_BUILD() { - return DEBUG_BUILD; + return DEBUG_BUILD ?? true; }, })); From a6d7afcdebef1ce84480e7a5b800c43af201f0fe Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 17:19:30 +0200 Subject: [PATCH 68/84] test(e2e): Add span streaming test app for Cloudflare Workers (#20681) Adds a `cloudflare-workers-streaming` E2E test app that mirrors `cloudflare-workers` with span streaming enabled. Closes https://github.com/getsentry/sentry-javascript/issues/20670 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../cloudflare-workers-streaming/.gitignore | 1 + .../cloudflare-workers-streaming/package.json | 39 +++++ .../playwright.config.ts | 23 +++ .../cloudflare-workers-streaming/src/env.d.ts | 7 + .../cloudflare-workers-streaming/src/index.ts | 137 +++++++++++++++ .../start-event-proxy.mjs | 6 + .../tests/index.test.ts | 163 ++++++++++++++++++ .../tests/memory.test.ts | 31 ++++ .../tests/tsconfig.json | 8 + .../tsconfig.json | 43 +++++ .../vitest.config.mts | 11 ++ .../wrangler.toml | 111 ++++++++++++ 12 files changed, 580 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/index.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/memory.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/vitest.config.mts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/wrangler.toml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/.gitignore new file mode 100644 index 000000000000..e71378008bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/.gitignore @@ -0,0 +1 @@ +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/package.json new file mode 100644 index 000000000000..4f314f5f4396 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/package.json @@ -0,0 +1,39 @@ +{ + "name": "cloudflare-workers-streaming", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "build": "wrangler deploy --dry-run", + "test": "vitest --run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@cloudflare/vitest-pool-workers": "^0.8.19", + "@cloudflare/workers-types": "^4.20240725.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.5.2", + "vitest": "~3.2.0", + "wrangler": "^4.61.0", + "ws": "^8.18.3" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "strip-literal": "~2.0.0" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/playwright.config.ts new file mode 100644 index 000000000000..5c49d7c8e302 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/playwright.config.ts @@ -0,0 +1,23 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38787; +export const INSPECTOR_PORT = 9230; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm dev --port ${APP_PORT} --inspector-port ${INSPECTOR_PORT}`, + port: APP_PORT, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + retries: 0, + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/env.d.ts new file mode 100644 index 000000000000..1701ed9f621a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/env.d.ts @@ -0,0 +1,7 @@ +// Generated by Wrangler on Mon Jul 29 2024 21:44:31 GMT-0400 (Eastern Daylight Time) +// by running `wrangler types` + +interface Env { + E2E_TEST_DSN: ''; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts new file mode 100644 index 000000000000..b8bf4529a602 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts @@ -0,0 +1,137 @@ +/** + * Welcome to Cloudflare Workers! This is your first worker. + * + * - Run `npm run dev` in your terminal to start a development server + * - Open a browser tab at http://localhost:8787/ to see your worker in action + * - Run `npm run deploy` to publish your worker + * + * Bind resources to your worker in `wrangler.toml`. After adding bindings, a type definition for the + * `Env` object can be regenerated with `npm run cf-typegen`. + * + * Learn more at https://developers.cloudflare.com/workers/ + */ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +class MyDurableObjectBase extends DurableObject { + private throwOnExit = new WeakMap(); + async throwException(): Promise { + throw new Error('Should be recorded in Sentry.'); + } + + async alarm(): Promise { + const action = await this.ctx.storage.get('alarm-action'); + if (action === 'throw') { + throw new Error('Alarm error captured by Sentry'); + } + } + + async fetch(request: Request) { + const url = new URL(request.url); + switch (url.pathname) { + case '/throwException': { + await this.throwException(); + break; + } + case '/ws': { + const webSocketPair = new WebSocketPair(); + const [client, server] = Object.values(webSocketPair); + this.ctx.acceptWebSocket(server); + return new Response(null, { status: 101, webSocket: client }); + } + case '/setAlarm': { + const action = url.searchParams.get('action') || 'succeed'; + await this.ctx.storage.put('alarm-action', action); + await this.ctx.storage.setAlarm(Date.now() + 500); + return new Response('Alarm set'); + } + case '/storage/put': { + await this.ctx.storage.put('test-key', 'test-value'); + return new Response('Stored'); + } + case '/storage/get': { + const value = await this.ctx.storage.get('test-key'); + return new Response(`Got: ${value}`); + } + } + return new Response('DO is fine'); + } + + webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void | Promise { + if (message === 'throwException') { + throw new Error('Should be recorded in Sentry: webSocketMessage'); + } else if (message === 'throwOnExit') { + this.throwOnExit.set(ws, new Error('Should be recorded in Sentry: webSocketClose')); + } + } + + webSocketClose(ws: WebSocket): void | Promise { + if (this.throwOnExit.has(ws)) { + const error = this.throwOnExit.get(ws)!; + this.throwOnExit.delete(ws); + throw error; + } + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + switch (url.pathname) { + case '/rpc/throwException': + { + const id = env.MY_DURABLE_OBJECT.idFromName('foo'); + const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub; + try { + await stub.throwException(); + } catch (e) { + //We will catch this to be sure not to log inside withSentry + return new Response(null, { status: 500 }); + } + } + break; + case '/throwException': + throw new Error('To be recorded in Sentry.'); + default: + if (url.pathname.startsWith('/pass-to-object/')) { + const id = env.MY_DURABLE_OBJECT.idFromName('foo'); + const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub; + url.pathname = url.pathname.replace('/pass-to-object/', ''); + return stub.fetch(new Request(url, request)); + } + } + return new Response('Hello World!'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..58f1fbfb123c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-workers-streaming', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/index.test.ts new file mode 100644 index 000000000000..e984247a01d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/index.test.ts @@ -0,0 +1,163 @@ +import { expect, test } from '@playwright/test'; +import { + getSpanOp, + waitForError, + waitForRequest, + waitForStreamedSpan, + waitForStreamedSpans, +} from '@sentry-internal/test-utils'; +import { SDK_VERSION } from '@sentry/cloudflare'; +import { WebSocket } from 'ws'; + +test('Index page', async ({ baseURL }) => { + const result = await fetch(baseURL!); + expect(result.status).toBe(200); + await expect(result.text()).resolves.toBe('Hello World!'); +}); + +test('Sends a streamed span for a basic request', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan('cloudflare-workers-streaming', span => { + return getSpanOp(span) === 'http.server' && span.is_segment; + }); + + await fetch(baseURL!); + + const span = await spanPromise; + + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.status).toBe('ok'); +}); + +test("worker's withSentry", async ({ baseURL }) => { + const eventWaiter = waitForError('cloudflare-workers-streaming', event => { + return event.exception?.values?.[0]?.mechanism?.type === 'auto.http.cloudflare'; + }); + const response = await fetch(`${baseURL}/throwException`); + expect(response.status).toBe(500); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('To be recorded in Sentry.'); +}); + +test('RPC method which throws an exception to be logged to sentry', async ({ baseURL }) => { + const eventWaiter = waitForError('cloudflare-workers-streaming', event => { + return event.exception?.values?.[0]?.mechanism?.type === 'auto.faas.cloudflare.durable_object'; + }); + const response = await fetch(`${baseURL}/rpc/throwException`); + expect(response.status).toBe(500); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.'); +}); + +test("Request processed by DurableObject's fetch is recorded", async ({ baseURL }) => { + const eventWaiter = waitForError('cloudflare-workers-streaming', event => { + return event.exception?.values?.[0]?.mechanism?.type === 'auto.faas.cloudflare.durable_object'; + }); + const response = await fetch(`${baseURL}/pass-to-object/throwException`); + expect(response.status).toBe(500); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.'); +}); + +test('Websocket.webSocketMessage', async ({ baseURL }) => { + const eventWaiter = waitForError('cloudflare-workers-streaming', event => { + return !!event.exception?.values?.[0]; + }); + const url = new URL('/pass-to-object/ws', baseURL); + url.protocol = url.protocol.replace('http', 'ws'); + const socket = new WebSocket(url.toString()); + socket.addEventListener('open', () => { + socket.send('throwException'); + }); + const event = await eventWaiter; + socket.close(); + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketMessage'); + expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object'); +}); + +test('Websocket.webSocketClose', async ({ baseURL }) => { + const eventWaiter = waitForError('cloudflare-workers-streaming', event => { + return !!event.exception?.values?.[0]; + }); + const url = new URL('/pass-to-object/ws', baseURL); + url.protocol = url.protocol.replace('http', 'ws'); + const socket = new WebSocket(url.toString()); + socket.addEventListener('open', () => { + socket.send('throwOnExit'); + socket.close(); + }); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketClose'); + expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object'); +}); + +test('sends user-agent header with SDK name and version in envelope requests', async ({ baseURL }) => { + const requestPromise = waitForRequest('cloudflare-workers-streaming', () => true); + + await fetch(`${baseURL}/throwException`); + + const request = await requestPromise; + + expect(request.rawProxyRequestHeaders).toMatchObject({ + 'user-agent': `sentry.javascript.cloudflare/${SDK_VERSION}`, + }); +}); + +test('Storage operations create spans in Durable Object', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('cloudflare-workers-streaming', spans => { + return spans.some(span => span.name === 'durable_object_storage_put' && getSpanOp(span) === 'db'); + }); + + const response = await fetch(`${baseURL}/pass-to-object/storage/put`); + expect(response.status).toBe(200); + + const spans = await spansPromise; + const putSpan = spans.find(span => span.name === 'durable_object_storage_put' && getSpanOp(span) === 'db'); + + expect(putSpan).toBeDefined(); + expect(putSpan?.attributes?.['db.system.name']?.value).toBe('cloudflare.durable_object.storage'); + expect(putSpan?.attributes?.['db.operation.name']?.value).toBe('put'); +}); + +test.describe('Alarm instrumentation', () => { + test.describe.configure({ mode: 'serial' }); + + test('captures error from alarm handler', async ({ baseURL }) => { + const errorWaiter = waitForError('cloudflare-workers-streaming', event => { + return event.exception?.values?.[0]?.value === 'Alarm error captured by Sentry'; + }); + + const response = await fetch(`${baseURL}/pass-to-object/setAlarm?action=throw`); + expect(response.status).toBe(200); + + const event = await errorWaiter; + expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object'); + }); + + test('creates a streamed span for alarm with new trace linked to setAlarm', async ({ baseURL }) => { + const setAlarmSpanPromise = waitForStreamedSpan('cloudflare-workers-streaming', span => { + return span.name === 'durable_object_storage_setAlarm' && span.is_segment === false; + }); + + const alarmSpanPromise = waitForStreamedSpan('cloudflare-workers-streaming', span => { + return span.name === 'alarm' && getSpanOp(span) === 'function' && span.is_segment; + }); + + const response = await fetch(`${baseURL}/pass-to-object/setAlarm`); + expect(response.status).toBe(200); + + const setAlarmSpan = await setAlarmSpanPromise; + const alarmSpan = await alarmSpanPromise; + + // Alarm creates a streamed span with correct attributes + expect(getSpanOp(alarmSpan)).toBe('function'); + expect(alarmSpan.attributes?.['sentry.origin']?.value).toBe('auto.faas.cloudflare.durable_object'); + + // Alarm starts a new trace (different trace ID from the request that called setAlarm) + expect(alarmSpan.trace_id).not.toBe(setAlarmSpan.trace_id); + + // Alarm links to the trace that called setAlarm via sentry.previous_trace attribute + const previousTrace = alarmSpan.attributes?.['sentry.previous_trace']?.value; + expect(previousTrace).toBeDefined(); + expect(previousTrace).toContain(setAlarmSpan.trace_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/memory.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/memory.test.ts new file mode 100644 index 000000000000..740961b3083f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/memory.test.ts @@ -0,0 +1,31 @@ +import { MemoryProfiler } from '@sentry-internal/test-utils'; +import { expect, test } from '@playwright/test'; +import { INSPECTOR_PORT } from '../playwright.config'; + +test.describe('Worker V8 isolate memory tests', () => { + test('worker memory is reclaimed after GC', async ({ baseURL }) => { + const profiler = new MemoryProfiler({ port: INSPECTOR_PORT }); + + // Warm up: make initial requests and let the runtime settle + for (let i = 0; i < 5; i++) { + await fetch(baseURL!); + } + + await profiler.connect(); + + const baselineSnapshot = await profiler.takeHeapSnapshot(); + + for (let i = 0; i < 50; i++) { + const res = await fetch(baseURL!); + expect(res.status).toBe(200); + await res.text(); + } + + const finalSnapshot = await profiler.takeHeapSnapshot(); + const result = profiler.compareSnapshots(baselineSnapshot, finalSnapshot); + + expect(result.nodeGrowthPercent).toBeLessThan(1); + + await profiler.close(); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/tsconfig.json new file mode 100644 index 000000000000..80bfbd97acc1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-pool-workers"] + }, + "include": ["./**/*.ts"], + "exclude": [] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tsconfig.json new file mode 100644 index 000000000000..87d4bbd5fab8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2021", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["es2021"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "es2022", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "Bundler", + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "exclude": ["test"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/vitest.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/vitest.config.mts new file mode 100644 index 000000000000..931e5113e0c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; + +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: './wrangler.toml' }, + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/wrangler.toml new file mode 100644 index 000000000000..d86500477814 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/wrangler.toml @@ -0,0 +1,111 @@ +#:schema node_modules/wrangler/config-schema.json +name = "cloudflare-workers-streaming" +main = "src/index.ts" +compatibility_date = "2024-07-25" +compatibility_flags = ["nodejs_compat"] + +# [vars] +# E2E_TEST_DSN = "" + +# Automatically place your workloads in an optimal location to minimize latency. +# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure +# rather than the end user may result in better performance. +# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement +# [placement] +# mode = "smart" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Docs: +# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables +# Note: Use secrets to store sensitive data. +# - https://developers.cloudflare.com/workers/configuration/secrets/ +# [vars] +# MY_VARIABLE = "production_value" + +# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai +# [ai] +# binding = "AI" + +# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets +# [[analytics_engine_datasets]] +# binding = "MY_DATASET" + +# Bind a headless browser instance running on Cloudflare's global network. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering +# [browser] +# binding = "MY_BROWSER" + +# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases +# [[d1_databases]] +# binding = "MY_DB" +# database_name = "my-database" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms +# [[dispatch_namespaces]] +# binding = "MY_DISPATCHER" +# namespace = "my-namespace" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects +[[durable_objects.bindings]] +name = "MY_DURABLE_OBJECT" +class_name = "MyDurableObject" + +# Durable Object migrations. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations +[[migrations]] +tag = "v1" +new_sqlite_classes = ["MyDurableObject"] + +# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive +# [[hyperdrive]] +# binding = "MY_HYPERDRIVE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates +# [[mtls_certificates]] +# binding = "MY_CERTIFICATE" +# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.consumers]] +# queue = "my-queue" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes +# [[vectorize]] +# binding = "MY_INDEX" +# index_name = "my-index" From bb5b66cd4bb713c9a765d61c55284e9420aac599 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 17:28:35 +0200 Subject: [PATCH 69/84] test(e2e): Add node-express-streaming E2E test app (#20684) Streaming-only clone of `node-express` E2E app with `traceLifecycle: 'stream'` + `spanStreamingIntegration()`, using `waitForStreamedSpan`/`waitForStreamedSpans` helpers. Closes https://github.com/getsentry/sentry-javascript/issues/20669 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../node-express-streaming/.gitignore | 1 + .../node-express-streaming/package.json | 35 ++ .../playwright.config.mjs | 7 + .../node-express-streaming/src/app.ts | 148 +++++++++ .../node-express-streaming/src/mcp.ts | 221 +++++++++++++ .../start-event-proxy.mjs | 6 + .../tests/errors.test.ts | 58 ++++ .../node-express-streaming/tests/logs.test.ts | 16 + .../node-express-streaming/tests/mcp.test.ts | 302 ++++++++++++++++++ .../node-express-streaming/tests/misc.test.ts | 15 + .../tests/spans.test.ts | 137 ++++++++ .../node-express-streaming/tests/trpc.test.ts | 105 ++++++ .../node-express-streaming/tsconfig.json | 11 + 13 files changed, 1062 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/src/mcp.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/logs.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/mcp.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/misc.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/trpc.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-streaming/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json b/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json new file mode 100644 index 000000000000..77124040ff6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json @@ -0,0 +1,35 @@ +{ + "name": "node-express-streaming-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@trpc/server": "10.45.4", + "@trpc/client": "10.45.4", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "express": "^4.21.2", + "typescript": "~5.0.0", + "zod": "~3.25.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz" + }, + "resolutions": { + "@types/qs": "6.9.17" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-streaming/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts new file mode 100644 index 000000000000..5a0d1afa4141 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts @@ -0,0 +1,148 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + enableLogs: true, + traceLifecycle: 'stream', + integrations: [ + Sentry.spanStreamingIntegration(), + Sentry.nativeNodeFetchIntegration({ + headersToSpanAttributes: { + responseHeaders: ['content-length'], + }, + }), + ], +}); + +import { TRPCError, initTRPC } from '@trpc/server'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import express from 'express'; +import { z } from 'zod'; +import { mcpRouter } from './mcp'; + +const app = express(); +const port = 3030; + +app.use(express.json()); + +app.use(mcpRouter); + +app.get('/crash-in-with-monitor/:id', async (req, res) => { + try { + await Sentry.withMonitor('express-crash', async () => { + throw new Error(`This is an exception withMonitor: ${req.params.id}`); + }); + res.sendStatus(200); + } catch (error: any) { + res.status(500); + res.send({ message: error.message, pid: process.pid }); + } +}); + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-log', function (req, res) { + Sentry.logger.debug('Accessed /test-log route'); + res.send({ message: 'Log sent' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-transaction', function (_req, res) { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + + res.send({ status: 'ok' }); +}); + +app.get('/test-outgoing-fetch', async function (_req, res) { + const response = await fetch('http://localhost:3030/test-success'); + const data = await response.json(); + res.send(data); +}); +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.get('/test-local-variables-uncaught', function (req, res) { + const randomVariableToRecord = Math.random(); + throw new Error(`Uncaught Local Variable Error - ${JSON.stringify({ randomVariableToRecord })}`); +}); + +app.get('/test-local-variables-caught', function (req, res) { + const randomVariableToRecord = Math.random(); + + let exceptionId: string; + try { + throw new Error('Local Variable Error'); + } catch (e) { + exceptionId = Sentry.captureException(e); + } + + res.send({ exceptionId, randomVariableToRecord }); +}); + +Sentry.setupExpressErrorHandler(app); + +// @ts-ignore +app.use(function onError(err, req, res, next) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); + +export const t = initTRPC.context().create(); + +const procedure = t.procedure.use(Sentry.trpcMiddleware({ attachRpcInput: true })); + +export const appRouter = t.router({ + getSomething: procedure.input(z.string()).query(opts => { + return { id: opts.input, name: 'Bilbo' }; + }), + createSomething: procedure.mutation(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + return { success: true }; + }), + crashSomething: procedure + .input(z.object({ nested: z.object({ nested: z.object({ nested: z.string() }) }) })) + .mutation(() => { + throw new Error('I crashed in a trpc handler'); + }), + badRequest: procedure.mutation(() => { + throw new TRPCError({ code: 'BAD_REQUEST', cause: new Error('Bad Request') }); + }), +}); + +export type AppRouter = typeof appRouter; + +const createContext = () => ({ someStaticValue: 'asdf' }); +type Context = Awaited>; + +app.use( + '/trpc', + trpcExpress.createExpressMiddleware({ + router: appRouter, + createContext, + }), +); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/mcp.ts new file mode 100644 index 000000000000..72c4535a3d6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/mcp.ts @@ -0,0 +1,221 @@ +import { randomUUID } from 'node:crypto'; +import express from 'express'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { z } from 'zod'; +import { wrapMcpServerWithSentry } from '@sentry/node'; + +// Helper to check if request is an initialize request (compatible with all MCP SDK versions) +function isInitializeRequest(body: unknown): boolean { + return typeof body === 'object' && body !== null && (body as { method?: string }).method === 'initialize'; +} + +const mcpRouter = express.Router(); + +const server = wrapMcpServerWithSentry( + new McpServer({ + name: 'Echo', + version: '1.0.0', + }), +); + +server.resource('echo', new ResourceTemplate('echo://{message}', { list: undefined }), async (uri, { message }) => ({ + contents: [ + { + uri: uri.href, + text: `Resource echo: ${message}`, + }, + ], +})); + +server.tool('echo', { message: z.string() }, async ({ message }, rest) => { + return { + content: [{ type: 'text', text: `Tool echo: ${message}` }], + }; +}); + +server.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + +server.prompt('echo', { message: z.string() }, ({ message }, extra) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}`, + }, + }, + ], +})); + +server.tool('always-error', {}, async () => { + throw new Error('intentional error for span status testing'); +}); + +const transports: Record = {}; + +mcpRouter.get('/sse', async (_, res) => { + const transport = new SSEServerTransport('/messages', res); + transports[transport.sessionId] = transport; + res.on('close', () => { + delete transports[transport.sessionId]; + }); + await server.connect(transport); +}); + +mcpRouter.post('/messages', async (req, res) => { + const sessionId = req.query.sessionId; + const transport = transports[sessionId as string]; + if (transport) { + await transport.handlePostMessage(req, res, req.body); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); + +// ============================================================================= +// Streamable HTTP Transport Endpoints +// This uses StreamableHTTPServerTransport which wraps WebStandardStreamableHTTPServerTransport +// and exercises the wrapper transport pattern that was fixed in the sessionId-based correlation +// See: https://github.com/getsentry/sentry-mcp/issues/767 +// ============================================================================= + +// Create a separate wrapped server for streamable HTTP (to test independent of SSE) +const streamableServer = wrapMcpServerWithSentry( + new McpServer({ + name: 'Echo-Streamable', + version: '1.0.0', + }), +); + +// Register the same handlers on the streamable server +streamableServer.resource( + 'echo', + new ResourceTemplate('echo://{message}', { list: undefined }), + async (uri, { message }) => ({ + contents: [ + { + uri: uri.href, + text: `Resource echo: ${message}`, + }, + ], + }), +); + +streamableServer.tool('echo', { message: z.string() }, async ({ message }) => { + return { + content: [{ type: 'text', text: `Tool echo: ${message}` }], + }; +}); + +streamableServer.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + +streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}`, + }, + }, + ], +})); + +// Map to store streamable transports by session ID +const streamableTransports: Record = {}; + +// POST endpoint for streamable HTTP (handles both initialization and subsequent requests) +mcpRouter.post('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && streamableTransports[sessionId]) { + // Reuse existing transport for session + transport = streamableTransports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request - create new transport + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: sid => { + // Store transport when session is initialized + streamableTransports[sid] = transport; + }, + }); + + // Clean up on close + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && streamableTransports[sid]) { + delete streamableTransports[sid]; + } + }; + + // Connect to server before handling request + await streamableServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + // Invalid request + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, + id: null, + }); + return; + } + + // Handle request with existing transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling streamable HTTP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null, + }); + } + } +}); + +// GET endpoint for SSE streams (server-initiated messages) +mcpRouter.get('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !streamableTransports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const transport = streamableTransports[sessionId]; + await transport.handleRequest(req, res); +}); + +// DELETE endpoint for session termination +mcpRouter.delete('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !streamableTransports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const transport = streamableTransports[sessionId]; + await transport.handleRequest(req, res); +}); + +export { mcpRouter }; diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..4ae5a5eab608 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-streaming', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts new file mode 100644 index 000000000000..628a48c56456 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-streaming', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is an exception with id 123'); + expect(exception?.mechanism).toEqual({ + type: 'auto.middleware.express', + handled: false, + }); + + expect(errorEvent.request).toMatchObject({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); + +test('Should record caught exceptions with local variable', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-streaming', event => { + return event.transaction === 'GET /test-local-variables-caught'; + }); + + await fetch(`${baseURL}/test-local-variables-caught`); + + const errorEvent = await errorEventPromise; + + const frames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + expect(frames?.[frames.length - 1]?.vars?.randomVariableToRecord).toBeDefined(); +}); + +test('To not crash app from withMonitor', async ({ baseURL }) => { + const doRequest = async (id: number) => { + const response = await fetch(`${baseURL}/crash-in-with-monitor/${id}`); + return response.json(); + }; + const [response1, response2] = await Promise.all([doRequest(1), doRequest(2)]); + expect(response1.message).toBe('This is an exception withMonitor: 1'); + expect(response2.message).toBe('This is an exception withMonitor: 2'); + expect(response1.pid).toBe(response2.pid); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/logs.test.ts new file mode 100644 index 000000000000..fddd80692dd0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/logs.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; +import type { SerializedLogContainer } from '@sentry/core'; + +test('should send logs', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('node-express-streaming', envelope => { + return envelope[0].type === 'log' && (envelope[1] as SerializedLogContainer).items[0]?.level === 'debug'; + }); + + await fetch(`${baseURL}/test-log`); + + const logEnvelope = await logEnvelopePromise; + const log = (logEnvelope[1] as SerializedLogContainer).items[0]; + expect(log?.level).toBe('debug'); + expect(log?.body).toBe('Accessed /test-log route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/mcp.test.ts new file mode 100644 index 000000000000..ec82de5af455 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/mcp.test.ts @@ -0,0 +1,302 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +// TODO: MCP handler spans (tools/call, resources/read, etc.) are not emitted as streamed spans +// with SSE transport — only the POST /messages HTTP server span arrives in the envelope. +// Re-enable once the MCP instrumentation supports span streaming over SSE. +test.skip('Should record streamed spans for mcp handlers', async ({ baseURL }) => { + const transport = new SSEClientTransport(new URL(`${baseURL}/sse`)); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const initializeSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'initialize' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + await client.connect(transport); + + await test.step('initialize handshake', async () => { + const initializeSpan = await initializeSpanPromise; + expect(initializeSpan).toBeDefined(); + expect(getSpanOp(initializeSpan)).toBe('mcp.server'); + expect(initializeSpan.attributes?.['mcp.method.name']?.value).toBe('initialize'); + expect(initializeSpan.attributes?.['mcp.client.name']?.value).toBe('test-client'); + expect(initializeSpan.attributes?.['mcp.server.name']?.value).toBe('Echo'); + }); + + await test.step('tool handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'tools/call echo' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const toolResult = await client.callTool({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'Tool echo: foobar', + type: 'text', + }, + ], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.attributes?.['mcp.method.name']?.value).toBe('tools/call'); + }); + + await test.step('registerTool handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'tools/call echo-register' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const toolResult = await client.callTool({ + name: 'echo-register', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'registerTool echo: foobar', + type: 'text', + }, + ], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.attributes?.['mcp.method.name']?.value).toBe('tools/call'); + expect(toolSpan.attributes?.['mcp.tool.name']?.value).toBe('echo-register'); + }); + + await test.step('resource handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const resourceSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'resources/read echo://foobar' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const resourceResult = await client.readResource({ + uri: 'echo://foobar', + }); + + expect(resourceResult).toMatchObject({ + contents: [{ text: 'Resource echo: foobar', uri: 'echo://foobar' }], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const resourceSpan = await resourceSpanPromise; + expect(resourceSpan).toBeDefined(); + expect(getSpanOp(resourceSpan)).toBe('mcp.server'); + expect(resourceSpan.attributes?.['mcp.method.name']?.value).toBe('resources/read'); + }); + + await test.step('prompt handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const promptSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'prompts/get echo' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const promptResult = await client.getPrompt({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(promptResult).toMatchObject({ + messages: [ + { + content: { + text: 'Please process this message: foobar', + type: 'text', + }, + role: 'user', + }, + ], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const promptSpan = await promptSpanPromise; + expect(promptSpan).toBeDefined(); + expect(getSpanOp(promptSpan)).toBe('mcp.server'); + expect(promptSpan.attributes?.['mcp.method.name']?.value).toBe('prompts/get'); + }); + + await test.step('error tool sets span status to error', async () => { + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'tools/call always-error' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + try { + await client.callTool({ name: 'always-error', arguments: {} }); + } catch { + // Expected: MCP SDK throws when the tool returns a JSON-RPC error + } + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.status).toBe('error'); + }); +}); + +test('Should record streamed spans for streamable HTTP transport (wrapper transport pattern)', async ({ baseURL }) => { + const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`)); + + const client = new Client({ + name: 'test-client-streamable', + version: '1.0.0', + }); + + const initializeSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'initialize' && + getSpanOp(span) === 'mcp.server' && + span.attributes?.['mcp.server.name']?.value === 'Echo-Streamable' + ); + }); + + await client.connect(transport); + + await test.step('initialize handshake', async () => { + const initializeSpan = await initializeSpanPromise; + expect(initializeSpan).toBeDefined(); + expect(getSpanOp(initializeSpan)).toBe('mcp.server'); + expect(initializeSpan.attributes?.['mcp.method.name']?.value).toBe('initialize'); + expect(initializeSpan.attributes?.['mcp.client.name']?.value).toBe('test-client-streamable'); + expect(initializeSpan.attributes?.['mcp.server.name']?.value).toBe('Echo-Streamable'); + expect(String(initializeSpan.attributes?.['mcp.transport']?.value)).toMatch(/StreamableHTTPServerTransport/); + }); + + await test.step('tool handler (tests wrapper transport correlation)', async () => { + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'tools/call echo' && + getSpanOp(span) === 'mcp.server' && + String(span.attributes?.['mcp.transport']?.value).includes('StreamableHTTPServerTransport') + ); + }); + + const toolResult = await client.callTool({ + name: 'echo', + arguments: { + message: 'wrapper-transport-test', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'Tool echo: wrapper-transport-test', + type: 'text', + }, + ], + }); + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.attributes?.['mcp.method.name']?.value).toBe('tools/call'); + expect(toolSpan.attributes?.['mcp.tool.name']?.value).toBe('echo'); + expect(toolSpan.attributes?.['mcp.tool.result.content_count']?.value).toBe(1); + }); + + await test.step('resource handler', async () => { + const resourceSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'resources/read echo://streamable-test' && + getSpanOp(span) === 'mcp.server' && + String(span.attributes?.['mcp.transport']?.value).includes('StreamableHTTPServerTransport') + ); + }); + + const resourceResult = await client.readResource({ + uri: 'echo://streamable-test', + }); + + expect(resourceResult).toMatchObject({ + contents: [{ text: 'Resource echo: streamable-test', uri: 'echo://streamable-test' }], + }); + + const resourceSpan = await resourceSpanPromise; + expect(resourceSpan).toBeDefined(); + expect(getSpanOp(resourceSpan)).toBe('mcp.server'); + expect(resourceSpan.attributes?.['mcp.method.name']?.value).toBe('resources/read'); + }); + + await test.step('prompt handler', async () => { + const promptSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'prompts/get echo' && + getSpanOp(span) === 'mcp.server' && + String(span.attributes?.['mcp.transport']?.value).includes('StreamableHTTPServerTransport') + ); + }); + + const promptResult = await client.getPrompt({ + name: 'echo', + arguments: { + message: 'streamable-prompt', + }, + }); + + expect(promptResult).toMatchObject({ + messages: [ + { + content: { + text: 'Please process this message: streamable-prompt', + type: 'text', + }, + role: 'user', + }, + ], + }); + + const promptSpan = await promptSpanPromise; + expect(promptSpan).toBeDefined(); + expect(getSpanOp(promptSpan)).toBe('mcp.server'); + expect(promptSpan.attributes?.['mcp.method.name']?.value).toBe('prompts/get'); + }); + + await client.close(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/misc.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/misc.test.ts new file mode 100644 index 000000000000..b6bc94e19232 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/misc.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; +import { waitForRequest } from '@sentry-internal/test-utils'; +import { SDK_VERSION } from '@sentry/node'; + +test('sends user-agent header with SDK name and version in envelope requests', async ({ baseURL }) => { + const requestPromise = waitForRequest('node-express-streaming', () => true); + + await fetch(`${baseURL}/test-exception/123`); + + const request = await requestPromise; + + expect(request.rawProxyRequestHeaders).toMatchObject({ + 'user-agent': `sentry.javascript.node/${SDK_VERSION}`, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts new file mode 100644 index 000000000000..38cb1402c623 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; + +test('Sends streamed spans for an API route', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('node-express-streaming', spans => { + return spans.some( + span => span.name === 'GET /test-transaction' && getSpanOp(span) === 'http.server' && span.is_segment, + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const spans = await spansPromise; + + const rootSpan = spans.find(span => span.is_segment); + expect(rootSpan).toBeDefined(); + expect(rootSpan!.name).toBe('GET /test-transaction'); + expect(getSpanOp(rootSpan!)).toBe('http.server'); + expect(rootSpan!.status).toBe('ok'); + expect(rootSpan!.trace_id).toMatch(/[a-f0-9]{32}/); + expect(rootSpan!.attributes?.['sentry.source']?.value).toBe('route'); + expect(rootSpan!.attributes?.['sentry.origin']?.value).toBe('auto.http.otel.http'); + expect(rootSpan!.attributes?.['http.response.status_code']?.value).toBe(200); + + const childSpans = spans.filter(span => !span.is_segment); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: 'test-span', + is_segment: false, + status: 'ok', + }), + ); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: 'query', + is_segment: false, + status: 'ok', + }), + ); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: 'expressInit', + is_segment: false, + status: 'ok', + }), + ); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: '/test-transaction', + is_segment: false, + status: 'ok', + }), + ); + + // All spans share the same trace_id + for (const span of spans) { + expect(span.trace_id).toBe(rootSpan!.trace_id); + } +}); + +test('Sends streamed spans for an errored route', async ({ baseURL }) => { + const rootSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'GET /test-exception/:id' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + await fetch(`${baseURL}/test-exception/777`); + + const rootSpan = await rootSpanPromise; + + expect(rootSpan.name).toBe('GET /test-exception/:id'); + expect(getSpanOp(rootSpan)).toBe('http.server'); + expect(rootSpan.status).toBe('error'); + expect(rootSpan.attributes?.['http.status_code']?.value).toBe(500); +}); + +test('Outgoing fetch spans are streamed', async ({ baseURL }) => { + const fetchSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return getSpanOp(span) === 'http.client' && !span.is_segment && span.name.includes('localhost:3030/test-success'); + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const fetchSpan = await fetchSpanPromise; + + expect(fetchSpan).toBeDefined(); + expect(fetchSpan.status).toBe('ok'); +}); + +// TODO: headersToSpanAttributes has a pre-existing type error in packed tarballs (also affects the +// non-streaming node-express app). Re-enable once the NodeFetchOptions type is fixed upstream. +test.skip('Outgoing fetch spans include response headers when headersToSpanAttributes is configured', async ({ + baseURL, +}) => { + const fetchSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return getSpanOp(span) === 'http.client' && !span.is_segment && span.name.includes('localhost:3030/test-success'); + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const fetchSpan = await fetchSpanPromise; + + expect(fetchSpan).toBeDefined(); + expect(fetchSpan.attributes?.['http.response.header.content-length']).toBeDefined(); +}); + +test('Extracts HTTP request headers as streamed span attributes', async ({ baseURL }) => { + const rootSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'GET /test-transaction' && + getSpanOp(span) === 'http.server' && + span.is_segment && + span.attributes?.['http.request.header.user_agent']?.value === 'Custom-Agent/1.0 (Test)' + ); + }); + + await fetch(`${baseURL}/test-transaction`, { + headers: { + 'User-Agent': 'Custom-Agent/1.0 (Test)', + 'Content-Type': 'application/json', + 'X-Custom-Header': 'test-value', + Accept: 'application/json, text/plain', + 'X-Request-ID': 'req-123', + }, + }); + + const rootSpan = await rootSpanPromise; + + expect(rootSpan.attributes?.['http.request.header.user_agent']?.value).toBe('Custom-Agent/1.0 (Test)'); + expect(rootSpan.attributes?.['http.request.header.content_type']?.value).toBe('application/json'); + expect(rootSpan.attributes?.['http.request.header.x_custom_header']?.value).toBe('test-value'); + expect(rootSpan.attributes?.['http.request.header.accept']?.value).toBe('application/json, text/plain'); + expect(rootSpan.attributes?.['http.request.header.x_request_id']?.value).toBe('req-123'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/trpc.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/trpc.test.ts new file mode 100644 index 000000000000..bc0b982adbe9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/trpc.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import type { AppRouter } from '../src/app'; + +test('Should record streamed span for trpc query', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/getSomething' && getSpanOp(span) === 'rpc.server'; + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.getSomething.query('foobar'); + + const trpcSpan = await trpcSpanPromise; + expect(trpcSpan).toBeDefined(); + expect(trpcSpan.name).toBe('trpc/getSomething'); + expect(getSpanOp(trpcSpan)).toBe('rpc.server'); + expect(trpcSpan.attributes?.['sentry.origin']?.value).toBe('auto.rpc.trpc'); +}); + +test('Should record streamed span for trpc mutation', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/createSomething' && getSpanOp(span) === 'rpc.server'; + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.createSomething.mutate(); + + const trpcSpan = await trpcSpanPromise; + expect(trpcSpan).toBeDefined(); + expect(trpcSpan.name).toBe('trpc/createSomething'); + expect(getSpanOp(trpcSpan)).toBe('rpc.server'); + expect(trpcSpan.attributes?.['sentry.origin']?.value).toBe('auto.rpc.trpc'); +}); + +test('Should record streamed span and error for a crashing trpc handler', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/crashSomething' && getSpanOp(span) === 'rpc.server'; + }); + + const errorEventPromise = waitForError('node-express-streaming', errorEvent => { + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('I crashed in a trpc handler')); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await expect(trpcClient.crashSomething.mutate({ nested: { nested: { nested: 'foobar' } } })).rejects.toBeDefined(); + + await expect(trpcSpanPromise).resolves.toBeDefined(); + await expect(errorEventPromise).resolves.toBeDefined(); + + expect((await errorEventPromise).contexts?.trpc?.['procedure_type']).toBe('mutation'); + expect((await errorEventPromise).contexts?.trpc?.['procedure_path']).toBe('crashSomething'); + + expect((await errorEventPromise).contexts?.trpc?.['input']).toEqual({ + nested: { + nested: { + nested: 'foobar', + }, + }, + }); +}); + +test('Should record streamed span and error for a trpc handler that returns a status code', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/badRequest' && getSpanOp(span) === 'rpc.server'; + }); + + const errorEventPromise = waitForError('node-express-streaming', errorEvent => { + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('Bad Request')); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await expect(trpcClient.badRequest.mutate()).rejects.toBeDefined(); + + await expect(trpcSpanPromise).resolves.toBeDefined(); + await expect(errorEventPromise).resolves.toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-streaming/tsconfig.json new file mode 100644 index 000000000000..0060abd94682 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} From 748d8cc02483e853baa259c56a5bda1c5d686556 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 17:37:18 +0200 Subject: [PATCH 70/84] test(e2e): Add span streaming test app for React Router 7 SPA (#20677) Adds a `react-router-7-spa-streaming` E2E test app that mirrors `react-router-7-spa` with `spanStreamingIntegration()` enabled. Converts the `waitForTransaction` tests (pageload, navigation, transactionName) to use `waitForStreamedSpan` and carries over the INP span and error tests. Closes https://github.com/getsentry/sentry-javascript/issues/20671 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../react-router-7-spa-streaming/.gitignore | 29 ++++++++ .../react-router-7-spa-streaming/index.html | 13 ++++ .../react-router-7-spa-streaming/package.json | 65 +++++++++++++++++ .../playwright.config.mjs | 8 +++ .../src/globals.d.ts | 5 ++ .../react-router-7-spa-streaming/src/main.tsx | 57 +++++++++++++++ .../src/pages/Index.tsx | 22 ++++++ .../src/pages/SSE.tsx | 58 ++++++++++++++++ .../src/pages/User.tsx | 7 ++ .../start-event-proxy.mjs | 6 ++ .../tests/errors.test.ts | 59 ++++++++++++++++ .../tests/spans.test.ts | 69 +++++++++++++++++++ .../tsconfig.json | 21 ++++++ .../vite.config.ts | 8 +++ 14 files changed, 427 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/index.html create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/main.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/Index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/SSE.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/User.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.gitignore @@ -0,0 +1,29 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/index.html b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/index.html new file mode 100644 index 000000000000..e4b78eae1230 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json new file mode 100644 index 000000000000..4fddbfa60945 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json @@ -0,0 +1,65 @@ +{ + "name": "react-router-7-spa-streaming", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "file:../../packed/sentry-react-packed.tgz", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router": "^7.13.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "vite": "^6.4.2", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.0.0" + }, + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-ts3.8": "pnpm install && pnpm add typescript@3.8 && pnpm build", + "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && pnpm build", + "test:assert": "pnpm test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-ts3.8", + "label": "react-router-7-spa-streaming (TS 3.8)" + } + ] + }, + "pnpm": { + "overrides": { + "esbuild": "0.24.0" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs new file mode 100644 index 000000000000..7fda76df18ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm preview --port 3030`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/main.tsx new file mode 100644 index 000000000000..9f69427b101d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/main.tsx @@ -0,0 +1,57 @@ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { + BrowserRouter, + Route, + Routes, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router'; +import Index from './pages/Index'; +import SSE from './pages/SSE'; +import User from './pages/User'; + +const replay = Sentry.replayIntegration(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + integrations: [ + Sentry.reactRouterV7BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + trackFetchStreamPerformance: true, + }), + Sentry.spanStreamingIntegration(), + replay, + ], + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + release: 'e2e-test', + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + tunnel: 'http://localhost:3031', + sendDefaultPii: true, +}); + +const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + + + } /> + } /> + } /> + + , +); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/Index.tsx new file mode 100644 index 000000000000..688cba53fb70 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/Index.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Link } from 'react-router'; + +const Index = () => { + return ( + <> + { + throw new Error('I am an error!'); + }} + /> + + navigate + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/SSE.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/SSE.tsx new file mode 100644 index 000000000000..4c0ae97036ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/SSE.tsx @@ -0,0 +1,58 @@ +import * as Sentry from '@sentry/react'; +import * as React from 'react'; + +const fetchSSE = async ({ timeout, abort = false }: { timeout: boolean; abort?: boolean }) => { + Sentry.startSpanManual({ name: 'sse stream using fetch' }, async span => { + const controller = new AbortController(); + + const res = await Sentry.startSpan({ name: 'sse fetch call' }, async () => { + const endpoint = `http://localhost:8080/${timeout ? 'sse-timeout' : 'sse'}`; + + const signal = controller.signal; + return await fetch(endpoint, { signal }); + }); + + const stream = res.body; + const reader = stream?.getReader(); + + const readChunk = async () => { + if (abort) { + controller.abort(); + } + const readRes = await reader?.read(); + if (readRes?.done) { + return; + } + + new TextDecoder().decode(readRes?.value); + + await readChunk(); + }; + + try { + await readChunk(); + } catch (error) { + console.error('Could not fetch sse', error); + } + + span.end(); + }); +}; + +const SSE = () => { + return ( + <> + + + + + ); +}; + +export default SSE; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/User.tsx new file mode 100644 index 000000000000..671455a92fff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/User.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const User = () => { + return

I am a blank page :)

; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..202974be53c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-spa-streaming', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/errors.test.ts new file mode 100644 index 000000000000..bd60e80c0246 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page, baseURL }) => { + const errorEventPromise = waitForError('react-router-7-spa-streaming', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const errorEventPromise = waitForError('react-router-7-spa-streaming', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + // Only capture error once pageload span was sent + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id || ''), + }); +}); 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 new file mode 100644 index 000000000000..0080a584463a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/`); + + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.status).toBe('ok'); + expect(span.attributes?.['sentry.origin']?.value).toBe('auto.pageload.react.reactrouter_v7'); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.trace_id).toMatch(/[a-f0-9]{32}/); + expect(navigationSpan.status).toBe('ok'); + expect(navigationSpan.attributes?.['sentry.origin']?.value).toBe('auto.navigation.react.reactrouter_v7'); + expect(navigationSpan.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('sends an INP span', async ({ page }) => { + const inpSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'ui.interaction.click'; + }); + + await page.goto(`/`); + + await page.click('#exception-button'); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + const inpSpan = await inpSpanPromise; + + expect(inpSpan.name).toBe('body > div#root > input#exception-button[type="button"]'); + expect(inpSpan.trace_id).toMatch(/[a-f0-9]{32}/); + 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.exclusive_time']?.value).toEqual(expect.any(Number)); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json new file mode 100644 index 000000000000..7af258198f12 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "types": ["vite/client"] + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/vite.config.ts new file mode 100644 index 000000000000..63c2c4317df7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/vite.config.ts @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + envPrefix: 'PUBLIC_', +}); From 8f7f640b87832be77e96e439bb5c375f913fe03f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 5 May 2026 11:47:12 -0400 Subject: [PATCH 71/84] feat(nitro): Add unstorage tracing channel instrumentation (#20615) Subscribes to `unstorage` tracing events to create storage and cache spans for all storage operations (`getItem`, `setItem`, `hasItem`, `removeItem`, `getKeys`, `clear`, etc.). The storage instrumentation now has full feature parity with Nuxt's SDK. Note: The instrumentation takes effect if the user uses `^3.0.260429-beta` since that's the version that shipped storage events, before that version the channel will be inert. I also sneaked in a minor refactor, we don't need the `NOOP` subscribers anymore since we patched the type in the `tracingChannel` helper we expose from `@sentry/opentelemetry`. closes #18022 --- .../test-applications/nitro-3/package.json | 2 +- .../nitro-3/server/api/test-cache.ts | 82 +++++++++ .../server/api/test-storage-aliases.ts | 45 +++++ .../nitro-3/server/api/test-storage.ts | 53 ++++++ .../nitro-3/tests/cache.test.ts | 142 +++++++++++++++ .../nitro-3/tests/storage-aliases.test.ts | 103 +++++++++++ .../nitro-3/tests/storage.test.ts | 143 +++++++++++++++ .../src/runtime/hooks/captureStorageEvents.ts | 169 ++++++++++++++++++ .../src/runtime/hooks/captureTracingEvents.ts | 13 -- packages/nitro/src/runtime/plugins/server.ts | 2 + 10 files changed, 740 insertions(+), 14 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-cache.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage-aliases.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/cache.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/storage-aliases.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/storage.test.ts create mode 100644 packages/nitro/src/runtime/hooks/captureStorageEvents.ts 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 ab92769115d1..99fd417c2a54 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -19,7 +19,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/core": "latest || *", - "nitro": "^3.0.260415-beta", + "nitro": "^3.0.260429-beta", "rolldown": "latest", "vite": "latest" }, diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-cache.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-cache.ts new file mode 100644 index 000000000000..e1dd1d2bb4e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-cache.ts @@ -0,0 +1,82 @@ +import { defineCachedFunction, defineCachedHandler } from 'nitro/cache'; +import { defineHandler, getQuery } from 'nitro/h3'; + +const getCachedUser = defineCachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +const getCachedData = defineCachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +const cachedHandler = defineCachedHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? '123'); + const dataKey = String(getQuery(event).data ?? 'test-key'); + + // cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // cachedEventHandler + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage-aliases.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage-aliases.ts new file mode 100644 index 000000000000..404e97985875 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage-aliases.ts @@ -0,0 +1,45 @@ +import { defineHandler } from 'nitro/h3'; +import { useStorage } from 'nitro/storage'; + +export default defineHandler(async () => { + const storage = useStorage('cache'); + + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage.ts new file mode 100644 index 000000000000..00891134af1f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage.ts @@ -0,0 +1,53 @@ +import { defineHandler } from 'nitro/h3'; +import { useStorage } from 'nitro/storage'; + +export default defineHandler(async () => { + const storage = useStorage('cache'); + + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/cache.test.ts new file mode 100644 index 000000000000..feda6b3ea3fe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/cache.test.ts @@ -0,0 +1,142 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nitro'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/test-cache') ?? false; + }); + + const response = await request.get('/api/test-cache'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nitro', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + expect(cacheMissSpan).toBeDefined(); + expect(cacheMissSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + }); + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + expect(cacheHitSpan).toBeDefined(); + expect(cacheHitSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + }); + + // setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + expect(cacheSetSpan).toBeDefined(); + expect(cacheSetSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + 'db.operation.name': 'setItem', + }); + + // Spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Spans for cachedHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/test-cache') ?? false; + }); + + await request.get(`/api/test-cache?user=${uniqueUser}&data=${uniqueData}`); + const transaction = await transactionPromise; + + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nitro', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + const allGetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get_item', + ); + const allSetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.set_item', + ); + + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // At least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // At least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..173e6c0b82d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage-aliases.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nitro'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `cache:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/test-storage-aliases') ?? false; + }); + + const response = await request.get('/api/test-storage-aliases'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.system.name': expect.any(String), + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.system.name': expect.any(String), + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.system.name': expect.any(String), + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.system.name': expect.any(String), + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.system.name': expect.any(String), + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nitro', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage.test.ts new file mode 100644 index 000000000000..e4a959219c10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage.test.ts @@ -0,0 +1,143 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nitro'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `cache:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/test-storage') ?? false; + }); + + const response = await request.get('/api/test-storage'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.system.name': expect.any(String), + }); + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.system.name': expect.any(String), + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.system.name': expect.any(String), + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.system.name': expect.any(String), + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.system.name': expect.any(String), + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + 'db.operation.name': 'getKeys', + 'db.system.name': expect.any(String), + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.system.name': expect.any(String), + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + 'db.operation.name': 'clear', + 'db.system.name': expect.any(String), + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nitro', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts new file mode 100644 index 000000000000..054fe6873ce3 --- /dev/null +++ b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts @@ -0,0 +1,169 @@ +import { + captureException, + flushIfServerless, + GLOBAL_OBJ, + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpanManual, +} from '@sentry/core'; +import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry/tracing-channel'; +import type { TraceContext } from 'unstorage/tracing'; + +const ORIGIN = 'auto.cache.nitro'; + +const globalWithStorageChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + __SENTRY_NITRO_STORAGE_CHANNELS_INSTRUMENTED__: boolean; +}; + +const TRACED_OPERATIONS = [ + 'hasItem', + 'getItem', + 'getItemRaw', + 'getItems', + 'setItem', + 'setItemRaw', + 'setItems', + 'removeItem', + 'getKeys', + 'clear', +] as const; + +type TracedOperation = (typeof TRACED_OPERATIONS)[number]; + +const CACHE_HIT_OPERATIONS = new Set(['hasItem', 'getItem', 'getItemRaw']); + +const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; + +/** + * Subscribes to unstorage tracing channels and creates Sentry spans for storage operations. + */ +export function captureStorageEvents(): void { + if (globalWithStorageChannels.__SENTRY_NITRO_STORAGE_CHANNELS_INSTRUMENTED__) { + return; + } + + for (const operation of TRACED_OPERATIONS) { + setupStorageTracingChannel(operation); + } + + globalWithStorageChannels.__SENTRY_NITRO_STORAGE_CHANNELS_INSTRUMENTED__ = true; +} + +function setupStorageTracingChannel(operation: TracedOperation): void { + const keys = (data: TraceContext): string[] => data.keys ?? []; + const mountBase = (data: TraceContext): string => (data.base ?? '').replace(/:$/, ''); + + const channel = tracingChannel(`unstorage.${operation}`, data => { + const cacheKeys = keys(data); + + return startSpanManual( + { + name: cacheKeys.join(', ') || operation, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(operation)}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: cacheKeys.length > 1 ? cacheKeys : cacheKeys[0], + 'db.operation.name': operation, + 'db.collection.name': mountBase(data), + 'db.system.name': data.driver?.name ?? 'unknown', + }, + }, + span => span, + ); + }); + + channel.subscribe({ + asyncEnd(data: TracingChannelContextWithSpan) { + if (data._sentrySpan && CACHE_HIT_OPERATIONS.has(operation)) { + const hit = operation === 'hasItem' ? Boolean(data.result) : isCacheHit(data.keys?.[0], data.result); + data._sentrySpan.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, hit); + } + + data._sentrySpan?.setStatus({ code: SPAN_STATUS_OK }); + data._sentrySpan?.end(); + + void flushIfServerless(); + }, + error(data: TracingChannelContextWithSpan) { + captureException(data.error, { + mechanism: { handled: false, type: ORIGIN }, + }); + + data._sentrySpan?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + data._sentrySpan?.end(); + + void flushIfServerless(); + }, + }); +} + +function normalizeMethodName(methodName: string): string { + return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +function isEmptyValue(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +interface CacheEntry { + value?: T; + expires?: number; +} + +interface ResponseCacheEntry { + status?: number; + body?: unknown; + headers?: Record; +} + +function isCacheHit(key: unknown, value: unknown): boolean { + try { + const isEmpty = isEmptyValue(value); + if (isEmpty || typeof key !== 'string' || !CACHED_FN_HANDLERS_RE.test(key)) { + return !isEmpty; + } + + const entry = typeof value === 'string' ? (JSON.parse(value) as CacheEntry) : (value as CacheEntry); + + return validateCacheEntry(key, entry); + } catch { + return false; + } +} + +function validateCacheEntry( + key: string, + entry: CacheEntry | CacheEntry, +): boolean { + if (isEmptyValue(entry.value)) { + return false; + } + + if (Date.now() > (entry.expires || 0)) { + return false; + } + + if (isResponseCacheEntry(key, entry)) { + if ((entry.value.status ?? 0) >= 400) { + return false; + } + + if (entry.value.body === undefined) { + return false; + } + + if (entry.value.headers?.etag === 'undefined' || entry.value.headers?.['last-modified'] === 'undefined') { + return false; + } + } + + return true; +} + +function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { + return key.startsWith('nitro:handlers:'); +} diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index bf70536b7800..f99c0e9e1dbd 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -41,11 +41,6 @@ export function captureTracingEvents(): void { globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__ = true; } -/** - * No-op function to satisfy the tracing channel subscribe callbacks - */ -const NOOP = (): void => {}; - /** * Extracts the HTTP status code from a tracing channel result. * The result is the return value of the traced handler, which is a Response for srvx @@ -126,8 +121,6 @@ function setupH3TracingChannels(): void { start: (data: H3TracingRequestEvent) => { setServerTimingHeaders(data.event); }, - asyncStart: NOOP, - end: NOOP, asyncEnd: (data: TracingChannelContextWithSpan) => { onTraceEnd(data); @@ -192,9 +185,6 @@ function setupSrvxTracingChannels(): void { // Subscribe to events (span already created in bindStore) fetchChannel.subscribe({ - start: () => {}, - asyncStart: () => {}, - end: () => {}, asyncEnd: data => { onTraceEnd(data); @@ -239,9 +229,6 @@ function setupSrvxTracingChannels(): void { // Subscribe to events (span already created in bindStore) middlewareChannel.subscribe({ - start: () => {}, - asyncStart: () => {}, - end: () => {}, asyncEnd: onTraceEnd, error: onTraceError, }); diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 2feee84bcc55..9fd3f93a6f40 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,9 +1,11 @@ import { definePlugin } from 'nitro'; import { captureErrorHook } from '../hooks/captureErrorHook'; +import { captureStorageEvents } from '../hooks/captureStorageEvents'; import { captureTracingEvents } from '../hooks/captureTracingEvents'; export default definePlugin(nitroApp => { nitroApp.hooks.hook('error', captureErrorHook); captureTracingEvents(); + captureStorageEvents(); }); From 4cf5c6195fe1c301b237f9d88a0638a29490f3ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 5 May 2026 20:18:52 +0200 Subject: [PATCH 72/84] feat(cloudflare): Support tracing for queue producer (#20529) --- .size-limit.js | 2 +- .../suites/queue/index.ts | 47 +++++ .../suites/queue/test.ts | 128 ++++++++++++ .../suites/queue/wrangler.jsonc | 22 +++ .../instrumentations/worker/instrumentEnv.ts | 25 ++- .../worker/instrumentQueue.ts | 2 + .../worker/instrumentQueueProducer.ts | 104 ++++++++++ packages/cloudflare/src/utils/isBinding.ts | 10 +- .../instrumentations/instrumentEnv.test.ts | 46 ++++- .../instrumentWorkerEntrypoint.test.ts | 24 --- .../worker/instrumentQueue.test.ts | 2 + .../worker/instrumentQueueProducer.test.ts | 183 ++++++++++++++++++ .../cloudflare/test/utils/isBinding.test.ts | 51 ++++- 13 files changed, 607 insertions(+), 39 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/queue/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/queue/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/queue/wrangler.jsonc create mode 100644 packages/cloudflare/src/instrumentations/worker/instrumentQueueProducer.ts create mode 100644 packages/cloudflare/test/instrumentations/worker/instrumentQueueProducer.test.ts diff --git a/.size-limit.js b/.size-limit.js index f6732eacafe2..c1be177bc5a2 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -437,7 +437,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '414 KiB', + limit: '420 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/dev-packages/cloudflare-integration-tests/suites/queue/index.ts b/dev-packages/cloudflare-integration-tests/suites/queue/index.ts new file mode 100644 index 000000000000..e06560ccf690 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/queue/index.ts @@ -0,0 +1,47 @@ +import type { MessageBatch, Queue } from '@cloudflare/workers-types'; +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + MY_QUEUE: Queue<{ trigger?: 'error'; payload?: string }>; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/enqueue/error') { + await env.MY_QUEUE.send({ trigger: 'error' }); + return new Response('enqueued error'); + } + + if (url.pathname === '/enqueue/ok') { + await env.MY_QUEUE.send({ payload: 'hello' }); + return new Response('enqueued ok'); + } + + if (url.pathname === '/enqueue/batch') { + await env.MY_QUEUE.sendBatch([ + { body: { payload: 'one' } }, + { body: { payload: 'two' } }, + { body: { payload: 'three' } }, + ]); + return new Response('enqueued batch'); + } + + return new Response('not found', { status: 404 }); + }, + async queue(batch: MessageBatch<{ trigger?: 'error'; payload?: string }>) { + for (const message of batch.messages) { + if (message.body.trigger === 'error') { + throw new Error('Boom from queue handler'); + } + } + }, + } as ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/queue/test.ts b/dev-packages/cloudflare-integration-tests/suites/queue/test.ts new file mode 100644 index 000000000000..f0886ba3f37c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/queue/test.ts @@ -0,0 +1,128 @@ +import type { Envelope } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +function envelopeItemType(envelope: Envelope): string | undefined { + return envelope[1][0]?.[0]?.type as string | undefined; +} + +function envelopeItem(envelope: Envelope): Record { + return envelope[1][0]![1] as Record; +} + +function findPublishSpan(envelope: Envelope): Record | undefined { + if (envelopeItemType(envelope) !== 'transaction') return undefined; + const tx = envelopeItem(envelope); + const spans = (tx.spans as Array>) || []; + return spans.find(s => (s.op as string) === 'queue.publish'); +} + +function isConsumerTransaction(envelope: Envelope): boolean { + if (envelopeItemType(envelope) !== 'transaction') return false; + const tx = envelopeItem(envelope); + return tx.transaction === 'process test-queue'; +} + +it('captures errors thrown by the queue handler with the correct mechanism', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('transaction') + .expect((envelope: Envelope) => { + expect(envelopeItemType(envelope)).toBe('event'); + const event = envelopeItem(envelope); + expect(event).toMatchObject({ + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'Boom from queue handler', + mechanism: { type: 'auto.faas.cloudflare.queue', handled: false }, + }, + ], + }, + }); + }) + .start(signal); + + await runner.makeRequest('post', '/enqueue/error'); + await runner.completed(); +}); + +it('emits a queue.publish span on env.MY_QUEUE.send and a queue.process transaction on the consumer', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .unordered() + .expect((envelope: Envelope) => { + // Producer transaction must contain a queue.publish child span + const publishSpan = findPublishSpan(envelope); + expect(publishSpan).toBeDefined(); + expect(publishSpan).toMatchObject({ + op: 'queue.publish', + description: 'send MY_QUEUE', + data: expect.objectContaining({ + 'messaging.system': 'cloudflare', + 'messaging.destination.name': 'MY_QUEUE', + 'messaging.operation.type': 'send', + 'messaging.operation.name': 'send', + 'sentry.origin': 'auto.faas.cloudflare.queue', + }), + }); + }) + .expect((envelope: Envelope) => { + expect(isConsumerTransaction(envelope)).toBe(true); + const tx = envelopeItem(envelope); + const trace = (tx.contexts as Record>).trace as Record; + expect(trace).toMatchObject({ + op: 'queue.process', + origin: 'auto.faas.cloudflare.queue', + data: expect.objectContaining({ + 'messaging.system': 'cloudflare', + 'messaging.destination.name': 'test-queue', + 'messaging.operation.type': 'process', + 'messaging.operation.name': 'process', + 'messaging.batch.message_count': 1, + 'faas.trigger': 'pubsub', + }), + }); + }) + .start(signal); + + await runner.makeRequest('post', '/enqueue/ok'); + await runner.completed(); +}); + +it('emits a queue.publish span with batch attributes on env.MY_QUEUE.sendBatch', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect((envelope: Envelope) => { + const publishSpan = findPublishSpan(envelope); + expect(publishSpan).toBeDefined(); + expect(publishSpan).toMatchObject({ + op: 'queue.publish', + description: 'send MY_QUEUE', + data: expect.objectContaining({ + 'messaging.system': 'cloudflare', + 'messaging.destination.name': 'MY_QUEUE', + 'messaging.operation.type': 'send', + 'messaging.operation.name': 'send', + 'messaging.batch.message_count': 3, + 'sentry.origin': 'auto.faas.cloudflare.queue', + }), + }); + }) + .expect((envelope: Envelope) => { + expect(isConsumerTransaction(envelope)).toBe(true); + const tx = envelopeItem(envelope); + const trace = (tx.contexts as Record>).trace as Record; + expect(trace).toMatchObject({ + data: expect.objectContaining({ + 'messaging.batch.message_count': 3, + }), + }); + }) + .start(signal); + + await runner.makeRequest('post', '/enqueue/batch'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/queue/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/queue/wrangler.jsonc new file mode 100644 index 000000000000..d731714bffe0 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/queue/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "queues": { + "producers": [ + { + "queue": "test-queue", + "binding": "MY_QUEUE", + }, + ], + "consumers": [ + { + "queue": "test-queue", + "max_batch_size": 10, + "max_batch_timeout": 1, + "max_retries": 0, + }, + ], + }, +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index a29bec79e2e5..3a386f0bb59d 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,9 +1,10 @@ import type { CloudflareOptions } from '../../client'; -import { isDurableObjectNamespace, isJSRPC } from '../../utils/isBinding'; +import { isDurableObjectNamespace, isJSRPC, isQueue } from '../../utils/isBinding'; import { appendRpcMeta } from '../../utils/rpcMeta'; import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace'; import { instrumentFetcher } from './instrumentFetcher'; +import { instrumentQueueProducer } from './instrumentQueueProducer'; function isProxyable(item: unknown): item is object { return item !== null && (typeof item === 'object' || typeof item === 'function'); @@ -17,9 +18,10 @@ const instrumentedBindings = new WeakMap(); * * Currently detects: * - DurableObjectNamespace (via `idFromName` duck-typing) - * - Service bindings / JSRPC proxies (wraps `fetch` for trace propagation) + * - Service bindings / JSRPC proxies + * - Queue producers (via `send` + `sendBatch` duck-typing) * - * Extensible for future binding types (KV, D1, Queue, etc.). + * Extensible for future binding types (KV, D1, etc.). * * @param env - The Cloudflare env object to instrument * @param options - Optional CloudflareOptions to control RPC trace propagation @@ -31,12 +33,6 @@ export function instrumentEnv>(env: Env, opt const rpcPropagation = options ? getEffectiveRpcPropagation(options) : false; - // As of now only trace propagation is used for the instrumentEnv - // so this is an optimization to avoid wrapping the env in a proxy if trace propagation is disabled - if (!rpcPropagation) { - return env; - } - return new Proxy(env, { get(target, prop, receiver) { const item = Reflect.get(target, prop, receiver); @@ -51,6 +47,17 @@ export function instrumentEnv>(env: Env, opt return cached; } + if (isQueue(item)) { + const bindingName = typeof prop === 'string' ? prop : String(prop); + const instrumented = instrumentQueueProducer(item, bindingName); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + + if (!rpcPropagation) { + return item; + } + if (isDurableObjectNamespace(item)) { const instrumented = instrumentDurableObjectNamespace(item); instrumentedBindings.set(item, instrumented); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts index c57b7abd8aaa..a99b81c6c341 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -42,6 +42,8 @@ function wrapQueueHandler( 'faas.trigger': 'pubsub', 'messaging.destination.name': batch.queue, 'messaging.system': 'cloudflare', + 'messaging.operation.type': 'process', + 'messaging.operation.name': 'process', 'messaging.batch.message_count': batch.messages.length, 'messaging.message.retry.count': batch.messages.reduce((acc, message) => acc + message.attempts - 1, 0), [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueueProducer.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueueProducer.ts new file mode 100644 index 000000000000..e52135704bb4 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueueProducer.ts @@ -0,0 +1,104 @@ +import type { MessageSendRequest, Queue, QueueSendBatchOptions, QueueSendOptions } from '@cloudflare/workers-types'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; + +const ORIGIN = 'auto.faas.cloudflare.queue'; + +function startPublishSpan( + options: { + bindingName: string; + bodySize: number | undefined; + messageCount?: number; + }, + callback: () => T, +): T { + const { bindingName, bodySize, messageCount } = options; + + return startSpan( + { + op: 'queue.publish', + name: `send ${bindingName}`, + attributes: { + 'messaging.system': 'cloudflare', + 'messaging.destination.name': bindingName, + 'messaging.operation.type': 'send', + 'messaging.operation.name': 'send', + ...(messageCount !== undefined && { 'messaging.batch.message_count': messageCount }), + 'messaging.message.body.size': bodySize, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.publish', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }, + }, + callback, + ); +} + +function getBodySize(body: unknown): number | undefined { + if (body == null) { + return undefined; + } + + if (typeof body === 'string') { + return new TextEncoder().encode(body).byteLength; + } + + if (body instanceof ArrayBuffer) { + return body.byteLength; + } + + if (ArrayBuffer.isView(body)) { + return body.byteLength; + } + + try { + return new TextEncoder().encode(JSON.stringify(body)).byteLength; + } catch { + return undefined; + } +} + +/** + * Wraps a Queue producer binding to create `queue.publish` spans on + * `send` and `sendBatch` calls. + * + * The queue's own name is not available on the binding object, so we use + * the env binding key (e.g. `MY_QUEUE`) as `messaging.destination.name`. + */ +export function instrumentQueueProducer(queue: T, bindingName: string): T { + return new Proxy(queue, { + get(target, prop, receiver) { + if (prop === 'send') { + const original = Reflect.get(target, prop, receiver) as Queue['send']; + + return function (this: unknown, message: unknown, options?: QueueSendOptions): Promise { + return startPublishSpan({ bindingName, bodySize: getBodySize(message) }, () => + Reflect.apply(original, target, [message, options]), + ); + }; + } + + if (prop === 'sendBatch') { + const original = Reflect.get(target, prop, receiver) as Queue['sendBatch']; + return function ( + this: unknown, + messages: Iterable, + options?: QueueSendBatchOptions, + ): Promise { + const messageArray = Array.from(messages); + const totalBodySize = messageArray.reduce((acc, m) => { + const size = getBodySize(m.body); + if (size === undefined) { + return acc; + } + return (acc ?? 0) + size; + }, undefined); + + return startPublishSpan({ bindingName, bodySize: totalBodySize, messageCount: messageArray.length }, () => + Reflect.apply(original, target, [messageArray, options]), + ); + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); +} diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index 26801578dde2..5ced12c78389 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 { DurableObjectNamespace } from '@cloudflare/workers-types'; +import type { DurableObjectNamespace, Queue } from '@cloudflare/workers-types'; /** * Checks if a value is a JSRPC proxy (service binding). @@ -59,3 +59,11 @@ const isNotJSRPC = (item: unknown): item is Record => !isJSRPC( export function isDurableObjectNamespace(item: unknown): item is DurableObjectNamespace { return item != null && isNotJSRPC(item) && typeof item.idFromName === 'function'; } + +/** + * Duck-type check for Queue producer bindings. + * Queue has `send` and `sendBatch` async methods. + */ +export function isQueue(item: unknown): item is Queue { + return item != null && isNotJSRPC(item) && typeof item.send === 'function' && typeof item.sendBatch === 'function'; +} diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index ab115317b7b0..a324cb1e3678 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -34,7 +34,7 @@ describe('instrumentEnv', () => { expect(instrumented.UNKNOWN).toBe(unknownBinding); }); - it('returns env as-is when enableRpcTracePropagation is disabled', () => { + it('does not instrument DurableObjectNamespace when enableRpcTracePropagation is disabled', () => { const doNamespace = { idFromName: vi.fn(), idFromString: vi.fn(), @@ -44,8 +44,7 @@ describe('instrumentEnv', () => { const env = { COUNTER: doNamespace }; const instrumented = instrumentEnv(env); - // When trace propagation is disabled, env is returned as-is - expect(instrumented).toBe(env); + // DO bindings pass through untouched when RPC propagation is disabled expect(instrumented.COUNTER).toBe(doNamespace); expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); }); @@ -176,6 +175,47 @@ describe('instrumentEnv', () => { expect(instrumented.UNDEF_VAL).toBeUndefined(); }); + it('wraps Queue bindings in a proxy', async () => { + const send = vi.fn().mockResolvedValue(undefined); + const sendBatch = vi.fn().mockResolvedValue(undefined); + const queue = { send, sendBatch }; + const env = { MY_QUEUE: queue }; + const instrumented = instrumentEnv(env); + + const wrapped = instrumented.MY_QUEUE as typeof queue; + // Wrapped binding is a Proxy, not the original reference + expect(wrapped).not.toBe(queue); + // Calls are forwarded to the underlying queue + await wrapped.send('hello'); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[0]).toBe('hello'); + }); + + it('caches the wrapped Queue binding across repeated access', () => { + const queue = { send: vi.fn(), sendBatch: vi.fn() }; + const env = { MY_QUEUE: queue }; + const instrumented = instrumentEnv(env); + + expect(instrumented.MY_QUEUE).toBe(instrumented.MY_QUEUE); + }); + + it('wraps Queue bindings independently from DO bindings', () => { + const queue = { send: vi.fn(), sendBatch: vi.fn() }; + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const env = { MY_QUEUE: queue, COUNTER: doNamespace }; + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + + // Access both — DO instrumentation only fires on property access + expect(instrumented.MY_QUEUE).not.toBe(queue); + instrumented.COUNTER; + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); + }); + describe('JSRPC RPC method instrumentation', () => { it('does not inject Sentry RPC meta by default (enableRpcTracePropagation not set)', () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 1e434b5de765..54069e14c251 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -317,30 +317,6 @@ describe('instrumentWorkerEntrypoint', () => { expect(constructorEnv).not.toBe(mockEnv); }); - it('passes original env to the constructor when enableRpcTracePropagation is disabled', () => { - const mockContext = createMockExecutionContext(); - const mockEnv = { SENTRY_DSN: 'dsn' }; - - let constructorEnv: unknown; - const TestClass = class extends WorkerEntrypoint { - constructor(ctx: ExecutionContext, env: typeof mockEnv) { - super(); - constructorEnv = env; - } - fetch() { - return new Response('ok'); - } - }; - - const instrumented = instrumentWorkerEntrypoint( - () => ({ enableRpcTracePropagation: false }), - TestClass as unknown as WorkerEntrypointConstructor, - ); - Reflect.construct(instrumented, [mockContext, mockEnv]); - - expect(constructorEnv).toBe(mockEnv); - }); - it('exposes instrumented DurableObjectNamespace via this.env when enableRpcTracePropagation is enabled', async () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts index 6930ff7180df..999073012b92 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts @@ -278,6 +278,8 @@ describe('instrumentQueue', () => { 'faas.trigger': 'pubsub', 'messaging.destination.name': batch.queue, 'messaging.system': 'cloudflare', + 'messaging.operation.type': 'process', + 'messaging.operation.name': 'process', 'messaging.batch.message_count': batch.messages.length, 'messaging.message.retry.count': batch.messages.reduce((acc, message) => acc + message.attempts - 1, 0), 'sentry.sample_rate': 1, diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentQueueProducer.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentQueueProducer.test.ts new file mode 100644 index 000000000000..b094641984ce --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentQueueProducer.test.ts @@ -0,0 +1,183 @@ +import type { Queue } from '@cloudflare/workers-types'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { instrumentQueueProducer } from '../../../src/instrumentations/worker/instrumentQueueProducer'; + +function createMockQueue(): Queue { + return { + send: vi.fn().mockResolvedValue(undefined), + sendBatch: vi.fn().mockResolvedValue(undefined), + } as unknown as Queue; +} + +describe('instrumentQueueProducer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('send', () => { + test('forwards the call to the underlying queue', async () => { + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + await wrapped.send({ hello: 'world' }, { contentType: 'json' }); + + expect(queue.send).toHaveBeenCalledTimes(1); + expect(queue.send).toHaveBeenLastCalledWith({ hello: 'world' }, { contentType: 'json' }); + }); + + test('starts a queue.publish span with messaging attributes', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + await wrapped.send('hello'); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + const [spanCtx] = startSpanSpy.mock.calls[0]!; + expect(spanCtx).toMatchObject({ + op: 'queue.publish', + name: 'send MY_QUEUE', + attributes: { + 'messaging.system': 'cloudflare', + 'messaging.destination.name': 'MY_QUEUE', + 'messaging.operation.type': 'send', + 'messaging.operation.name': 'send', + 'messaging.message.body.size': 5, + 'sentry.op': 'queue.publish', + 'sentry.origin': 'auto.faas.cloudflare.queue', + }, + }); + }); + + test('computes body size for object payloads via JSON.stringify', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + await wrapped.send({ a: 1 }); + + const attrs = startSpanSpy.mock.calls[0]![0].attributes!; + expect(attrs['messaging.message.body.size']).toBe(JSON.stringify({ a: 1 }).length); + }); + + test('computes body size for ArrayBuffer payloads', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + const buf = new ArrayBuffer(42); + await wrapped.send(buf); + + const attrs = startSpanSpy.mock.calls[0]![0].attributes!; + expect(attrs['messaging.message.body.size']).toBe(42); + }); + + test('omits body size when payload cannot be serialized', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + // Circular reference - JSON.stringify throws + const circular: Record = {}; + circular.self = circular; + await wrapped.send(circular); + + const attrs = startSpanSpy.mock.calls[0]![0].attributes!; + expect(attrs['messaging.message.body.size']).toBeUndefined(); + }); + }); + + describe('sendBatch', () => { + test('forwards the call to the underlying queue', async () => { + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + await wrapped.sendBatch([{ body: 'a' }, { body: 'b' }]); + + expect(queue.sendBatch).toHaveBeenCalledTimes(1); + }); + + test('starts a queue.publish span with batch attributes', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + await wrapped.sendBatch([{ body: 'aa' }, { body: 'bbb' }]); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + const [spanCtx] = startSpanSpy.mock.calls[0]!; + expect(spanCtx).toMatchObject({ + op: 'queue.publish', + name: 'send MY_QUEUE', + attributes: { + 'messaging.system': 'cloudflare', + 'messaging.destination.name': 'MY_QUEUE', + 'messaging.operation.type': 'send', + 'messaging.operation.name': 'send', + 'messaging.batch.message_count': 2, + 'messaging.message.body.size': 5, + 'sentry.op': 'queue.publish', + 'sentry.origin': 'auto.faas.cloudflare.queue', + }, + }); + }); + + test('handles iterables (not just arrays)', async () => { + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + function* gen() { + yield { body: 'a' }; + yield { body: 'b' }; + } + + await wrapped.sendBatch(gen()); + + expect(queue.sendBatch).toHaveBeenCalledTimes(1); + const passed = (queue.sendBatch as unknown as ReturnType).mock.calls[0]![0]; + expect(Array.isArray(passed)).toBe(true); + expect(passed).toHaveLength(2); + }); + + test('omits body size when all payloads cannot be serialized', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + const circular1: Record = {}; + circular1.self = circular1; + const circular2: Record = {}; + circular2.self = circular2; + + await wrapped.sendBatch([{ body: circular1 }, { body: circular2 }]); + + const attrs = startSpanSpy.mock.calls[0]![0].attributes!; + expect(attrs['messaging.message.body.size']).toBeUndefined(); + }); + + test('sums only sizable bodies when batch contains mixed payloads', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + const circular: Record = {}; + circular.self = circular; + + await wrapped.sendBatch([{ body: 'aa' }, { body: circular }, { body: 'bbb' }]); + + const attrs = startSpanSpy.mock.calls[0]![0].attributes!; + expect(attrs['messaging.message.body.size']).toBe(5); + }); + }); + + test('forwards unknown property accesses transparently', () => { + const queue = Object.assign(createMockQueue(), { + customMethod: vi.fn().mockReturnValue('hi'), + }) as unknown as Queue & { + customMethod: () => string; + }; + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE') as Queue & { customMethod: () => string }; + expect(wrapped.customMethod()).toBe('hi'); + }); +}); diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts index 2c6599ed2e42..95db6e1ff3e9 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 { isDurableObjectNamespace, isJSRPC } from '../../src/utils/isBinding'; +import { isDurableObjectNamespace, isJSRPC, isQueue } from '../../src/utils/isBinding'; describe('isJSRPC', () => { it('returns false for a plain object', () => { @@ -120,3 +120,52 @@ describe('isDurableObjectNamespace', () => { expect(isDurableObjectNamespace({ idFromName: 'not-a-function' })).toBe(false); }); }); + +describe('isQueue', () => { + it('returns true for an object with send and sendBatch methods', () => { + const queue = { + send: async () => {}, + sendBatch: async () => {}, + }; + expect(isQueue(queue)).toBe(true); + }); + + it('returns false when send is missing', () => { + expect(isQueue({ sendBatch: async () => {} })).toBe(false); + }); + + it('returns false when sendBatch is missing', () => { + expect(isQueue({ send: async () => {} })).toBe(false); + }); + + it('returns false when send is not a function', () => { + expect(isQueue({ send: 'nope', sendBatch: async () => {} })).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(isQueue(null)).toBe(false); + expect(isQueue(undefined)).toBe(false); + }); + + it('returns false for a JSRPC proxy even though it returns functions for send/sendBatch', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isQueue(jsrpcProxy)).toBe(false); + }); + + it('returns false for a DurableObjectNamespace-like object', () => { + const doNamespace = { + idFromName: () => ({}), + idFromString: () => ({}), + get: () => ({}), + newUniqueId: () => ({}), + }; + expect(isQueue(doNamespace)).toBe(false); + }); +}); From c1b4a334e8d305367bc0f71d3830ef099872aed7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 6 May 2026 09:45:59 +0200 Subject: [PATCH 73/84] fix(tests): Use stable instrumentations api in rr tests (#20690) `unstable_instrumentations` [became stable](https://reactrouter.com/changelog#v7150) in the latest minor version. --- .../app/entry.client.tsx | 3 +-- .../app/entry.server.tsx | 4 +--- packages/react-router/src/client/index.ts | 2 +- packages/react-router/src/client/tracingIntegration.ts | 2 +- packages/react-router/src/common/types.ts | 3 +-- packages/react-router/src/server/index.ts | 2 +- packages/react-router/test/client/hydratedRouter.test.ts | 2 +- packages/react-router/test/client/tracingIntegration.test.ts | 2 +- 8 files changed, 8 insertions(+), 12 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx index 9c9ccd812edd..877edfe0c2ce 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx @@ -26,8 +26,7 @@ startTransition(() => { hydrateRoot( document, - {/* unstable_instrumentations is React Router 7.x's prop name (will become `instrumentations` in v8) */} - + , ); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx index 1cbc6b6166fe..178a8ed4e377 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx @@ -17,6 +17,4 @@ export default handleRequest; export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true }); -// Use Sentry's instrumentation API for server-side tracing -// `unstable_instrumentations` is React Router 7.x's export name (will become `instrumentations` in v8) -export const unstable_instrumentations = [Sentry.createSentryServerInstrumentation()]; +export const instrumentations = [Sentry.createSentryServerInstrumentation()]; diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index 24431843710c..0f5de3975b3f 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -24,7 +24,7 @@ export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; */ export type { ErrorBoundaryProps, FallbackRender } from '@sentry/react'; -// React Router instrumentation API for use with unstable_instrumentations (React Router 7.x) +// React Router instrumentation API for use with HydratedRouter's `instrumentations` prop export { createSentryClientInstrumentation, isClientInstrumentationApiUsed, diff --git a/packages/react-router/src/client/tracingIntegration.ts b/packages/react-router/src/client/tracingIntegration.ts index a711eb986508..c4d0bd452123 100644 --- a/packages/react-router/src/client/tracingIntegration.ts +++ b/packages/react-router/src/client/tracingIntegration.ts @@ -19,7 +19,7 @@ export interface ReactRouterTracingIntegrationOptions { /** * Enable React Router's instrumentation API. - * When true, prepares for use with HydratedRouter's `unstable_instrumentations` prop. + * When true, prepares for use with HydratedRouter's `instrumentations` prop. * @experimental * @default false */ diff --git a/packages/react-router/src/common/types.ts b/packages/react-router/src/common/types.ts index 23cbb174f167..245ffdb8c378 100644 --- a/packages/react-router/src/common/types.ts +++ b/packages/react-router/src/common/types.ts @@ -1,8 +1,7 @@ /** * Types for React Router's instrumentation API. * - * Derived from React Router v7.x `unstable_instrumentations` API. - * The stable `instrumentations` API is planned for React Router v8. + * Derived from React Router's `instrumentations` API. * If React Router changes these types, this file must be updated. * * @see https://reactrouter.com/how-to/instrumentation diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index e0b8c8981632..82b20668271a 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -12,7 +12,7 @@ export { wrapServerLoader } from './wrapServerLoader'; export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; export { getMetaTagTransformer } from './getMetaTagTransformer'; -// React Router instrumentation API support (works with both unstable_instrumentations and instrumentations) +// React Router instrumentation API support export { createSentryServerInstrumentation, isInstrumentationApiUsed, diff --git a/packages/react-router/test/client/hydratedRouter.test.ts b/packages/react-router/test/client/hydratedRouter.test.ts index eb0a27073a9f..b5a5fec1f84d 100644 --- a/packages/react-router/test/client/hydratedRouter.test.ts +++ b/packages/react-router/test/client/hydratedRouter.test.ts @@ -163,7 +163,7 @@ describe('instrumentHydratedRouter', () => { it('creates navigation span in Framework Mode (flag not set means router() was never called)', () => { // This is a regression test for Framework Mode (e.g., Remix) where: // 1. createSentryClientInstrumentation() may be called during SDK init - // 2. But the framework doesn't support unstable_instrumentations, so router() is never called + // 2. But the framework doesn't invoke the instrumentations API, so router() is never called // 3. In this case, the legacy navigation instrumentation should still create spans // // We simulate this by ensuring the flag is NOT set (since router() was never called) diff --git a/packages/react-router/test/client/tracingIntegration.test.ts b/packages/react-router/test/client/tracingIntegration.test.ts index 81a3360f1457..2a4326246b83 100644 --- a/packages/react-router/test/client/tracingIntegration.test.ts +++ b/packages/react-router/test/client/tracingIntegration.test.ts @@ -156,7 +156,7 @@ describe('reactRouterTracingIntegration', () => { // Scenario: // 1. User sets useInstrumentationAPI: true in reactRouterTracingIntegration options // 2. createSentryClientInstrumentation() is called eagerly during SDK init - // 3. BUT in Framework Mode, React Router doesn't support unstable_instrumentations, + // 3. BUT in Framework Mode, React Router doesn't invoke the instrumentations API, // so router() method is NEVER called by the framework // 4. The SENTRY_CLIENT_INSTRUMENTATION_FLAG must NOT be set in this case // 5. isClientInstrumentationApiUsed() must return false From f4eaf92722c44fa2b892cbd0d99dd4dadf7f2fba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 10:56:22 +0200 Subject: [PATCH 74/84] chore(deps): Bump axios from 1.15.0 to 1.15.2 (#20665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.15.2.
Release notes

Sourced from axios's releases.

v1.15.2

This release delivers prototype-pollution hardening for the Node HTTP adapter, adds an opt-in allowedSocketPaths allowlist to mitigate SSRF via Unix domain sockets, fixes a keep-alive socket memory leak, and ships supply-chain hardening across CI and security docs.

🔒 Security Fixes

  • Prototype Pollution Hardening (HTTP Adapter): Hardened the Node HTTP adapter and resolveConfig/mergeConfig/validator paths to read only own properties and use null-prototype config objects, preventing polluted auth, baseURL, socketPath, beforeRedirect, and insecureHTTPParser from influencing requests. (#10779)
  • SSRF via socketPath: Rejects non-string socketPath values and adds an opt-in allowedSocketPaths config option to restrict permitted Unix domain socket paths, returning AxiosError ERR_BAD_OPTION_VALUE on mismatch. (#10777)
  • Supply-chain Hardening: Added .npmrc with ignore-scripts=true, lockfile lint CI, non-blocking reproducible build diff, scoped CODEOWNERS, expanded SECURITY.md/THREATMODEL.md with provenance verification (npm audit signatures), 60-day resolution policy, and maintainer incident-response runbook. (#10776)

🚀 New Features

  • allowedSocketPaths Config Option: New request config option (and TypeScript types) to allowlist Unix domain socket paths used by the Node http adapter; backwards compatible when unset. (#10777)

🐛 Bug Fixes

  • Keep-alive Socket Memory Leak: Installs a single per-socket error listener tracking the active request via kAxiosSocketListener/kAxiosCurrentReq, eliminating per-request listener accumulation, MaxListenersExceededWarning, and linear heap growth under concurrent or long-running keep-alive workloads (fixes #10780). (#10788)

🔧 Maintenance & Chores

  • Changelog: Updated CHANGELOG.md with v1.15.1 release notes. (#10781)

Full Changelog

v1.15.1

This release ships a coordinated set of security hardening fixes across headers, body/redirect limits, multipart handling, and XSRF/prototype-pollution vectors, alongside a broad sweep of bug fixes, test migrations, and threat-model documentation updates.

🔒 Security Fixes

  • Header Injection Hardening: Tightened validation and sanitisation across request header construction to close the header-injection attack surface. (#10749)
  • CRLF Stripping in Multipart Headers: Correctly strips CR/LF from multipart header values to prevent injection via field names and filenames. (#10758)
  • Prototype Pollution / Auth Bypass: Replaced unsafe in checks with hasOwnProperty to prevent authentication bypass via prototype pollution on config objects, with additional regression tests. (#10761, #10760)
  • withXSRFToken Truthy Bypass: Short-circuits on any truthy non-boolean value, so an ambiguous config no longer silently leaks the XSRF token cross-origin. (#10762)
  • maxBodyLength With Zero Redirects: Enforces maxBodyLength even when maxRedirects is set to 0, closing a bypass path for oversized request bodies. (#10753)
  • Streamed Response maxContentLength Bypass: Applies maxContentLength to streamed responses that previously bypassed the cap. (#10754)
  • Follow-up CVE Completion: Completes an earlier incomplete CVE fix to fully close the regression window. (#10755)

🚀 New Features

  • AI-Based Docs Translations: Initial scaffold for AI-assisted translations of the documentation site. (#10705)
  • Location Request Header Type: Adds Location to CommonRequestHeadersList for accurate typing of redirect-aware requests. (#7528)

🐛 Bug Fixes

  • FormData Handling: Removes Content-Type when no boundary is present on FormData fetch requests, supports multi-select fields, cancels request.body instead of the source stream on fetch abort, and fixes a recursion bug in form-data serialisation. (#7314, #10676, #10702, #10726)
  • HTTP Adapter: Handles socket-only request errors without leaking keep-alive listeners. (#10576)
  • Progress Events: Clamps loaded to total for computable upload/download progress events. (#7458)
  • Types: Aligns runWhen type with the runtime behaviour in InterceptorManager and makes response header keys case-insensitive. (#7529, #10677)
  • buildFullPath: Uses strict equality in the base/relative URL check. (#7252)
  • AxiosURLSearchParams Regex: Improves the regex used for param serialisation to avoid edge-case mismatches. (#10736)
  • Resilient Value Parsing: Parses out header/config values instead of throwing on malformed input. (#10687)

... (truncated)

Changelog

Sourced from axios's changelog.

v1.15.2 - April 21, 2026

This release delivers prototype-pollution hardening for the Node HTTP adapter, adds an opt-in allowedSocketPaths allowlist to mitigate SSRF via Unix domain sockets, fixes a keep-alive socket memory leak, and ships supply-chain hardening across CI and security docs.

🔒 Security Fixes

  • Prototype Pollution Hardening (HTTP Adapter): Hardened the Node HTTP adapter and resolveConfig/mergeConfig/validator paths to read only own properties and use null-prototype config objects, preventing polluted auth, baseURL, socketPath, beforeRedirect, and insecureHTTPParser from influencing requests. (#10779)
  • SSRF via socketPath: Rejects non-string socketPath values and adds an opt-in allowedSocketPaths config option to restrict permitted Unix domain socket paths, returning AxiosError ERR_BAD_OPTION_VALUE on mismatch. (#10777)
  • Supply-chain Hardening: Added .npmrc with ignore-scripts=true, lockfile lint CI, non-blocking reproducible build diff, scoped CODEOWNERS, expanded SECURITY.md/THREATMODEL.md with provenance verification (npm audit signatures), 60-day resolution policy, and maintainer incident-response runbook. (#10776)

🚀 New Features

  • allowedSocketPaths Config Option: New request config option (and TypeScript types) to allowlist Unix domain socket paths used by the Node http adapter; backwards compatible when unset. (#10777)

🐛 Bug Fixes

  • Keep-alive Socket Memory Leak: Installs a single per-socket error listener tracking the active request via kAxiosSocketListener/kAxiosCurrentReq, eliminating per-request listener accumulation, MaxListenersExceededWarning, and linear heap growth under concurrent or long-running keep-alive workloads (fixes #10780). (#10788)

🔧 Maintenance & Chores

  • Changelog: Updated CHANGELOG.md with v1.15.1 release notes. (#10781)

Full Changelog


v1.15.1 - April 19, 2026

This release ships a coordinated set of security hardening fixes across headers, body/redirect limits, multipart handling, and XSRF/prototype-pollution vectors, alongside a broad sweep of bug fixes, test migrations, and threat-model documentation updates.

🔒 Security Fixes

  • Header Injection Hardening: Tightened validation and sanitisation across request header construction to close the header-injection attack surface. (#10749)

  • CRLF Stripping in Multipart Headers: Correctly strips CR/LF from multipart header values to prevent injection via field names and filenames. (#10758)

  • Prototype Pollution / Auth Bypass: Replaced unsafe in checks with hasOwnProperty to prevent authentication bypass via prototype pollution on config objects, with additional regression tests. (#10761, #10760)

  • withXSRFToken Truthy Bypass: Short-circuits on any truthy non-boolean value, so an ambiguous config no longer silently leaks the XSRF token cross-origin. (#10762)

  • maxBodyLength With Zero Redirects: Enforces maxBodyLength even when maxRedirects is set to 0, closing a bypass path for oversized request bodies. (#10753)

  • Streamed Response maxContentLength Bypass: Applies maxContentLength to streamed responses that previously bypassed the cap. (#10754)

  • Follow-up CVE Completion: Completes an earlier incomplete CVE fix to fully close the regression window. (#10755)

🚀 New Features

  • AI-Based Docs Translations: Initial scaffold for AI-assisted translations of the documentation site. (#10705)

... (truncated)

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Charly Gomez --- dev-packages/browser-integration-tests/package.json | 2 +- yarn.lock | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 0a7c2a5d59e7..8514553c1f1a 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -64,7 +64,7 @@ "@sentry-internal/replay": "10.51.0", "@sentry/opentelemetry": "10.51.0", "@supabase/supabase-js": "2.49.3", - "axios": "1.15.0", + "axios": "1.15.2", "babel-loader": "^10.1.1", "fflate": "0.8.2", "html-webpack-plugin": "^5.5.0", diff --git a/yarn.lock b/yarn.lock index 02db431ae28b..6153d178db91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11754,6 +11754,15 @@ axios@1.15.0: form-data "^4.0.5" proxy-from-env "^2.1.0" +axios@1.15.2: + version "1.15.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.2.tgz#eb8fb6d30349abace6ade5b4cb4d9e8a0dc23e5b" + integrity sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A== + dependencies: + follow-redirects "^1.15.11" + form-data "^4.0.5" + proxy-from-env "^2.1.0" + axobject-query@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -28466,6 +28475,7 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" + uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From d46e885f8d75bc4c5e8db3a644865bd31b0ba78d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 11:20:13 +0200 Subject: [PATCH 75/84] chore(deps): Bump simple-git from 3.33.0 to 3.36.0 (#20696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [simple-git](https://github.com/steveukx/git-js/tree/HEAD/simple-git) from 3.33.0 to 3.36.0.
Release notes

Sourced from simple-git's releases.

simple-git@3.36.0

Minor Changes

  • 89a2294: Extend known exploitable configuration keys and per-task environment variables.

    Note - ParsedVulnerabilities from argv-parser is removed in favour of a readonly array of Vulnerability to match usage in simple-git, rolled into the new vulnerabilityCheck for simpler access to the identified issues.

    Thanks to @​zebbern for identifying the need to block core.fsmonitor. Thanks to @​kodareef5 for identifying the need to block GIT_CONFIG_COUNT environment variables and --template / merge related config.

Patch Changes

  • 1ad57e8: Remove conflicting node:buffer import
  • Updated dependencies [89a2294]
  • Updated dependencies [675570a]
    • @​simple-git/argv-parser@​1.1.0
    • @​simple-git/args-pathspec@​1.0.3

simple-git@3.35.2

Patch Changes

  • 0cf9d8c: Improvements for mono-repo publishing pipeline
  • Updated dependencies [0cf9d8c]
    • @​simple-git/args-pathspec@​1.0.2
    • @​simple-git/argv-parser@​1.0.3

simple-git@3.35.1

Patch Changes

  • 0de400e: Update monorepo version handling during publish
  • Updated dependencies [0de400e]
    • @​simple-git/argv-parser@​1.0.2
Changelog

Sourced from simple-git's changelog.

3.36.0

Minor Changes

  • 89a2294: Extend known exploitable configuration keys and per-task environment variables.

    Note - ParsedVulnerabilities from argv-parser is removed in favour of a readonly array of Vulnerability to match usage in simple-git, rolled into the new vulnerabilityCheck for simpler access to the identified issues.

    Thanks to @​zebbern for identifying the need to block core.fsmonitor. Thanks to @​kodareef5 for identifying the need to block GIT_CONFIG_COUNT environment variables and --template / merge related config.

Patch Changes

  • 1ad57e8: Remove conflicting node:buffer import
  • Updated dependencies [89a2294]
  • Updated dependencies [675570a]
    • @​simple-git/argv-parser@​1.1.0
    • @​simple-git/args-pathspec@​1.0.3

3.35.2

Patch Changes

  • 0cf9d8c: Improvements for mono-repo publishing pipeline
  • Updated dependencies [0cf9d8c]
    • @​simple-git/args-pathspec@​1.0.2
    • @​simple-git/argv-parser@​1.0.3

3.35.1

Patch Changes

  • 0de400e: Update monorepo version handling during publish
  • Updated dependencies [0de400e]
    • @​simple-git/argv-parser@​1.0.2

3.35.0

Minor Changes

  • 3d8708b: Updating publish config

Patch Changes

  • Updated dependencies [3d8708b]
    • @​simple-git/args-pathspec@​1.0.1
    • @​simple-git/argv-parser@​1.0.1

3.34.0

... (truncated)

Commits
  • 7dc1a53 Version Packages
  • 76f5376 Merge pull request #1061 from Vinzent03/fix/buffer-import
  • 89a2294 Environment Parsing (#1156)
  • 1b91b76 fix: remove explicit node:buffer import
  • e390685 Version Packages
  • 3c9e4b8 Pin version of @​simple-git/args-pathspec
  • 94ee21f Export pathspec types through simple-git for backward compatibility
  • 6d7cb51 Version Packages
  • 0de400e Switch to semver from workspace revisions
  • 2264722 Version Packages
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=simple-git&package-manager=npm_and_yarn&previous-version=3.33.0&new-version=3.36.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6153d178db91..4a1a2bac8396 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7905,6 +7905,18 @@ resolved "https://registry.yarnpkg.com/@simple-dom/interface/-/interface-1.4.0.tgz#e8feea579232017f89b0138e2726facda6fbb71f" integrity sha512-l5qumKFWU0S+4ZzMaLXFU8tQZsicHEMEyAxI5kDFGhJsRqDwe0a7/iPA/GdxlGyDKseQQAgIz5kzU7eXTrlSpA== +"@simple-git/args-pathspec@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz#9ef4a2ad5f49ab4056362d03f93f775b93118ca5" + integrity sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA== + +"@simple-git/argv-parser@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz#275b839c6eeb5030872c73b1ea839a416885da9d" + integrity sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw== + dependencies: + "@simple-git/args-pathspec" "^1.0.3" + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -27507,12 +27519,14 @@ simple-get@^4.0.0, simple-get@^4.0.1: simple-concat "^1.0.0" simple-git@^3.28.0: - version "3.33.0" - resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.33.0.tgz#b903dc70f5b93535a4f64ff39172da43058cfb88" - integrity sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng== + version "3.36.0" + resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.36.0.tgz#019b28c0a35847ee34299c6fb63770ab1b2dffb7" + integrity sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q== dependencies: "@kwsites/file-exists" "^1.1.1" "@kwsites/promise-deferred" "^1.1.1" + "@simple-git/args-pathspec" "^1.0.3" + "@simple-git/argv-parser" "^1.1.0" debug "^4.4.0" simple-html-tokenizer@^0.5.11: From d34e92c63818b532085424cdfc1c778e2e3df694 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 11:20:47 +0200 Subject: [PATCH 76/84] chore(deps): Bump ip-address from 10.1.0 to 10.2.0 (#20695) Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.1.0 to 10.2.0.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ip-address&package-manager=npm_and_yarn&previous-version=10.1.0&new-version=10.2.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 4a1a2bac8396..131a1ff002af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19473,9 +19473,9 @@ ioredis@^5.4.1, ioredis@^5.9.1: standard-as-callback "^2.1.0" ip-address@^10.0.1: - version "10.1.0" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.1.0.tgz#d8dcffb34d0e02eb241427444a6e23f5b0595aa4" - integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q== + version "10.2.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206" + integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== ipaddr.js@1.9.1: version "1.9.1" From f1f534c572d9dad46a1b210f72d67fb1d3f89d94 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 6 May 2026 12:03:39 +0200 Subject: [PATCH 77/84] fix(deps): Bump transitive deps for medium security fixes (#20683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `postcss` 8.5.6 → 8.5.14 (XSS via unescaped ``) - `picomatch` 2.3.1 → 2.3.2 (method injection in POSIX character classes) - `yaml` 1.10.2 → 1.10.3 (stack overflow via deeply nested collections) - `@hono/node-server` 1.19.10 → 1.19.13 (middleware bypass via repeated slashes) - Fixes Dependabot alerts [1431](https://github.com/getsentry/sentry-javascript/security/dependabot/1431), [1253](https://github.com/getsentry/sentry-javascript/security/dependabot/1253), [1249](https://github.com/getsentry/sentry-javascript/security/dependabot/1249), [1348](https://github.com/getsentry/sentry-javascript/security/dependabot/1348) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- yarn.lock | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/yarn.lock b/yarn.lock index 131a1ff002af..5876796341f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4921,9 +4921,9 @@ "@hapi/hoek" "^11.0.2" "@hono/node-server@^1.19.13": - version "1.19.13" - resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.13.tgz#4838c766a1237253d4dde3281cf7d5c65186fd32" - integrity sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ== + version "1.19.14" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.14.tgz#e30f844bc77e3ce7be442aac3b1f73ad8b58d181" + integrity sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw== "@humanwhocodes/config-array@^0.11.14": version "0.11.14" @@ -24455,9 +24455,9 @@ picocolors@1.1.1, picocolors@^1.0.0, picocolors@^1.1.0, picocolors@^1.1.1: integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: version "4.0.4" @@ -25230,9 +25230,9 @@ postcss@8.4.31: source-map-js "^1.0.2" postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.27, postcss@^8.4.39, postcss@^8.4.43, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.3, postcss@^8.5.6: - version "8.5.6" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" - integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + version "8.5.14" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c" + integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" @@ -31292,14 +31292,14 @@ yaml-types@^0.4.0: integrity sha512-XfbA30NUg4/LWUiplMbiufUiwYhgB9jvBhTWel7XQqjV+GaB79c2tROu/8/Tu7jO0HvDvnKWtBk5ksWRrhQ/0g== yaml@^1.10.0: - version "1.10.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + version "1.10.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3" + integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA== yaml@^2.6.0, yaml@^2.8.0, yaml@^2.8.3: - version "2.8.3" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" - integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== + version "2.8.4" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.4.tgz#4b5f411dd25f9544914d8673d4da7f29248e5e2e" + integrity sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog== yargs-parser@21.1.1, yargs-parser@^21.0.0, yargs-parser@^21.1.1: version "21.1.1" From 12cd3e51a5e7092b33cb36c875278c114c1bdc28 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 6 May 2026 13:46:18 +0200 Subject: [PATCH 78/84] fix(nextjs): Skip build modification when SRI is enabled (#20694) With this we detect ant [SRI](https://nextjs.org/docs/app/guides/content-security-policy#subresource-integrity-experimental) config and, if enabled, skip the logic for stripping sourcemap comments on finished builds. closes https://github.com/getsentry/sentry-javascript/issues/20675 --- .../nextjs-16/app/sri-test/page.tsx | 20 +++++++ .../nextjs-16/app/sri-test/target/page.tsx | 20 +++++++ .../nextjs-16/next.config.ts | 8 ++- .../nextjs-16/tests/sri.test.ts | 60 +++++++++++++++++++ .../config/handleRunAfterProductionCompile.ts | 20 ++++++- packages/nextjs/src/config/types.ts | 1 + .../getFinalConfigObjectBundlerUtils.ts | 2 + .../handleRunAfterProductionCompile.test.ts | 37 ++++++++++++ 8 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/target/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/sri.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/page.tsx new file mode 100644 index 000000000000..94e17a8bb4ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +export default function SriTestPage() { + const [count, setCount] = useState(0); + + return ( +
+

SRI Test Page

+ + + Go to target + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/target/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/target/page.tsx new file mode 100644 index 000000000000..80ea89c506d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/target/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +export default function SriTestTargetPage() { + const [clicked, setClicked] = useState(false); + + return ( +
+

SRI Target Page

+ + + Go back + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts index 41814b8152d0..ee93730e8d1d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts @@ -4,7 +4,13 @@ import type { NextConfig } from 'next'; // Simulate Vercel environment for cron monitoring tests process.env.VERCEL = '1'; -const nextConfig: NextConfig = {}; +const nextConfig: NextConfig = { + experimental: { + sri: { + algorithm: 'sha256', + }, + }, +}; export default withSentryConfig(nextConfig, { silent: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/sri.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/sri.test.ts new file mode 100644 index 000000000000..c68d23c21de6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/sri.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; + +const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); + +test.describe('Subresource Integrity (SRI)', () => { + test('page with client components loads correctly with SRI enabled', async ({ page }) => { + // SRI is only relevant for production builds + test.skip(isDevMode, 'SRI only applies to production builds'); + + const consoleErrors: string[] = []; + page.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await page.goto('/sri-test'); + + const heading = page.locator('#sri-test-heading'); + await expect(heading).toBeVisible(); + + // Verify client-side interactivity works (scripts loaded correctly) + const button = page.locator('#counter-button'); + await expect(button).toContainText('Count: 0'); + await button.click(); + await expect(button).toContainText('Count: 1'); + + expect(consoleErrors.filter(e => e.includes('integrity'))).toHaveLength(0); + }); + + test('client-side navigation works with SRI enabled', async ({ page }) => { + test.skip(isDevMode, 'SRI only applies to production builds'); + + const consoleErrors: string[] = []; + page.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await page.goto('/sri-test'); + await expect(page.locator('#sri-test-heading')).toBeVisible(); + + // Navigate to target page via client-side link + await page.locator('#navigate-link').click(); + await expect(page.locator('#sri-target-heading')).toBeVisible(); + + // Verify client-side interactivity on the target page + const targetButton = page.locator('#target-button'); + await expect(targetButton).toContainText('Click me'); + await targetButton.click(); + await expect(targetButton).toContainText('Clicked!'); + + // Navigate back + await page.locator('#back-link').click(); + await expect(page.locator('#sri-test-heading')).toBeVisible(); + + expect(consoleErrors.filter(e => e.includes('integrity'))).toHaveLength(0); + }); +}); diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts index f6e5a3b21617..25e4d1a6a485 100644 --- a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -15,7 +15,14 @@ export async function handleRunAfterProductionCompile( distDir, buildTool, usesNativeDebugIds, - }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack'; usesNativeDebugIds?: boolean }, + sriEnabled, + }: { + releaseName?: string; + distDir: string; + buildTool: 'webpack' | 'turbopack'; + usesNativeDebugIds?: boolean; + sriEnabled?: boolean; + }, sentryBuildOptions: SentryBuildOptions, ): Promise { if (sentryBuildOptions.debug) { @@ -68,10 +75,19 @@ export async function handleRunAfterProductionCompile( // the deleted .map files, and in Next.js 16 (turbopack) those requests fall through // to the app router instead of returning 404, which can break middleware-dependent // features like Clerk auth. + // When SRI is enabled, we must skip this step because Next.js computes integrity + // hashes during the build — modifying files afterward invalidates those hashes. const deleteSourcemapsAfterUpload = sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload ?? false; - if (deleteSourcemapsAfterUpload && buildTool === 'turbopack') { + if (deleteSourcemapsAfterUpload && buildTool === 'turbopack' && !sriEnabled) { await stripSourceMappingURLComments(path.join(distDir, 'static'), sentryBuildOptions.debug); } + + if (deleteSourcemapsAfterUpload && buildTool === 'turbopack' && sriEnabled && sentryBuildOptions.debug) { + // eslint-disable-next-line no-console + console.debug( + '[@sentry/nextjs] Skipping sourceMappingURL comment stripping because Subresource Integrity (SRI) is enabled.', + ); + } } const SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\/[#@] sourceMappingURL=[^\n]+$/; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 9aa31f79e535..86068841e773 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -46,6 +46,7 @@ export type NextConfigObject = { instrumentationHook?: boolean; clientTraceMetadata?: string[]; serverComponentsExternalPackages?: string[]; // next < v15.0.0 + sri?: { algorithm?: string }; }; productionBrowserSourceMaps?: boolean; // https://nextjs.org/docs/pages/api-reference/next-config-js/env diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts index 76187ca319f9..edd62b8ba8c3 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts @@ -140,6 +140,7 @@ export function maybeSetUpRunAfterProductionCompileHook({ distDir, buildTool: bundlerInfo.isTurbopack ? 'turbopack' : 'webpack', usesNativeDebugIds: bundlerInfo.isTurbopack ? turboPackConfig?.debugIds : undefined, + sriEnabled: !!incomingUserNextConfigObject.experimental?.sri, }, userSentryOptions, ); @@ -160,6 +161,7 @@ export function maybeSetUpRunAfterProductionCompileHook({ distDir, buildTool: bundlerInfo.isTurbopack ? 'turbopack' : 'webpack', usesNativeDebugIds: bundlerInfo.isTurbopack ? turboPackConfig?.debugIds : undefined, + sriEnabled: !!incomingUserNextConfigObject.experimental?.sri, }, userSentryOptions, ); diff --git a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts index 3d551dfb6c40..7c21c26d2ef6 100644 --- a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts +++ b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts @@ -388,6 +388,43 @@ describe('handleRunAfterProductionCompile', () => { expect(readdirSpy).not.toHaveBeenCalled(); }); + + it('does NOT strip sourceMappingURL comments when SRI is enabled', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + sriEnabled: true, + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(readdirSpy).not.toHaveBeenCalled(); + }); + + it('strips sourceMappingURL comments when SRI is not enabled', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + sriEnabled: false, + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(readdirSpy).toHaveBeenCalledWith( + path.join('/path/to/.next', 'static'), + expect.objectContaining({ recursive: true }), + ); + }); }); describe('path handling', () => { From 01d0a709e601e6f71fca04f81304b0db919d08ab Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 6 May 2026 14:29:21 +0200 Subject: [PATCH 79/84] feat(core): Migrate Vercel AI event processor to span streaming (#20608) Migrates the Vercel AI event processor so it also works in the span streaming path via the `processSpan` hook. The event processor currently serves three purposes: **Attribute renaming.** The Vercel AI SDK emits attributes under `ai.*` names that need to be renamed to OpenTelemetry semantic conventions (`gen_ai.*`) and the `vercel.ai.*` namespace. This logic is straightforward to port. It's extracted into a shared `processVercelAiSpanAttributes` helper that is now called from both the legacy event processor path (for transactions) and the new `processSpan` hook (for streamed spans). **Token accumulation on parent spans.** The event processor aggregates token usage from child spans onto their parent `invoke_agent` spans. This is a cross-span operation that fundamentally doesn't work in the streaming model where spans are processed individually. The [span streaming implementation guide](https://develop.sentry.dev/sdk/telemetry/spans/implementation/) explicitly lists this as a case that cannot be replaced. Since the plan is for parent-level token accumulation to go away regardless, we simply drop it for the streaming path. **Tool descriptions on execute_tool spans.** The event processor iterates over all spans in a transaction to find `gen_ai.request.available_tools` on `doGenerate` spans and applies the matching description to sibling `execute_tool` spans. This cross-span iteration doesn't work when spans are processed individually. Instead, we parse and store tool descriptions in a map at `spanStart` time of the `doGenerate` span. Since `execute_tool` spans are siblings of `doGenerate` (both children of `invoke_agent`), we key the map by the parent span ID so `execute_tool` spans can look up descriptions by their own `parent_span_id`. This assumes a flat sibling hierarchy, which holds for our test scenarios. If we encounter more complex cases down the road, I think it's fine to address in a follow-up. Closes https://github.com/getsentry/sentry-javascript/issues/20377 --- .../vercelai/scenario-error-in-tool.mjs | 2 + .../suites/tracing/vercelai/scenario.mjs | 2 + .../instrument-with-pii.mjs} | 1 + .../instrument-with-truncation.mjs} | 0 .../vercelai/span-streaming-v4/instrument.mjs | 11 + .../scenario-error-in-tool.mjs | 46 +++ .../scenario-truncation.mjs} | 0 .../vercelai/span-streaming-v4/scenario.mjs | 82 +++++ .../vercelai/span-streaming-v4/test.ts | 347 ++++++++++++++++++ .../span-streaming-v6/instrument-with-pii.mjs | 12 + .../vercelai/span-streaming-v6/instrument.mjs | 11 + .../scenario-error-in-tool.mjs | 47 +++ .../vercelai/span-streaming-v6/scenario.mjs | 95 +++++ .../vercelai/span-streaming-v6/test.ts | 335 +++++++++++++++++ .../suites/tracing/vercelai/test.ts | 47 --- .../core/src/tracing/vercel-ai/constants.ts | 7 + packages/core/src/tracing/vercel-ai/index.ts | 114 +++++- 17 files changed, 1095 insertions(+), 64 deletions(-) rename dev-packages/node-integration-tests/suites/tracing/vercelai/{instrument-streaming.mjs => span-streaming-v4/instrument-with-pii.mjs} (86%) rename dev-packages/node-integration-tests/suites/tracing/vercelai/{instrument-streaming-with-truncation.mjs => span-streaming-v4/instrument-with-truncation.mjs} (100%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-error-in-tool.mjs rename dev-packages/node-integration-tests/suites/tracing/vercelai/{scenario-span-streaming.mjs => span-streaming-v4/scenario-truncation.mjs} (100%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-error-in-tool.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs index 4185d972da4d..501375fecd80 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs @@ -35,6 +35,8 @@ async function run() { prompt: 'What is the weather in San Francisco?', }); }); + + await Sentry.flush(2000); } run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs index b6abe6fdf673..ef8cb19c3646 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs @@ -75,6 +75,8 @@ async function run() { prompt: 'Where is the third span?', }); }); + + await Sentry.flush(2000); } run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-pii.mjs similarity index 86% rename from dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs rename to dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-pii.mjs index 48a860c510c5..0cc510d5c9e6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-pii.mjs @@ -8,4 +8,5 @@ Sentry.init({ sendDefaultPii: true, transport: loggingTransport, traceLifecycle: 'stream', + integrations: [Sentry.vercelAIIntegration()], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-truncation.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming-with-truncation.mjs rename to dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-truncation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument.mjs new file mode 100644 index 000000000000..cf42383e5c6e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-error-in-tool.mjs new file mode 100644 index 000000000000..a305d0bd2dd8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-error-in-tool.mjs @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + Sentry.setTag('test-tag', 'test-value'); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + try { + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async () => { + throw new Error('Error in tool'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + } catch { + // Expected error - we want the spans to still be flushed + } + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-truncation.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-span-streaming.mjs rename to dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-truncation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario.mjs new file mode 100644 index 000000000000..ef8cb19c3646 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario.mjs @@ -0,0 +1,82 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'First span here!', + }), + }), + prompt: 'Where is the first span?', + }); + + // This span should have input and output prompts attached because telemetry is explicitly enabled. + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Second span here!', + }), + }), + prompt: 'Where is the second span?', + }); + + // This span should include tool calls and tool results + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + description: 'Get the current weather for a location', + parameters: z.object({ location: z.string() }), + execute: async args => { + return `Weather in ${args.location}: Sunny, 72°F`; + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + + // This span should not be captured because we've disabled telemetry + await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Third span here!', + }), + }), + prompt: 'Where is the third span?', + }); + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts new file mode 100644 index 000000000000..66a96cad2317 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts @@ -0,0 +1,347 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_TOOL_CALL_ID_ATTRIBUTE, + GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE, + GEN_AI_TOOL_INPUT_ATTRIBUTE, + GEN_AI_TOOL_NAME_ATTRIBUTE, + GEN_AI_TOOL_OUTPUT_ATTRIBUTE, + GEN_AI_TOOL_TYPE_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +/** + * Helper to match a typed attribute value in a SerializedStreamedSpan. + * Streamed span attributes are `{ value: X, type: Y }` objects, unlike transaction + * span `data` which stores values directly. + */ +function attr(value: unknown) { + return expect.objectContaining({ value }); +} + +describe('Vercel AI integration (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_SPANS_DEFAULT_PII_FALSE = { + items: expect.arrayContaining([ + // First span - invoke_agent for simple generateText + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText'), + 'vercel.ai.streaming': attr(false), + }), + }), + // Second span - generate_content for simple generateText + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText.doGenerate'), + 'vercel.ai.streaming': attr(false), + }), + }), + // Third span - invoke_agent for explicit telemetry generateText + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fourth span - tool call invoke_agent + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fifth span - tool call generate_content + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Sixth span - execute_tool + // Note: gen_ai.tool.description is NOT present when sendDefaultPii: false because ai.prompt.tools is not recorded + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + const EXPECTED_SPANS_DEFAULT_PII_TRUE = { + items: expect.arrayContaining([ + // First span - invoke_agent with input/output messages (PII enabled) + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: attr(1), + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr('[{"role":"user","content":"Where is the first span?"}]'), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText'), + 'vercel.ai.streaming': attr(false), + }), + }), + // Second span - generate_content with input/output messages + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Third span - explicit telemetry invoke_agent with messages + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr('[{"role":"user","content":"Where is the second span?"}]'), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"Second span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fourth span - tool call invoke_agent with messages + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"user","content":"What is the weather in San Francisco?"}]', + ), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"Tool call completed!"},{"type":"tool_call","id":"call-1","name":"getWeather","arguments":"{ \\"location\\": \\"San Francisco\\" }"}],"finish_reason":"tool_call"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fifth span - tool call generate_content with available_tools + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE]: expect.objectContaining({ + value: expect.stringContaining('getWeather'), + }), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Sixth span - execute_tool with description and input/output + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE]: attr('Get the current weather for a location'), + [GEN_AI_TOOL_INPUT_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + const EXPECTED_SPANS_ERROR_IN_TOOL = { + items: expect.arrayContaining([ + expect.objectContaining({ + name: 'invoke_agent', + status: 'error', + attributes: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'error', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates ai related spans in streaming mode with sendDefaultPii: false', async () => { + await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_FALSE }).start().completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates ai related spans in streaming mode with sendDefaultPii: true', async () => { + await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_TRUE }).start().completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-error-in-tool.mjs', 'instrument.mjs', (createRunner, test) => { + test('normalizes error status in streaming mode', async () => { + await createRunner().ignore('event').expect({ span: EXPECTED_SPANS_ERROR_IN_TOOL }).start().completed(); + }); + }); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-truncation.mjs', 'instrument.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-truncation.mjs', 'instrument-with-truncation.mjs', (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, content should be truncated despite streaming. + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('[{"role":"user","content":"AAAA'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongContent.length, + ); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument-with-pii.mjs new file mode 100644 index 000000000000..0cc510d5c9e6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument-with-pii.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument.mjs new file mode 100644 index 000000000000..cf42383e5c6e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-error-in-tool.mjs new file mode 100644 index 000000000000..51bdae176158 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-error-in-tool.mjs @@ -0,0 +1,47 @@ +import * as Sentry from '@sentry/node'; +import { generateText, tool } from 'ai'; +import { MockLanguageModelV3 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + try { + await generateText({ + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 15, noCache: 15, cached: 0 }, + outputTokens: { total: 25, noCache: 25, cached: 0 }, + totalTokens: { total: 40, noCache: 40, cached: 0 }, + }, + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'getWeather', + input: JSON.stringify({ location: 'San Francisco' }), + }, + ], + warnings: [], + }), + }), + tools: { + getWeather: tool({ + inputSchema: z.object({ location: z.string() }), + execute: async () => { + throw new Error('Error in tool'); + }, + }), + }, + prompt: 'What is the weather in San Francisco?', + }); + } catch { + // Expected error - we want the spans to still be flushed + } + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario.mjs new file mode 100644 index 000000000000..bf5e43a32e65 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario.mjs @@ -0,0 +1,95 @@ +import * as Sentry from '@sentry/node'; +import { generateText, tool } from 'ai'; +import { MockLanguageModelV3 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 10, noCache: 10, cached: 0 }, + outputTokens: { total: 20, noCache: 20, cached: 0 }, + totalTokens: { total: 30, noCache: 30, cached: 0 }, + }, + content: [{ type: 'text', text: 'First span here!' }], + warnings: [], + }), + }), + prompt: 'Where is the first span?', + }); + + // This span should have input and output prompts attached because telemetry is explicitly enabled. + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 10, noCache: 10, cached: 0 }, + outputTokens: { total: 20, noCache: 20, cached: 0 }, + totalTokens: { total: 30, noCache: 30, cached: 0 }, + }, + content: [{ type: 'text', text: 'Second span here!' }], + warnings: [], + }), + }), + prompt: 'Where is the second span?', + }); + + // This span should include tool calls and tool results + await generateText({ + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 15, noCache: 15, cached: 0 }, + outputTokens: { total: 25, noCache: 25, cached: 0 }, + totalTokens: { total: 40, noCache: 40, cached: 0 }, + }, + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'getWeather', + input: JSON.stringify({ location: 'San Francisco' }), + }, + ], + warnings: [], + }), + }), + tools: { + getWeather: tool({ + description: 'Get the current weather for a location', + inputSchema: z.object({ location: z.string() }), + execute: async ({ location }) => `Weather in ${location}: Sunny, 72°F`, + }), + }, + prompt: 'What is the weather in San Francisco?', + }); + + // This span should not be captured because we've disabled telemetry + await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 10, noCache: 10, cached: 0 }, + outputTokens: { total: 20, noCache: 20, cached: 0 }, + totalTokens: { total: 30, noCache: 30, cached: 0 }, + }, + content: [{ type: 'text', text: 'Third span here!' }], + warnings: [], + }), + }), + prompt: 'Where is the third span?', + }); + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts new file mode 100644 index 000000000000..05226300e160 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts @@ -0,0 +1,335 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_TOOL_CALL_ID_ATTRIBUTE, + GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE, + GEN_AI_TOOL_INPUT_ATTRIBUTE, + GEN_AI_TOOL_NAME_ATTRIBUTE, + GEN_AI_TOOL_OUTPUT_ATTRIBUTE, + GEN_AI_TOOL_TYPE_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +/** + * Helper to match a typed attribute value in a SerializedStreamedSpan. + * Streamed span attributes are `{ value: X, type: Y }` objects, unlike transaction + * span `data` which stores values directly. + */ +function attr(value: unknown) { + return expect.objectContaining({ value }); +} + +describe('Vercel AI integration (streaming, V6)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_SPANS_DEFAULT_PII_FALSE = { + items: expect.arrayContaining([ + // First span - invoke_agent for simple generateText + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText'), + 'vercel.ai.streaming': attr(false), + 'vercel.ai.request.headers.user-agent': expect.objectContaining({ value: expect.any(String) }), + }), + }), + // Second span - generate_content for simple generateText + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText.doGenerate'), + 'vercel.ai.streaming': attr(false), + }), + }), + // Third span - invoke_agent for explicit telemetry generateText + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fourth span - tool call invoke_agent + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fifth span - tool call generate_content + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Sixth span - execute_tool + // Note: gen_ai.tool.description is NOT present when sendDefaultPii: false because ai.prompt.tools is not recorded + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + const EXPECTED_SPANS_DEFAULT_PII_TRUE = { + items: expect.arrayContaining([ + // First span - invoke_agent with input/output messages (PII enabled) + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: attr(1), + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr('[{"role":"user","content":"Where is the first span?"}]'), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText'), + 'vercel.ai.streaming': attr(false), + }), + }), + // Second span - generate_content with input/output messages + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Third span - explicit telemetry invoke_agent with messages + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr('[{"role":"user","content":"Where is the second span?"}]'), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"Second span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fourth span - tool call invoke_agent with messages (V6: no text part, only tool_call) + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"user","content":"What is the weather in San Francisco?"}]', + ), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"tool_call","id":"call-1","name":"getWeather","arguments":"{\\"location\\":\\"San Francisco\\"}"}],"finish_reason":"tool_call"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fifth span - tool call generate_content with available_tools + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE]: expect.objectContaining({ + value: expect.stringContaining('getWeather'), + }), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Sixth span - execute_tool with description and input/output + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE]: attr('Get the current weather for a location'), + [GEN_AI_TOOL_INPUT_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + const EXPECTED_SPANS_ERROR_IN_TOOL = { + items: expect.arrayContaining([ + expect.objectContaining({ + name: 'invoke_agent', + attributes: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'error', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates ai related spans in streaming mode with sendDefaultPii: false', async () => { + await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_FALSE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('creates ai related spans in streaming mode with sendDefaultPii: true', async () => { + await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_TRUE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-error-in-tool.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('normalizes error status in streaming mode', async () => { + await createRunner().ignore('event').expect({ span: EXPECTED_SPANS_ERROR_IN_TOOL }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index 5aa1dc8342a5..d75a1faf8ea0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -983,51 +983,4 @@ describe('Vercel AI integration', () => { }); }, ); - - const streamingLongContent = 'A'.repeat(50_000); - - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('automatically disables truncation when span streaming is enabled', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - const chatSpan = spans.find(s => - s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), - ); - expect(chatSpan).toBeDefined(); - }, - }) - .start() - .completed(); - }); - }); - - createEsmAndCjsTests( - __dirname, - 'scenario-span-streaming.mjs', - 'instrument-streaming-with-truncation.mjs', - (createRunner, test) => { - test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - // With explicit enableTruncation: true, content should be truncated despite streaming. - const chatSpan = spans.find(s => - s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('[{"role":"user","content":"AAAA'), - ); - expect(chatSpan).toBeDefined(); - expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( - streamingLongContent.length, - ); - }, - }) - .start() - .completed(); - }); - }, - ); }); diff --git a/packages/core/src/tracing/vercel-ai/constants.ts b/packages/core/src/tracing/vercel-ai/constants.ts index f1d9d3168e84..27c2b901554d 100644 --- a/packages/core/src/tracing/vercel-ai/constants.ts +++ b/packages/core/src/tracing/vercel-ai/constants.ts @@ -5,6 +5,13 @@ import type { ToolCallSpanContext } from './types'; // without keeping full Span objects (and their potentially large attributes) alive. export const toolCallSpanContextMap = new Map(); +// Used to make tool descriptions available to execute_tool spans in the span streaming path. +// Streamed spans are processed individually, so execute_tool spans cannot look up descriptions +// from their sibling doGenerate span on span end (as we do for transactions). +// Instead we store descriptions at spanStart and apply them in the processSpan hook. +// Stores parent_span_id -> Map +export const toolDescriptionMap = new Map>(); + /** Maps Vercel AI span names to standardized OpenTelemetry operation names. */ export const SPAN_TO_OPERATION_NAME = new Map([ ['ai.generateText', 'invoke_agent'], diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 55b53c362612..c6ff4c784dde 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -4,7 +4,7 @@ import { getClient } from '../../currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { shouldEnableTruncation } from '../ai/utils'; import type { Event } from '../../types-hoist/event'; -import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON } from '../../types-hoist/span'; +import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON, StreamedSpanJSON } from '../../types-hoist/span'; import { spanToJSON } from '../../utils/spanUtils'; import { GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE, @@ -14,6 +14,7 @@ import { GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, GEN_AI_TOOL_CALL_ID_ATTRIBUTE, + GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE, GEN_AI_TOOL_INPUT_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE, GEN_AI_TOOL_OUTPUT_ATTRIBUTE, @@ -24,8 +25,9 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { SPAN_TO_OPERATION_NAME, toolCallSpanContextMap } from './constants'; +import { SPAN_TO_OPERATION_NAME, toolCallSpanContextMap, toolDescriptionMap } from './constants'; import type { TokenSummary } from './types'; +import { hasSpanStreamingEnabled } from '../spans/hasSpanStreamingEnabled'; import { accumulateTokensForParent, applyAccumulatedTokens, @@ -233,19 +235,12 @@ function buildOutputMessages(attributes: Record): void { /** * Post-process spans emitted by the Vercel AI SDK. */ -function processEndedVercelAiSpan(span: SpanJSON): void { - const { data: attributes, origin } = span; - - if (origin !== 'auto.vercelai.otel') { - return; - } - - // The Vercel AI SDK sets span status to raw error message strings. - // Any such value should be normalized to a SpanStatusType value. We pick internal_error as it is the most generic. - if (span.status && span.status !== 'ok') { - span.status = 'internal_error'; - } - +/** + * Rename and normalize Vercel AI SDK attributes to OpenTelemetry semantic conventions. + * This is the shared attribute processing logic used by both the legacy event processor + * path (SpanJSON) and the streamed span path (StreamedSpanJSON). + */ +export function processVercelAiSpanAttributes(attributes: Record): void { renameAttributeKey(attributes, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE); renameAttributeKey(attributes, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE); renameAttributeKey(attributes, AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE); @@ -338,6 +333,49 @@ function processEndedVercelAiSpan(span: SpanJSON): void { } } +function processEndedVercelAiSpan(span: SpanJSON): void { + const { data: attributes, origin } = span; + + if (origin !== 'auto.vercelai.otel') { + return; + } + + // The Vercel AI SDK sets span status to raw error message strings. + // Any such value should be normalized to a SpanStatusType value. We pick internal_error as it is the most generic. + if (span.status && span.status !== 'ok') { + span.status = 'internal_error'; + } + + processVercelAiSpanAttributes(attributes); +} + +function processVercelAiStreamedSpan(span: StreamedSpanJSON): void { + const attributes = span.attributes; + if (attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] !== 'auto.vercelai.otel') { + return; + } + + processVercelAiSpanAttributes(attributes); + + // Look up tool description from the toolDescriptionMap for execute_tool spans + if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'gen_ai.execute_tool' && span.parent_span_id) { + const descriptions = toolDescriptionMap.get(span.parent_span_id); + + if (descriptions) { + const toolName = attributes[GEN_AI_TOOL_NAME_ATTRIBUTE]; + if (typeof toolName === 'string') { + const desc = descriptions.get(toolName); + if (desc) { + attributes[GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE] = desc; + } + } + } + } + + // Clean up tool descriptions when the parent span ends + toolDescriptionMap.delete(span.span_id); +} + /** * Renames an attribute key in the provided attributes object if the old key exists. * This function safely handles null and undefined values. @@ -418,6 +456,41 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute if (modelId && operationName) { span.updateName(`${operationName} ${modelId}`); } + + // Store tool descriptions in the toolDescriptionMap so processSpan can apply them to execute_tool spans. + // This is only needed for span streaming (transaction path handles this separately) + const client = getClient(); + if ( + client && + hasSpanStreamingEnabled(client) && + attributes[AI_PROMPT_TOOLS_ATTRIBUTE] && + Array.isArray(attributes[AI_PROMPT_TOOLS_ATTRIBUTE]) + ) { + const descriptions = new Map(); + + // parse tool names and descriptions from tool string array + for (const toolStr of attributes[AI_PROMPT_TOOLS_ATTRIBUTE] as unknown[]) { + try { + const parsed = (typeof toolStr === 'string' ? JSON.parse(toolStr) : toolStr) as { + name?: string; + description?: string; + }; + if (parsed?.name && parsed?.description) { + descriptions.set(parsed.name, parsed.description); + } + } catch { + // ignore parse errors + } + } + if (descriptions.size > 0) { + // Tool call spans are siblings of doGenerate (both children of invoke_agent), + // so we key by the parent span ID (the invoke_agent span). + const parentSpanId = spanToJSON(span).parent_span_id; + if (parentSpanId) { + toolDescriptionMap.set(parentSpanId, descriptions); + } + } + } } /** @@ -427,9 +500,12 @@ export function addVercelAiProcessors(client: Client): void { client.on('spanStart', onVercelAiSpanStart); // Note: We cannot do this on `spanEnd`, because the span cannot be mutated anymore at this point client.addEventProcessor(Object.assign(vercelAiEventProcessor, { id: 'VercelAiEventProcessor' })); + client.on('processSpan', span => { + processVercelAiStreamedSpan(span); + }); } -function addProviderMetadataToAttributes(attributes: SpanAttributes): void { +function addProviderMetadataToAttributes(attributes: Record): void { const providerMetadata = attributes[AI_RESPONSE_PROVIDER_METADATA_ATTRIBUTE] as string | undefined; if (providerMetadata) { try { @@ -506,7 +582,11 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { /** * Sets an attribute only if the value is not null or undefined. */ -function setAttributeIfDefined(attributes: SpanAttributes, key: string, value: SpanAttributeValue | undefined): void { +function setAttributeIfDefined( + attributes: Record, + key: string, + value: SpanAttributeValue | undefined, +): void { if (value != null) { attributes[key] = value; } From 7efc03f0c04ec96821916ff6c04d24a70316e627 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 6 May 2026 16:46:36 +0200 Subject: [PATCH 80/84] feat(core): Apply request data to segment spans in span streaming (#20654) Implements the span-streaming counterpart of the `requestDataIntegration.processEvent` hook. Request data from the scope's `sdkProcessingMetadata` is mapped to span attributes following sentry-conventions, reusing `httpHeadersToSpanAttributes` for sensitivity filtering and gating IP extraction behind `sendDefaultPii`. Note: The logic is also guarded by `client.getIntegrationByName('RequestData')` so users who opt out of the integration don't get request data on streamed spans either. This approach was chosen over adding a `processSegmentSpan` hook to the integration because captureSpan is tree-shakeable for non-streaming users, keeping the request data code out of bundles that don't need it (see linked ticket). Closes https://github.com/getsentry/sentry-javascript/issues/20380 --- .../instrument-without-request-data.mjs | 12 + .../requestData-streamed/instrument.mjs | 11 + .../tracing/requestData-streamed/server.mjs | 13 + .../tracing/requestData-streamed/test.ts | 74 +++++ packages/core/src/integrations/requestdata.ts | 91 +++++- .../core/src/tracing/spans/captureSpan.ts | 36 +-- .../test/lib/integrations/requestdata.test.ts | 269 +++++++++++++++++- 7 files changed, 483 insertions(+), 23 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument-without-request-data.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requestData-streamed/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requestData-streamed/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument-without-request-data.mjs b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument-without-request-data.mjs new file mode 100644 index 000000000000..04492fbce291 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument-without-request-data.mjs @@ -0,0 +1,12 @@ +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: 'stream', + sendDefaultPii: true, + integrations: defaults => defaults.filter(i => i.name !== 'RequestData'), +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument.mjs new file mode 100644 index 000000000000..761e5f9c474d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + sendDefaultPii: true, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/server.mjs b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/server.mjs new file mode 100644 index 000000000000..07398392cb75 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/server.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +app.get('/test', (_req, res) => { + res.send({ response: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/test.ts new file mode 100644 index 000000000000..c5657cef6c3a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/test.ts @@ -0,0 +1,74 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('requestData-streamed', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('applies request data attributes to the segment span', async () => { + const runner = createRunner() + .expect({ + span: container => { + const serverSpan = container.items.find(item => item.is_segment); + + expect(serverSpan).toBeDefined(); + + expect(serverSpan?.attributes?.['url.full']).toEqual({ + type: 'string', + value: expect.stringContaining('/test?foo=bar'), + }); + + expect(serverSpan?.attributes?.['http.request.method']).toEqual({ + type: 'string', + value: 'GET', + }); + + expect(serverSpan?.attributes?.['url.query']).toEqual({ + type: 'string', + value: 'foo=bar', + }); + + expect(serverSpan?.attributes?.['http.request.header.host']).toEqual({ + type: 'string', + value: expect.any(String), + }); + + expect(serverSpan?.attributes?.['user.ip_address']).toEqual({ + type: 'string', + value: expect.any(String), + }); + }, + }) + .start(); + + await runner.makeRequest('get', '/test?foo=bar'); + + await runner.completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-without-request-data.mjs', (createRunner, test) => { + test('does not apply request data attributes when requestDataIntegration is removed', async () => { + const runner = createRunner() + .expect({ + span: container => { + const serverSpan = container.items.find(item => item.is_segment); + + expect(serverSpan).toBeDefined(); + + // url.query and user.ip_address are only set by applyScopeToSegmentSpan + // (not by OTel instrumentation), so they should be absent when the integration is removed + expect(serverSpan?.attributes?.['url.query']).toBeUndefined(); + expect(serverSpan?.attributes?.['user.ip_address']).toBeUndefined(); + }, + }) + .start(); + + await runner.makeRequest('get', '/test?foo=bar'); + + await runner.completed(); + }); + }); +}); diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 9ff6033ed7a2..7c462954e075 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -1,9 +1,14 @@ +import { getIsolationScope } from '../currentScopes'; import { defineIntegration } from '../integration'; +import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS } from '../semanticAttributes'; import type { Event } from '../types-hoist/event'; import type { IntegrationFn } from '../types-hoist/integration'; -import type { RequestEventData } from '../types-hoist/request'; +import type { QueryParams, RequestEventData } from '../types-hoist/request'; +import type { StreamedSpanJSON } from '../types-hoist/span'; import { parseCookie } from '../utils/cookie'; +import { httpHeadersToSpanAttributes } from '../utils/request'; import { getClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress'; +import { safeSetSpanJSONAttributes } from '../tracing/spans/captureSpan'; interface RequestDataIncludeOptions { cookies?: boolean; @@ -55,6 +60,22 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = return event; }, + processSegmentSpan(span, client) { + const { sdkProcessingMetadata = {} } = getIsolationScope().getScopeData(); + const { normalizedRequest, ipAddress } = sdkProcessingMetadata; + + if (!normalizedRequest) { + return; + } + + const { sendDefaultPii } = client.getOptions(); + const includeWithDefaultPiiApplied: RequestDataIncludeOptions = { + ...include, + ip: include.ip ?? sendDefaultPii, + }; + + addNormalizedRequestDataToSpan(span, normalizedRequest, ipAddress, includeWithDefaultPiiApplied, sendDefaultPii); + }, }; }) satisfies IntegrationFn; @@ -91,6 +112,60 @@ function addNormalizedRequestDataToEvent( } } +function addNormalizedRequestDataToSpan( + span: StreamedSpanJSON, + normalizedRequest: RequestEventData, + ipAddress: string | undefined, + include: RequestDataIncludeOptions, + sendDefaultPii: boolean | undefined, +): void { + const requestData = extractNormalizedRequestData(normalizedRequest, include); + const attributes: Record = {}; + + if (requestData.url) { + attributes['url.full'] = requestData.url; + } + + if (requestData.method) { + attributes['http.request.method'] = requestData.method; + } + + if (requestData.query_string) { + attributes['url.query'] = normalizeQueryString(requestData.query_string); + } + + safeSetSpanJSONAttributes(span, attributes); + + // Process cookies before headers so normalizedRequest.cookies takes precedence + // over the raw cookie header (matching the processEvent path). + if (requestData.cookies && Object.keys(requestData.cookies).length > 0) { + const cookieString = Object.entries(requestData.cookies) + .map(([name, value]) => `${name}=${value}`) + .join('; '); + const cookieAttributes = httpHeadersToSpanAttributes({ cookie: cookieString }, sendDefaultPii ?? false, 'request'); + safeSetSpanJSONAttributes(span, cookieAttributes); + } + + if (requestData.headers) { + const headerAttributes = httpHeadersToSpanAttributes(requestData.headers, sendDefaultPii ?? false, 'request'); + safeSetSpanJSONAttributes(span, headerAttributes); + } + + if (requestData.data != null) { + const serialized = typeof requestData.data === 'string' ? requestData.data : JSON.stringify(requestData.data); + if (serialized) { + safeSetSpanJSONAttributes(span, { 'http.request.body.data': serialized }); + } + } + + if (include.ip) { + const ip = (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress || undefined; + if (ip) { + safeSetSpanJSONAttributes(span, { [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: ip }); + } + } +} + function extractNormalizedRequestData( normalizedRequest: RequestEventData, include: RequestDataIncludeOptions, @@ -101,13 +176,10 @@ function extractNormalizedRequestData( if (include.headers) { requestData.headers = headers; - // Remove the Cookie header in case cookie data should not be included in the event if (!include.cookies) { delete (headers as { cookie?: string }).cookie; } - // Remove IP headers in case IP data should not be included in the event. - // Match case-insensitively — same as getClientIPAddress — so lowercase keys are stripped too. if (!include.ip) { const ipHeaderNamesLower = new Set(ipHeaderNames.map(name => name.toLowerCase())); for (const key of Object.keys(headers)) { @@ -140,3 +212,14 @@ function extractNormalizedRequestData( return requestData; } + +function normalizeQueryString(queryString: QueryParams): string | undefined { + if (typeof queryString === 'string') { + return queryString || undefined; + } + + const pairs = Array.isArray(queryString) ? queryString : Object.entries(queryString); + const result = pairs.map(([key, value]) => `${key}=${value}`).join('&'); + + return result || undefined; +} diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index c06d4ce43560..bed3f1790740 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -97,10 +97,27 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW } function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void { - // TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span + // TODO: Apply contexts data from auto instrumentation to segment span // This will follow in a separate PR } +/** + * Safely set attributes on a span JSON. + * If an attribute already exists, it will not be overwritten. + */ +export function safeSetSpanJSONAttributes( + spanJSON: StreamedSpanJSON, + newAttributes: RawAttributes>, +): void { + const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); + + Object.entries(newAttributes).forEach(([key, value]) => { + if (value != null && !(key in originalAttributes)) { + originalAttributes[key] = value; + } + }); +} + function applyCommonSpanAttributes( spanJSON: StreamedSpanJSON, serializedSegmentSpan: StreamedSpanJSON, @@ -145,23 +162,6 @@ export function applyBeforeSendSpanCallback( return modifedSpan; } -/** - * Safely set attributes on a span JSON. - * If an attribute already exists, it will not be overwritten. - */ -export function safeSetSpanJSONAttributes( - spanJSON: StreamedSpanJSON, - newAttributes: RawAttributes>, -): void { - const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); - - Object.entries(newAttributes).forEach(([key, value]) => { - if (value != null && !(key in originalAttributes)) { - originalAttributes[key] = value; - } - }); -} - // OTel SpanKind values (numeric to avoid importing from @opentelemetry/api) const SPAN_KIND_SERVER = 1; const SPAN_KIND_CLIENT = 2; diff --git a/packages/core/test/lib/integrations/requestdata.test.ts b/packages/core/test/lib/integrations/requestdata.test.ts index df8e8d4d8766..7b2dca819ea3 100644 --- a/packages/core/test/lib/integrations/requestdata.test.ts +++ b/packages/core/test/lib/integrations/requestdata.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Client } from '../../../src/client'; +import * as currentScopes from '../../../src/currentScopes'; import { requestDataIntegration } from '../../../src/integrations/requestdata'; import type { Event } from '../../../src/types-hoist/event'; +import type { StreamedSpanJSON } from '../../../src/types-hoist/span'; import { ipHeaderNames } from '../../../src/vendor/getIpAddress'; function mockClient(sendDefaultPii: boolean | undefined): Client { @@ -602,3 +604,268 @@ describe('requestDataIntegration', () => { expect(event.request?.headers?.['X-Forwarded-For']).toBeUndefined(); }); }); + +describe('requestDataIntegration processSegmentSpan', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function makeSpan(overrides: Partial = {}): StreamedSpanJSON { + return { + name: 'GET /test', + span_id: 'abc123', + trace_id: 'def456', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: true, + attributes: {}, + ...overrides, + }; + } + + function mockIsolationScope(normalizedRequest: Record, ipAddress?: string): void { + vi.spyOn(currentScopes, 'getIsolationScope').mockReturnValue({ + getScopeData: () => ({ + sdkProcessingMetadata: { normalizedRequest, ipAddress }, + }), + } as ReturnType); + } + + it('applies request data attributes to the segment span', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com/api/users', + method: 'GET', + query_string: 'page=1&limit=10', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'url.full': 'https://example.com/api/users', + 'http.request.method': 'GET', + 'url.query': 'page=1&limit=10', + 'http.request.header.content_type': 'application/json', + 'http.request.header.accept': 'application/json', + }); + }); + + it('does not apply attributes when normalizedRequest is missing', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({}); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toEqual({}); + }); + + it('sets user.ip_address from headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com', + headers: { 'x-forwarded-for': '203.0.113.50' }, + }); + + integration.processSegmentSpan!(span, mockClient(true)); + + expect(span.attributes).toMatchObject({ + 'user.ip_address': '203.0.113.50', + }); + }); + + it('falls back to ipAddress from sdkProcessingMetadata', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', headers: {} }, '192.168.1.1'); + + integration.processSegmentSpan!(span, mockClient(true)); + + expect(span.attributes).toMatchObject({ + 'user.ip_address': '192.168.1.1', + }); + }); + + it('does not set user.ip_address when sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com', + headers: { 'x-forwarded-for': '203.0.113.50' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('user.ip_address'); + }); + + it('applies cookies from normalizedRequest.cookies', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + cookies: { theme: 'dark', locale: 'en' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.cookie.theme': 'dark', + 'http.request.header.cookie.locale': 'en', + }); + }); + + it('falls back to cookie header when normalizedRequest.cookies is not set', () => { + const integration = requestDataIntegration({ include: { headers: false } }); + const span = makeSpan(); + + mockIsolationScope({ + headers: { cookie: 'theme=dark; locale=en' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.cookie.theme': 'dark', + 'http.request.header.cookie.locale': 'en', + }); + }); + + it('filters sensitive cookies', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + cookies: { theme: 'dark', 'connect.sid': 'secret', session_token: 'secret' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.cookie.theme': 'dark', + 'http.request.header.cookie.connect.sid': '[Filtered]', + 'http.request.header.cookie.session_token': '[Filtered]', + }); + }); + + it('applies request body data', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ data: { key: 'value' } }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.body.data': '{"key":"value"}', + }); + }); + + it('handles query_string in object format', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ query_string: { page: '1', limit: '10' } }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'url.query': 'page=1&limit=10', + }); + }); + + describe('respects include options', () => { + it('excludes url when include.url is false', () => { + const integration = requestDataIntegration({ include: { url: false } }); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', method: 'GET' }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('url.full'); + expect(span.attributes).toMatchObject({ 'http.request.method': 'GET' }); + }); + + it('excludes headers when include.headers is false', () => { + const integration = requestDataIntegration({ include: { headers: false } }); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com', + headers: { 'content-type': 'application/json' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('http.request.header.content_type'); + }); + + it('strips cookie header when include.cookies is false', () => { + const integration = requestDataIntegration({ include: { cookies: false } }); + const span = makeSpan(); + + mockIsolationScope({ + headers: { 'content-type': 'application/json', cookie: 'theme=dark' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.content_type': 'application/json', + }); + expect(span.attributes).not.toHaveProperty('http.request.header.cookie.theme'); + }); + + it('strips IP headers when include.ip is false', () => { + const integration = requestDataIntegration({ include: { ip: false } }); + const span = makeSpan(); + + mockIsolationScope({ + headers: { 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.50' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.content_type': 'application/json', + }); + expect(span.attributes).not.toHaveProperty('http.request.header.x_forwarded_for'); + expect(span.attributes).not.toHaveProperty('user.ip_address'); + }); + + it('excludes data when include.data is false', () => { + const integration = requestDataIntegration({ include: { data: false } }); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', data: { key: 'value' } }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('http.request.body.data'); + }); + + it('excludes query_string when include.query_string is false', () => { + const integration = requestDataIntegration({ include: { query_string: false } }); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', query_string: 'page=1' }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('url.query'); + }); + }); +}); From a8ab7155daa18403b28a4629c0231e1716a871e4 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 6 May 2026 14:04:38 -0400 Subject: [PATCH 81/84] feat(replay): Reset replay id from DSC on session expiry/refresh (#20129) Its possible that a user returns to an old Sentry tab, an error gets thrown and ingested w/ the expired replay id in DSC. This error then gets link in our UI because of the replay id in DSC and causes the duration to appear to be very long (>>> 1 hr). This PR adds a check in handleGlobalEvent to clear the replay id from DSC if the replay session is expired. It also updates the DSC when in session mode and replay session is refreshed. --- .../src/coreHandlers/handleGlobalEvent.ts | 16 ++ packages/replay-internal/src/replay.ts | 16 +- .../resetReplayIdOnDynamicSamplingContext.ts | 21 +++ .../coreHandlers/handleGlobalEvent.test.ts | 152 +++++++++++++++++- .../test/integration/session.test.ts | 51 ++++++ 5 files changed, 252 insertions(+), 4 deletions(-) diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index b28d4547265e..3a7ea6a87fc6 100644 --- a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts @@ -4,6 +4,7 @@ import { saveSession } from '../session/saveSession'; import type { ReplayContainer } from '../types'; import { isErrorEvent, isFeedbackEvent, isReplayEvent, isTransactionEvent } from '../util/eventUtils'; import { isRrwebError } from '../util/isRrwebError'; +import { shouldRefreshSession } from '../session/shouldRefreshSession'; import { debug } from '../util/logger'; import { resetReplayIdOnDynamicSamplingContext } from '../util/resetReplayIdOnDynamicSamplingContext'; import { addFeedbackBreadcrumb } from './util/addFeedbackBreadcrumb'; @@ -15,6 +16,21 @@ import { shouldSampleForBufferEvent } from './util/shouldSampleForBufferEvent'; export function handleGlobalEventListener(replay: ReplayContainer): (event: Event, hint: EventHint) => Event | null { return Object.assign( (event: Event, hint: EventHint) => { + // Check for expired session and clean stale replay_id from DSC. + // This must run BEFORE the isEnabled/isPaused guards because when paused, + // the guards short-circuit without cleaning DSC. Uses shouldRefreshSession + // instead of isSessionExpired to respect the buffer-mode carve-out: + // buffer sessions with segmentId === 0 are kept alive even when time-expired. + if ( + replay.session && + shouldRefreshSession(replay.session, { + maxReplayDuration: replay.getOptions().maxReplayDuration, + sessionIdleExpire: replay.timeouts.sessionIdleExpire, + }) + ) { + resetReplayIdOnDynamicSamplingContext(); + } + // Do nothing if replay has been disabled or paused if (!replay.isEnabled() || replay.isPaused()) { return event; diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index d80f47a6704b..de2e596a0be1 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -50,9 +50,11 @@ import { debounce } from './util/debounce'; import { getRecordingSamplingOptions } from './util/getRecordingSamplingOptions'; import { getHandleRecordingEmit } from './util/handleRecordingEmit'; import { isExpired } from './util/isExpired'; -import { isSessionExpired } from './util/isSessionExpired'; import { debug } from './util/logger'; -import { resetReplayIdOnDynamicSamplingContext } from './util/resetReplayIdOnDynamicSamplingContext'; +import { + resetReplayIdOnDynamicSamplingContext, + setReplayIdOnDynamicSamplingContext, +} from './util/resetReplayIdOnDynamicSamplingContext'; import { closestElementOfNode } from './util/rrweb'; import { sendReplay } from './util/sendReplay'; import { RateLimitError, ReplayDurationLimitError } from './util/sendReplayRequest'; @@ -876,6 +878,13 @@ export class ReplayContainer implements ReplayContainerInterface { } this.startRecording(); + + // Update the cached DSC with the new replay_id when in session mode. + // The cached DSC on the scope (set by browserTracingIntegration) persists + // across session refreshes, and the `createDsc` hook won't fire for it. + if (this.recordingMode === 'session' && this.session) { + setReplayIdOnDynamicSamplingContext(this.session.id); + } } /** @@ -1001,12 +1010,13 @@ export class ReplayContainer implements ReplayContainerInterface { return; } - const expired = isSessionExpired(this.session, { + const expired = shouldRefreshSession(this.session, { maxReplayDuration: this._options.maxReplayDuration, sessionIdleExpire: this.timeouts.sessionIdleExpire, }); if (expired) { + resetReplayIdOnDynamicSamplingContext(); return; } diff --git a/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts b/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts index 7d3139aa447d..4839300d7fd2 100644 --- a/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts +++ b/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts @@ -18,3 +18,24 @@ export function resetReplayIdOnDynamicSamplingContext(): void { delete (dsc as Partial).replay_id; } } + +/** + * Set the `replay_id` field on the cached DSC. + * This is needed after a session refresh because the cached DSC on the scope + * (set by browserTracingIntegration when the idle span ended) persists across + * session boundaries. Without updating it, the new session's replay_id would + * never appear in DSC since `getDynamicSamplingContextFromClient` (and its + * `createDsc` hook) is not called when a cached DSC already exists. + */ +export function setReplayIdOnDynamicSamplingContext(replayId: string): void { + const dsc = getCurrentScope().getPropagationContext().dsc; + if (dsc) { + dsc.replay_id = replayId; + } + + const activeSpan = getActiveSpan(); + if (activeSpan) { + const dsc = getDynamicSamplingContextFromSpan(activeSpan); + (dsc as Partial).replay_id = replayId; + } +} diff --git a/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts b/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts index 956b8a93e72b..6c7230e2c680 100644 --- a/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts +++ b/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts @@ -6,7 +6,7 @@ import '../../utils/mock-internal-setTimeout'; import type { Event } from '@sentry/core'; import { getClient } from '@sentry/core'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { REPLAY_EVENT_NAME, SESSION_IDLE_EXPIRE_DURATION } from '../../../src/constants'; +import { MAX_REPLAY_DURATION, REPLAY_EVENT_NAME, SESSION_IDLE_EXPIRE_DURATION } from '../../../src/constants'; import { handleGlobalEventListener } from '../../../src/coreHandlers/handleGlobalEvent'; import type { ReplayContainer } from '../../../src/replay'; import { makeSession } from '../../../src/session/Session'; @@ -435,4 +435,154 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => { expect(resetReplayIdSpy).toHaveBeenCalledTimes(2); }); + + it('resets replayId on DSC when replay is paused and session has expired', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1, + started: now - SESSION_IDLE_EXPIRE_DURATION - 1, + sampled: 'session', + }); + + replay['_isPaused'] = true; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + // Should have been called even though replay is paused + expect(resetReplayIdSpy).toHaveBeenCalledTimes(1); + }); + + it('does not reset replayId on DSC when replay is paused but session is still valid', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + lastActivity: now, + started: now, + sampled: 'session', + }); + + replay['_isPaused'] = true; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + // Should NOT have been called because session is still valid + expect(resetReplayIdSpy).not.toHaveBeenCalled(); + }); + + it('resets replayId on DSC when replay is paused and session exceeds max duration', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + // Recent activity, but session started too long ago + lastActivity: now, + started: now - MAX_REPLAY_DURATION - 1, + sampled: 'session', + }); + + replay['_isPaused'] = true; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + expect(resetReplayIdSpy).toHaveBeenCalledTimes(1); + }); + + it('does not reset replayId on DSC for expired buffer session with segmentId 0', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1, + started: now - SESSION_IDLE_EXPIRE_DURATION - 1, + sampled: 'buffer', + }); + + replay['_isPaused'] = true; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + // Should NOT reset DSC: buffer sessions with segmentId 0 are kept alive + // even when time-expired (shouldRefreshSession carve-out) + expect(resetReplayIdSpy).not.toHaveBeenCalled(); + }); + + it('resets replayId on DSC for expired buffer session with segmentId > 0', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 1, + lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1, + started: now - SESSION_IDLE_EXPIRE_DURATION - 1, + sampled: 'buffer', + }); + + replay['_isPaused'] = true; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + // Buffer session with segmentId > 0 that is expired SHOULD have DSC reset + expect(resetReplayIdSpy).toHaveBeenCalledTimes(1); + }); + + it('resets replayId on DSC when replay is disabled and session has expired', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1, + started: now - SESSION_IDLE_EXPIRE_DURATION - 1, + sampled: 'session', + }); + + replay['_isEnabled'] = false; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + expect(resetReplayIdSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/replay-internal/test/integration/session.test.ts b/packages/replay-internal/test/integration/session.test.ts index f867c43efbe8..1c4b49bb1fad 100644 --- a/packages/replay-internal/test/integration/session.test.ts +++ b/packages/replay-internal/test/integration/session.test.ts @@ -438,6 +438,57 @@ describe('Integration | session', () => { ); }); + it('updates DSC with new replay_id after session refresh', async () => { + const { getCurrentScope } = await import('@sentry/core'); + + const initialSession = { ...replay.session } as Session; + + // Simulate a cached DSC on the scope (as browserTracingIntegration does + // when the idle span ends) with the old session's replay_id. + const scope = getCurrentScope(); + scope.setPropagationContext({ + ...scope.getPropagationContext(), + dsc: { + trace_id: 'test-trace-id', + public_key: 'test-public-key', + replay_id: initialSession.id, + }, + }); + + // Idle past expiration + const ELAPSED = SESSION_IDLE_EXPIRE_DURATION + 1; + vi.advanceTimersByTime(ELAPSED); + + // Emit a recording event to put replay into paused state (mirrors the + // "creates a new session" test which does this before clicking) + const TEST_EVENT = getTestEventIncremental({ + data: { name: 'lost event' }, + timestamp: BASE_TIMESTAMP, + }); + mockRecord._emitter(TEST_EVENT); + await new Promise(process.nextTick); + + expect(replay.isPaused()).toBe(true); + + // Trigger user activity to cause session refresh + domHandler({ + name: 'click', + event: new Event('click'), + }); + + // _refreshSession is async (calls await stop() then initializeSampling) + await vi.advanceTimersByTimeAsync(DEFAULT_FLUSH_MIN_DELAY); + await new Promise(process.nextTick); + + // Should be a new session + expect(replay).not.toHaveSameSession(initialSession); + + // The cached DSC should now have the NEW session's replay_id, not the old one + const dsc = scope.getPropagationContext().dsc; + expect(dsc?.replay_id).toBe(replay.session?.id); + expect(dsc?.replay_id).not.toBe(initialSession.id); + }); + it('increases segment id after each event', async () => { clearSession(replay); replay['_initializeSessionForSampling'](); From 7e4957133deca6d05ab3bf49604bcf59327c403a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 6 May 2026 17:30:36 -0400 Subject: [PATCH 82/84] feat(node): use diagnostics_channel for redis >= 5.12.0 (#20573) Builds on #20510 and adds tracing channels subscribers via `node:diagnostics_channel`. The module patchers are narrowed to `>=5.0.0 <5.12.0` while the subscriber path runs unconditionally. it will be inert in older releases anyways and will activate when version 5.12 publishes the events. Verified that it works on Node and Bun equally as well, while IITM fails on Bun. --------- Co-authored-by: isaacs --- .../node-integration-tests/package.json | 1 + .../tracing/redis-dc/docker-compose.yml | 15 ++ .../tracing/redis-dc/scenario-redis-5.js | 47 ++++ .../suites/tracing/redis-dc/test.ts | 110 +++++++++ packages/node/rollup.npm.config.mjs | 1 + .../src/integrations/tracing/redis/index.ts | 9 +- .../tracing/redis/redis-dc-subscriber.ts | 231 ++++++++++++++++++ .../redis/vendored/ioredis-instrumentation.ts | 3 + .../redis/vendored/redis-instrumentation.ts | 10 +- .../test/integrations/tracing/redis.test.ts | 4 +- .../tracing/redis/redis-dc-subscriber.test.ts | 214 ++++++++++++++++ 11 files changed, 637 insertions(+), 8 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/redis-dc/docker-compose.yml create mode 100644 dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js create mode 100644 dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts create mode 100644 packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts create mode 100644 packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 3fa61d5b6576..13cffc27fcde 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -80,6 +80,7 @@ "prisma": "6.15.0", "proxy": "^2.1.1", "redis-4": "npm:redis@^4.6.14", + "redis-5": "npm:redis@^5.12.0", "reflect-metadata": "0.2.1", "rxjs": "^7.8.2", "tedious": "^19.2.1", diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/redis-dc/docker-compose.yml new file mode 100644 index 000000000000..9cad2efa4eff --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.9' + +services: + db: + image: redis:latest + restart: always + container_name: integration-tests-redis-dc + ports: + - '6379:6379' + healthcheck: + test: ['CMD-SHELL', 'redis-cli ping | grep -q PONG'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js new file mode 100644 index 000000000000..5cf455203d48 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js @@ -0,0 +1,47 @@ +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', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.redisIntegration({ cachePrefixes: ['dc-cache:'] })], +}); + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +async function run() { + // Yield a microtick so the DC subscriber (deferred via Promise.resolve().then) + // is registered before node-redis eagerly creates its native TracingChannels on require(). + await Promise.resolve(); + + const { createClient } = require('redis-5'); + const redisClient = await createClient({ socket: { host: '127.0.0.1', port: 6379 } }).connect(); + + await Sentry.startSpan( + { + name: 'Test Span Redis 5 DC', + op: 'test-span-redis-5-dc', + }, + async () => { + try { + await redisClient.set('dc-test-key', 'test-value'); + await redisClient.set('dc-cache:test-key', 'test-value'); + + await redisClient.set('dc-cache:test-key-ex', 'test-value', { EX: 10 }); + + await redisClient.get('dc-test-key'); + await redisClient.get('dc-cache:test-key'); + await redisClient.get('dc-cache:unavailable-data'); + + await redisClient.mGet(['dc-test-key', 'dc-cache:test-key', 'dc-cache:unavailable-data']); + } finally { + await redisClient.disconnect(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts new file mode 100644 index 000000000000..7b7c111f4d29 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts @@ -0,0 +1,110 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('redis v5 diagnostics_channel auto instrumentation', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should create spans for redis v5 commands via diagnostics_channel', { timeout: 60_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Span Redis 5 DC', + spans: expect.arrayContaining([ + expect.objectContaining({ + op: 'db.redis', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.op': 'db.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.system': 'redis', + 'db.statement': 'SET dc-test-key [1 other arguments]', + }), + }), + // cache SET: span name updated to key by cacheResponseHook + expect.objectContaining({ + description: 'dc-cache:test-key', + op: 'cache.put', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.statement': 'SET dc-cache:test-key [1 other arguments]', + 'cache.key': ['dc-cache:test-key'], + 'cache.item_size': 2, + }), + }), + // cache SET with EX option: redis v5 sends SET key value EX 10 as the command + expect.objectContaining({ + description: 'dc-cache:test-key-ex', + op: 'cache.put', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.statement': 'SET dc-cache:test-key-ex [3 other arguments]', + 'cache.key': ['dc-cache:test-key-ex'], + 'cache.item_size': 2, + }), + }), + expect.objectContaining({ + op: 'db.redis', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.op': 'db.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.system': 'redis', + 'db.statement': 'GET dc-test-key', + }), + }), + // cache GET (hit) + expect.objectContaining({ + description: 'dc-cache:test-key', + op: 'cache.get', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.statement': 'GET dc-cache:test-key', + 'cache.hit': true, + 'cache.key': ['dc-cache:test-key'], + 'cache.item_size': 10, + }), + }), + // cache GET (miss) + expect.objectContaining({ + description: 'dc-cache:unavailable-data', + op: 'cache.get', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.statement': 'GET dc-cache:unavailable-data', + 'cache.hit': false, + 'cache.key': ['dc-cache:unavailable-data'], + }), + }), + // MGET: node-redis sanitizes args for diagnostics_channel (keys become '?'), + // so cache detection cannot match prefixes — remains a plain db.redis span. + expect.objectContaining({ + op: 'db.redis', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.op': 'db.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.system': 'redis', + 'db.statement': 'MGET [3 other arguments]', + }), + }), + ]), + }; + + // node-redis emits a node-redis:connect DC event for the initial connection. + // That fires before startSpan so it arrives as the first envelope. + const EXPECTED_CONNECT = { + transaction: 'redis-connect', + }; + + await createRunner(__dirname, 'scenario-redis-5.js') + .withDockerCompose({ workingDirectory: [__dirname] }) + .expect({ transaction: EXPECTED_CONNECT }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); +}); diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 93fd1d8c16ca..741c6ec27fe5 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -6,6 +6,7 @@ export default [ makeBaseNPMConfig({ entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts'], packageSpecificConfig: { + external: [/^@sentry\/opentelemetry/], output: { // set exports to 'named' or 'auto' so that rollup doesn't warn exports: 'named', diff --git a/packages/node/src/integrations/tracing/redis/index.ts b/packages/node/src/integrations/tracing/redis/index.ts index c2bff42e4107..2e5268c14c6f 100644 --- a/packages/node/src/integrations/tracing/redis/index.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -6,7 +6,6 @@ import { SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, SEMANTIC_ATTRIBUTE_CACHE_KEY, SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, truncate, } from '@sentry/core'; @@ -23,6 +22,7 @@ import { import type { IORedisResponseCustomAttributeFunction } from './vendored/types'; import { IORedisInstrumentation } from './vendored/ioredis-instrumentation'; import { RedisInstrumentation } from './vendored/redis-instrumentation'; +import { subscribeRedisDiagnosticChannels } from './redis-dc-subscriber'; interface RedisOptions { /** @@ -53,8 +53,6 @@ export const cacheResponseHook: IORedisResponseCustomAttributeFunction = ( cmdArgs: IORedisCommandArgs, response: unknown, ) => { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.redis'); - const safeKey = getCacheKeySafely(redisCommand, cmdArgs); const cacheOperation = getCacheOperation(redisCommand); @@ -120,6 +118,11 @@ export const instrumentRedis = Object.assign( (): void => { instrumentIORedis(); instrumentRedisModule(); + // node-redis >= 5.12.0 publishes via diagnostics_channel. The subscriber uses + // `@sentry/opentelemetry/tracing-channel`, which needs the Sentry OTel context manager + // to be registered before it can `bindStore`. `initOpenTelemetry()` runs after integration + // `setupOnce`, so defer to the next tick. + void Promise.resolve().then(() => subscribeRedisDiagnosticChannels(cacheResponseHook)); // todo: implement them gradually // new LegacyRedisInstrumentation({}), diff --git a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts new file mode 100644 index 000000000000..4a2ddaf8a9b2 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts @@ -0,0 +1,231 @@ +import type { Span } from '@opentelemetry/api'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startSpanManual, +} from '@sentry/core'; +import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry/tracing-channel'; +import { defaultDbStatementSerializer } from './vendored/redis-common'; +import { + ATTR_DB_STATEMENT, + ATTR_DB_SYSTEM, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, + DB_SYSTEM_VALUE_REDIS, +} from './vendored/semconv'; +import type { IORedisInstrumentationConfig } from './vendored/types'; + +// Channel names as published by node-redis >= 5.12.0. +// Hardcoded so we don't import `redis` at module-load time. +const CHANNEL_COMMAND = 'node-redis:command'; +const CHANNEL_BATCH = 'node-redis:batch'; +const CHANNEL_CONNECT = 'node-redis:connect'; + +const ORIGIN = 'auto.db.redis.diagnostic_channel'; + +interface CommandData { + command: string; + args: Array; + database?: number; + serverAddress?: string; + serverPort?: number; + result?: unknown; + error?: Error; +} + +interface BatchData { + batchMode?: 'MULTI' | 'PIPELINE'; + batchSize?: number; + database?: number; + clientId?: string | number; + serverAddress?: string; + serverPort?: number; + result?: unknown[]; + error?: Error; +} + +interface ConnectData { + serverAddress?: string; + serverPort?: number; + url?: string; + error?: Error; +} + +const NOOP = (): void => {}; + +let subscribed = false; +let currentResponseHook: IORedisInstrumentationConfig['responseHook'] | undefined; + +/** + * Subscribe Sentry handlers to node-redis diagnostics_channel events (>= 5.12.0). + * + * Uses `@sentry/opentelemetry/tracing-channel` so OTel AsyncLocalStorage context propagates + * automatically via `bindStore` — without it, spans created in `start` would not become + * the active context for subsequent operations. + * + * Safe on every runtime that exposes `node:diagnostics_channel` (Node, Bun, Deno, Workers). + * In node-redis < 5.12.0 the channels are never published to, so subscribers are inert and + * there is no double-instrumentation against the IITM-based patcher (gated to < 5.12.0). + */ +export function subscribeRedisDiagnosticChannels(responseHook?: IORedisInstrumentationConfig['responseHook']): void { + currentResponseHook = responseHook; + if (subscribed) return; + + try { + setupCommandChannel(); + setupBatchChannel(); + setupConnectChannel(); + subscribed = true; + } catch { + // tracingChannel from @sentry/opentelemetry requires `node:diagnostics_channel`. + // On runtimes where it isn't available, fail closed. + } +} + +function setupCommandChannel(): void { + const channel = tracingChannel(CHANNEL_COMMAND, data => { + // node-redis >= 5.12.0 includes the command name as args[0] in the DC payload. + // Strip it so serialization and cache key extraction see only the actual arguments. + const actualArgs = data.args.slice(1); + const statement = safeSerialize(data.command, actualArgs); + return startSpanManual( + { + name: `redis-${data.command}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + ...(statement != null ? { [ATTR_DB_STATEMENT]: statement } : {}), + ...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}), + }, + }, + span => span, + ) as Span; + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + const span = data._sentrySpan; + // only end if error handler isn't going to + if (!span || data.error) return; + // Same slice: strip command name from args before passing to the response hook. + runResponseHook(span, data.command, data.args.slice(1), data.result); + span.end(); + }, + error: data => { + const span = data._sentrySpan; + if (!span) return; + if (data.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message }); + } + span.end(); + }, + }); +} + +function setupBatchChannel(): void { + const channel = tracingChannel(CHANNEL_BATCH, data => { + const operationName = data.batchMode === 'PIPELINE' ? 'PIPELINE' : 'MULTI'; + + return startSpanManual( + { + name: operationName, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + ...(data.batchSize != null ? { 'db.redis.batch_size': data.batchSize } : {}), + ...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}), + }, + }, + span => span, + ) as Span; + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + // only end if the error handler isn't going to + if (!data.error) data._sentrySpan?.end(); + }, + error: data => { + const span = data._sentrySpan; + if (!span) return; + if (data.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message }); + } + span.end(); + }, + }); +} + +function setupConnectChannel(): void { + const channel = tracingChannel(CHANNEL_CONNECT, data => { + return startSpanManual( + { + name: 'redis-connect', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis.connect', + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + ...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}), + }, + }, + span => span, + ) as Span; + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + // only end if the error handler isn't going to + if (!data.error) data._sentrySpan?.end(); + }, + error: data => { + const span = data._sentrySpan; + if (!span) return; + if (data.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message }); + } + span.end(); + }, + }); +} + +function runResponseHook(span: Span, command: string, args: Array, result: unknown): void { + const hook = currentResponseHook; + if (!hook) return; + try { + hook(span, command, args as unknown as Parameters[2], result); + } catch { + // never let user hooks break instrumentation + } +} + +function safeSerialize(command: string, args: Array): string | undefined { + try { + return defaultDbStatementSerializer(command, args); + } catch { + return undefined; + } +} + +// Test-only helper. +export function _resetRedisDiagnosticChannelsForTesting(): void { + subscribed = false; + currentResponseHook = undefined; +} + +// Suppress unused-import lint when only used in types. +export type { TracingChannelContextWithSpan }; diff --git a/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts b/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts index d55cb2e31420..a97900ab4f9d 100644 --- a/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts +++ b/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts @@ -22,6 +22,7 @@ import { context, diag, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; import type { Span } from '@opentelemetry/api'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { InstrumentationBase, InstrumentationNodeModuleDefinition, @@ -166,6 +167,7 @@ export class IORedisInstrumentation extends InstrumentationBase=5.0.0 <5.12.0'], (moduleExports: any) => { const redisClientMultiCommandPrototype = moduleExports?.default?.prototype; if (isWrapped(redisClientMultiCommandPrototype?.exec)) { @@ -401,7 +403,7 @@ class RedisInstrumentationV4_V5 extends InstrumentationBase=5.0.0 <5.12.0'], (moduleExports: any) => { const redisClientPrototype = moduleExports?.default?.prototype; if (redisClientPrototype?.multi) { @@ -445,7 +447,7 @@ class RedisInstrumentationV4_V5 extends InstrumentationBase=5.0.0 <5.12.0'], (moduleExports: any) => moduleExports, () => {}, [commanderModuleFile, multiCommanderModule, clientIndexModule], @@ -535,6 +537,7 @@ class RedisInstrumentationV4_V5 extends InstrumentationBase { { desc: 'unsupported command', cmd: 'exists', args: ['key'], response: 'test' }, { desc: 'no cache prefixes', cmd: 'get', args: ['key'], response: 'test', options: {} }, { desc: 'non-matching prefix', cmd: 'get', args: ['key'], response: 'test', options: { cachePrefixes: ['c'] } }, - ])('should always set sentry.origin but return early when $desc', ({ cmd, args, response, options = {} }) => { + ])('should return early without modifying span when $desc', ({ cmd, args, response, options = {} }) => { Object.assign(_redisOptions, options); cacheResponseHook(mockSpan, cmd, args, response); - expect(mockSpan.setAttribute).toHaveBeenCalledWith('sentry.origin', 'auto.db.otel.redis'); + expect(mockSpan.setAttribute).not.toHaveBeenCalled(); expect(mockSpan.setAttributes).not.toHaveBeenCalled(); expect(mockSpan.updateName).not.toHaveBeenCalled(); }); diff --git a/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts b/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts new file mode 100644 index 000000000000..852298b3370c --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts @@ -0,0 +1,214 @@ +import { SPAN_STATUS_ERROR } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// channels registry must be created before the vi.mock factory runs +const channels = vi.hoisted(() => ({}) as Record void> }>); + +vi.mock('@sentry/opentelemetry/tracing-channel', () => ({ + tracingChannel: (name: string, _transform: unknown) => { + const subs: Record void> = {}; + channels[name] = { subs }; + return { subscribe: (s: Record void>) => Object.assign(subs, s) }; + }, +})); + +import { + _resetRedisDiagnosticChannelsForTesting, + subscribeRedisDiagnosticChannels, +} from '../../../../src/integrations/tracing/redis/redis-dc-subscriber'; + +const CHANNEL_COMMAND = 'node-redis:command'; +const CHANNEL_BATCH = 'node-redis:batch'; +const CHANNEL_CONNECT = 'node-redis:connect'; + +const subs = (name: string) => + channels[name]?.subs as { + asyncEnd: (data: any) => void; + error: (data: any) => void; + }; + +function makeSpan() { + return { + end: vi.fn(), + setStatus: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), + updateName: vi.fn(), + spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id', traceFlags: 1 }), + }; +} + +describe('redis-dc-subscriber', () => { + let mockSpan: ReturnType; + let responseHook: ReturnType; + + beforeEach(() => { + _resetRedisDiagnosticChannelsForTesting(); + mockSpan = makeSpan(); + responseHook = vi.fn(); + subscribeRedisDiagnosticChannels(responseHook); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('command channel', () => { + describe('asyncEnd (success path)', () => { + it('calls the response hook with sliced args and ends the span', () => { + const data = { + command: 'GET', + args: ['GET', 'cache:key'], + result: 'hit-value', + _sentrySpan: mockSpan, + }; + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).toHaveBeenCalledWith(mockSpan, 'GET', ['cache:key'], 'hit-value'); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('strips the command name from args before passing to the response hook', () => { + const data = { + command: 'MGET', + args: ['MGET', 'key1', 'key2', 'key3'], + result: ['v1', 'v2', 'v3'], + _sentrySpan: mockSpan, + }; + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).toHaveBeenCalledWith(mockSpan, 'MGET', ['key1', 'key2', 'key3'], ['v1', 'v2', 'v3']); + }); + + it('bails early when _sentrySpan is absent', () => { + subs(CHANNEL_COMMAND).asyncEnd({ command: 'GET', args: ['GET', 'k'], result: 'v' }); + + expect(responseHook).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + it('sets error status and ends the span in the error handler', () => { + const error = new Error('ECONNREFUSED'); + const data = { command: 'SET', args: ['SET', 'k', 'v'], error, _sentrySpan: mockSpan }; + subs(CHANNEL_COMMAND).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'ECONNREFUSED' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not call the response hook or end the span a second time in asyncEnd when error is set', () => { + const error = new Error('ECONNREFUSED'); + const data = { command: 'GET', args: ['GET', 'k'], error, _sentrySpan: mockSpan }; + + // TracingChannel fires error first, then asyncEnd, on the same data object + subs(CHANNEL_COMMAND).error(data); + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).not.toHaveBeenCalled(); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('bails early in error handler when _sentrySpan is absent', () => { + subs(CHANNEL_COMMAND).error({ command: 'GET', args: ['GET', 'k'], error: new Error('x') }); + + expect(mockSpan.setStatus).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + }); + + describe('batch channel', () => { + describe('asyncEnd (success path)', () => { + it('ends the span', () => { + const data = { batchMode: 'PIPELINE', batchSize: 3, _sentrySpan: mockSpan }; + subs(CHANNEL_BATCH).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('bails early when _sentrySpan is absent', () => { + subs(CHANNEL_BATCH).asyncEnd({ batchMode: 'MULTI' }); + + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + it('sets error status and ends the span in the error handler', () => { + const error = new Error('MULTI aborted'); + const data = { batchMode: 'MULTI', error, _sentrySpan: mockSpan }; + subs(CHANNEL_BATCH).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'MULTI aborted' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not end the span a second time in asyncEnd when error is set', () => { + const error = new Error('MULTI aborted'); + const data = { batchMode: 'MULTI', error, _sentrySpan: mockSpan }; + + subs(CHANNEL_BATCH).error(data); + subs(CHANNEL_BATCH).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('connect channel', () => { + describe('asyncEnd (success path)', () => { + it('ends the span', () => { + const data = { serverAddress: '127.0.0.1', serverPort: 6379, _sentrySpan: mockSpan }; + subs(CHANNEL_CONNECT).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('bails early when _sentrySpan is absent', () => { + subs(CHANNEL_CONNECT).asyncEnd({ serverAddress: '127.0.0.1' }); + + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + it('sets error status and ends the span in the error handler', () => { + const error = new Error('connect ECONNREFUSED'); + const data = { serverAddress: '127.0.0.1', serverPort: 6379, error, _sentrySpan: mockSpan }; + subs(CHANNEL_CONNECT).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'connect ECONNREFUSED' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not end the span a second time in asyncEnd when error is set', () => { + const error = new Error('connect ECONNREFUSED'); + const data = { serverAddress: '127.0.0.1', error, _sentrySpan: mockSpan }; + + subs(CHANNEL_CONNECT).error(data); + subs(CHANNEL_CONNECT).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('subscribeRedisDiagnosticChannels', () => { + it('is idempotent — does not re-subscribe if called again', () => { + // subscribeRedisDiagnosticChannels was already called in beforeEach. + // Calling again should not throw or overwrite subscribers. + const secondHook = vi.fn(); + subscribeRedisDiagnosticChannels(secondHook); + + // The second hook should still be active (currentResponseHook is updated regardless) + // but no new channel setup should occur. + const data = { command: 'GET', args: ['GET', 'k'], result: 'v', _sentrySpan: mockSpan }; + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(secondHook).toHaveBeenCalledTimes(1); + expect(responseHook).not.toHaveBeenCalled(); + }); + }); +}); From e1858184d418be059dcf24f71900d821406feb44 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 7 May 2026 09:09:02 +0200 Subject: [PATCH 83/84] feat(node-core): Add `processSegmentSpan` to node context integration (#20678) Adds `processSegmentSpan` to the `nodeContextIntegration` for span streaming support. Node equivalent to https://github.com/getsentry/sentry-javascript/pull/20613 closes https://linear.app/getsentry/issue/JS-2219 --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Nicolas Hrubec --- .../startSpan/basic-usage-streamed/test.ts | 39 +++- .../suites/context-streamed/scenario.ts | 15 ++ .../suites/context-streamed/test.ts | 43 ++++ .../startSpan/basic-usage-streamed/test.ts | 39 +++- .../node-core/src/integrations/context.ts | 184 ++++++++++++++---- .../test/integrations/context.test.ts | 162 ++++++++++++++- 6 files changed, 424 insertions(+), 58 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/context-streamed/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/context-streamed/test.ts diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index 3184aae69d64..ee018e45e53b 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -123,16 +123,37 @@ test('sends a streamed span envelope with correct spans for a manually started s status: 'ok', }); + const expectedAttributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + 'process.runtime.engine.name': { type: 'string', value: 'v8' }, + 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, + 'app.start_time': { type: 'string', value: expect.any(String) }, + 'app.memory': { type: 'integer', value: expect.any(Number) }, + 'device.boot_time': { type: 'string', value: expect.any(String) }, + 'device.memory_size': { type: 'integer', value: expect.any(Number) }, + 'device.free_memory': { type: 'integer', value: expect.any(Number) }, + 'device.processor_count': { type: 'integer', value: expect.any(Number) }, + 'device.cpu_description': { type: 'string', value: expect.any(String) }, + 'device.processor_frequency': { type: 'integer', value: expect.any(Number) }, + 'culture.locale': { type: 'string', value: expect.any(String) }, + 'culture.timezone': { type: 'string', value: expect.any(String) }, + // TODO: device.archs is an array and currently dropped during serialization + // 'device.archs': { type: 'array', value: [expect.any(String)] }, + }; + + // process.availableMemory is only available in Node 22+ + if (typeof (process as any).availableMemory === 'function') { + expectedAttributes['app.free_memory'] = { type: 'integer', value: expect.any(Number) }; + } + expect(segmentSpan).toEqual({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - }, + attributes: expectedAttributes, name: 'test-span', is_segment: true, trace_id: traceId, diff --git a/dev-packages/node-integration-tests/suites/context-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/context-streamed/scenario.ts new file mode 100644 index 000000000000..7f1b5ddd053f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/context-streamed/scenario.ts @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +Sentry.startSpan({ name: 'test-span' }, () => { + // noop +}); + +void Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/context-streamed/test.ts b/dev-packages/node-integration-tests/suites/context-streamed/test.ts new file mode 100644 index 000000000000..9d1a6ca5099a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/context-streamed/test.ts @@ -0,0 +1,43 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('nodeContextIntegration sets context attributes on segment spans', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + span: container => { + const segmentSpan = container.items.find(s => !!s.is_segment); + expect(segmentSpan).toBeDefined(); + + const attrs = segmentSpan!.attributes!; + + // Static attributes + expect(attrs['app.start_time']).toEqual({ type: 'string', value: expect.any(String) }); + // TODO: device.archs is an array and currently dropped during serialization + // expect(attrs['device.archs']).toEqual({ type: 'array', value: [expect.any(String)] }); + expect(attrs['device.boot_time']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['device.processor_count']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['device.cpu_description']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['device.processor_frequency']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['device.memory_size']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['culture.locale']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['culture.timezone']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['process.runtime.engine.name']).toEqual({ type: 'string', value: 'v8' }); + expect(attrs['process.runtime.engine.version']).toEqual({ type: 'string', value: expect.any(String) }); + + // Dynamic attributes + expect(attrs['app.memory']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['device.free_memory']).toEqual({ type: 'integer', value: expect.any(Number) }); + + // process.availableMemory is only available in Node 22+ + if (typeof (process as any).availableMemory === 'function') { + expect(attrs['app.free_memory']).toEqual({ type: 'integer', value: expect.any(Number) }); + } + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index b31ca320df53..88e3f3686622 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -123,16 +123,37 @@ test('sends a streamed span envelope with correct spans for a manually started s status: 'ok', }); + const expectedAttributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + 'process.runtime.engine.name': { type: 'string', value: 'v8' }, + 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, + 'app.start_time': { type: 'string', value: expect.any(String) }, + 'app.memory': { type: 'integer', value: expect.any(Number) }, + // TODO: device.archs is an array and currently dropped during serialization + // 'device.archs': { type: 'array', value: [expect.any(String)] }, + 'device.boot_time': { type: 'string', value: expect.any(String) }, + 'device.memory_size': { type: 'integer', value: expect.any(Number) }, + 'device.free_memory': { type: 'integer', value: expect.any(Number) }, + 'device.processor_count': { type: 'integer', value: expect.any(Number) }, + 'device.cpu_description': { type: 'string', value: expect.any(String) }, + 'device.processor_frequency': { type: 'integer', value: expect.any(Number) }, + 'culture.locale': { type: 'string', value: expect.any(String) }, + 'culture.timezone': { type: 'string', value: expect.any(String) }, + }; + + // process.availableMemory is only available in Node 22+ + if (typeof (process as any).availableMemory === 'function') { + expectedAttributes['app.free_memory'] = { type: 'integer', value: expect.any(Number) }; + } + expect(segmentSpan).toEqual({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - }, + attributes: expectedAttributes, name: 'test-span', is_segment: true, trace_id: traceId, diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index a39f75bfa2a9..3c3904582315 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -15,7 +15,7 @@ import type { IntegrationFn, OsContext, } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core'; export const readFileAsync = promisify(readFile); export const readDirAsync = promisify(readdir); @@ -42,8 +42,6 @@ interface ContextOptions { } const _nodeContextIntegration = ((options: ContextOptions = {}) => { - let cachedContext: Promise | undefined; - const _options = { app: true, os: true, @@ -53,13 +51,56 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { ...options, }; - /** Add contexts to the event. Caches the context so we only look it up once. */ - async function addContext(event: Event): Promise { - if (cachedContext === undefined) { - cachedContext = _getContexts(); + // Compute contexts eagerly (shared between tx and span paths) + const appContext = _options.app ? getAppContext() : undefined; + const deviceContext = _options.device ? getDeviceContext(_options.device) : undefined; + const cultureContext = _options.culture ? getCultureContext() : undefined; + const cloudResourceContext = _options.cloudResource ? getCloudResourceContext() : undefined; + const osContextPromise = _options.os ? getOsContext() : undefined; + + // Map static context data to span attributes + const cachedSpanAttributes: Record = { + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + ...contextsToSpanAttributes({ + app: appContext, + device: deviceContext, + culture: cultureContext, + cloud_resource: cloudResourceContext, + }), + }; + + if (osContextPromise) { + osContextPromise + .then(osCtx => Object.assign(cachedSpanAttributes, contextsToSpanAttributes({ os: osCtx }))) + .catch(() => { + // Ignore - os attributes will be undefined + }); + } + + // Build contexts for event processing (reuses same data, awaits async OS context) + const contextsPromise: Promise = (async () => { + const contexts: Contexts = {}; + if (osContextPromise) { + contexts.os = await osContextPromise; + } + if (appContext) { + contexts.app = appContext; } + if (deviceContext) { + contexts.device = deviceContext; + } + if (cultureContext) { + contexts.culture = cultureContext; + } + if (cloudResourceContext) { + contexts.cloud_resource = cloudResourceContext; + } + return contexts; + })(); - const updatedContext = _updateContext(await cachedContext); + async function addContext(event: Event): Promise { + const updatedContext = _updateContext(await contextsPromise); // TODO(v11): conditional with `sendDefaultPii` here? event.contexts = { @@ -74,42 +115,15 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { return event; } - /** Get the contexts from node. */ - async function _getContexts(): Promise { - const contexts: Contexts = {}; - - if (_options.os) { - contexts.os = await getOsContext(); - } - - if (_options.app) { - contexts.app = getAppContext(); - } - - if (_options.device) { - contexts.device = getDeviceContext(_options.device); - } - - if (_options.culture) { - const culture = getCultureContext(); - - if (culture) { - contexts.culture = culture; - } - } - - if (_options.cloudResource) { - contexts.cloud_resource = getCloudResourceContext(); - } - - return contexts; - } - return { name: INTEGRATION_NAME, processEvent(event) { return addContext(event); }, + processSegmentSpan(span) { + safeSetSpanJSONAttributes(span, cachedSpanAttributes); + safeSetSpanJSONAttributes(span, getDynamicSpanAttributes(appContext, deviceContext)); + }, }; }) satisfies IntegrationFn; @@ -142,6 +156,98 @@ function _updateContext(contexts: Contexts): Contexts { return contexts; } +export function contextsToSpanAttributes(contexts: Contexts): Record { + const attrs: Record = {}; + + const { app, device, os: osCtx, culture, cloud_resource } = contexts; + + if (app) { + if (app.app_start_time) { + attrs['app.start_time'] = app.app_start_time; + } + } + + if (device) { + if (device.arch) { + attrs['device.archs'] = [device.arch]; + } + if (device.boot_time) { + attrs['device.boot_time'] = device.boot_time; + } + if (device.memory_size != null) { + attrs['device.memory_size'] = device.memory_size; + } + if (device.processor_count != null) { + attrs['device.processor_count'] = device.processor_count; + } + if (device.cpu_description) { + attrs['device.cpu_description'] = device.cpu_description; + } + if (device.processor_frequency != null) { + attrs['device.processor_frequency'] = device.processor_frequency; + } + } + + if (osCtx) { + if (osCtx.name) { + attrs['os.name'] = osCtx.name; + } + if (osCtx.version) { + attrs['os.version'] = osCtx.version; + } + if (osCtx.kernel_version) { + attrs['os.kernel_version'] = osCtx.kernel_version; + } + if (osCtx.build) { + attrs['os.build'] = osCtx.build; + } + } + + if (culture) { + if (culture.locale) { + attrs['culture.locale'] = culture.locale; + } + if (culture.timezone) { + attrs['culture.timezone'] = culture.timezone; + } + } + + // CloudResourceContext already uses dot-notation keys matching span attribute conventions + if (cloud_resource) { + for (const [key, value] of Object.entries(cloud_resource)) { + if (value != null) { + attrs[key] = value; + } + } + } + + return attrs; +} + +export function getDynamicSpanAttributes( + appContext: AppContext | undefined, + deviceContext: DeviceContext | undefined, +): Record { + const attrs: Record = {}; + + if (appContext) { + attrs['app.memory'] = process.memoryUsage().rss; + if (typeof (process as ProcessWithCurrentValues).availableMemory === 'function') { + const freeMemory = (process as ProcessWithCurrentValues).availableMemory?.(); + if (freeMemory != null) { + attrs['app.free_memory'] = freeMemory; + } + } + } + + // Only include if memory tracking was initially enabled (indicated by free_memory being set) + if (deviceContext?.free_memory != null) { + attrs['device.free_memory'] = os.freemem(); + } + + return attrs; +} + /** * Returns the operating system context. * diff --git a/packages/node-core/test/integrations/context.test.ts b/packages/node-core/test/integrations/context.test.ts index b8c3f8e3d49b..3bcc1af80589 100644 --- a/packages/node-core/test/integrations/context.test.ts +++ b/packages/node-core/test/integrations/context.test.ts @@ -1,6 +1,13 @@ import * as os from 'node:os'; +import type { StreamedSpanJSON } from '@sentry/core'; import { afterAll, describe, expect, it, vi } from 'vitest'; -import { getAppContext, getDeviceContext } from '../../src/integrations/context'; +import { + contextsToSpanAttributes, + getAppContext, + getDeviceContext, + getDynamicSpanAttributes, + nodeContextIntegration, +} from '../../src/integrations/context'; import { conditionalTest } from '../helpers/conditional'; vi.mock('node:os', async () => { @@ -53,4 +60,157 @@ describe('Context', () => { expect(deviceCtx.boot_time).toBeUndefined(); }); }); + + describe('contextsToSpanAttributes', () => { + it('maps app context', () => { + const attrs = contextsToSpanAttributes({ app: { app_start_time: '2026-01-01T00:00:00.000Z', app_memory: 100 } }); + expect(attrs).toEqual({ 'app.start_time': '2026-01-01T00:00:00.000Z' }); + }); + + it('maps device context', () => { + const attrs = contextsToSpanAttributes({ + device: { + arch: 'arm64', + boot_time: '2026-01-01T00:00:00.000Z', + memory_size: 1024, + processor_count: 8, + cpu_description: 'Apple M1', + processor_frequency: 3200, + free_memory: 512, + }, + }); + expect(attrs).toEqual({ + 'device.archs': ['arm64'], + 'device.boot_time': '2026-01-01T00:00:00.000Z', + 'device.memory_size': 1024, + 'device.processor_count': 8, + 'device.cpu_description': 'Apple M1', + 'device.processor_frequency': 3200, + }); + }); + + it('maps os context', () => { + const attrs = contextsToSpanAttributes({ os: { name: 'macOS', version: '15.0', kernel_version: '24.0.0' } }); + expect(attrs).toEqual({ 'os.name': 'macOS', 'os.version': '15.0', 'os.kernel_version': '24.0.0' }); + }); + + it('maps culture context', () => { + const attrs = contextsToSpanAttributes({ culture: { locale: 'en-US', timezone: 'America/New_York' } }); + expect(attrs).toEqual({ 'culture.locale': 'en-US', 'culture.timezone': 'America/New_York' }); + }); + + it('maps cloud resource context', () => { + const attrs = contextsToSpanAttributes({ + cloud_resource: { 'cloud.provider': 'aws', 'cloud.region': 'us-east-1' }, + }); + expect(attrs).toEqual({ 'cloud.provider': 'aws', 'cloud.region': 'us-east-1' }); + }); + + it('skips undefined values', () => { + const attrs = contextsToSpanAttributes({ app: {}, device: {}, os: {} }); + expect(attrs).toEqual({}); + }); + }); + + describe('getDynamicSpanAttributes', () => { + it('includes app memory when app context is provided', () => { + const attrs = getDynamicSpanAttributes(getAppContext(), undefined); + expect(attrs['app.memory']).toEqual(expect.any(Number)); + }); + + it('includes device free memory when device context has free_memory', () => { + const attrs = getDynamicSpanAttributes(undefined, { free_memory: 1024 }); + expect(attrs['device.free_memory']).toEqual(expect.any(Number)); + }); + + it('excludes device free memory when device context has no free_memory', () => { + const attrs = getDynamicSpanAttributes(undefined, { arch: 'arm64' }); + expect(attrs['device.free_memory']).toBeUndefined(); + }); + + it('returns empty when no contexts provided', () => { + const attrs = getDynamicSpanAttributes(undefined, undefined); + expect(attrs).toEqual({}); + }); + }); + + describe('processSegmentSpan', () => { + it('sets static and dynamic context attributes on segment span', () => { + const integration = nodeContextIntegration(); + + const span: StreamedSpanJSON = { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: Date.now(), + end_timestamp: Date.now(), + status: 'ok', + is_segment: true, + attributes: {}, + }; + + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toMatchObject({ + 'app.start_time': expect.any(String), + 'device.memory_size': expect.any(Number), + 'device.processor_count': expect.any(Number), + 'device.cpu_description': expect.any(String), + 'device.processor_frequency': expect.any(Number), + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + 'app.memory': expect.any(Number), + 'device.free_memory': expect.any(Number), + }); + }); + + it('does not overwrite existing attributes', () => { + const integration = nodeContextIntegration(); + + const span: StreamedSpanJSON = { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: Date.now(), + end_timestamp: Date.now(), + status: 'ok', + is_segment: true, + attributes: { + 'process.runtime.engine.name': 'custom-engine', + }, + }; + + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes!['process.runtime.engine.name']).toBe('custom-engine'); + }); + + it('respects disabled options', () => { + const integration = nodeContextIntegration({ + app: false, + device: false, + os: false, + culture: false, + cloudResource: false, + }); + + const span: StreamedSpanJSON = { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: Date.now(), + end_timestamp: Date.now(), + status: 'ok', + is_segment: true, + attributes: {}, + }; + + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toEqual({ + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + }); + }); + }); }); From 11a64f61dafc151957c6ae298ee9f00cdb4babef Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 7 May 2026 09:31:50 +0200 Subject: [PATCH 84/84] meta(changelog): Update changelog for 10.52.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35efd12e575a..f23fe92ce89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,92 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @sbs44. Thank you for your contribution! +## 10.52.0 + +### Important Changes + +- **Beta release of the official Hono Sentry SDK** + + This release marks the beta release of the `@sentry/hono` Sentry SDK. For details on how to use it, check out the + [Sentry Hono SDK docs](https://docs.sentry.io/platforms/javascript/guides/hono/). Please reach out on + [GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have any feedback or concerns. - **feat(browser): Add `ingest_settings` to v2 log envelope payload ([#20453](https://github.com/getsentry/sentry-javascript/pull/20453))** Inference of user data (e.g. IP address, browser name/version) on log events is now gated behind the `sendDefaultPii` option. Previously, this data was always inferred by default. +### Other Changes + +- docs(hono): Add new docs link and move to BETA release ([#20666](https://github.com/getsentry/sentry-javascript/pull/20666)) +- feat(browser): Add `ingest_settings` to v2 metrics envelope payload ([#20454](https://github.com/getsentry/sentry-javascript/pull/20454)) +- feat(browser): Migrate spotlight event processor to `ignoreSpans` ([#20595](https://github.com/getsentry/sentry-javascript/pull/20595)) +- feat(cloudflare): Capture request body via httpServerIntegration ([#20614](https://github.com/getsentry/sentry-javascript/pull/20614)) +- feat(cloudflare): Support rpc trace propagation for WorkerEntrypoint ([#20523](https://github.com/getsentry/sentry-javascript/pull/20523)) +- feat(cloudflare): Support tracing for queue producer ([#20529](https://github.com/getsentry/sentry-javascript/pull/20529)) +- feat(core): Apply request data to segment spans in span streaming ([#20654](https://github.com/getsentry/sentry-javascript/pull/20654)) +- feat(core): Migrate Vercel AI event processor to span streaming ([#20608](https://github.com/getsentry/sentry-javascript/pull/20608)) +- feat(deno): Add `processSegmentSpan` to Deno context integration ([#20613](https://github.com/getsentry/sentry-javascript/pull/20613)) +- feat(http): Portable node:http client instrumentation ([#20393](https://github.com/getsentry/sentry-javascript/pull/20393)) +- feat(nitro): Add unstorage tracing channel instrumentation ([#20615](https://github.com/getsentry/sentry-javascript/pull/20615)) +- feat(node-core): Add `processSegmentSpan` to node context integration ([#20678](https://github.com/getsentry/sentry-javascript/pull/20678)) +- feat(node): Use diagnostics_channel for redis >= 5.12.0 ([#20573](https://github.com/getsentry/sentry-javascript/pull/20573)) +- feat(node): Vendor ioredis, redis instrumentations ([#20510](https://github.com/getsentry/sentry-javascript/pull/20510)) +- feat(replay): Reset replay id from DSC on session expiry/refresh ([#20129](https://github.com/getsentry/sentry-javascript/pull/20129)) +- fix: Bump fast-xml-parser to fix vulnerability ([#20644](https://github.com/getsentry/sentry-javascript/pull/20644)) +- fix: Bump vite versions to fix vulnerability ([#20646](https://github.com/getsentry/sentry-javascript/pull/20646)) +- fix(core): Drain buffers in flush() when there is no transport ([#20207](https://github.com/getsentry/sentry-javascript/pull/20207)) +- fix(core): Guard against undefined chained in copyProps ([#20637](https://github.com/getsentry/sentry-javascript/pull/20637)) +- fix(deps): Bump rollup-plugin-license to fix lodash vulnerabilities ([#20636](https://github.com/getsentry/sentry-javascript/pull/20636)) +- fix(deps): Bump transitive deps for medium security fixes ([#20683](https://github.com/getsentry/sentry-javascript/pull/20683)) +- fix(hono): Do not capture 3xx and 4xx errors and add tests ([#20640](https://github.com/getsentry/sentry-javascript/pull/20640)) +- fix(nextjs): Skip build modification when SRI is enabled ([#20694](https://github.com/getsentry/sentry-javascript/pull/20694)) +- fix(opentelemetry): Respect OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES ([#20509](https://github.com/getsentry/sentry-javascript/pull/20509)) + +
+ Internal Changes + +- chore: Remove `bundle-analyzer-scenarios` dev packages ([#20680](https://github.com/getsentry/sentry-javascript/pull/20680)) +- chore(deps): Bump @hono/node-server from 1.19.10 to 1.19.13 ([#20117](https://github.com/getsentry/sentry-javascript/pull/20117)) +- chore(deps): Bump @nestjs packages to fix path-to-regexp ReDoS ([#20642](https://github.com/getsentry/sentry-javascript/pull/20642)) +- chore(deps): Bump axios from 1.15.0 to 1.15.2 ([#20665](https://github.com/getsentry/sentry-javascript/pull/20665)) +- chore(deps): Bump ip-address from 10.1.0 to 10.2.0 ([#20695](https://github.com/getsentry/sentry-javascript/pull/20695)) +- chore(deps): Bump simple-git from 3.33.0 to 3.36.0 ([#20696](https://github.com/getsentry/sentry-javascript/pull/20696)) +- chore(deps): Bump vulnerable testem version ([#20634](https://github.com/getsentry/sentry-javascript/pull/20634)) +- ci(deps): Bump actions/checkout from 4 to 6 ([#20620](https://github.com/getsentry/sentry-javascript/pull/20620)) +- ci(deps): Bump actions/create-github-app-token from 2 to 3 ([#20079](https://github.com/getsentry/sentry-javascript/pull/20079)) +- ci(deps): Bump denoland/setup-deno from 2.0.3 to 2.0.4 ([#20080](https://github.com/getsentry/sentry-javascript/pull/20080)) +- ci(deps): Bump getsentry/craft from 2.24.1 to 2.26.2 ([#20621](https://github.com/getsentry/sentry-javascript/pull/20621)) +- feat(deps): Bump @xmldom/xmldom from 0.8.12 to 0.8.13 ([#20457](https://github.com/getsentry/sentry-javascript/pull/20457)) +- feat(deps): Bump follow-redirects from 1.15.11 to 1.16.0 ([#20267](https://github.com/getsentry/sentry-javascript/pull/20267)) +- feat(deps): Bump hono from 4.12.12 to 4.12.14 ([#20340](https://github.com/getsentry/sentry-javascript/pull/20340)) +- fix(tests): Use stable instrumentations api in rr tests ([#20690](https://github.com/getsentry/sentry-javascript/pull/20690)) +- ref(tests): Rename streamed http.client span test folders ([#20602](https://github.com/getsentry/sentry-javascript/pull/20602)) +- test(browser): Fix browserTracingIntegration unit test ([#20604](https://github.com/getsentry/sentry-javascript/pull/20604)) +- test(browser): Fix flaky browser integration test for profiles ([#20587](https://github.com/getsentry/sentry-javascript/pull/20587)) +- test(browser): Fix flaky loader test ([#20596](https://github.com/getsentry/sentry-javascript/pull/20596)) +- test(browser): Fix flaky loader test ([#20655](https://github.com/getsentry/sentry-javascript/pull/20655)) +- test(browser): Make browser profiling test less flaky ([#20664](https://github.com/getsentry/sentry-javascript/pull/20664)) +- test(cloudflare): Add e2e test for MCPAgent with DurableObject instrumentation ([#20601](https://github.com/getsentry/sentry-javascript/pull/20601)) +- test(cloudflare): Add integration tests for scheduled, D1, and workflow ([#20609](https://github.com/getsentry/sentry-javascript/pull/20609)) +- test(cloudflare): Reduce flakiness for cloudflare with sub workers ([#20632](https://github.com/getsentry/sentry-javascript/pull/20632)) +- test(cloudflare): Use Node v24 for Cloudflare e2e tests ([#20628](https://github.com/getsentry/sentry-javascript/pull/20628)) +- test(deps): Bump Next.js in E2E test apps to fix Server Components DoS ([#20633](https://github.com/getsentry/sentry-javascript/pull/20633)) +- test(e2e): Add node-express-streaming E2E test app ([#20684](https://github.com/getsentry/sentry-javascript/pull/20684)) +- test(e2e): Add span streaming test app for Cloudflare Workers ([#20681](https://github.com/getsentry/sentry-javascript/pull/20681)) +- test(e2e): Add span streaming test app for next 16 ([#20648](https://github.com/getsentry/sentry-javascript/pull/20648)) +- test(e2e): Add span streaming test app for React Router 7 SPA ([#20677](https://github.com/getsentry/sentry-javascript/pull/20677)) +- test(e2e): Remove remaining `npmrc` pointing to Verdaccio ([#20611](https://github.com/getsentry/sentry-javascript/pull/20611)) +- test(nextjs): Fix flaky node runtime metrics E2E tests ([#20624](https://github.com/getsentry/sentry-javascript/pull/20624)) +- test(node): Fix ANR test for flakiness ([#20656](https://github.com/getsentry/sentry-javascript/pull/20656)) +- test(node): Fix flaky node cron test ([#20661](https://github.com/getsentry/sentry-javascript/pull/20661)) +- test(node): Unflake mongodb test ([#20662](https://github.com/getsentry/sentry-javascript/pull/20662)) +- test(react-router): Fix flaky E2E tests ([#20630](https://github.com/getsentry/sentry-javascript/pull/20630)) +- test(test-utils): Add MemoryProfiler for heap snapshot testing via CDP ([#20555](https://github.com/getsentry/sentry-javascript/pull/20555)) + +
+ +Work in this release was contributed by @sbs44. Thank you for your contribution! + ## 10.51.0 ### Important Changes