From aaa0feaa16e05c4a5ed360a45ca22f1d4c2674c4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 19 Dec 2025 12:20:32 +0100 Subject: [PATCH 01/76] chore(size-limit): Add size checks for metrics and logs (#18573) Adds 3 size-limit entries for logs, metrics and both combined. I have some optimizations lined up for reducing bundle size in both but I need a baseline to see if they actually do any good. --- .size-limit.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.size-limit.js b/.size-limit.js index aa0d45ce176c..24772d8380f5 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -112,6 +112,27 @@ module.exports = [ gzip: true, limit: '35 KB', }, + { + name: '@sentry/browser (incl. Metrics)', + path: 'packages/browser/build/npm/esm/prod/index.js', + import: createImport('init', 'metrics'), + gzip: true, + limit: '27 KB', + }, + { + name: '@sentry/browser (incl. Logs)', + path: 'packages/browser/build/npm/esm/prod/index.js', + import: createImport('init', 'logger'), + gzip: true, + limit: '27 KB', + }, + { + name: '@sentry/browser (incl. Metrics & Logs)', + path: 'packages/browser/build/npm/esm/prod/index.js', + import: createImport('init', 'metrics', 'logger'), + gzip: true, + limit: '28 KB', + }, // React SDK (ESM) { name: '@sentry/react', From e2ef68124016c718626d7a74bc1264767d48e9e0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 19 Dec 2025 15:53:54 +0100 Subject: [PATCH 02/76] fix(browser): Reduce number of `visibilitystate` and `pagehide` listeners (#18581) Previously, we called `onHidden` in our slightly modified version of the vendored in `whenIdleOrHidden` API from web-vitals. This caused 2 additional event listeners to be registered with each `whenIdleOrHidden` invocation, which also didn't get cleaned up properly. To fix this, and prevent similar situations, this PR: - inlines the `pagehide` event listener registration in `whenIdleOrHidden` so that we can remove the `onHidden` call - adds `once: true` to the listener registration so that the callback is only invoked once - deprecates `onHidden` because IMHO we should remove it in v11 and replace the one remaining use of it with a direct `visibilityChange` subscription. Closes #18584 (added automatically) closes https://linear.app/getsentry/issue/JS-1339/investigate-multiple-event-listeners-in-nextjs-sdk --- packages/browser-utils/src/metrics/utils.ts | 1 + .../src/metrics/web-vitals/lib/onHidden.ts | 36 ++++++++++++------- .../web-vitals/lib/whenIdleOrHidden.ts | 12 ++++--- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index 4012d4118ad3..084d17becb8d 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -218,6 +218,7 @@ export function listenForWebVitalReportEvents( collected = true; } + // eslint-disable-next-line deprecation/deprecation onHidden(() => { _runCollectorCallbackOnce('pagehide'); }); diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts index d9dc2f6718ed..f48346e5e46d 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts @@ -21,16 +21,28 @@ export interface OnHiddenCallback { (event: Event): void; } -// Sentry-specific change: -// This function's logic was NOT updated to web-vitals 4.2.4 or 5.x but we continue -// to use the web-vitals 3.5.2 versiondue to us having stricter browser support. -// PR with context that made the changes: https://github.com/GoogleChrome/web-vitals/pull/442/files#r1530492402 -// The PR removed listening to the `pagehide` event, in favour of only listening to `visibilitychange` event. -// This is "more correct" but some browsers we still support (Safari <14.4) don't fully support `visibilitychange` -// or have known bugs w.r.t the `visibilitychange` event. -// TODO (v11): If we decide to drop support for Safari 14.4, we can use the logic from web-vitals 4.2.4 -// In this case, we also need to update the integration tests that currently trigger the `pagehide` event to -// simulate the page being hidden. +/** + * Sentry-specific change: + * + * This function's logic was NOT updated to web-vitals 4.2.4 or 5.x but we continue + * to use the web-vitals 3.5.2 version due to having stricter browser support. + * + * PR with context that made the changes: + * https://github.com/GoogleChrome/web-vitals/pull/442/files#r1530492402 + * + * The PR removed listening to the `pagehide` event, in favour of only listening to + * the `visibilitychange` event. This is "more correct" but some browsers we still + * support (Safari <14.4) don't fully support `visibilitychange` or have known bugs + * with respect to the `visibilitychange` event. + * + * TODO (v11): If we decide to drop support for Safari 14.4, we can use the logic + * from web-vitals 4.2.4. In this case, we also need to update the integration tests + * that currently trigger the `pagehide` event to simulate the page being hidden. + * + * @param {OnHiddenCallback} cb - Callback to be executed when the page is hidden or unloaded. + * + * @deprecated use `whenIdleOrHidden` or `addPageListener('visibilitychange')` instead + */ export const onHidden = (cb: OnHiddenCallback) => { const onHiddenOrPageHide = (event: Event) => { if (event.type === 'pagehide' || WINDOW.document?.visibilityState === 'hidden') { @@ -38,8 +50,8 @@ export const onHidden = (cb: OnHiddenCallback) => { } }; - addPageListener('visibilitychange', onHiddenOrPageHide, true); + addPageListener('visibilitychange', onHiddenOrPageHide, { capture: true, once: true }); // Some browsers have buggy implementations of visibilitychange, // so we use pagehide in addition, just to be safe. - addPageListener('pagehide', onHiddenOrPageHide, true); + addPageListener('pagehide', onHiddenOrPageHide, { capture: true, once: true }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts index 008aac8dc4c2..0bf8b2ce5894 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts @@ -16,7 +16,6 @@ import { WINDOW } from '../../../types.js'; import { addPageListener, removePageListener } from './globalListeners.js'; -import { onHidden } from './onHidden.js'; import { runOnce } from './runOnce.js'; /** @@ -34,15 +33,18 @@ export const whenIdleOrHidden = (cb: () => void) => { // eslint-disable-next-line no-param-reassign cb = runOnce(cb); addPageListener('visibilitychange', cb, { once: true, capture: true }); + // sentry: we use pagehide instead of directly listening to visibilitychange + // because some browsers we still support (Safari <14.4) don't fully support + // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. + // TODO(v11): remove this once we drop support for Safari <14.4 + addPageListener('pagehide', cb, { once: true, capture: true }); rIC(() => { cb(); // Remove the above event listener since no longer required. // See: https://github.com/GoogleChrome/web-vitals/issues/622 removePageListener('visibilitychange', cb, { capture: true }); + // TODO(v11): remove this once we drop support for Safari <14.4 + removePageListener('pagehide', cb, { capture: true }); }); - // sentry: we use onHidden instead of directly listening to visibilitychange - // because some browsers we still support (Safari <14.4) don't fully support - // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. - onHidden(cb); } }; From 63daea2066058d74fe03423ba464bab1fbc76a7f Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:05:45 +0100 Subject: [PATCH 03/76] feat(deps): Bump bundler plugins to ^4.6.1 (#17980) --- .../test-applications/browser-webworker-vite/package.json | 2 +- .../test-applications/debug-id-sourcemaps/package.json | 2 +- .../e2e-tests/test-applications/remix-hydrogen/package.json | 2 +- packages/astro/package.json | 2 +- packages/solidstart/package.json | 2 +- packages/sveltekit/package.json | 2 +- yarn.lock | 2 +- 7 files changed, 7 insertions(+), 7 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 37954bd3cbbc..f6eddbbdeb58 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 @@ -19,7 +19,7 @@ }, "dependencies": { "@sentry/browser": "latest || *", - "@sentry/vite-plugin": "^4.0.0" + "@sentry/vite-plugin": "^4.6.1" }, "volta": { "node": "20.19.2", diff --git a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json index 68973f3ffd72..0230683d8e5d 100644 --- a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json @@ -15,7 +15,7 @@ "devDependencies": { "rollup": "^4.35.0", "vitest": "^0.34.6", - "@sentry/rollup-plugin": "^4.0.0" + "@sentry/rollup-plugin": "^4.6.1" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json index 4314393034bb..40da7f5fb859 100644 --- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json @@ -19,7 +19,7 @@ "@remix-run/cloudflare-pages": "^2.15.2", "@sentry/cloudflare": "latest || *", "@sentry/remix": "latest || *", - "@sentry/vite-plugin": "^4.0.0", + "@sentry/vite-plugin": "^4.6.1", "@shopify/hydrogen": "2025.4.0", "@shopify/remix-oxygen": "^2.0.10", "graphql": "^16.6.0", diff --git a/packages/astro/package.json b/packages/astro/package.json index 3f588bd9c414..cb7ea61b933a 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -59,7 +59,7 @@ "@sentry/browser": "10.32.1", "@sentry/core": "10.32.1", "@sentry/node": "10.32.1", - "@sentry/vite-plugin": "^4.1.0" + "@sentry/vite-plugin": "^4.6.1" }, "devDependencies": { "astro": "^3.5.0", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index ba4f4e379818..b0a8f5dcaa87 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -69,7 +69,7 @@ "@sentry/core": "10.32.1", "@sentry/node": "10.32.1", "@sentry/solid": "10.32.1", - "@sentry/vite-plugin": "^4.1.0" + "@sentry/vite-plugin": "^4.6.1" }, "devDependencies": { "@solidjs/router": "^0.15.0", diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index ff5d8ff134bb..a9eccf52c55f 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -52,7 +52,7 @@ "@sentry/core": "10.32.1", "@sentry/node": "10.32.1", "@sentry/svelte": "10.32.1", - "@sentry/vite-plugin": "^4.1.0", + "@sentry/vite-plugin": "^4.6.1", "magic-string": "0.30.7", "recast": "0.23.11", "sorcery": "1.0.0" diff --git a/yarn.lock b/yarn.lock index e62326e9210f..eaceb4f6e0da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7156,7 +7156,7 @@ "@sentry/bundler-plugin-core" "4.6.1" unplugin "1.0.1" -"@sentry/vite-plugin@^4.1.0", "@sentry/vite-plugin@^4.6.1": +"@sentry/vite-plugin@^4.6.1": version "4.6.1" resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-4.6.1.tgz#883d8448c033b309528985e12e0d5d1af99ee1c6" integrity sha512-Qvys1y3o8/bfL3ikrHnJS9zxdjt0z3POshdBl3967UcflrTqBmnGNkcVk53SlmtJWIfh85fgmrLvGYwZ2YiqNg== From b41efe0acd974af69bbe967a855d9ac370e2b0a0 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 22 Dec 2025 15:01:31 +0100 Subject: [PATCH 04/76] chore(tests): Add unordered mode to cloudflare test runner (#18596) So far if multiple expected envelopes were chained on the cloudflare test runner, the runner expected them to arrive in the order pre-defined by the caller, else the test would fail. This can sometimes be too strict, resulting in flakes. This PR adds an unordered mode to the runner that lifts the order restriction on incoming envelopes. This mode is also used in the hono cloudflare test now. Closes https://github.com/getsentry/sentry-javascript/issues/18589 --- .../cloudflare-integration-tests/runner.ts | 50 +++++++++++++++---- .../suites/hono/basic/test.ts | 1 + 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index b945bee2eeea..90990369743c 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -57,6 +57,9 @@ type StartResult = { export function createRunner(...paths: string[]) { const testPath = join(...paths); + // controls whether envelopes are expected in predefined order or not + let unordered = false; + if (!existsSync(testPath)) { throw new Error(`Test scenario not found: ${testPath}`); } @@ -76,6 +79,10 @@ export function createRunner(...paths: string[]) { } return this; }, + unordered: function () { + unordered = true; + return this; + }, ignore: function (...types: EnvelopeItemType[]) { types.forEach(t => ignored.add(t)); return this; @@ -102,6 +109,14 @@ export function createRunner(...paths: string[]) { } } + function assertEnvelopeMatches(expected: Expected, envelope: Envelope): void { + if (typeof expected === 'function') { + expected(envelope); + } else { + expect(envelope).toEqual(expected); + } + } + function newEnvelope(envelope: Envelope): void { if (process.env.DEBUG) log('newEnvelope', inspect(envelope, false, null, true)); @@ -111,19 +126,36 @@ export function createRunner(...paths: string[]) { return; } - const expected = expectedEnvelopes.shift(); - - // Catch any error or failed assertions and pass them to done to end the test quickly try { - if (!expected) { - return; - } + if (unordered) { + // find any matching expected envelope + const matchIndex = expectedEnvelopes.findIndex(candidate => { + try { + assertEnvelopeMatches(candidate, envelope); + return true; + } catch { + return false; + } + }); - if (typeof expected === 'function') { - expected(envelope); + // no match found + if (matchIndex < 0) { + return; + } + + // remove the matching expected envelope + expectedEnvelopes.splice(matchIndex, 1); } else { - expect(envelope).toEqual(expected); + // in ordered mode we just look at the next expected envelope + const expected = expectedEnvelopes.shift(); + + if (!expected) { + return; + } + + assertEnvelopeMatches(expected, envelope); } + expectCallbackCalled(); } catch (e) { reject(e); diff --git a/dev-packages/cloudflare-integration-tests/suites/hono/basic/test.ts b/dev-packages/cloudflare-integration-tests/suites/hono/basic/test.ts index 9d7eb264f76e..727d61cca130 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono/basic/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono/basic/test.ts @@ -47,6 +47,7 @@ it('Hono app captures errors', async ({ signal }) => { }), ); }) + .unordered() .start(signal); await runner.makeRequest('get', '/error', { expectError: true }); await runner.completed(); From 6ef3ce700591f85fd043a8146e3175ec767785ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 22 Dec 2025 16:10:55 +0100 Subject: [PATCH 05/76] fix(cloudflare): Consume body of fetch in the Cloudflare transport (#18545) (closes #18534) (closes [JS-1319](https://linear.app/getsentry/issue/JS-1319/cloudflare-transport-doesnt-consume-fetch-response-bodies)) This consumes the body so it is safe to be closed by Cloudflare Workers. Unfortunately this cannot be reproduced locally and is only happening when the worker is being deployed (so there is no E2E test for this) @AbhiPrasad do you think it is necessary to add this code snippet in the other transports we have? It is what I've read specifically to Cloudflare, since they seem to be super strict on it, but this could potentially be a problem in other runtimes as well. --- packages/cloudflare/src/transport.ts | 13 +++- packages/cloudflare/test/transport.test.ts | 72 ++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/transport.ts b/packages/cloudflare/src/transport.ts index 8881e2dd6567..8e0e82aae7e0 100644 --- a/packages/cloudflare/src/transport.ts +++ b/packages/cloudflare/src/transport.ts @@ -89,7 +89,18 @@ export function makeCloudflareTransport(options: CloudflareTransportOptions): Tr }; return suppressTracing(() => { - return (options.fetch ?? fetch)(options.url, requestOptions).then(response => { + return (options.fetch ?? fetch)(options.url, requestOptions).then(async response => { + // Consume the response body to satisfy Cloudflare Workers' fetch requirements. + // The runtime requires all fetch response bodies to be read or explicitly canceled + // to prevent connection stalls and potential deadlocks. We read the body as text + // even though we don't use the content, as Sentry's response information is in the headers. + // See: https://github.com/getsentry/sentry-javascript/issues/18534 + try { + await response.text(); + } catch { + // no-op + } + return { statusCode: response.status, headers: { diff --git a/packages/cloudflare/test/transport.test.ts b/packages/cloudflare/test/transport.test.ts index 71b231f542af..fdb9fbc5e30f 100644 --- a/packages/cloudflare/test/transport.test.ts +++ b/packages/cloudflare/test/transport.test.ts @@ -106,6 +106,78 @@ describe('Edge Transport', () => { ...REQUEST_OPTIONS, }); }); + + describe('Response body consumption (issue #18534)', () => { + it('consumes the response body to prevent Cloudflare stalled connection warnings', async () => { + const textMock = vi.fn(() => Promise.resolve('OK')); + const headers = { + get: vi.fn(), + }; + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + text: textMock, + }), + ); + + const transport = makeCloudflareTransport(DEFAULT_EDGE_TRANSPORT_OPTIONS); + + await transport.send(ERROR_ENVELOPE); + await transport.flush(); + + expect(textMock).toHaveBeenCalledTimes(1); + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + + it('handles response body consumption errors gracefully', async () => { + const textMock = vi.fn(() => Promise.reject(new Error('Body read error'))); + const headers = { + get: vi.fn(), + }; + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + text: textMock, + }), + ); + + const transport = makeCloudflareTransport(DEFAULT_EDGE_TRANSPORT_OPTIONS); + + await expect(transport.send(ERROR_ENVELOPE)).resolves.toBeDefined(); + await expect(transport.flush()).resolves.toBeDefined(); + + expect(textMock).toHaveBeenCalledTimes(1); + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + + it('handles a potential never existing use case of a non existing text method', async () => { + const headers = { + get: vi.fn(), + }; + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + }), + ); + + const transport = makeCloudflareTransport(DEFAULT_EDGE_TRANSPORT_OPTIONS); + + await expect(transport.send(ERROR_ENVELOPE)).resolves.toBeDefined(); + await expect(transport.flush()).resolves.toBeDefined(); + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + }); }); describe('IsolatedPromiseBuffer', () => { From 13076517a15199b3527991be728d58c3de52ae64 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:17:02 +0100 Subject: [PATCH 06/76] fix(pino): Allow custom namespaces for `msg` and `err` (#18597) Pino allows using custom namespaces and this PR makes sure they are actually used. The symbols are extracted with a method, although Pino would allow getting them from `pino.symbols` ([docs](https://getpino.io/#/docs/api?id=pino-symbols)). However, we only have access to the `logger` instance. Closes https://github.com/getsentry/sentry-javascript/issues/18594 --- .../suites/pino/scenario-custom-keys.mjs | 22 ++++++ .../suites/pino/test.ts | 75 +++++++++++++++++++ packages/node-core/src/integrations/pino.ts | 25 ++++++- 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/pino/scenario-custom-keys.mjs diff --git a/dev-packages/node-integration-tests/suites/pino/scenario-custom-keys.mjs b/dev-packages/node-integration-tests/suites/pino/scenario-custom-keys.mjs new file mode 100644 index 000000000000..41bbf6ddc57e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/scenario-custom-keys.mjs @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node'; +import pino from 'pino'; + +const logger = pino({ + name: 'myapp', + messageKey: 'message', // Custom key instead of 'msg' + errorKey: 'error', // Custom key instead of 'err' +}); + +Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'custom-keys-test' }, () => { + logger.info({ user: 'user-123', action: 'custom-key-test' }, 'Custom message key'); + }); +}); + +setTimeout(() => { + Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'error-custom-key' }, () => { + logger.error(new Error('Custom error key')); + }); + }); +}, 500); diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index a0a16c422dc2..7ae9c0dd5fbf 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -295,4 +295,79 @@ conditionalTest({ min: 20 })('Pino integration', () => { .start() .completed(); }); + + test('captures logs with custom messageKey and errorKey', async () => { + const instrumentPath = join(__dirname, 'instrument.mjs'); + + await createRunner(__dirname, 'scenario-custom-keys.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'Custom error key', + mechanism: { + type: 'pino', + handled: true, + }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: '?', + in_app: true, + module: 'scenario-custom-keys', + }), + ]), + }, + }, + ], + }, + }, + }) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'Custom message key', + trace_id: expect.any(String), + severity_number: 9, + attributes: { + name: { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 30, type: 'integer' }, + user: { value: 'user-123', type: 'string' }, + action: { value: 'custom-key-test', type: 'string' }, + message: { value: 'Custom message key', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Custom error key', + trace_id: expect.any(String), + severity_number: 17, + attributes: { + name: { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 50, type: 'integer' }, + message: { value: 'Custom error key', type: 'string' }, + error: { value: expect.any(String), type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }, + }, + ], + }, + }) + .start() + .completed(); + }); }); diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index f20d4bae4098..bd8c8ba40b4f 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -19,10 +19,27 @@ type LevelMapping = { }; type Pino = { + [key: symbol]: unknown; levels: LevelMapping; [SENTRY_TRACK_SYMBOL]?: 'track' | 'ignore'; }; +/** + * Gets a custom Pino key from a logger instance by searching for the symbol. + * Pino uses non-global symbols like Symbol('pino.messageKey'): https://github.com/pinojs/pino/blob/8a816c0b1f72de5ae9181f3bb402109b66f7d812/lib/symbols.js + */ +function getPinoKey(logger: Pino, symbolName: string, defaultKey: string): string { + const symbols = Object.getOwnPropertySymbols(logger); + const symbolString = `Symbol(${symbolName})`; + for (const sym of symbols) { + if (sym.toString() === symbolString) { + const value = logger[sym]; + return typeof value === 'string' ? value : defaultKey; + } + } + return defaultKey; +} + type MergeObject = { [key: string]: unknown; err?: Error; @@ -134,7 +151,8 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial = { @@ -163,8 +181,9 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial Date: Tue, 23 Dec 2025 10:35:31 +0100 Subject: [PATCH 07/76] test(e2e): Pin solid/vue tanstack router to 1.41.8 (#18610) E2E tests for solid/vue tanstack router fail starting `1.42.x`. Pinning these for now until we have figured out what's going on. Closes #18612 (added automatically) --- .../test-applications/solid-tanstack-router/package.json | 2 +- .../test-applications/vue-tanstack-router/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 8b2cceecb30a..973ab3c5b921 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 @@ -15,7 +15,7 @@ "dependencies": { "@sentry/solid": "latest || *", "@tailwindcss/vite": "^4.0.6", - "@tanstack/solid-router": "^1.132.25", + "@tanstack/solid-router": "1.141.8", "@tanstack/solid-router-devtools": "^1.132.25", "@tanstack/solid-start": "^1.132.25", "solid-js": "^1.9.5", diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json index fc99c9ea3c6c..1e3d436e101c 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@sentry/vue": "latest || *", - "@tanstack/vue-router": "^1.64.0", + "@tanstack/vue-router": "1.141.8", "vue": "^3.4.15" }, "devDependencies": { From 9c40849cdfc9e66c0e0eb8ef927b9de2ef18e16c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 23 Dec 2025 10:39:54 +0100 Subject: [PATCH 08/76] test(e2e): Pin agents package in cloudflare-mcp test (#18609) https://github.com/cloudflare/agents/releases/tag/agents%400.2.34 started externalizing the ai package. Pinning this to the version before this let's our tests pass. When bumping the `agents` package to `0.3.0` and adding `ai` with v6 types start breaking. Closes #18611 (added automatically) --- .../e2e-tests/test-applications/cloudflare-mcp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 93404de22833..7aad0d2966ac 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json @@ -17,7 +17,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.24.0", "@sentry/cloudflare": "latest || *", - "agents": "^0.2.23", + "agents": "0.2.32", "zod": "^3.25.76" }, "devDependencies": { From 159cb23397faeb364033916fd02819988e5f2d28 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 23 Dec 2025 14:00:44 +0100 Subject: [PATCH 09/76] chore(deps): Bump IITM to ^2.0.1 (#18599) IITM has a new patch release with a fix that we need to properly instrument submodule imports in our Otel integrations (for instance to make [this](https://github.com/getsentry/sentry-javascript/pull/18403) work). Closes https://github.com/getsentry/sentry-javascript/issues/18613 --- package.json | 3 ++- packages/node-core/package.json | 2 +- packages/node/package.json | 2 +- yarn.lock | 8 ++++---- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index b0f32aa93a50..c92a18b0dfe1 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,8 @@ "gauge/strip-ansi": "6.0.1", "wide-align/string-width": "4.2.3", "cliui/wrap-ansi": "7.0.0", - "sucrase": "getsentry/sucrase#es2020-polyfills" + "sucrase": "getsentry/sucrase#es2020-polyfills", + "import-in-the-middle": "2.0.1" }, "version": "0.0.0", "name": "sentry-javascript", diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 5308dd52ae48..ad99efc5fcba 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -69,7 +69,7 @@ "@apm-js-collab/tracing-hooks": "^0.3.1", "@sentry/core": "10.32.1", "@sentry/opentelemetry": "10.32.1", - "import-in-the-middle": "^2" + "import-in-the-middle": "^2.0.1" }, "devDependencies": { "@apm-js-collab/code-transformer": "^0.8.2", diff --git a/packages/node/package.json b/packages/node/package.json index 7a4926837e52..bd312657e92f 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -98,7 +98,7 @@ "@sentry/core": "10.32.1", "@sentry/node-core": "10.32.1", "@sentry/opentelemetry": "10.32.1", - "import-in-the-middle": "^2", + "import-in-the-middle": "^2.0.1", "minimatch": "^9.0.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index eaceb4f6e0da..ce3d9f3223f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19238,10 +19238,10 @@ import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-in-the-middle@^2, import-in-the-middle@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-2.0.0.tgz#295948cee94d0565314824c6bd75379d13e5b1a5" - integrity sha512-yNZhyQYqXpkT0AKq3F3KLasUSK4fHvebNH5hOsKQw2dhGSALvQ4U0BqUc5suziKvydO5u5hgN2hy1RJaho8U5A== +import-in-the-middle@2.0.1, import-in-the-middle@^2.0.0, import-in-the-middle@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-2.0.1.tgz#8d1aa2db18374f2c811de2aa4756ebd6e9859243" + integrity sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA== dependencies: acorn "^8.14.0" acorn-import-attributes "^1.9.5" From 821e9861b951f9a7a3e6dbe93bfe9029af99981b Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:04:50 +0100 Subject: [PATCH 10/76] fix(browser): Respect `tunnel` in `diagnoseSdkConnectivity` (#18616) The example pages the Sentry wizard adds to the codebase use `.diagnoseSdkConnectivity()`. It's also possible to already set up a tunnel through the wizard. However, this tunnel was previously not respected. Closes https://github.com/getsentry/sentry-javascript/issues/17991 --- packages/browser/src/diagnose-sdk.ts | 33 ++++++---- packages/browser/test/diagnose-sdk.test.ts | 76 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/packages/browser/src/diagnose-sdk.ts b/packages/browser/src/diagnose-sdk.ts index 0ad4bef69d6c..0a5fdd0da05b 100644 --- a/packages/browser/src/diagnose-sdk.ts +++ b/packages/browser/src/diagnose-sdk.ts @@ -22,23 +22,28 @@ export async function diagnoseSdkConnectivity(): Promise< return 'no-dsn-configured'; } + // Check if a tunnel is configured and use it if available + const tunnel = client.getOptions().tunnel; + + // We are using the + // - "sentry-sdks" org with id 447951 not to pollute any actual organizations. + // - "diagnose-sdk-connectivity" project with id 4509632503087104 + // - the public key of said org/project, which is disabled in the project settings + // => this DSN: https://c1dfb07d783ad5325c245c1fd3725390@o447951.ingest.us.sentry.io/4509632503087104 (i.e. disabled) + const defaultUrl = + 'https://o447951.ingest.sentry.io/api/4509632503087104/envelope/?sentry_version=7&sentry_key=c1dfb07d783ad5325c245c1fd3725390&sentry_client=sentry.javascript.browser%2F1.33.7'; + + const url = tunnel || defaultUrl; + try { await suppressTracing(() => // If fetch throws, there is likely an ad blocker active or there are other connective issues. - fetch( - // We are using the - // - "sentry-sdks" org with id 447951 not to pollute any actual organizations. - // - "diagnose-sdk-connectivity" project with id 4509632503087104 - // - the public key of said org/project, which is disabled in the project settings - // => this DSN: https://c1dfb07d783ad5325c245c1fd3725390@o447951.ingest.us.sentry.io/4509632503087104 (i.e. disabled) - 'https://o447951.ingest.sentry.io/api/4509632503087104/envelope/?sentry_version=7&sentry_key=c1dfb07d783ad5325c245c1fd3725390&sentry_client=sentry.javascript.browser%2F1.33.7', - { - body: '{}', - method: 'POST', - mode: 'cors', - credentials: 'omit', - }, - ), + fetch(url, { + body: '{}', + method: 'POST', + mode: 'cors', + credentials: 'omit', + }), ); } catch { return 'sentry-unreachable'; diff --git a/packages/browser/test/diagnose-sdk.test.ts b/packages/browser/test/diagnose-sdk.test.ts index 5bc05dc6cf56..85b60047361e 100644 --- a/packages/browser/test/diagnose-sdk.test.ts +++ b/packages/browser/test/diagnose-sdk.test.ts @@ -42,6 +42,7 @@ describe('diagnoseSdkConnectivity', () => { it('returns "no-dsn-configured" when client.getDsn() returns undefined', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue(undefined), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); @@ -55,6 +56,7 @@ describe('diagnoseSdkConnectivity', () => { it('returns "sentry-unreachable" when fetch throws an error', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockRejectedValue(new Error('Network error')); @@ -77,6 +79,7 @@ describe('diagnoseSdkConnectivity', () => { it('returns "sentry-unreachable" when fetch throws a TypeError (common for network issues)', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockRejectedValue(new TypeError('Failed to fetch')); @@ -91,6 +94,7 @@ describe('diagnoseSdkConnectivity', () => { it('returns undefined when connectivity check succeeds', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); @@ -113,6 +117,7 @@ describe('diagnoseSdkConnectivity', () => { it('returns undefined even when fetch returns an error status (4xx, 5xx)', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); // Mock a 403 response (expected since the DSN is disabled) @@ -129,6 +134,7 @@ describe('diagnoseSdkConnectivity', () => { it('uses the correct test endpoint URL', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); @@ -149,6 +155,7 @@ describe('diagnoseSdkConnectivity', () => { it('uses correct fetch options', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); @@ -168,6 +175,7 @@ describe('diagnoseSdkConnectivity', () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); @@ -176,4 +184,72 @@ describe('diagnoseSdkConnectivity', () => { expect(suppressTracingSpy).toHaveBeenCalledTimes(1); }); + + it('uses tunnel URL when tunnel option is configured', async () => { + const tunnelUrl = '/monitor'; + const mockClient: Partial = { + getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({ tunnel: tunnelUrl }), + }; + mockGetClient.mockReturnValue(mockClient); + mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); + + const result = await diagnoseSdkConnectivity(); + + expect(result).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledWith( + tunnelUrl, + expect.objectContaining({ + body: '{}', + method: 'POST', + mode: 'cors', + credentials: 'omit', + }), + ); + }); + + it('uses default URL when tunnel is not configured', async () => { + const mockClient: Partial = { + getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), + }; + mockGetClient.mockReturnValue(mockClient); + mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); + + const result = await diagnoseSdkConnectivity(); + + expect(result).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://o447951.ingest.sentry.io/api/4509632503087104/envelope/?sentry_version=7&sentry_key=c1dfb07d783ad5325c245c1fd3725390&sentry_client=sentry.javascript.browser%2F1.33.7', + expect.objectContaining({ + body: '{}', + method: 'POST', + mode: 'cors', + credentials: 'omit', + }), + ); + }); + + it('returns "sentry-unreachable" when tunnel is configured but unreachable', async () => { + const tunnelUrl = '/monitor'; + const mockClient: Partial = { + getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({ tunnel: tunnelUrl }), + }; + mockGetClient.mockReturnValue(mockClient); + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await diagnoseSdkConnectivity(); + + expect(result).toBe('sentry-unreachable'); + expect(mockFetch).toHaveBeenCalledWith( + tunnelUrl, + expect.objectContaining({ + body: '{}', + method: 'POST', + mode: 'cors', + credentials: 'omit', + }), + ); + }); }); From 9ecd7f5fea6b6b850f8131f4a2920c6cae7e413c Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 29 Dec 2025 09:28:09 +0000 Subject: [PATCH 11/76] chore(react): Inline `hoist-non-react-statics` package (#18102) Ref / Resolves: https://github.com/getsentry/sentry-javascript/issues/18089 `hoist-non-react-statics` package we depend on is not ESM compatible, and causing issues for users on Cloudflare environments. As this package has not been updated in 5 years, and our use has not changed for long time, I think we can just inline this to make it compatible, and save users from explicitly externalizing this package. --------- Co-authored-by: Charly Gomez --- packages/react/package.json | 5 +- packages/react/src/hoist-non-react-statics.ts | 172 +++++++++- .../test/hoist-non-react-statics.test.tsx | 293 ++++++++++++++++++ yarn.lock | 18 +- 4 files changed, 472 insertions(+), 16 deletions(-) create mode 100644 packages/react/test/hoist-non-react-statics.test.tsx diff --git a/packages/react/package.json b/packages/react/package.json index 52fcaabcafe7..aaa63b032ca6 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -40,8 +40,7 @@ }, "dependencies": { "@sentry/browser": "10.32.1", - "@sentry/core": "10.32.1", - "hoist-non-react-statics": "^3.3.2" + "@sentry/core": "10.32.1" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" @@ -51,7 +50,7 @@ "@testing-library/react-hooks": "^7.0.2", "@types/history-4": "npm:@types/history@4.7.8", "@types/history-5": "npm:@types/history@4.7.8", - "@types/hoist-non-react-statics": "^3.3.5", + "@types/node-fetch": "^2.6.11", "@types/react": "17.0.3", "@types/react-router-4": "npm:@types/react-router@4.0.25", "@types/react-router-5": "npm:@types/react-router@5.1.20", diff --git a/packages/react/src/hoist-non-react-statics.ts b/packages/react/src/hoist-non-react-statics.ts index 970928c80d23..3ab5cb69d257 100644 --- a/packages/react/src/hoist-non-react-statics.ts +++ b/packages/react/src/hoist-non-react-statics.ts @@ -1,5 +1,169 @@ -import * as hoistNonReactStaticsImport from 'hoist-non-react-statics'; +/** + * Inlined implementation of hoist-non-react-statics + * Original library: https://github.com/mridgway/hoist-non-react-statics + * License: BSD-3-Clause + * Copyright 2015, Yahoo! Inc. + * + * This is an inlined version to avoid ESM compatibility issues with the original package. + */ -// Ensure we use the default export from hoist-non-react-statics if available, -// falling back to the module itself. This handles both ESM and CJS usage. -export const hoistNonReactStatics = hoistNonReactStaticsImport.default || hoistNonReactStaticsImport; +import type * as React from 'react'; + +/** + * React statics that should not be hoisted + */ +const REACT_STATICS = { + childContextTypes: true, + contextType: true, + contextTypes: true, + defaultProps: true, + displayName: true, + getDefaultProps: true, + getDerivedStateFromError: true, + getDerivedStateFromProps: true, + mixins: true, + propTypes: true, + type: true, +} as const; + +/** + * Known JavaScript function statics that should not be hoisted + */ +const KNOWN_STATICS = { + name: true, + length: true, + prototype: true, + caller: true, + callee: true, + arguments: true, + arity: true, +} as const; + +/** + * Statics specific to ForwardRef components + */ +const FORWARD_REF_STATICS = { + $$typeof: true, + render: true, + defaultProps: true, + displayName: true, + propTypes: true, +} as const; + +/** + * Statics specific to Memo components + */ +const MEMO_STATICS = { + $$typeof: true, + compare: true, + defaultProps: true, + displayName: true, + propTypes: true, + type: true, +} as const; + +/** + * Inlined react-is utilities + * We only need to detect ForwardRef and Memo types + */ +const ForwardRefType = Symbol.for('react.forward_ref'); +const MemoType = Symbol.for('react.memo'); + +/** + * Check if a component is a Memo component + */ +function isMemo(component: unknown): boolean { + return ( + typeof component === 'object' && component !== null && (component as { $$typeof?: symbol }).$$typeof === MemoType + ); +} + +/** + * Map of React component types to their specific statics + */ +const TYPE_STATICS: Record> = {}; +TYPE_STATICS[ForwardRefType] = FORWARD_REF_STATICS; +TYPE_STATICS[MemoType] = MEMO_STATICS; + +/** + * Get the appropriate statics object for a given component + */ +function getStatics(component: React.ComponentType): Record { + // React v16.11 and below + if (isMemo(component)) { + return MEMO_STATICS; + } + + // React v16.12 and above + const componentType = (component as { $$typeof?: symbol }).$$typeof; + return (componentType && TYPE_STATICS[componentType]) || REACT_STATICS; +} + +const defineProperty = Object.defineProperty.bind(Object); +const getOwnPropertyNames = Object.getOwnPropertyNames.bind(Object); +const getOwnPropertySymbols = Object.getOwnPropertySymbols?.bind(Object); +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor.bind(Object); +const getPrototypeOf = Object.getPrototypeOf.bind(Object); +const objectPrototype = Object.prototype; + +/** + * Copies non-react specific statics from a child component to a parent component. + * Similar to Object.assign, but copies all static properties from source to target, + * excluding React-specific statics and known JavaScript statics. + * + * @param targetComponent - The component to copy statics to + * @param sourceComponent - The component to copy statics from + * @param excludelist - An optional object of keys to exclude from hoisting + * @returns The target component with hoisted statics + */ +export function hoistNonReactStatics< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends React.ComponentType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + S extends React.ComponentType, + C extends Record = Record, +>(targetComponent: T, sourceComponent: S, excludelist?: C): T { + if (typeof sourceComponent !== 'string') { + // Don't hoist over string (html) components + if (objectPrototype) { + const inheritedComponent = getPrototypeOf(sourceComponent); + + if (inheritedComponent && inheritedComponent !== objectPrototype) { + hoistNonReactStatics(targetComponent, inheritedComponent, excludelist); + } + } + + let keys: (string | symbol)[] = getOwnPropertyNames(sourceComponent); + + if (getOwnPropertySymbols) { + keys = keys.concat(getOwnPropertySymbols(sourceComponent)); + } + + const targetStatics = getStatics(targetComponent); + const sourceStatics = getStatics(sourceComponent); + + for (const key of keys) { + const keyStr = String(key); + if ( + !KNOWN_STATICS[keyStr as keyof typeof KNOWN_STATICS] && + !excludelist?.[keyStr] && + !sourceStatics?.[keyStr] && + !targetStatics?.[keyStr] && + !getOwnPropertyDescriptor(targetComponent, key) // Don't overwrite existing properties + ) { + const descriptor = getOwnPropertyDescriptor(sourceComponent, key); + + if (descriptor) { + try { + // Avoid failures from read-only properties + defineProperty(targetComponent, key, descriptor); + } catch (e) { + // Silently ignore errors + } + } + } + } + } + + return targetComponent; +} diff --git a/packages/react/test/hoist-non-react-statics.test.tsx b/packages/react/test/hoist-non-react-statics.test.tsx new file mode 100644 index 000000000000..5c4ecb82126b --- /dev/null +++ b/packages/react/test/hoist-non-react-statics.test.tsx @@ -0,0 +1,293 @@ +import * as React from 'react'; +import { describe, expect, it } from 'vitest'; +import { hoistNonReactStatics } from '../src/hoist-non-react-statics'; + +describe('hoistNonReactStatics', () => { + it('hoists custom static properties', () => { + class Source extends React.Component { + static customStatic = 'customValue'; + static anotherStatic = 42; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('customValue'); + expect((Target as any).anotherStatic).toBe(42); + }); + + it('does not overwrite existing properties on target', () => { + class Source extends React.Component { + static customStatic = 'sourceValue'; + } + class Target extends React.Component { + static customStatic = 'targetValue'; + } + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('targetValue'); + }); + + it('returns the target component', () => { + class Source extends React.Component {} + class Target extends React.Component {} + + const result = hoistNonReactStatics(Target, Source); + + expect(result).toBe(Target); + }); + + it('handles function components', () => { + const Source = () =>
Source
; + (Source as any).customStatic = 'value'; + const Target = () =>
Target
; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('value'); + }); + + it('does not hoist known JavaScript statics', () => { + class Source extends React.Component { + static customStatic = 'customValue'; + } + class Target extends React.Component {} + const originalName = Target.name; + const originalLength = Target.length; + + hoistNonReactStatics(Target, Source); + + expect(Target.name).toBe(originalName); + expect(Target.length).toBe(originalLength); + expect((Target as any).customStatic).toBe('customValue'); + }); + + it('does not hoist React-specific statics', () => { + class Source extends React.Component { + static defaultProps = { foo: 'bar' }; + static customStatic = 'customValue'; + } + class Target extends React.Component { + static defaultProps = { baz: 'qux' }; + } + const originalDefaultProps = Target.defaultProps; + + hoistNonReactStatics(Target, Source); + + expect(Target.defaultProps).toBe(originalDefaultProps); + expect((Target as any).customStatic).toBe('customValue'); + }); + + it('does not hoist displayName', () => { + const Source = () =>
; + (Source as any).displayName = 'SourceComponent'; + (Source as any).customStatic = 'value'; + const Target = () =>
; + (Target as any).displayName = 'TargetComponent'; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).displayName).toBe('TargetComponent'); + expect((Target as any).customStatic).toBe('value'); + }); + + it('respects custom excludelist', () => { + class Source extends React.Component { + static customStatic1 = 'value1'; + static customStatic2 = 'value2'; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source, { customStatic1: true }); + + expect((Target as any).customStatic1).toBeUndefined(); + expect((Target as any).customStatic2).toBe('value2'); + }); + + it('handles ForwardRef components', () => { + const SourceInner = (_props: any, _ref: any) =>
; + const Source = React.forwardRef(SourceInner); + (Source as any).customStatic = 'value'; + const TargetInner = (_props: any, _ref: any) =>
; + const Target = React.forwardRef(TargetInner); + const originalRender = (Target as any).render; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).render).toBe(originalRender); + expect((Target as any).customStatic).toBe('value'); + }); + + it('handles Memo components', () => { + const SourceComponent = () =>
; + const Source = React.memo(SourceComponent); + (Source as any).customStatic = 'value'; + const TargetComponent = () =>
; + const Target = React.memo(TargetComponent); + const originalType = (Target as any).type; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).type).toBe(originalType); + expect((Target as any).customStatic).toBe('value'); + }); + + it('hoists symbol properties', () => { + const customSymbol = Symbol('custom'); + class Source extends React.Component {} + (Source as any)[customSymbol] = 'symbolValue'; + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any)[customSymbol]).toBe('symbolValue'); + }); + + it('preserves property descriptors', () => { + class Source extends React.Component {} + Object.defineProperty(Source, 'customStatic', { + value: 'value', + writable: false, + enumerable: true, + configurable: false, + }); + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + const descriptor = Object.getOwnPropertyDescriptor(Target, 'customStatic'); + expect(descriptor?.value).toBe('value'); + expect(descriptor?.writable).toBe(false); + expect(descriptor?.enumerable).toBe(true); + expect(descriptor?.configurable).toBe(false); + }); + + it('handles getters and setters', () => { + let backingValue = 'initial'; + class Source extends React.Component {} + Object.defineProperty(Source, 'customStatic', { + get: () => backingValue, + set: (value: string) => { + backingValue = value; + }, + }); + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('initial'); + (Target as any).customStatic = 'modified'; + expect((Target as any).customStatic).toBe('modified'); + }); + + it('silently handles read-only property errors', () => { + class Source extends React.Component {} + Object.defineProperty(Source, 'customStatic', { value: 'sourceValue', writable: true }); + class Target extends React.Component {} + Object.defineProperty(Target, 'customStatic', { value: 'targetValue', writable: false }); + + expect(() => hoistNonReactStatics(Target, Source)).not.toThrow(); + expect((Target as any).customStatic).toBe('targetValue'); + }); + + it('hoists statics from the prototype chain', () => { + class GrandParent extends React.Component { + static grandParentStatic = 'grandParent'; + } + class Parent extends GrandParent { + static parentStatic = 'parent'; + } + class Source extends Parent { + static sourceStatic = 'source'; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).sourceStatic).toBe('source'); + expect((Target as any).parentStatic).toBe('parent'); + expect((Target as any).grandParentStatic).toBe('grandParent'); + }); + + it('does not hoist from Object.prototype', () => { + class Source extends React.Component { + static customStatic = 'value'; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('value'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect((Target as any).hasOwnProperty).toBe(Object.prototype.hasOwnProperty); + }); + + it('handles string components', () => { + const Target = () =>
; + (Target as any).existingStatic = 'value'; + + hoistNonReactStatics(Target, 'div' as any); + + expect((Target as any).existingStatic).toBe('value'); + }); + + it('handles falsy static values', () => { + class Source extends React.Component {} + (Source as any).nullStatic = null; + (Source as any).undefinedStatic = undefined; + (Source as any).zeroStatic = 0; + (Source as any).falseStatic = false; + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).nullStatic).toBeNull(); + expect((Target as any).undefinedStatic).toBeUndefined(); + expect((Target as any).zeroStatic).toBe(0); + expect((Target as any).falseStatic).toBe(false); + }); + + it('works with HOC pattern', () => { + class OriginalComponent extends React.Component { + static customMethod() { + return 'custom'; + } + render() { + return
Original
; + } + } + const WrappedComponent: React.FC = () => ; + + hoistNonReactStatics(WrappedComponent, OriginalComponent); + + expect((WrappedComponent as any).customMethod()).toBe('custom'); + }); + + it('preserves target displayName in HOC pattern', () => { + const OriginalComponent = () =>
Original
; + (OriginalComponent as any).displayName = 'Original'; + (OriginalComponent as any).someStaticProp = 'value'; + const WrappedComponent: React.FC = () => ; + (WrappedComponent as any).displayName = 'ErrorBoundary(Original)'; + + hoistNonReactStatics(WrappedComponent, OriginalComponent); + + expect((WrappedComponent as any).displayName).toBe('ErrorBoundary(Original)'); + expect((WrappedComponent as any).someStaticProp).toBe('value'); + }); + + it('works with multiple HOC composition', () => { + class Original extends React.Component { + static originalStatic = 'original'; + } + const Hoc1 = () => ; + (Hoc1 as any).hoc1Static = 'hoc1'; + hoistNonReactStatics(Hoc1, Original); + const Hoc2 = () => ; + hoistNonReactStatics(Hoc2, Hoc1); + + expect((Hoc2 as any).originalStatic).toBe('original'); + expect((Hoc2 as any).hoc1Static).toBe('hoc1'); + }); +}); diff --git a/yarn.lock b/yarn.lock index ce3d9f3223f9..f7631f3f3746 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8758,14 +8758,6 @@ resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" integrity sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA== -"@types/hoist-non-react-statics@^3.3.5": - version "3.3.5" - resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz#dab7867ef789d87e2b4b0003c9d65c49cc44a494" - integrity sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg== - dependencies: - "@types/react" "*" - hoist-non-react-statics "^3.3.0" - "@types/html-minifier-terser@^6.0.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" @@ -8916,6 +8908,14 @@ resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-3.0.11.tgz#70b7131f65038ae63cfe841354c8aba363632344" integrity sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg== +"@types/node-fetch@^2.6.11": + version "2.6.13" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.13.tgz#e0c9b7b5edbdb1b50ce32c127e85e880872d56ee" + integrity sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw== + dependencies: + "@types/node" "*" + form-data "^4.0.4" + "@types/node-forge@^1.3.0": version "1.3.11" resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" @@ -18834,7 +18834,7 @@ hoist-non-react-statics@^1.2.0: resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" integrity sha1-qkSM8JhtVcxAdzsXF0t90GbLfPs= -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== From a10cc107c18d1b85a6a0eb4915e1b38feac44455 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 29 Dec 2025 12:10:40 +0200 Subject: [PATCH 12/76] ref(nextjs): Drop `resolve` dependency (#18618) ### Summary Removes `resolve` dependency and replaces it with `createRequire` which would work in esm modules as well, since our min Node.js version is 18 then it should be fine to do this replacement. This should reduce the npm install size and the SSR bundle size for the SDK. closes #12860 --- CHANGELOG.md | 2 ++ packages/nextjs/package.json | 2 -- packages/nextjs/src/config/util.ts | 4 ++-- packages/nextjs/src/config/webpack.ts | 4 ++-- yarn.lock | 14 -------------- 5 files changed, 6 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf5c81b9d377..8947655e877d 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 +- ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) + ## 10.32.1 - fix(cloudflare): Add hono transaction name when error is thrown ([#18529](https://github.com/getsentry/sentry-javascript/pull/18529)) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index a113781f7140..893da0f71c73 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -87,12 +87,10 @@ "@sentry/react": "10.32.1", "@sentry/vercel-edge": "10.32.1", "@sentry/webpack-plugin": "^4.6.1", - "resolve": "1.22.8", "rollup": "^4.35.0", "stacktrace-parser": "^0.1.10" }, "devDependencies": { - "@types/resolve": "1.20.3", "eslint-plugin-react": "^7.31.11", "next": "13.5.9", "react": "^18.3.1", diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 0d4a55687d2f..d4ff32be0c0a 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -1,6 +1,6 @@ import { parseSemver } from '@sentry/core'; import * as fs from 'fs'; -import { sync as resolveSync } from 'resolve'; +import { createRequire } from 'module'; /** * Returns the version of Next.js installed in the project, or undefined if it cannot be determined. @@ -23,7 +23,7 @@ export function getNextjsVersion(): string | undefined { function resolveNextjsPackageJson(): string | undefined { try { - return resolveSync('next/package.json', { basedir: process.cwd() }); + return createRequire(`${process.cwd()}/`).resolve('next/package.json'); } catch { return undefined; } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 60f227b3c42c..7ae5b6859330 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -3,8 +3,8 @@ import { debug, escapeStringForRegex, loadModule, parseSemver } from '@sentry/core'; import * as fs from 'fs'; +import { createRequire } from 'module'; import * as path from 'path'; -import { sync as resolveSync } from 'resolve'; import type { VercelCronsConfig } from '../common/types'; import { getBuildPluginOptions, normalizePathForGlob } from './getBuildPluginOptions'; import type { RouteManifest } from './manifest/types'; @@ -790,7 +790,7 @@ function addValueInjectionLoader({ function resolveNextPackageDirFromDirectory(basedir: string): string | undefined { try { - return path.dirname(resolveSync('next/package.json', { basedir })); + return path.dirname(createRequire(`${basedir}/`).resolve('next/package.json')); } catch { // Should not happen in theory return undefined; diff --git a/yarn.lock b/yarn.lock index f7631f3f3746..268bb8529193 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9069,11 +9069,6 @@ resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== -"@types/resolve@1.20.3": - version "1.20.3" - resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.3.tgz#066742d69a0bbba8c5d7d517f82e1140ddeb3c3c" - integrity sha512-NH5oErHOtHZYcjCtg69t26aXEk4BN2zLWqf7wnDZ+dpe0iR7Rds1SPGEItl3fca21oOe0n3OCnZ4W7jBxu7FOw== - "@types/retry@0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" @@ -27064,15 +27059,6 @@ resolve@1.22.1: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@1.22.8: - version "1.22.8" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" - integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== - dependencies: - is-core-module "^2.13.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1, resolve@^1.22.10, resolve@^1.22.4, resolve@^1.22.6, resolve@^1.22.8, resolve@^1.4.0, resolve@^1.5.0: version "1.22.10" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" From 8787a879af3146ed258c41ed8ddb84bdb396e69b Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 29 Dec 2025 10:33:42 +0000 Subject: [PATCH 13/76] ref(react-router): Use snake_case for span op names (#18617) This came up while working on #18580 The span ops should be using snake_case as per our spec: https://develop.sentry.dev/sdk/telemetry/traces/span-operations/ --- .../tests/performance/performance.server.test.ts | 8 ++++---- .../tests/performance/performance.server.test.ts | 8 ++++---- .../tests/performance/performance.server.test.ts | 8 ++++---- packages/react-router/src/server/instrumentation/util.ts | 6 +++--- packages/react-router/src/server/wrapServerAction.ts | 2 +- packages/react-router/src/server/wrapServerLoader.ts | 2 +- .../react-router/test/server/wrapServerAction.test.ts | 4 ++-- .../react-router/test/server/wrapServerLoader.test.ts | 4 ++-- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts index 5244ec499d33..18b7ce9f3c6c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts @@ -153,14 +153,14 @@ test.describe('server - performance', () => { trace_id: expect.any(String), data: { 'sentry.origin': 'auto.http.react_router.loader', - 'sentry.op': 'function.react-router.loader', + 'sentry.op': 'function.react_router.loader', }, description: 'Executing Server Loader', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), timestamp: expect.any(Number), status: 'ok', - op: 'function.react-router.loader', + op: 'function.react_router.loader', origin: 'auto.http.react_router.loader', }); }); @@ -213,14 +213,14 @@ test.describe('server - performance', () => { trace_id: expect.any(String), data: { 'sentry.origin': 'auto.http.react_router.action', - 'sentry.op': 'function.react-router.action', + 'sentry.op': 'function.react_router.action', }, description: 'Executing Server Action', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), timestamp: expect.any(Number), status: 'ok', - op: 'function.react-router.action', + op: 'function.react_router.action', origin: 'auto.http.react_router.action', }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts index 6058403bb81e..e0ca27a19e10 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts @@ -153,11 +153,11 @@ test.describe('server - performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.op': 'function.react-router.loader', + 'sentry.op': 'function.react_router.loader', 'sentry.origin': 'auto.http.react_router.server', }, description: 'Executing Server Loader', - op: 'function.react-router.loader', + op: 'function.react_router.loader', origin: 'auto.http.react_router.server', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -213,11 +213,11 @@ test.describe('server - performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.op': 'function.react-router.action', + 'sentry.op': 'function.react_router.action', 'sentry.origin': 'auto.http.react_router.server', }, description: 'Executing Server Action', - op: 'function.react-router.action', + op: 'function.react_router.action', origin: 'auto.http.react_router.server', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts index bc7b44591f30..8cbc4c46a460 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts @@ -122,14 +122,14 @@ test.describe('server - performance', () => { trace_id: expect.any(String), data: { 'sentry.origin': 'auto.http.react_router', - 'sentry.op': 'function.react-router.loader', + 'sentry.op': 'function.react_router.loader', }, description: 'Executing Server Loader', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), timestamp: expect.any(Number), status: 'ok', - op: 'function.react-router.loader', + op: 'function.react_router.loader', origin: 'auto.http.react_router', }); }); @@ -150,14 +150,14 @@ test.describe('server - performance', () => { trace_id: expect.any(String), data: { 'sentry.origin': 'auto.http.react_router', - 'sentry.op': 'function.react-router.action', + 'sentry.op': 'function.react_router.action', }, description: 'Executing Server Action', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), timestamp: expect.any(Number), status: 'ok', - op: 'function.react-router.action', + op: 'function.react_router.action', origin: 'auto.http.react_router', }); }); diff --git a/packages/react-router/src/server/instrumentation/util.ts b/packages/react-router/src/server/instrumentation/util.ts index 19aec91999fc..3cad321dcfcc 100644 --- a/packages/react-router/src/server/instrumentation/util.ts +++ b/packages/react-router/src/server/instrumentation/util.ts @@ -5,10 +5,10 @@ */ export function getOpName(pathName: string, requestMethod: string): string { return isLoaderRequest(pathName, requestMethod) - ? 'function.react-router.loader' + ? 'function.react_router.loader' : isActionRequest(pathName, requestMethod) - ? 'function.react-router.action' - : 'function.react-router'; + ? 'function.react_router.action' + : 'function.react_router'; } /** diff --git a/packages/react-router/src/server/wrapServerAction.ts b/packages/react-router/src/server/wrapServerAction.ts index e816c3c63886..991327a60d10 100644 --- a/packages/react-router/src/server/wrapServerAction.ts +++ b/packages/react-router/src/server/wrapServerAction.ts @@ -67,7 +67,7 @@ export function wrapServerAction(options: SpanOptions = {}, actionFn: (args: ...options, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.action', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.action', ...options.attributes, }, }, diff --git a/packages/react-router/src/server/wrapServerLoader.ts b/packages/react-router/src/server/wrapServerLoader.ts index 7e5083d4d5c8..fc28d504637f 100644 --- a/packages/react-router/src/server/wrapServerLoader.ts +++ b/packages/react-router/src/server/wrapServerLoader.ts @@ -67,7 +67,7 @@ export function wrapServerLoader(options: SpanOptions = {}, loaderFn: (args: ...options, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.loader', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.loader', ...options.attributes, }, }, diff --git a/packages/react-router/test/server/wrapServerAction.test.ts b/packages/react-router/test/server/wrapServerAction.test.ts index 5eb92ef53b3b..c0cde751e472 100644 --- a/packages/react-router/test/server/wrapServerAction.test.ts +++ b/packages/react-router/test/server/wrapServerAction.test.ts @@ -31,7 +31,7 @@ describe('wrapServerAction', () => { name: 'Executing Server Action', attributes: { [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.action', - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.action', }, }, expect.any(Function), @@ -61,7 +61,7 @@ describe('wrapServerAction', () => { name: 'Custom Action', attributes: { [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.action', - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.action', 'sentry.custom': 'value', }, }, diff --git a/packages/react-router/test/server/wrapServerLoader.test.ts b/packages/react-router/test/server/wrapServerLoader.test.ts index b375d9b4da51..032107c1075e 100644 --- a/packages/react-router/test/server/wrapServerLoader.test.ts +++ b/packages/react-router/test/server/wrapServerLoader.test.ts @@ -31,7 +31,7 @@ describe('wrapServerLoader', () => { name: 'Executing Server Loader', attributes: { [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.loader', - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.loader', }, }, expect.any(Function), @@ -61,7 +61,7 @@ describe('wrapServerLoader', () => { name: 'Custom Loader', attributes: { [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.loader', - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.loader', 'sentry.custom': 'value', }, }, From 4942b07be32a026a263d6cf2a73ad58f29583a0d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 29 Dec 2025 13:12:31 +0200 Subject: [PATCH 14/76] fix(node): relax Fastify's `setupFastifyErrorHandler` argument type (#18620) When using [exactOptionalPropertyTypes](https://www.typescriptlang.org/tsconfig/#exactOptionalPropertyTypes), it is pretty hard to match Fastify's instance types given all the missing overloads from the partial signature we have. The problematic area is around `addHook`. We either need to have all the signatures implemented exactly, or outright import Fastify's types which is not ideal since it is not a dependency. I opted to relax the types specifically for `setupFastifyErrorHandler` by providing a minimal instance with the one method it needs to avoid TS trying to matching the other properties and methods signatures, not ideal but simple enough. I verified that this works for Fastify v3 throughout v5 closes #18619 --- packages/node/src/integrations/tracing/fastify/index.ts | 4 ++-- packages/node/src/integrations/tracing/fastify/types.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/node/src/integrations/tracing/fastify/index.ts b/packages/node/src/integrations/tracing/fastify/index.ts index c777fa20136d..02f9a2adde4b 100644 --- a/packages/node/src/integrations/tracing/fastify/index.ts +++ b/packages/node/src/integrations/tracing/fastify/index.ts @@ -14,7 +14,7 @@ import { import { generateInstrumentOnce } from '@sentry/node-core'; import { DEBUG_BUILD } from '../../../debug-build'; import { FastifyOtelInstrumentation } from './fastify-otel/index'; -import type { FastifyInstance, FastifyReply, FastifyRequest } from './types'; +import type { FastifyInstance, FastifyMinimal, FastifyReply, FastifyRequest } from './types'; import { FastifyInstrumentationV3 } from './v3/instrumentation'; /** @@ -244,7 +244,7 @@ function defaultShouldHandleError(_error: Error, _request: FastifyRequest, reply * app.listen({ port: 3000 }); * ``` */ -export function setupFastifyErrorHandler(fastify: FastifyInstance, options?: Partial): void { +export function setupFastifyErrorHandler(fastify: FastifyMinimal, options?: Partial): void { if (options?.shouldHandleError) { getFastifyIntegration()?.setShouldHandleError(options.shouldHandleError); } diff --git a/packages/node/src/integrations/tracing/fastify/types.ts b/packages/node/src/integrations/tracing/fastify/types.ts index 1bc426e58aad..7068afabadb0 100644 --- a/packages/node/src/integrations/tracing/fastify/types.ts +++ b/packages/node/src/integrations/tracing/fastify/types.ts @@ -27,6 +27,15 @@ export interface FastifyInstance { addHook(hook: 'onRequest', handler: (request: FastifyRequest, reply: FastifyReply) => void): FastifyInstance; } +/** + * Minimal type for `setupFastifyErrorHandler` parameter. + * Uses structural typing without overloads to avoid exactOptionalPropertyTypes issues. + * https://github.com/getsentry/sentry-javascript/issues/18619 + */ +export type FastifyMinimal = { + register: (plugin: (instance: any, opts: any, done: () => void) => void) => unknown; +}; + export interface FastifyReply { send: () => FastifyReply; statusCode: number; From 165781523b7565438c1b70d16248e92fd8a79500 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 29 Dec 2025 13:17:07 +0100 Subject: [PATCH 15/76] test(core): Improve unit test performance for offline transport tests (#18628) The `offline.test.ts` unit tests in core have been bugging me for a long time because they take almost 30s to run locally. Which makes running tests in watch mode in core quite annoying. The core problem in these tests was that we used real timers and waited for up to 25s in one test. This PR: - uses vitest's fake timers instead of real timers - removes the `waitUntil` calls in favor of advancing timers. Mostly by the upper bound we specified before, though in some situations, I had to use a lower value because the previous waitUntil condition was satisfied earlier. - re-enables the rate limiting test which was flaky before. I'm curious if it'll flake again but my guess is that with the more strict fake timers, it might just behave as we hope. Now, the tests only take a couple of ms to complete. Closes #18630 (added automatically) --- .../core/test/lib/transports/offline.test.ts | 293 +++++++++--------- 1 file changed, 154 insertions(+), 139 deletions(-) diff --git a/packages/core/test/lib/transports/offline.test.ts b/packages/core/test/lib/transports/offline.test.ts index 3b491753c8bb..d09401367e5d 100644 --- a/packages/core/test/lib/transports/offline.test.ts +++ b/packages/core/test/lib/transports/offline.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; import { createClientReportEnvelope, createEnvelope, @@ -9,7 +9,7 @@ import { parseEnvelope, } from '../../../src'; import type { CreateOfflineStore, OfflineTransportOptions } from '../../../src/transports/offline'; -import { makeOfflineTransport, START_DELAY } from '../../../src/transports/offline'; +import { makeOfflineTransport, MIN_DELAY, START_DELAY } from '../../../src/transports/offline'; import type { ClientReport } from '../../../src/types-hoist/clientreport'; import type { Envelope, EventEnvelope, EventItem, ReplayEnvelope } from '../../../src/types-hoist/envelope'; import type { ReplayEvent } from '../../../src/types-hoist/replay'; @@ -139,23 +139,13 @@ function createTestStore(...popResults: MockResult[]): { }; } -function waitUntil(fn: () => boolean, timeout: number): Promise { - return new Promise(resolve => { - let runtime = 0; - - const interval = setInterval(() => { - runtime += 100; - - if (fn() || runtime >= timeout) { - clearTimeout(interval); - resolve(); - } - }, 100); - }); -} - describe('makeOfflineTransport', () => { it('Sends envelope and checks the store for further envelopes', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const { getCalls, store } = createTestStore(); const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }); let queuedCount = 0; @@ -173,13 +163,18 @@ describe('makeOfflineTransport', () => { expect(queuedCount).toEqual(0); expect(getSendCount()).toEqual(1); - await waitUntil(() => getCalls().length == 1, 1_000); + await vi.advanceTimersByTimeAsync(START_DELAY); // After a successful send, the store should be checked expect(getCalls()).toEqual(['shift']); }); it('Envelopes are added after existing envelopes in the queue', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const { getCalls, store } = createTestStore(ERROR_ENVELOPE); const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 }); const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); @@ -187,7 +182,7 @@ describe('makeOfflineTransport', () => { expect(result).toEqual({ statusCode: 200 }); - await waitUntil(() => getCalls().length == 2, 1_000); + await vi.advanceTimersByTimeAsync(START_DELAY); expect(getSendCount()).toEqual(2); // After a successful send from the store, the store should be checked again to ensure it's empty @@ -195,6 +190,11 @@ describe('makeOfflineTransport', () => { }); it('Queues envelope if wrapped transport throws error', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const { getCalls, store } = createTestStore(); const { getSendCount, baseTransport } = createTestTransport(new Error()); let queuedCount = 0; @@ -210,7 +210,7 @@ describe('makeOfflineTransport', () => { expect(result).toEqual({}); - await waitUntil(() => getCalls().length === 1, 1_000); + await vi.advanceTimersByTimeAsync(1_000); expect(getSendCount()).toEqual(0); expect(queuedCount).toEqual(1); @@ -218,6 +218,11 @@ describe('makeOfflineTransport', () => { }); it('Does not queue envelopes if status code >= 400', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const { getCalls, store } = createTestStore(); const { getSendCount, baseTransport } = createTestTransport({ statusCode: 500 }); let queuedCount = 0; @@ -233,101 +238,106 @@ describe('makeOfflineTransport', () => { expect(result).toEqual({ statusCode: 500 }); - await waitUntil(() => getSendCount() === 1, 1_000); + await vi.advanceTimersByTimeAsync(1_000); expect(getSendCount()).toEqual(1); expect(queuedCount).toEqual(0); expect(getCalls()).toEqual([]); }); - it( - 'Retries sending envelope after failure', - async () => { - const { getCalls, store } = createTestStore(); - const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }); - const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); - const result = await transport.send(ERROR_ENVELOPE); - expect(result).toEqual({}); - expect(getCalls()).toEqual(['push']); + it('Retries sending envelope after failure', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); - await waitUntil(() => getCalls().length === 3 && getSendCount() === 1, START_DELAY * 2); + const { getCalls, store } = createTestStore(); + const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }); + const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); + const result = await transport.send(ERROR_ENVELOPE); + expect(result).toEqual({}); + expect(getCalls()).toEqual(['push']); - expect(getSendCount()).toEqual(1); - expect(getCalls()).toEqual(['push', 'shift', 'shift']); - }, - START_DELAY + 2_000, - ); + await vi.advanceTimersByTimeAsync(START_DELAY * 2); - it( - 'When flushAtStartup is enabled, sends envelopes found in store shortly after startup', - async () => { - const { getCalls, store } = createTestStore(ERROR_ENVELOPE, ERROR_ENVELOPE); - const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _transport = makeOfflineTransport(baseTransport)({ - ...transportOptions, - createStore: store, - flushAtStartup: true, - }); + expect(getSendCount()).toEqual(1); + expect(getCalls()).toEqual(['push', 'shift', 'shift']); + }); - await waitUntil(() => getCalls().length === 3 && getSendCount() === 2, START_DELAY * 2); + it('When flushAtStartup is enabled, sends envelopes found in store shortly after startup', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); - expect(getSendCount()).toEqual(2); - expect(getCalls()).toEqual(['shift', 'shift', 'shift']); - }, - START_DELAY + 2_000, - ); + const { getCalls, store } = createTestStore(ERROR_ENVELOPE, ERROR_ENVELOPE); + const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _transport = makeOfflineTransport(baseTransport)({ + ...transportOptions, + createStore: store, + flushAtStartup: true, + }); - it( - 'Unshifts envelopes on retry failure', - async () => { - const { getCalls, store } = createTestStore(ERROR_ENVELOPE); - const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _transport = makeOfflineTransport(baseTransport)({ - ...transportOptions, - createStore: store, - flushAtStartup: true, - }); + await vi.advanceTimersByTimeAsync(START_DELAY * 2); + + expect(getSendCount()).toEqual(2); + expect(getCalls()).toEqual(['shift', 'shift', 'shift']); + }); - await waitUntil(() => getCalls().length === 2, START_DELAY * 2); + it('Unshifts envelopes on retry failure', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); - expect(getSendCount()).toEqual(0); - expect(getCalls()).toEqual(['shift', 'unshift']); - }, - START_DELAY + 2_000, - ); + const { getCalls, store } = createTestStore(ERROR_ENVELOPE); + const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _transport = makeOfflineTransport(baseTransport)({ + ...transportOptions, + createStore: store, + flushAtStartup: true, + }); - it( - 'Updates sent_at envelope header on retry', - async () => { - const testStartTime = new Date(); + await vi.advanceTimersByTimeAsync(START_DELAY * 2); + + expect(getSendCount()).toEqual(0); + expect(getCalls()).toEqual(['shift', 'unshift']); + }); - // Create an envelope with a sent_at header very far in the past - const env: EventEnvelope = [...ERROR_ENVELOPE]; - env[0]!.sent_at = new Date(2020, 1, 1).toISOString(); + it('Updates sent_at envelope header on retry', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); - const { getCalls, store } = createTestStore(ERROR_ENVELOPE); - const { getSentEnvelopes, baseTransport } = createTestTransport({ statusCode: 200 }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _transport = makeOfflineTransport(baseTransport)({ - ...transportOptions, - createStore: store, - flushAtStartup: true, - }); + const testStartTime = new Date(); - await waitUntil(() => getCalls().length >= 1, START_DELAY * 2); - expect(getCalls()).toEqual(['shift']); + // Create an envelope with a sent_at header very far in the past + const env: EventEnvelope = [...ERROR_ENVELOPE]; + env[0]!.sent_at = new Date(2020, 1, 1).toISOString(); - // When it gets shifted out of the store, the sent_at header should be updated - const envelopes = getSentEnvelopes().map(parseEnvelope) as EventEnvelope[]; - expect(envelopes[0]?.[0]).toBeDefined(); - const sent_at = new Date(envelopes[0]![0].sent_at); + const { getCalls, store } = createTestStore(ERROR_ENVELOPE); + const { getSentEnvelopes, baseTransport } = createTestTransport({ statusCode: 200 }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _transport = makeOfflineTransport(baseTransport)({ + ...transportOptions, + createStore: store, + flushAtStartup: true, + }); - expect(sent_at.getTime()).toBeGreaterThan(testStartTime.getTime()); - }, - START_DELAY + 2_000, - ); + await vi.advanceTimersByTimeAsync(START_DELAY); + + expect(getCalls()).toEqual(['shift']); + + // When it gets shifted out of the store, the sent_at header should be updated + const envelopes = getSentEnvelopes().map(parseEnvelope) as EventEnvelope[]; + expect(envelopes[0]?.[0]).toBeDefined(); + const sent_at = new Date(envelopes[0]![0].sent_at); + + expect(sent_at.getTime()).toBeGreaterThan(testStartTime.getTime()); + }); it('shouldStore can stop envelopes from being stored on send failure', async () => { const { getCalls, store } = createTestStore(); @@ -384,51 +394,56 @@ describe('makeOfflineTransport', () => { expect(getCalls()).toEqual([]); }); - it( - 'Sends replay envelopes in order', - async () => { - const { getCalls, store } = createTestStore(REPLAY_ENVELOPE('1'), REPLAY_ENVELOPE('2')); - const { getSendCount, getSentEnvelopes, baseTransport } = createTestTransport( - new Error(), - { statusCode: 200 }, - { statusCode: 200 }, - { statusCode: 200 }, - ); - const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); - const result = await transport.send(REPLAY_ENVELOPE('3')); - - expect(result).toEqual({}); - expect(getCalls()).toEqual(['push']); - - await waitUntil(() => getCalls().length === 6 && getSendCount() === 3, START_DELAY * 5); - - expect(getSendCount()).toEqual(3); - expect(getCalls()).toEqual([ - // We're sending a replay envelope and they always get queued - 'push', - // The first envelope popped out fails to send so it gets added to the front of the queue - 'shift', - 'unshift', - // The rest of the attempts succeed - 'shift', - 'shift', - 'shift', - ]); - - const envelopes = getSentEnvelopes().map(parseEnvelope); - - // Ensure they're still in the correct order - expect((envelopes[0]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('1'); - expect((envelopes[1]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('2'); - expect((envelopes[2]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('3'); - }, - START_DELAY + 2_000, - ); + it('Sends replay envelopes in order', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + + const { getCalls, store } = createTestStore(REPLAY_ENVELOPE('1'), REPLAY_ENVELOPE('2')); + const { getSendCount, getSentEnvelopes, baseTransport } = createTestTransport( + new Error(), + { statusCode: 200 }, + { statusCode: 200 }, + { statusCode: 200 }, + ); + const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); + const result = await transport.send(REPLAY_ENVELOPE('3')); + + expect(result).toEqual({}); + expect(getCalls()).toEqual(['push']); + + await vi.advanceTimersByTimeAsync(START_DELAY + MIN_DELAY * 3); + + expect(getSendCount()).toEqual(3); + expect(getCalls()).toEqual([ + // We're sending a replay envelope and they always get queued + 'push', + // The first envelope popped out fails to send so it gets added to the front of the queue + 'shift', + 'unshift', + // The rest of the attempts succeed + 'shift', + 'shift', + 'shift', + ]); + + const envelopes = getSentEnvelopes().map(parseEnvelope); + + // Ensure they're still in the correct order + expect((envelopes[0]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('1'); + expect((envelopes[1]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('2'); + expect((envelopes[2]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('3'); + }); - // eslint-disable-next-line @sentry-internal/sdk/no-skipped-tests - it.skip( + it( 'Follows the Retry-After header', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const { getCalls, store } = createTestStore(ERROR_ENVELOPE); const { getSendCount, baseTransport } = createTestTransport( { @@ -454,11 +469,11 @@ describe('makeOfflineTransport', () => { headers: { 'x-sentry-rate-limits': '', 'retry-after': '3' }, }); - await waitUntil(() => getSendCount() === 1, 500); + await vi.advanceTimersByTimeAsync(2_999); expect(getSendCount()).toEqual(1); - await waitUntil(() => getCalls().length === 2, START_DELAY * 2); + await vi.advanceTimersByTimeAsync(START_DELAY); expect(getSendCount()).toEqual(2); expect(queuedCount).toEqual(0); From 121eb925eccc550e39bdc8ee2ba4d0bd3fc14d91 Mon Sep 17 00:00:00 2001 From: Xge <29895712+xgedev@users.noreply.github.com> Date: Tue, 30 Dec 2025 10:45:05 +0100 Subject: [PATCH 16/76] feat(core): Support IPv6 hosts in the DSN (#2996) (#17708) Setting an IPv6 URL as a DSN previously didn't work, see #2996. This patch updates the `DSN_REGEX` to correctly match IPv6 and fixes the then occuring issue that the brackets "[" and "]" are in the request's hostname and prevent the request from being made. --------- Co-authored-by: Lukas Stracke --- .../suites/ipv6/init.js | 9 +++ .../suites/ipv6/subject.js | 1 + .../suites/ipv6/template.html | 9 +++ .../suites/ipv6/test.ts | 20 ++++++ .../suites/ipv6/scenario.ts | 12 ++++ .../suites/ipv6/test.ts | 17 +++++ .../suites/ipv6/scenario.ts | 12 ++++ .../suites/ipv6/test.ts | 17 +++++ packages/core/src/utils/dsn.ts | 2 +- packages/core/test/lib/utils/dsn.test.ts | 63 ++++++++++++++++--- packages/node-core/src/transports/http.ts | 5 +- 11 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/ipv6/init.js create mode 100644 dev-packages/browser-integration-tests/suites/ipv6/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/ipv6/template.html create mode 100644 dev-packages/browser-integration-tests/suites/ipv6/test.ts create mode 100644 dev-packages/node-core-integration-tests/suites/ipv6/scenario.ts create mode 100644 dev-packages/node-core-integration-tests/suites/ipv6/test.ts create mode 100644 dev-packages/node-integration-tests/suites/ipv6/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/ipv6/test.ts diff --git a/dev-packages/browser-integration-tests/suites/ipv6/init.js b/dev-packages/browser-integration-tests/suites/ipv6/init.js new file mode 100644 index 000000000000..de8412c65a86 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/ipv6/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@[2001:db8::1]/1337', + sendClientReports: false, + defaultIntegrations: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/ipv6/subject.js b/dev-packages/browser-integration-tests/suites/ipv6/subject.js new file mode 100644 index 000000000000..8dc99f21ee0e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/ipv6/subject.js @@ -0,0 +1 @@ +Sentry.captureException(new Error('Test error')); diff --git a/dev-packages/browser-integration-tests/suites/ipv6/template.html b/dev-packages/browser-integration-tests/suites/ipv6/template.html new file mode 100644 index 000000000000..39082f45e532 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/ipv6/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/ipv6/test.ts b/dev-packages/browser-integration-tests/suites/ipv6/test.ts new file mode 100644 index 000000000000..7c4ed4da876f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/ipv6/test.ts @@ -0,0 +1,20 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../utils/fixtures'; +import { envelopeRequestParser } from '../../utils/helpers'; + +sentryTest('sends event to an IPv6 DSN', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Technically, we could also use `waitForErrorRequest` but it listens to every POST request, regardless + // of URL. Therefore, waiting on the ipv6 URL request, makes the test a bit more robust. + // We simplify things further by setting up the SDK for errors-only, so that no other request is made. + const requestPromise = page.waitForRequest(req => req.method() === 'POST' && req.url().includes('[2001:db8::1]')); + + await page.goto(url); + + const errorRequest = envelopeRequestParser(await requestPromise); + + expect(errorRequest.exception?.values?.[0]?.value).toBe('Test error'); + + await page.waitForTimeout(1000); +}); diff --git a/dev-packages/node-core-integration-tests/suites/ipv6/scenario.ts b/dev-packages/node-core-integration-tests/suites/ipv6/scenario.ts new file mode 100644 index 000000000000..0023a1bc4b48 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/ipv6/scenario.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@[2001:db8::1]/1337', + defaultIntegrations: false, + sendClientReports: false, + release: '1.0', + transport: loggingTransport, +}); + +Sentry.captureException(new Error(Sentry.getClient()?.getDsn()?.host)); diff --git a/dev-packages/node-core-integration-tests/suites/ipv6/test.ts b/dev-packages/node-core-integration-tests/suites/ipv6/test.ts new file mode 100644 index 000000000000..ef670645c520 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/ipv6/test.ts @@ -0,0 +1,17 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture a simple error with message', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: event => { + expect(event.exception?.values?.[0]?.value).toBe('[2001:db8::1]'); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/ipv6/scenario.ts b/dev-packages/node-integration-tests/suites/ipv6/scenario.ts new file mode 100644 index 000000000000..0023a1bc4b48 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/ipv6/scenario.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@[2001:db8::1]/1337', + defaultIntegrations: false, + sendClientReports: false, + release: '1.0', + transport: loggingTransport, +}); + +Sentry.captureException(new Error(Sentry.getClient()?.getDsn()?.host)); diff --git a/dev-packages/node-integration-tests/suites/ipv6/test.ts b/dev-packages/node-integration-tests/suites/ipv6/test.ts new file mode 100644 index 000000000000..ef670645c520 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/ipv6/test.ts @@ -0,0 +1,17 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture a simple error with message', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: event => { + expect(event.exception?.values?.[0]?.value).toBe('[2001:db8::1]'); + }, + }) + .start() + .completed(); +}); diff --git a/packages/core/src/utils/dsn.ts b/packages/core/src/utils/dsn.ts index 492f2398c390..1a6b6c8130af 100644 --- a/packages/core/src/utils/dsn.ts +++ b/packages/core/src/utils/dsn.ts @@ -7,7 +7,7 @@ import { consoleSandbox, debug } from './debug-logger'; const ORG_ID_REGEX = /^o(\d+)\./; /** Regular expression used to parse a Dsn. */ -const DSN_REGEX = /^(?:(\w+):)\/\/(?:(\w+)(?::(\w+)?)?@)([\w.-]+)(?::(\d+))?\/(.+)/; +const DSN_REGEX = /^(?:(\w+):)\/\/(?:(\w+)(?::(\w+)?)?@)((?:\[[:.%\w]+\]|[\w.-]+))(?::(\d+))?\/(.+)/; function isValidProtocol(protocol?: string): protocol is DsnProtocol { return protocol === 'http' || protocol === 'https'; diff --git a/packages/core/test/lib/utils/dsn.test.ts b/packages/core/test/lib/utils/dsn.test.ts index 0555ae583c02..29c6dc12884c 100644 --- a/packages/core/test/lib/utils/dsn.test.ts +++ b/packages/core/test/lib/utils/dsn.test.ts @@ -1,12 +1,15 @@ import { beforeEach, describe, expect, it, test, vi } from 'vitest'; -import { DEBUG_BUILD } from '../../../src/debug-build'; import { debug } from '../../../src/utils/debug-logger'; import { dsnToString, extractOrgIdFromClient, extractOrgIdFromDsnHost, makeDsn } from '../../../src/utils/dsn'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; -function testIf(condition: boolean) { - return condition ? test : test.skip; -} +let mockDebugBuild = true; + +vi.mock('../../../src/debug-build', () => ({ + get DEBUG_BUILD() { + return mockDebugBuild; + }, +})); const loggerErrorSpy = vi.spyOn(debug, 'error').mockImplementation(() => {}); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -14,6 +17,7 @@ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); describe('Dsn', () => { beforeEach(() => { vi.clearAllMocks(); + mockDebugBuild = true; }); describe('fromComponents', () => { @@ -51,7 +55,7 @@ describe('Dsn', () => { expect(dsn?.projectId).toBe('123'); }); - testIf(DEBUG_BUILD)('returns `undefined` for missing components', () => { + it('returns `undefined` for missing components', () => { expect( makeDsn({ host: '', @@ -88,7 +92,7 @@ describe('Dsn', () => { expect(loggerErrorSpy).toHaveBeenCalledTimes(4); }); - testIf(DEBUG_BUILD)('returns `undefined` if components are invalid', () => { + it('returns `undefined` if components are invalid', () => { expect( makeDsn({ host: 'sentry.io', @@ -167,12 +171,53 @@ describe('Dsn', () => { expect(dsn?.projectId).toBe('321'); }); - testIf(DEBUG_BUILD)('returns undefined when provided invalid Dsn', () => { + test('with IPv4 hostname', () => { + const dsn = makeDsn('https://abc@192.168.1.1/123'); + expect(dsn?.protocol).toBe('https'); + expect(dsn?.publicKey).toBe('abc'); + expect(dsn?.pass).toBe(''); + expect(dsn?.host).toBe('192.168.1.1'); + expect(dsn?.port).toBe(''); + expect(dsn?.path).toBe(''); + expect(dsn?.projectId).toBe('123'); + }); + + test.each([ + '[2001:db8::1]', + '[::1]', // loopback + '[::ffff:192.0.2.1]', // IPv4-mapped IPv6 (contains dots) + '[fe80::1]', // link-local + '[2001:db8:85a3::8a2e:370:7334]', // compressed in middle + '[2001:db8::]', // trailing zeros compressed + '[2001:0db8:0000:0000:0000:0000:0000:0001]', // full form with leading zeros + '[fe80::1%eth0]', // zone identifier with interface name (contains percent sign) + '[fe80::1%25eth0]', // zone identifier URL-encoded (percent as %25) + '[fe80::a:b:c:d%en0]', // zone identifier with different interface + ])('with IPv6 hostname %s', hostname => { + const dsn = makeDsn(`https://abc@${hostname}/123`); + expect(dsn?.protocol).toBe('https'); + expect(dsn?.publicKey).toBe('abc'); + expect(dsn?.pass).toBe(''); + expect(dsn?.host).toBe(hostname); + expect(dsn?.port).toBe(''); + expect(dsn?.path).toBe(''); + expect(dsn?.projectId).toBe('123'); + }); + + test('skips validation for non-debug builds', () => { + mockDebugBuild = false; + const dsn = makeDsn('httx://abc@192.168.1.1/123'); + expect(dsn?.protocol).toBe('httx'); + expect(dsn?.publicKey).toBe('abc'); + expect(dsn?.pass).toBe(''); + }); + + it('returns undefined when provided invalid Dsn', () => { expect(makeDsn('some@random.dsn')).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalledTimes(1); }); - testIf(DEBUG_BUILD)('returns undefined if mandatory fields are missing', () => { + it('returns undefined if mandatory fields are missing', () => { expect(makeDsn('://abc@sentry.io/123')).toBeUndefined(); expect(makeDsn('https://@sentry.io/123')).toBeUndefined(); expect(makeDsn('https://abc@123')).toBeUndefined(); @@ -180,7 +225,7 @@ describe('Dsn', () => { expect(consoleErrorSpy).toHaveBeenCalledTimes(4); }); - testIf(DEBUG_BUILD)('returns undefined if fields are invalid', () => { + it('returns undefined if fields are invalid', () => { expect(makeDsn('httpx://abc@sentry.io/123')).toBeUndefined(); expect(makeDsn('httpx://abc@sentry.io:xxx/123')).toBeUndefined(); expect(makeDsn('http://abc@sentry.io/abc')).toBeUndefined(); diff --git a/packages/node-core/src/transports/http.ts b/packages/node-core/src/transports/http.ts index 3319353aff14..7b2cea994a17 100644 --- a/packages/node-core/src/transports/http.ts +++ b/packages/node-core/src/transports/http.ts @@ -125,12 +125,15 @@ function createRequestExecutor( body = body.pipe(createGzip()); } + const hostnameIsIPv6 = hostname.startsWith('['); + const req = httpModule.request( { method: 'POST', agent, headers, - hostname, + // Remove "[" and "]" from IPv6 hostnames + hostname: hostnameIsIPv6 ? hostname.slice(1, -1) : hostname, path: `${pathname}${search}`, port, protocol, From ddd79bf9391236361bf3afa283c97286764d063c Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Tue, 30 Dec 2025 10:56:29 +0100 Subject: [PATCH 17/76] chore: Add external contributor to CHANGELOG.md (#18633) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #17708 Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8947655e877d..81099860806e 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 @xgedev. Thank you for your contribution! + - ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) ## 10.32.1 From 1b618fcecf10ec55b18d0011d23d7dd814ad7bb7 Mon Sep 17 00:00:00 2001 From: sebws <53290489+sebws@users.noreply.github.com> Date: Tue, 30 Dec 2025 21:51:39 +1100 Subject: [PATCH 18/76] feat(node-core): Add `isolateTrace` option to `node-cron` instrumentation (#18416) Adds the previously introduced `isolateTrace` option to the `node-cron` instrumentation. This ensures that independent tasks triggered by the same cron have distinct traces when `isolateTrace` is set. --- .../cron/node-cron/{ => base}/scenario.ts | 2 +- .../suites/cron/node-cron/{ => base}/test.ts | 2 +- .../cron/node-cron/isolateTrace/scenario.ts | 59 +++++++++++++++++++ .../cron/node-cron/isolateTrace/test.ts | 49 +++++++++++++++ .../cron/node-cron/{ => base}/scenario.ts | 0 .../suites/cron/node-cron/{ => base}/test.ts | 2 +- .../cron/node-cron/isolateTrace/scenario.ts | 56 ++++++++++++++++++ .../cron/node-cron/isolateTrace/test.ts | 49 +++++++++++++++ packages/node-core/src/cron/node-cron.ts | 8 ++- 9 files changed, 222 insertions(+), 5 deletions(-) rename dev-packages/node-core-integration-tests/suites/cron/node-cron/{ => base}/scenario.ts (93%) rename dev-packages/node-core-integration-tests/suites/cron/node-cron/{ => base}/test.ts (96%) create mode 100644 dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts create mode 100644 dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/test.ts rename dev-packages/node-integration-tests/suites/cron/node-cron/{ => base}/scenario.ts (100%) rename dev-packages/node-integration-tests/suites/cron/node-cron/{ => base}/test.ts (96%) create mode 100644 dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-cron/scenario.ts b/dev-packages/node-core-integration-tests/suites/cron/node-cron/base/scenario.ts similarity index 93% rename from dev-packages/node-core-integration-tests/suites/cron/node-cron/scenario.ts rename to dev-packages/node-core-integration-tests/suites/cron/node-cron/base/scenario.ts index 818bf7b63871..0cfa7d79c135 100644 --- a/dev-packages/node-core-integration-tests/suites/cron/node-cron/scenario.ts +++ b/dev-packages/node-core-integration-tests/suites/cron/node-cron/base/scenario.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/node-core'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as cron from 'node-cron'; -import { setupOtel } from '../../../utils/setupOtel'; +import { setupOtel } from '../../../../utils/setupOtel'; const client = Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-cron/test.ts b/dev-packages/node-core-integration-tests/suites/cron/node-cron/base/test.ts similarity index 96% rename from dev-packages/node-core-integration-tests/suites/cron/node-cron/test.ts rename to dev-packages/node-core-integration-tests/suites/cron/node-cron/base/test.ts index dcdb1ba4c4d9..6935fb289b16 100644 --- a/dev-packages/node-core-integration-tests/suites/cron/node-cron/test.ts +++ b/dev-packages/node-core-integration-tests/suites/cron/node-cron/base/test.ts @@ -1,5 +1,5 @@ import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; afterAll(() => { cleanupChildProcesses(); diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts b/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts new file mode 100644 index 000000000000..e06814477bf5 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts @@ -0,0 +1,59 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as cron from 'node-cron'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const cronWithCheckIn = Sentry.cron.instrumentNodeCron(cron, { isolateTrace: true }); + +let closeNext1 = false; +let closeNext2 = false; + +const task = cronWithCheckIn.schedule( + '* * * * * *', + () => { + if (closeNext1) { + // https://github.com/node-cron/node-cron/issues/317 + setImmediate(() => { + task.stop(); + }); + + throw new Error('Error in cron job'); + } + + // eslint-disable-next-line no-console + console.log('You will see this message every second'); + closeNext1 = true; + }, + { name: 'my-cron-job' }, +); + +const task2 = cronWithCheckIn.schedule( + '* * * * * *', + () => { + if (closeNext2) { + // https://github.com/node-cron/node-cron/issues/317 + setImmediate(() => { + task2.stop(); + }); + + throw new Error('Error in cron job 2'); + } + + // eslint-disable-next-line no-console + console.log('You will see this message every second'); + closeNext2 = true; + }, + { name: 'my-2nd-cron-job' }, +); + +setTimeout(() => { + process.exit(); +}, 5000); diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/test.ts b/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/test.ts new file mode 100644 index 000000000000..cf469d2e6acd --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/test.ts @@ -0,0 +1,49 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('node-cron instrumentation with isolateTrace creates distinct traces for each cron job', async () => { + let firstErrorTraceId: string | undefined; + + await createRunner(__dirname, 'scenario.ts') + .ignore('check_in') + .expect({ + event: event => { + const traceId = event.contexts?.trace?.trace_id; + const spanId = event.contexts?.trace?.span_id; + + expect(traceId).toMatch(/[a-f\d]{32}/); + expect(spanId).toMatch(/[a-f\d]{16}/); + + firstErrorTraceId = traceId; + + expect(event.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: expect.stringMatching(/^Error in cron job( 2)?$/), + mechanism: { type: 'auto.function.node-cron.instrumentNodeCron', handled: false }, + }); + }, + }) + .expect({ + event: event => { + const traceId = event.contexts?.trace?.trace_id; + const spanId = event.contexts?.trace?.span_id; + + expect(traceId).toMatch(/[a-f\d]{32}/); + expect(spanId).toMatch(/[a-f\d]{16}/); + + expect(traceId).not.toBe(firstErrorTraceId); + + expect(event.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: expect.stringMatching(/^Error in cron job( 2)?$/), + mechanism: { type: 'auto.function.node-cron.instrumentNodeCron', handled: false }, + }); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/scenario.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts similarity index 100% rename from dev-packages/node-integration-tests/suites/cron/node-cron/scenario.ts rename to dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts similarity index 96% rename from dev-packages/node-integration-tests/suites/cron/node-cron/test.ts rename to dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts index a986e3f83d92..990af6028235 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts @@ -1,5 +1,5 @@ import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; afterAll(() => { cleanupChildProcesses(); diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts new file mode 100644 index 000000000000..5a670d9e6cf2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts @@ -0,0 +1,56 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as cron from 'node-cron'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +const cronWithCheckIn = Sentry.cron.instrumentNodeCron(cron, { isolateTrace: true }); + +let closeNext1 = false; +let closeNext2 = false; + +const task = cronWithCheckIn.schedule( + '* * * * * *', + () => { + if (closeNext1) { + // https://github.com/node-cron/node-cron/issues/317 + setImmediate(() => { + task.stop(); + }); + + throw new Error('Error in cron job'); + } + + // eslint-disable-next-line no-console + console.log('You will see this message every second'); + closeNext1 = true; + }, + { name: 'my-cron-job' }, +); + +const task2 = cronWithCheckIn.schedule( + '* * * * * *', + () => { + if (closeNext2) { + // https://github.com/node-cron/node-cron/issues/317 + setImmediate(() => { + task2.stop(); + }); + + throw new Error('Error in cron job 2'); + } + + // eslint-disable-next-line no-console + console.log('You will see this message every second'); + closeNext2 = true; + }, + { name: 'my-2nd-cron-job' }, +); + +setTimeout(() => { + process.exit(); +}, 5000); diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts new file mode 100644 index 000000000000..ea044ca22ec6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts @@ -0,0 +1,49 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('node-cron instrumentation', async () => { + let firstErrorTraceId: string | undefined; + + await createRunner(__dirname, 'scenario.ts') + .ignore('check_in') + .expect({ + event: event => { + const traceId = event.contexts?.trace?.trace_id; + const spanId = event.contexts?.trace?.span_id; + + expect(traceId).toMatch(/[a-f\d]{32}/); + expect(spanId).toMatch(/[a-f\d]{16}/); + + firstErrorTraceId = traceId; + + expect(event.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: expect.stringMatching(/^Error in cron job( 2)?$/), + mechanism: { type: 'auto.function.node-cron.instrumentNodeCron', handled: false }, + }); + }, + }) + .expect({ + event: event => { + const traceId = event.contexts?.trace?.trace_id; + const spanId = event.contexts?.trace?.span_id; + + expect(traceId).toMatch(/[a-f\d]{32}/); + expect(spanId).toMatch(/[a-f\d]{16}/); + + expect(traceId).not.toBe(firstErrorTraceId); + + expect(event.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: expect.stringMatching(/^Error in cron job( 2)?$/), + mechanism: { type: 'auto.function.node-cron.instrumentNodeCron', handled: false }, + }); + }, + }) + .start() + .completed(); +}); diff --git a/packages/node-core/src/cron/node-cron.ts b/packages/node-core/src/cron/node-cron.ts index 0c6d2a8e5ca1..763b260353cf 100644 --- a/packages/node-core/src/cron/node-cron.ts +++ b/packages/node-core/src/cron/node-cron.ts @@ -1,4 +1,4 @@ -import { captureException, withMonitor } from '@sentry/core'; +import { captureException, type MonitorConfig, withMonitor } from '@sentry/core'; import { replaceCronNames } from './common'; export interface NodeCronOptions { @@ -32,7 +32,10 @@ export interface NodeCron { * ); * ``` */ -export function instrumentNodeCron(lib: Partial & T): T { +export function instrumentNodeCron( + lib: Partial & T, + monitorConfig: Pick = {}, +): T { return new Proxy(lib, { get(target, prop) { if (prop === 'schedule' && target.schedule) { @@ -69,6 +72,7 @@ export function instrumentNodeCron(lib: Partial & T): T { { schedule: { type: 'crontab', value: replaceCronNames(expression) }, timezone, + ...monitorConfig, }, ); }; From f5becb4e052a0b3252eaa3e902c83498482600e9 Mon Sep 17 00:00:00 2001 From: Mohataseem Khan Date: Tue, 30 Dec 2025 16:22:29 +0530 Subject: [PATCH 19/76] chore(bun): Fix `install-bun.js` version check and improve upgrade feedback (#18492) 1. fixed incorrect Bun version command 2. removed unused variable 3. improved user feedback(logs a clear message when Bun is already on the latest version instead of silently exiting) --------- Co-authored-by: Lukas Stracke --- packages/bun/scripts/install-bun.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/bun/scripts/install-bun.js b/packages/bun/scripts/install-bun.js index 2f885c4f2b7d..e2221e549d3e 100644 --- a/packages/bun/scripts/install-bun.js +++ b/packages/bun/scripts/install-bun.js @@ -10,13 +10,11 @@ const https = require('https'); const installScriptUrl = 'https://bun.sh/install'; // Check if bun is installed -exec('bun -version', (error, version) => { +exec('bun --version', (error, version) => { if (error) { console.error('bun is not installed. Installing...'); installLatestBun(); } else { - const versionBefore = version.trim(); - exec('bun upgrade', (error, stdout, stderr) => { if (error) { console.error('Failed to upgrade bun:', error); @@ -26,6 +24,7 @@ exec('bun -version', (error, version) => { const out = [stdout, stderr].join('\n'); if (out.includes("You're already on the latest version of Bun")) { + console.log('Bun is already up to date.'); return; } From e923eac9abd699cfd84b43c672a8fcc023f73708 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Tue, 30 Dec 2025 12:07:18 +0100 Subject: [PATCH 20/76] chore: Add external contributor to CHANGELOG.md (#18636) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #18416 Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81099860806e..d43202722ee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @xgedev. Thank you for your contribution! +Work in this release was contributed by @xgedev and @sebws. Thank you for your contributions! - ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) From 2fb0f9972aa53e7857fc888f883a60e85e8769aa Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Tue, 30 Dec 2025 12:11:35 +0100 Subject: [PATCH 21/76] chore: Add external contributor to CHANGELOG.md (#18637) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #18492 Co-authored-by: Lukas Stracke --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d43202722ee5..a3da1aaad2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @xgedev and @sebws. Thank you for your contributions! +Work in this release was contributed by @xgedev, @Mohataseem89 and @sebws. Thank you for your contributions! - ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) From fdbddaabf90bce39e20eca8317a63de4770a16ee Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 30 Dec 2025 13:27:00 +0100 Subject: [PATCH 22/76] feat(core): Add `ignoreSentryInternalFrames` option to `thirdPartyErrorFilterIntegration` (#18632) Some users get flooded by third party errors despite having set their third party error filtering to `"drop-error-if-contains-third-party-frames"`. This comes from the fact that often the stacktrace includes our internal wrapper logic as last trace in the stack. With this PR we're trying to work around this by specifically ignoring these frames with a new opt-in mechanism. Marked this as experimental, so users will know this option might lead to errors being misclassified. - Adds a new experimental option `experimentalExcludeSentryInternalFrames` to the `thirdPartyErrorFilterIntegration` - Once enabled we apply a strict filter for frames to detect our internal wrapping logic and filter them out so they do not misclassify injected code as internal errors. Closes https://github.com/getsentry/sentry-javascript/issues/13835 --- .../integrations/third-party-errors-filter.ts | 78 +++- .../third-party-errors-filter.test.ts | 385 ++++++++++++++++++ 2 files changed, 450 insertions(+), 13 deletions(-) diff --git a/packages/core/src/integrations/third-party-errors-filter.ts b/packages/core/src/integrations/third-party-errors-filter.ts index 53739c9efd2d..36c88105a283 100644 --- a/packages/core/src/integrations/third-party-errors-filter.ts +++ b/packages/core/src/integrations/third-party-errors-filter.ts @@ -2,6 +2,7 @@ import { defineIntegration } from '../integration'; import { addMetadataToStackFrames, stripMetadataFromStackFrames } from '../metadata'; import type { EventItem } from '../types-hoist/envelope'; import type { Event } from '../types-hoist/event'; +import type { StackFrame } from '../types-hoist/stackframe'; import { forEachEnvelopeItem } from '../utils/envelope'; import { getFramesFromEvent } from '../utils/stacktrace'; @@ -32,6 +33,13 @@ interface Options { | 'drop-error-if-exclusively-contains-third-party-frames' | 'apply-tag-if-contains-third-party-frames' | 'apply-tag-if-exclusively-contains-third-party-frames'; + + /** + * @experimental + * If set to true, the integration will ignore frames that are internal to the Sentry SDK from the third-party frame detection. + * Note that enabling this option might lead to errors being misclassified as third-party errors. + */ + ignoreSentryInternalFrames?: boolean; } /** @@ -67,7 +75,7 @@ export const thirdPartyErrorFilterIntegration = defineIntegration((options: Opti }, processEvent(event) { - const frameKeys = getBundleKeysForAllFramesWithFilenames(event); + const frameKeys = getBundleKeysForAllFramesWithFilenames(event, options.ignoreSentryInternalFrames); if (frameKeys) { const arrayMethod = @@ -98,27 +106,71 @@ export const thirdPartyErrorFilterIntegration = defineIntegration((options: Opti }; }); -function getBundleKeysForAllFramesWithFilenames(event: Event): string[][] | undefined { +/** + * Checks if a stack frame is a Sentry internal frame by strictly matching: + * 1. The frame must be the last frame in the stack + * 2. The filename must indicate the internal helpers file + * 3. The context_line must contain the exact pattern "fn.apply(this, wrappedArguments)" + * 4. The comment pattern "Attempt to invoke user-land function" must be present in pre_context + * + */ +function isSentryInternalFrame(frame: StackFrame, frameIndex: number): boolean { + // Only match the last frame (index 0 in reversed stack) + if (frameIndex !== 0 || !frame.context_line || !frame.filename) { + return false; + } + + if ( + !frame.filename.includes('sentry') || + !frame.filename.includes('helpers') || // Filename would look something like this: 'node_modules/@sentry/browser/build/npm/esm/helpers.js' + !frame.context_line.includes(SENTRY_INTERNAL_FN_APPLY) // Must have context_line with the exact fn.apply pattern + ) { + return false; + } + + // Check pre_context array for comment pattern + if (frame.pre_context) { + const len = frame.pre_context.length; + for (let i = 0; i < len; i++) { + if (frame.pre_context[i]?.includes(SENTRY_INTERNAL_COMMENT)) { + return true; + } + } + } + + return false; +} + +function getBundleKeysForAllFramesWithFilenames( + event: Event, + ignoreSentryInternalFrames?: boolean, +): string[][] | undefined { const frames = getFramesFromEvent(event); if (!frames) { return undefined; } - return ( - frames + return frames + .filter((frame, index) => { // Exclude frames without a filename or without lineno and colno, // since these are likely native code or built-ins - .filter(frame => !!frame.filename && (frame.lineno ?? frame.colno) != null) - .map(frame => { - if (frame.module_metadata) { - return Object.keys(frame.module_metadata) - .filter(key => key.startsWith(BUNDLER_PLUGIN_APP_KEY_PREFIX)) - .map(key => key.slice(BUNDLER_PLUGIN_APP_KEY_PREFIX.length)); - } + if (!frame.filename || (frame.lineno == null && frame.colno == null)) { + return false; + } + // Optionally ignore Sentry internal frames + return !ignoreSentryInternalFrames || !isSentryInternalFrame(frame, index); + }) + .map(frame => { + if (!frame.module_metadata) { return []; - }) - ); + } + return Object.keys(frame.module_metadata) + .filter(key => key.startsWith(BUNDLER_PLUGIN_APP_KEY_PREFIX)) + .map(key => key.slice(BUNDLER_PLUGIN_APP_KEY_PREFIX.length)); + }); } const BUNDLER_PLUGIN_APP_KEY_PREFIX = '_sentryBundlerPluginAppKey:'; +const SENTRY_INTERNAL_COMMENT = 'Attempt to invoke user-land function'; +const SENTRY_INTERNAL_FN_APPLY = 'fn.apply(this, wrappedArguments)'; diff --git a/packages/core/test/lib/integrations/third-party-errors-filter.test.ts b/packages/core/test/lib/integrations/third-party-errors-filter.test.ts index 2b5445a4544e..e519cfd2564c 100644 --- a/packages/core/test/lib/integrations/third-party-errors-filter.test.ts +++ b/packages/core/test/lib/integrations/third-party-errors-filter.test.ts @@ -132,6 +132,78 @@ const eventWithOnlyThirdPartyFrames: Event = { }, }; +const eventWithThirdPartyAndSentryInternalFrames: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ' // means the sentry.javascript SDK caught an error invoking your application code. This', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Third party error', + }, + ], + }, +}; + +const eventWithThirdPartySentryInternalAndFirstPartyFrames: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 1, + filename: __filename, + function: 'function', + lineno: 1, + }, + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ' // means the sentry.javascript SDK caught an error invoking your application code. This', + ], + }, + { + colno: 3, + filename: 'other-file.js', + function: 'function', + lineno: 3, + }, + ], + }, + type: 'Error', + value: 'Mixed error', + }, + ], + }, +}; + // This only needs the stackParser const MOCK_CLIENT = {} as unknown as Client; @@ -146,6 +218,8 @@ describe('ThirdPartyErrorFilter', () => { addMetadataToStackFrames(stackParser, eventWithThirdAndFirstPartyFrames); addMetadataToStackFrames(stackParser, eventWithOnlyFirstPartyFrames); addMetadataToStackFrames(stackParser, eventWithOnlyThirdPartyFrames); + addMetadataToStackFrames(stackParser, eventWithThirdPartyAndSentryInternalFrames); + addMetadataToStackFrames(stackParser, eventWithThirdPartySentryInternalAndFirstPartyFrames); }); describe('drop-error-if-contains-third-party-frames', () => { @@ -287,4 +361,315 @@ describe('ThirdPartyErrorFilter', () => { expect(result?.tags).toMatchObject({ third_party_code: true }); }); }); + + describe('experimentalExcludeSentryInternalFrames', () => { + describe('drop-error-if-exclusively-contains-third-party-frames', () => { + it('drops event with only third-party + Sentry internal frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('keeps event with third-party + Sentry internal + first-party frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartySentryInternalAndFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBeDefined(); + }); + + it('does not drop event with only third-party + Sentry internal frames when option is disabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: false, + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBeDefined(); + }); + + it('defaults to false', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + // experimentalExcludeSentryInternalFrames not set, should default to false + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because option defaults to false + expect(result).toBeDefined(); + }); + }); + + describe('drop-error-if-contains-third-party-frames', () => { + it('drops event with third-party + Sentry internal frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('keeps event with third-party + Sentry internal + first-party frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartySentryInternalAndFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should drop because it contains third-party frames (even with first-party frames) + expect(result).toBe(null); + }); + }); + + describe('comment pattern detection', () => { + it('detects Sentry internal frame by context_line with both patterns', async () => { + const eventWithContextLine: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithContextLine); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('detects Sentry internal frame by pre_context with both patterns', async () => { + const eventWithPreContext: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithPreContext); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('does not detect Sentry internal frame when fn.apply pattern is missing', async () => { + const eventWithoutFnApply: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 115, + context_line: ' const wrappedArguments = args.map(arg => wrap(arg, options));', + post_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithoutFnApply); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because fn.apply pattern is missing + expect(result).toBeDefined(); + }); + + it('does not match when Sentry internal frame is not the last frame', async () => { + const eventWithSentryFrameNotLast: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 3, + filename: 'another-file.js', + function: 'function', + lineno: 3, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithSentryFrameNotLast); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because Sentry frame is not the last frame + expect(result).toBeDefined(); + }); + + it('does not match when filename does not contain both helpers and sentry', async () => { + const eventWithWrongFilename: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: 'some-helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithWrongFilename); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because filename doesn't contain "sentry" + expect(result).toBeDefined(); + }); + }); + }); }); From da1c41f217f24fbc655782895529c2986d450822 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 30 Dec 2025 13:27:28 +0100 Subject: [PATCH 23/76] feat(nextjs): Emit warning for conflicting treeshaking / debug settings (#18638) With this PR we emit a warning for client, node and edge whenever the user sets `debug: true` in `init` but at the same time treeshakes logging statements using webpack. This will not emit anything for turbopack. closes https://linear.app/getsentry/issue/FE-610/warn-at-build-time-for-conflicting-disablelogger-and-debug-settings-in --- packages/nextjs/src/client/index.ts | 10 +++ packages/nextjs/src/edge/index.ts | 8 ++ packages/nextjs/src/server/index.ts | 7 ++ .../config/conflictingDebugOptions.test.ts | 82 +++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 packages/nextjs/test/config/conflictingDebugOptions.test.ts diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 07d1ee5c4e84..d7a2478987ae 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -5,6 +5,7 @@ import type { Client, EventProcessor, Integration } from '@sentry/core'; import { addEventProcessor, applySdkMetadata, consoleSandbox, getGlobalScope, GLOBAL_OBJ } from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react'; +import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; @@ -48,6 +49,15 @@ export function init(options: BrowserOptions): Client | undefined { } clientIsInitialized = true; + if (!DEBUG_BUILD && options.debug) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You have enabled `debug: true`, but Sentry debug logging was removed from your bundle (likely via `withSentryConfig({ disableLogger: true })` / `webpack.treeshake.removeDebugLogging: true`). Set that option to `false` to see Sentry debug output.', + ); + }); + } + // Remove cached trace meta tags for ISR/SSG pages before initializing // This prevents the browser tracing integration from using stale trace IDs if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index fcaad178b9fa..9fa05c94e978 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -22,6 +22,7 @@ import { import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; +import { DEBUG_BUILD } from '../common/debug-build'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; @@ -55,6 +56,13 @@ export function init(options: VercelEdgeOptions = {}): void { return; } + if (!DEBUG_BUILD && options.debug) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You have enabled `debug: true`, but Sentry debug logging was removed from your bundle (likely via `withSentryConfig({ disableLogger: true })` / `webpack.treeshake.removeDebugLogging: true`). Set that option to `false` to see Sentry debug output.', + ); + } + const customDefaultIntegrations = getDefaultIntegrations(options); // This value is injected at build time, based on the output directory specified in the build config. Though a default diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 7dc533e171b1..18f3db003177 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -96,6 +96,13 @@ export function init(options: NodeOptions): NodeClient | undefined { return; } + if (!DEBUG_BUILD && options.debug) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You have enabled `debug: true`, but Sentry debug logging was removed from your bundle (likely via `withSentryConfig({ disableLogger: true })` / `webpack.treeshake.removeDebugLogging: true`). Set that option to `false` to see Sentry debug output.', + ); + } + const customDefaultIntegrations = getDefaultIntegrations(options) .filter(integration => integration.name !== 'Http') .concat( diff --git a/packages/nextjs/test/config/conflictingDebugOptions.test.ts b/packages/nextjs/test/config/conflictingDebugOptions.test.ts new file mode 100644 index 000000000000..8c0920382c4a --- /dev/null +++ b/packages/nextjs/test/config/conflictingDebugOptions.test.ts @@ -0,0 +1,82 @@ +import { JSDOM } from 'jsdom'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +function didWarnAboutDebugRemoved(warnSpy: ReturnType): boolean { + return warnSpy.mock.calls.some(call => + call.some( + arg => + typeof arg === 'string' && + arg.includes('You have enabled `debug: true`') && + arg.includes('debug logging was removed from your bundle'), + ), + ); +} + +describe('debug: true + removeDebugLogging warning', () => { + let dom: JSDOM; + let originalDocument: unknown; + let originalLocation: unknown; + let originalAddEventListener: unknown; + + beforeAll(() => { + dom = new JSDOM('', { url: 'https://example.com/' }); + + originalDocument = (globalThis as any).document; + originalLocation = (globalThis as any).location; + originalAddEventListener = (globalThis as any).addEventListener; + + Object.defineProperty(globalThis, 'document', { value: dom.window.document, writable: true }); + Object.defineProperty(globalThis, 'location', { value: dom.window.location, writable: true }); + Object.defineProperty(globalThis, 'addEventListener', { value: () => undefined, writable: true }); + }); + + afterAll(() => { + Object.defineProperty(globalThis, 'document', { value: originalDocument, writable: true }); + Object.defineProperty(globalThis, 'location', { value: originalLocation, writable: true }); + Object.defineProperty(globalThis, 'addEventListener', { value: originalAddEventListener, writable: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unmock('../../src/common/debug-build.js'); + delete process.env.NEXT_OTEL_FETCH_DISABLED; + delete process.env.NEXT_PHASE; + }); + + it('warns on client/server/edge when debug is true but DEBUG_BUILD is false', async () => { + vi.doMock('../../src/common/debug-build.js', () => ({ DEBUG_BUILD: false })); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = await import('../../src/client/index.js'); + client.init({ dsn: TEST_DSN, debug: true } as any); + + const server = await import('../../src/server/index.js'); + server.init({ dsn: TEST_DSN, debug: true } as any); + + const edge = await import('../../src/edge/index.js'); + edge.init({ dsn: TEST_DSN, debug: true } as any); + + expect(didWarnAboutDebugRemoved(warnSpy)).toBe(true); + }); + + it('does not emit that warning when DEBUG_BUILD is true', async () => { + vi.doMock('../../src/common/debug-build.js', () => ({ DEBUG_BUILD: true })); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = await import('../../src/client/index.js'); + client.init({ dsn: TEST_DSN, debug: true } as any); + + const server = await import('../../src/server/index.js'); + server.init({ dsn: TEST_DSN, debug: true } as any); + + const edge = await import('../../src/edge/index.js'); + edge.init({ dsn: TEST_DSN, debug: true } as any); + + expect(didWarnAboutDebugRemoved(warnSpy)).toBe(false); + }); +}); From 5425ffb43c911e7916719c02670163ddd6300fd1 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Tue, 30 Dec 2025 14:29:53 +0200 Subject: [PATCH 24/76] fix(core): Set op on ended Vercel AI spans (#18601) Ensures Vercel AI span op is always set correctly at span start, regardless of whether the model ID is available. Changes: - Removed the model ID check gate before calling processGenerateSpan - the op is determined by span name, not model ID - Span name updates (e.g., generate_text gpt-4) are now only applied when model ID exists, avoiding undefined in names - Added integration test for late model ID scenario Closes https://github.com/getsentry/sentry-javascript/issues/18448 --- .../vercelai/scenario-late-model-id.mjs | 36 +++++++ .../suites/tracing/vercelai/test.ts | 36 +++++++ .../core/src/tracing/ai/gen-ai-attributes.ts | 35 ++++++ packages/core/src/tracing/vercel-ai/index.ts | 102 ++++++------------ packages/core/src/tracing/vercel-ai/utils.ts | 42 ++++++++ 5 files changed, 179 insertions(+), 72 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-late-model-id.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-late-model-id.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-late-model-id.mjs new file mode 100644 index 000000000000..05b8190cc0b4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-late-model-id.mjs @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; + +// Custom mock model that doesn't set modelId initially (simulates late model ID setting) +// This tests that the op is correctly set even when model ID is not available at span start. +// The span name update (e.g., 'generate_text gpt-4') is skipped when model ID is missing.t +class LateModelIdMock { + specificationVersion = 'v1'; + provider = 'late-model-provider'; + // modelId is intentionally undefined initially to simulate late setting + modelId = undefined; + defaultObjectGenerationMode = 'json'; + + async doGenerate() { + // Model ID is only "available" during generation, not at span start + this.modelId = 'late-mock-model-id'; + + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 5, completionTokens: 10 }, + text: 'Response from late model!', + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + model: new LateModelIdMock(), + prompt: 'Test prompt for late model ID', + }); + }); +} + +run(); 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 de228303ab0e..2ccf8a1dc212 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -699,4 +699,40 @@ describe('Vercel AI integration', () => { expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-late-model-id.mjs', 'instrument.mjs', (createRunner, test) => { + test('sets op correctly even when model ID is not available at span start', async () => { + const expectedTransaction = { + transaction: 'main', + spans: expect.arrayContaining([ + // The generateText span should have the correct op even though model ID was not available at span start + expect.objectContaining({ + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + data: expect.objectContaining({ + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + 'gen_ai.operation.name': 'ai.generateText', + }), + }), + // The doGenerate span - name stays as 'generateText.doGenerate' since model ID is missing + expect.objectContaining({ + description: 'generateText.doGenerate', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + data: expect.objectContaining({ + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + }), + }), + ]), + }; + + await createRunner().expect({ transaction: expectedTransaction }).start().completed(); + }); + }); }); diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index e2808d5f2642..e76b2945b497 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -179,6 +179,41 @@ export const GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE = 'gen_ai.usage.input_to */ export const GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE = 'gen_ai.invoke_agent'; +/** + * The span operation name for generating text + */ +export const GEN_AI_GENERATE_TEXT_DO_GENERATE_OPERATION_ATTRIBUTE = 'gen_ai.generate_text'; + +/** + * The span operation name for streaming text + */ +export const GEN_AI_STREAM_TEXT_DO_STREAM_OPERATION_ATTRIBUTE = 'gen_ai.stream_text'; + +/** + * The span operation name for generating object + */ +export const GEN_AI_GENERATE_OBJECT_DO_GENERATE_OPERATION_ATTRIBUTE = 'gen_ai.generate_object'; + +/** + * The span operation name for streaming object + */ +export const GEN_AI_STREAM_OBJECT_DO_STREAM_OPERATION_ATTRIBUTE = 'gen_ai.stream_object'; + +/** + * The span operation name for embedding + */ +export const GEN_AI_EMBED_DO_EMBED_OPERATION_ATTRIBUTE = 'gen_ai.embed'; + +/** + * The span operation name for embedding many + */ +export const GEN_AI_EMBED_MANY_DO_EMBED_OPERATION_ATTRIBUTE = 'gen_ai.embed_many'; + +/** + * The span operation name for executing a tool + */ +export const GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE = 'gen_ai.execute_tool'; + // ============================================================================= // OPENAI-SPECIFIC ATTRIBUTES // ============================================================================= diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index e64b4b1a9cbf..6b59feb7a0ec 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -19,6 +19,7 @@ import { accumulateTokensForParent, applyAccumulatedTokens, convertAvailableToolsToJsonString, + getSpanOpFromName, requestMessagesFromPrompt, } from './utils'; import type { ProviderMetadata } from './vercel-ai-attributes'; @@ -64,10 +65,8 @@ function onVercelAiSpanStart(span: Span): void { return; } - // The AI model ID must be defined for generate, stream, and embed spans. - // The provider is optional and may not always be present. - const aiModelId = attributes[AI_MODEL_ID_ATTRIBUTE]; - if (typeof aiModelId !== 'string' || !aiModelId) { + // Check if this is a Vercel AI span by name pattern. + if (!name.startsWith('ai.')) { return; } @@ -225,76 +224,35 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute } span.setAttribute('ai.streaming', name.includes('stream')); - // Generate Spans - if (name === 'ai.generateText') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.generateText.doGenerate') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_text'); - span.updateName(`generate_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.streamText') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.streamText.doStream') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_text'); - span.updateName(`stream_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.generateObject') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.generateObject.doGenerate') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_object'); - span.updateName(`generate_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.streamObject') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.streamObject.doStream') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_object'); - span.updateName(`stream_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.embed') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.embed.doEmbed') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed'); - span.updateName(`embed ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; + // Set the op based on the span name + const op = getSpanOpFromName(name); + if (op) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, op); } - if (name === 'ai.embedMany') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.embedMany.doEmbed') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed_many'); - span.updateName(`embed_many ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name.startsWith('ai.stream')) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'ai.run'); - return; + // Update span names for .do* spans to include the model ID (only if model ID exists) + const modelId = attributes[AI_MODEL_ID_ATTRIBUTE]; + if (modelId) { + switch (name) { + case 'ai.generateText.doGenerate': + span.updateName(`generate_text ${modelId}`); + break; + case 'ai.streamText.doStream': + span.updateName(`stream_text ${modelId}`); + break; + case 'ai.generateObject.doGenerate': + span.updateName(`generate_object ${modelId}`); + break; + case 'ai.streamObject.doStream': + span.updateName(`stream_object ${modelId}`); + break; + case 'ai.embed.doEmbed': + span.updateName(`embed ${modelId}`); + break; + case 'ai.embedMany.doEmbed': + span.updateName(`embed_many ${modelId}`); + break; + } } } diff --git a/packages/core/src/tracing/vercel-ai/utils.ts b/packages/core/src/tracing/vercel-ai/utils.ts index bc390ccc1672..b6c5b0ad5aab 100644 --- a/packages/core/src/tracing/vercel-ai/utils.ts +++ b/packages/core/src/tracing/vercel-ai/utils.ts @@ -1,7 +1,15 @@ import type { TraceContext } from '../../types-hoist/context'; import type { Span, SpanAttributes, SpanJSON } from '../../types-hoist/span'; import { + GEN_AI_EMBED_DO_EMBED_OPERATION_ATTRIBUTE, + GEN_AI_EMBED_MANY_DO_EMBED_OPERATION_ATTRIBUTE, + GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE, + GEN_AI_GENERATE_OBJECT_DO_GENERATE_OPERATION_ATTRIBUTE, + GEN_AI_GENERATE_TEXT_DO_GENERATE_OPERATION_ATTRIBUTE, + GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_STREAM_OBJECT_DO_STREAM_OPERATION_ATTRIBUTE, + GEN_AI_STREAM_TEXT_DO_STREAM_OPERATION_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; @@ -137,3 +145,37 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes if (messages.length) span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, getTruncatedJsonString(messages)); } } + +/** + * Maps a Vercel AI span name to the corresponding Sentry op. + */ +export function getSpanOpFromName(name: string): string | undefined { + switch (name) { + case 'ai.generateText': + case 'ai.streamText': + case 'ai.generateObject': + case 'ai.streamObject': + case 'ai.embed': + case 'ai.embedMany': + return GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE; + case 'ai.generateText.doGenerate': + return GEN_AI_GENERATE_TEXT_DO_GENERATE_OPERATION_ATTRIBUTE; + case 'ai.streamText.doStream': + return GEN_AI_STREAM_TEXT_DO_STREAM_OPERATION_ATTRIBUTE; + case 'ai.generateObject.doGenerate': + return GEN_AI_GENERATE_OBJECT_DO_GENERATE_OPERATION_ATTRIBUTE; + case 'ai.streamObject.doStream': + return GEN_AI_STREAM_OBJECT_DO_STREAM_OPERATION_ATTRIBUTE; + case 'ai.embed.doEmbed': + return GEN_AI_EMBED_DO_EMBED_OPERATION_ATTRIBUTE; + case 'ai.embedMany.doEmbed': + return GEN_AI_EMBED_MANY_DO_EMBED_OPERATION_ATTRIBUTE; + case 'ai.toolCall': + return GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE; + default: + if (name.startsWith('ai.stream')) { + return 'ai.run'; + } + return undefined; + } +} From e4ad84678f8a3c280945777019291437ac94893b Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Wed, 31 Dec 2025 03:00:26 +1300 Subject: [PATCH 25/76] fix(core): Update client options to allow explicit `undefined` (#18024) Explicitly allow `undefined` as a value for some init options to support TS setups with `exactOptionalPropertyTypes` enabled. --- packages/core/src/types-hoist/options.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 3d4ad7b67ea5..ac4ce839ff85 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -78,7 +78,7 @@ export interface ClientOptions Date: Tue, 30 Dec 2025 15:18:04 +0100 Subject: [PATCH 26/76] chore: Add external contributor to CHANGELOG.md (#18641) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #18024 Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3da1aaad2ea..e8fdc6d9fb1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @xgedev, @Mohataseem89 and @sebws. Thank you for your contributions! +Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, and @G-Rath. Thank you for your contributions! - ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) From 363a910f118c7e33956191a1b9b4cb1c17083000 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 30 Dec 2025 15:36:08 +0100 Subject: [PATCH 27/76] feat(node): Use `process.on('SIGTERM')` for flushing in Vercel functions (#17583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a better way to ensure we flush in node-based vercel functions. Note that this _does not_ work in edge functions, so we need to keep the `vercelWaitUntil` code around for this - but we can noop it in node and instead rely on the better way to handle this, I believe. @chargome when you're back, can you verify if this makes sense? 😅 Closes https://github.com/getsentry/sentry-javascript/issues/17567 --------- Co-authored-by: Charly Gomez --- .../suites/vercel/sigterm-flush/scenario.ts | 36 ++++++++++++ .../suites/vercel/sigterm-flush/test.ts | 39 +++++++++++++ .../node-integration-tests/utils/runner.ts | 4 ++ packages/core/src/utils/flushIfServerless.ts | 2 + packages/core/src/utils/vercelWaitUntil.ts | 7 +++ .../test/lib/utils/vercelWaitUntil.test.ts | 37 +++++++++--- packages/node-core/src/sdk/index.ts | 9 +++ packages/node-core/test/sdk/init.test.ts | 58 +++++++++++++++++++ 8 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/vercel/sigterm-flush/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/vercel/sigterm-flush/test.ts diff --git a/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/scenario.ts b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/scenario.ts new file mode 100644 index 000000000000..51e1b4d09ccf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/scenario.ts @@ -0,0 +1,36 @@ +import type { BaseTransportOptions, Envelope, Transport, TransportMakeRequestResponse } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +function bufferedLoggingTransport(_options: BaseTransportOptions): Transport { + const bufferedEnvelopes: Envelope[] = []; + + return { + send(envelope: Envelope): Promise { + bufferedEnvelopes.push(envelope); + return Promise.resolve({ statusCode: 200 }); + }, + flush(_timeout?: number): PromiseLike { + // Print envelopes once flushed to verify they were sent. + for (const envelope of bufferedEnvelopes.splice(0, bufferedEnvelopes.length)) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(envelope)); + } + + return Promise.resolve(true); + }, + }; +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: bufferedLoggingTransport, +}); + +Sentry.captureMessage('SIGTERM flush message'); + +// Signal that we're ready to receive SIGTERM. +// eslint-disable-next-line no-console +console.log('READY'); + +// Keep the process alive so the integration test can send SIGTERM. +setInterval(() => undefined, 1_000); diff --git a/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/test.ts b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/test.ts new file mode 100644 index 000000000000..d605895555ae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/test.ts @@ -0,0 +1,39 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('flushes buffered events when SIGTERM is received on Vercel', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .withEnv({ VERCEL: '1' }) + .expect({ + event: { + message: 'SIGTERM flush message', + }, + }) + .start(); + + // Wait for the scenario to signal it's ready (SIGTERM handler is registered). + const waitForReady = async (): Promise => { + const maxWait = 10_000; + const start = Date.now(); + while (Date.now() - start < maxWait) { + if (runner.getLogs().some(line => line.includes('READY'))) { + return; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error('Timed out waiting for scenario to be ready'); + }; + + await waitForReady(); + + runner.sendSignal('SIGTERM'); + + await runner.completed(); + + // Check that the child didn't crash (it may be killed by the runner after completion). + expect(runner.getLogs().join('\n')).not.toMatch(/Error starting child process/i); +}); diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index 97c4021ccc89..985db0a80e6c 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -172,6 +172,7 @@ type StartResult = { childHasExited(): boolean; getLogs(): string[]; getPort(): number | undefined; + sendSignal(signal: NodeJS.Signals): void; makeRequest( method: 'get' | 'post' | 'put' | 'delete' | 'patch', path: string, @@ -668,6 +669,9 @@ export function createRunner(...paths: string[]) { getPort(): number | undefined { return scenarioServerPort; }, + sendSignal(signal: NodeJS.Signals): void { + child?.kill(signal); + }, makeRequest: async function ( method: 'get' | 'post' | 'put' | 'delete' | 'patch', path: string, diff --git a/packages/core/src/utils/flushIfServerless.ts b/packages/core/src/utils/flushIfServerless.ts index 2f8d387990c9..6258f2222915 100644 --- a/packages/core/src/utils/flushIfServerless.ts +++ b/packages/core/src/utils/flushIfServerless.ts @@ -51,6 +51,8 @@ export async function flushIfServerless( return; } + // Note: vercelWaitUntil only does something in Vercel Edge runtime + // In Node runtime, we use process.on('SIGTERM') instead // @ts-expect-error This is not typed if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) { // Vercel has a waitUntil equivalent that works without execution context diff --git a/packages/core/src/utils/vercelWaitUntil.ts b/packages/core/src/utils/vercelWaitUntil.ts index bfcaa6b4b832..32d801a6723c 100644 --- a/packages/core/src/utils/vercelWaitUntil.ts +++ b/packages/core/src/utils/vercelWaitUntil.ts @@ -1,5 +1,7 @@ import { GLOBAL_OBJ } from './worldwide'; +declare const EdgeRuntime: string | undefined; + interface VercelRequestContextGlobal { get?(): | { @@ -14,6 +16,11 @@ interface VercelRequestContextGlobal { * Vendored from https://www.npmjs.com/package/@vercel/functions */ export function vercelWaitUntil(task: Promise): void { + // We only flush manually in Vercel Edge runtime + // In Node runtime, we use process.on('SIGTERM') instead + if (typeof EdgeRuntime !== 'string') { + return; + } const vercelRequestContextGlobal: VercelRequestContextGlobal | undefined = // @ts-expect-error This is not typed GLOBAL_OBJ[Symbol.for('@vercel/request-context')]; diff --git a/packages/core/test/lib/utils/vercelWaitUntil.test.ts b/packages/core/test/lib/utils/vercelWaitUntil.test.ts index 78637cb3ef18..1f6be3b7924f 100644 --- a/packages/core/test/lib/utils/vercelWaitUntil.test.ts +++ b/packages/core/test/lib/utils/vercelWaitUntil.test.ts @@ -1,8 +1,28 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { vercelWaitUntil } from '../../../src/utils/vercelWaitUntil'; import { GLOBAL_OBJ } from '../../../src/utils/worldwide'; describe('vercelWaitUntil', () => { + const VERCEL_REQUEST_CONTEXT_SYMBOL = Symbol.for('@vercel/request-context'); + const globalWithEdgeRuntime = globalThis as typeof globalThis & { EdgeRuntime?: string }; + const globalWithVercelRequestContext = GLOBAL_OBJ as unknown as Record; + + // `vercelWaitUntil` only runs in Vercel Edge runtime, which is detected via the global `EdgeRuntime` variable. + // In tests we set it explicitly so the logic is actually exercised. + const originalEdgeRuntime = globalWithEdgeRuntime.EdgeRuntime; + + beforeEach(() => { + globalWithEdgeRuntime.EdgeRuntime = 'edge-runtime'; + }); + + afterEach(() => { + if (originalEdgeRuntime === undefined) { + delete globalWithEdgeRuntime.EdgeRuntime; + } else { + globalWithEdgeRuntime.EdgeRuntime = originalEdgeRuntime; + } + }); + it('should do nothing if GLOBAL_OBJ does not have the @vercel/request-context symbol', () => { const task = Promise.resolve(); vercelWaitUntil(task); @@ -10,31 +30,34 @@ describe('vercelWaitUntil', () => { }); it('should do nothing if get method is not defined', () => { - // @ts-expect-error - Not typed - GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = {}; + const originalRequestContext = globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL]; + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = {}; const task = Promise.resolve(); vercelWaitUntil(task); // No assertions needed, just ensuring no errors are thrown + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = originalRequestContext; }); it('should do nothing if waitUntil method is not defined', () => { - // @ts-expect-error - Not typed - GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = { + const originalRequestContext = globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL]; + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = { get: () => ({}), }; const task = Promise.resolve(); vercelWaitUntil(task); // No assertions needed, just ensuring no errors are thrown + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = originalRequestContext; }); it('should call waitUntil method if it is defined', () => { + const originalRequestContext = globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL]; const waitUntilMock = vi.fn(); - // @ts-expect-error - Not typed - GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = { + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = { get: () => ({ waitUntil: waitUntilMock }), }; const task = Promise.resolve(); vercelWaitUntil(task); expect(waitUntilMock).toHaveBeenCalledWith(task); + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = originalRequestContext; }); }); diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index 0814ab401535..1f0fd8835340 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -143,6 +143,15 @@ function _init( enhanceDscWithOpenTelemetryRootSpanName(client); setupEventContextTrace(client); + // Ensure we flush events when vercel functions are ended + // See: https://vercel.com/docs/functions/functions-api-reference#sigterm-signal + if (process.env.VERCEL) { + process.on('SIGTERM', async () => { + // We have 500ms for processing here, so we try to make sure to have enough time to send the events + await client.flush(200); + }); + } + return client; } diff --git a/packages/node-core/test/sdk/init.test.ts b/packages/node-core/test/sdk/init.test.ts index d5f150f03a59..144ff3e2dc37 100644 --- a/packages/node-core/test/sdk/init.test.ts +++ b/packages/node-core/test/sdk/init.test.ts @@ -111,6 +111,64 @@ describe('init()', () => { expect(client).toBeInstanceOf(NodeClient); }); + it('registers a SIGTERM handler on Vercel', () => { + const originalVercelEnv = process.env.VERCEL; + process.env.VERCEL = '1'; + + const baselineListeners = process.listeners('SIGTERM'); + + init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: true }); + + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + + expect(addedListeners).toHaveLength(1); + + // Cleanup: remove the handler we added in this test. + process.off('SIGTERM', addedListeners[0] as any); + process.env.VERCEL = originalVercelEnv; + }); + + it('flushes when SIGTERM is received on Vercel', () => { + const originalVercelEnv = process.env.VERCEL; + process.env.VERCEL = '1'; + + const baselineListeners = process.listeners('SIGTERM'); + + const client = init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: true }); + expect(client).toBeInstanceOf(NodeClient); + + const flushSpy = vi.spyOn(client as NodeClient, 'flush').mockResolvedValue(true); + + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + expect(addedListeners).toHaveLength(1); + + process.emit('SIGTERM'); + + expect(flushSpy).toHaveBeenCalledWith(200); + + // Cleanup: remove the handler we added in this test. + process.off('SIGTERM', addedListeners[0] as any); + process.env.VERCEL = originalVercelEnv; + }); + + it('does not register a SIGTERM handler when not running on Vercel', () => { + const originalVercelEnv = process.env.VERCEL; + delete process.env.VERCEL; + + const baselineListeners = process.listeners('SIGTERM'); + + init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: true }); + + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + + expect(addedListeners).toHaveLength(0); + + process.env.VERCEL = originalVercelEnv; + }); + describe('environment variable options', () => { const originalProcessEnv = { ...process.env }; From 995f78807885afd401af1f77eb6c9a7048da55fd Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 30 Dec 2025 17:12:05 +0100 Subject: [PATCH 28/76] test(e2e): Add e2e metrics tests in Next.js 16 (#18643) closes https://github.com/getsentry/sentry-javascript/issues/18186 --- .../nextjs-16/app/metrics/page.tsx | 34 +++++ .../app/metrics/route-handler/route.ts | 23 +++ .../nextjs-16/tests/metrics.test.ts | 133 ++++++++++++++++++ .../test-utils/src/event-proxy-server.ts | 38 ++++- dev-packages/test-utils/src/index.ts | 1 + 5 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/metrics/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/metrics/route-handler/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/metrics.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/metrics/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/metrics/page.tsx new file mode 100644 index 000000000000..fdb7bc0a40a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/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/app/metrics/route-handler/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/metrics/route-handler/route.ts new file mode 100644 index 000000000000..84e81960f9c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/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/tests/metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/metrics.test.ts new file mode 100644 index 000000000000..43edb917d526 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/metrics.test.ts @@ -0,0 +1,133 @@ +import { expect, test } from '@playwright/test'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +test('Should emit metrics from server and client', async ({ request, page }) => { + const clientCountPromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.page.count'; + }); + + const clientDistributionPromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.page.distribution'; + }); + + const clientGaugePromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.page.gauge'; + }); + + const serverCountPromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.route.handler.count'; + }); + + const serverDistributionPromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.route.handler.distribution'; + }); + + const serverGaugePromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.route.handler.gauge'; + }); + + await page.goto('/metrics'); + await page.getByText('Emit').click(); + const clientCount = await clientCountPromise; + const clientDistribution = await clientDistributionPromise; + const clientGauge = await clientGaugePromise; + const serverCount = await serverCountPromise; + const serverDistribution = await serverDistributionPromise; + const serverGauge = await serverGaugePromise; + + expect(clientCount).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + name: 'test.page.count', + type: 'counter', + value: 1, + attributes: { + page: { value: '/metrics', type: 'string' }, + 'random.attribute': { value: 'Apples', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(clientDistribution).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + name: 'test.page.distribution', + type: 'distribution', + value: 100, + attributes: { + page: { value: '/metrics', type: 'string' }, + 'random.attribute': { value: 'Manzanas', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(clientGauge).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + name: 'test.page.gauge', + type: 'gauge', + value: 200, + attributes: { + page: { value: '/metrics', type: 'string' }, + 'random.attribute': { value: 'Mele', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(serverCount).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.route.handler.count', + type: 'counter', + value: 1, + attributes: { + 'server.address': { value: expect.any(String), type: 'string' }, + 'random.attribute': { value: 'Potatoes', type: 'string' }, + endpoint: { value: '/metrics/route-handler', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(serverDistribution).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.route.handler.distribution', + type: 'distribution', + value: 100, + attributes: { + 'server.address': { value: expect.any(String), type: 'string' }, + 'random.attribute': { value: 'Patatas', type: 'string' }, + endpoint: { value: '/metrics/route-handler', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(serverGauge).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.route.handler.gauge', + type: 'gauge', + value: 200, + attributes: { + 'server.address': { value: expect.any(String), type: 'string' }, + 'random.attribute': { value: 'Patate', type: 'string' }, + endpoint: { value: '/metrics/route-handler', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); +}); diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 08fa39db950f..9c411c3fc015 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -1,5 +1,12 @@ /* eslint-disable max-lines */ -import type { Envelope, EnvelopeItem, Event, SerializedSession } from '@sentry/core'; +import type { + Envelope, + EnvelopeItem, + Event, + SerializedMetric, + SerializedMetricContainer, + SerializedSession, +} from '@sentry/core'; import { parseEnvelope } from '@sentry/core'; import * as fs from 'fs'; import * as http from 'http'; @@ -391,6 +398,35 @@ export function waitForTransaction( }); } +/** + * Wait for metric items to be sent. + */ +export function waitForMetric( + proxyServerName: string, + callback: (metricEvent: SerializedMetric) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForEnvelopeItem( + proxyServerName, + async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + const metricContainer = envelopeItemBody as SerializedMetricContainer; + if (envelopeItemHeader.type === 'trace_metric') { + for (const metric of metricContainer.items) { + if (await callback(metric)) { + resolve(metric); + return true; + } + } + } + return false; + }, + timestamp, + ).catch(reject); + }); +} + const TEMP_FILE_PREFIX = 'event-proxy-server-'; async function registerCallbackServerPort(serverName: string, port: string): Promise { diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index e9ae76f592ed..b14248aabd95 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -7,6 +7,7 @@ export { waitForTransaction, waitForSession, waitForPlainRequest, + waitForMetric, } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; From 4a5ba93d205c6f0a55b48ec737df9406af8381df Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 30 Dec 2025 17:19:52 +0100 Subject: [PATCH 29/76] ref(core): Extract and reuse `getCombinedScopeData` helper (#18585) Pre-work for #18160 We have multiple places in which we need to combine the `ScopeData` of global, isolation and current scopes, to apply this data to telemetry items (events, logs, metrics, soon also spansV2). Previously, we did this in-place or with helpers that were not re-used. This PR now unifies the various locations to one helper from core which can be reused everywhere. Closes #18586 (added automatically) --- packages/core/src/index.ts | 2 +- packages/core/src/logs/internal.ts | 21 +------ packages/core/src/metrics/internal.ts | 22 ++------ packages/core/src/utils/prepareEvent.ts | 15 +---- ...{applyScopeDataToEvent.ts => scopeData.ts} | 17 +++++- packages/core/test/lib/scope.test.ts | 2 +- ...eDataToEvent.test.ts => scopeData.test.ts} | 55 ++++++++++++++++++- .../node-core/src/integrations/anr/index.ts | 7 +-- 8 files changed, 81 insertions(+), 60 deletions(-) rename packages/core/src/utils/{applyScopeDataToEvent.ts => scopeData.ts} (90%) rename packages/core/test/lib/utils/{applyScopeDataToEvent.test.ts => scopeData.test.ts} (87%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e18ea294f182..c4884edf939b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,7 +61,7 @@ export { _INTERNAL_shouldSkipAiProviderWrapping, _INTERNAL_clearAiProviderSkips, } from './utils/ai/providerSkip'; -export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent'; +export { applyScopeDataToEvent, mergeScopeData, getCombinedScopeData } from './utils/scopeData'; export { prepareEvent } from './utils/prepareEvent'; export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index a39aa75d7074..3408b01a5f96 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -1,14 +1,13 @@ import { serializeAttributes } from '../attributes'; import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; -import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes'; +import { getClient, getCurrentScope, getIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import type { Scope, ScopeData } from '../scope'; import type { Integration } from '../types-hoist/integration'; import type { Log, SerializedLog } from '../types-hoist/log'; -import { mergeScopeData } from '../utils/applyScopeDataToEvent'; import { consoleSandbox, debug } from '../utils/debug-logger'; import { isParameterizedString } from '../utils/is'; +import { getCombinedScopeData } from '../utils/scopeData'; import { _getSpanForScope } from '../utils/spanOnScope'; import { timestampInSeconds } from '../utils/time'; import { _getTraceInfoFromScope } from '../utils/trace-info'; @@ -98,7 +97,7 @@ export function _INTERNAL_captureLog( const { user: { id, email, username }, attributes: scopeAttributes = {}, - } = getMergedScopeData(currentScope); + } = getCombinedScopeData(getIsolationScope(), currentScope); setLogAttribute(processedLogAttributes, 'user.id', id, false); setLogAttribute(processedLogAttributes, 'user.email', email, false); @@ -212,20 +211,6 @@ export function _INTERNAL_getLogBuffer(client: Client): Array | u return _getBufferMap().get(client); } -/** - * Get the scope data for the current scope after merging with the - * global scope and isolation scope. - * - * @param currentScope - The current scope. - * @returns The scope data. - */ -function getMergedScopeData(currentScope: Scope): ScopeData { - const scopeData = getGlobalScope().getScopeData(); - mergeScopeData(scopeData, getIsolationScope().getScopeData()); - mergeScopeData(scopeData, currentScope.getScopeData()); - return scopeData; -} - function _getBufferMap(): WeakMap> { // The reference to the Client <> LogBuffer map is stored on the carrier to ensure it's always the same return getGlobalSingleton('clientToLogBufferMap', () => new WeakMap>()); diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 7ac1372d1285..db98c476fff7 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -1,12 +1,12 @@ import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; -import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes'; +import { getClient, getCurrentScope, getIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import type { Scope, ScopeData } from '../scope'; +import type { Scope } from '../scope'; import type { Integration } from '../types-hoist/integration'; import type { Metric, SerializedMetric, SerializedMetricAttributeValue } from '../types-hoist/metric'; -import { mergeScopeData } from '../utils/applyScopeDataToEvent'; import { debug } from '../utils/debug-logger'; +import { getCombinedScopeData } from '../utils/scopeData'; import { _getSpanForScope } from '../utils/spanOnScope'; import { timestampInSeconds } from '../utils/time'; import { _getTraceInfoFromScope } from '../utils/trace-info'; @@ -130,7 +130,7 @@ function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentSc // Add user attributes const { user: { id, email, username }, - } = getMergedScopeData(currentScope); + } = getCombinedScopeData(getIsolationScope(), currentScope); setMetricAttribute(processedMetricAttributes, 'user.id', id, false); setMetricAttribute(processedMetricAttributes, 'user.email', email, false); setMetricAttribute(processedMetricAttributes, 'user.name', username, false); @@ -288,20 +288,6 @@ export function _INTERNAL_getMetricBuffer(client: Client): Array> { // The reference to the Client <> MetricBuffer map is stored on the carrier to ensure it's always the same return getGlobalSingleton('clientToMetricBufferMap', () => new WeakMap>()); diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts index fd1cb62440f4..3a127d332686 100644 --- a/packages/core/src/utils/prepareEvent.ts +++ b/packages/core/src/utils/prepareEvent.ts @@ -1,16 +1,15 @@ import type { Client } from '../client'; import { DEFAULT_ENVIRONMENT } from '../constants'; -import { getGlobalScope } from '../currentScopes'; import { notifyEventProcessors } from '../eventProcessors'; import type { CaptureContext, ScopeContext } from '../scope'; import { Scope } from '../scope'; import type { Event, EventHint } from '../types-hoist/event'; import type { ClientOptions } from '../types-hoist/options'; import type { StackParser } from '../types-hoist/stacktrace'; -import { applyScopeDataToEvent, mergeScopeData } from './applyScopeDataToEvent'; import { getFilenameToDebugIdMap } from './debug-ids'; import { addExceptionMechanism, uuid4 } from './misc'; import { normalize } from './normalize'; +import { applyScopeDataToEvent, getCombinedScopeData } from './scopeData'; import { truncate } from './string'; import { dateTimestampInSeconds } from './time'; @@ -79,17 +78,7 @@ export function prepareEvent( // This should be the last thing called, since we want that // {@link Scope.addEventProcessor} gets the finished prepared event. // Merge scope data together - const data = getGlobalScope().getScopeData(); - - if (isolationScope) { - const isolationData = isolationScope.getScopeData(); - mergeScopeData(data, isolationData); - } - - if (finalScope) { - const finalScopeData = finalScope.getScopeData(); - mergeScopeData(data, finalScopeData); - } + const data = getCombinedScopeData(isolationScope, finalScope); const attachments = [...(hint.attachments || []), ...data.attachments]; if (attachments.length) { diff --git a/packages/core/src/utils/applyScopeDataToEvent.ts b/packages/core/src/utils/scopeData.ts similarity index 90% rename from packages/core/src/utils/applyScopeDataToEvent.ts rename to packages/core/src/utils/scopeData.ts index 3770c41977dc..6d8f68c747b5 100644 --- a/packages/core/src/utils/applyScopeDataToEvent.ts +++ b/packages/core/src/utils/scopeData.ts @@ -1,4 +1,5 @@ -import type { ScopeData } from '../scope'; +import { getGlobalScope } from '../currentScopes'; +import type { Scope, ScopeData } from '../scope'; import { getDynamicSamplingContextFromSpan } from '../tracing/dynamicSamplingContext'; import type { Breadcrumb } from '../types-hoist/breadcrumb'; import type { Event } from '../types-hoist/event'; @@ -113,6 +114,20 @@ export function mergeArray( event[prop] = merged.length ? merged : undefined; } +/** + * Get the scope data for the current scope after merging with the + * global scope and isolation scope. + * + * @param currentScope - The current scope. + * @returns The scope data. + */ +export function getCombinedScopeData(isolationScope: Scope | undefined, currentScope: Scope | undefined): ScopeData { + const scopeData = getGlobalScope().getScopeData(); + isolationScope && mergeScopeData(scopeData, isolationScope.getScopeData()); + currentScope && mergeScopeData(scopeData, currentScope.getScopeData()); + return scopeData; +} + function applyDataToEvent(event: Event, data: ScopeData): void { const { extra, tags, user, contexts, level, transactionName } = data; diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index 339a57828e5b..f1e5c58550be 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -10,7 +10,7 @@ import { import { Scope } from '../../src/scope'; import type { Breadcrumb } from '../../src/types-hoist/breadcrumb'; import type { Event } from '../../src/types-hoist/event'; -import { applyScopeDataToEvent } from '../../src/utils/applyScopeDataToEvent'; +import { applyScopeDataToEvent } from '../../src/utils/scopeData'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; import { clearGlobalScope } from '../testutils'; diff --git a/packages/core/test/lib/utils/applyScopeDataToEvent.test.ts b/packages/core/test/lib/utils/scopeData.test.ts similarity index 87% rename from packages/core/test/lib/utils/applyScopeDataToEvent.test.ts rename to packages/core/test/lib/utils/scopeData.test.ts index a23404eaf70f..50af1179a4c4 100644 --- a/packages/core/test/lib/utils/applyScopeDataToEvent.test.ts +++ b/packages/core/test/lib/utils/scopeData.test.ts @@ -1,16 +1,18 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ScopeData } from '../../../src'; -import { startInactiveSpan } from '../../../src'; +import { Scope, startInactiveSpan } from '../../../src'; +import * as currentScopes from '../../../src/currentScopes'; import type { Attachment } from '../../../src/types-hoist/attachment'; import type { Breadcrumb } from '../../../src/types-hoist/breadcrumb'; import type { Event, EventType } from '../../../src/types-hoist/event'; import type { EventProcessor } from '../../../src/types-hoist/eventprocessor'; import { applyScopeDataToEvent, + getCombinedScopeData, mergeAndOverwriteScopeData, mergeArray, mergeScopeData, -} from '../../../src/utils/applyScopeDataToEvent'; +} from '../../../src/utils/scopeData'; describe('mergeArray', () => { it.each([ @@ -353,3 +355,50 @@ describe('applyScopeDataToEvent', () => { }, ); }); + +describe('getCombinedScopeData', () => { + const globalScope = new Scope(); + const isolationScope = new Scope(); + const currentScope = new Scope(); + + it('returns the combined scope data with correct precedence', () => { + globalScope.setTag('foo', 'bar'); + globalScope.setTag('dogs', 'boring'); + globalScope.setTag('global', 'global'); + + isolationScope.setTag('dogs', 'great'); + isolationScope.setTag('foo', 'nope'); + isolationScope.setTag('isolation', 'isolation'); + + currentScope.setTag('foo', 'baz'); + currentScope.setTag('current', 'current'); + + vi.spyOn(currentScopes, 'getGlobalScope').mockReturnValue(globalScope); + + expect(getCombinedScopeData(isolationScope, currentScope)).toEqual({ + attachments: [], + attributes: {}, + breadcrumbs: [], + contexts: {}, + eventProcessors: [], + extra: {}, + fingerprint: [], + level: undefined, + propagationContext: { + sampleRand: expect.any(Number), + traceId: expect.any(String), + }, + sdkProcessingMetadata: {}, + span: undefined, + tags: { + current: 'current', + global: 'global', + isolation: 'isolation', + foo: 'baz', + dogs: 'great', + }, + transactionName: undefined, + user: {}, + }); + }); +}); diff --git a/packages/node-core/src/integrations/anr/index.ts b/packages/node-core/src/integrations/anr/index.ts index e33c92a1eb3b..e2207f9379c7 100644 --- a/packages/node-core/src/integrations/anr/index.ts +++ b/packages/node-core/src/integrations/anr/index.ts @@ -5,12 +5,11 @@ import { debug, defineIntegration, getClient, + getCombinedScopeData, getCurrentScope, getFilenameToDebugIdMap, - getGlobalScope, getIsolationScope, GLOBAL_OBJ, - mergeScopeData, } from '@sentry/core'; import { NODE_VERSION } from '../../nodeVersion'; import type { NodeClient } from '../../sdk/client'; @@ -35,9 +34,7 @@ function globalWithScopeFetchFn(): typeof GLOBAL_OBJ & { __SENTRY_GET_SCOPES__?: /** Fetches merged scope data */ function getScopeData(): ScopeData { - const scope = getGlobalScope().getScopeData(); - mergeScopeData(scope, getIsolationScope().getScopeData()); - mergeScopeData(scope, getCurrentScope().getScopeData()); + const scope = getCombinedScopeData(getIsolationScope(), getCurrentScope()); // We remove attachments because they likely won't serialize well as json scope.attachments = []; From 9fdd03f95b565bb5978f2f92603669cbe7928e31 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Jan 2026 10:11:23 +0100 Subject: [PATCH 30/76] test(core): Use fake timers in promisebuffer tests to ensure deterministic behavior (#18659) The `drain()` tests in `promisebuffer.test.ts` were flaky. The tests currently rely on real timers with small increments to test the timeout parameter of `drain()`. This can lead to race conditions between promise resolution and drain timeout. This PR switches to [Vitest's fake timers](https://vitest.dev/guide/mocking/timers), giving us deterministic control over time progression and should thereby eliminate the race condition. Closes https://github.com/getsentry/sentry-javascript/issues/18642 --- .../core/test/lib/utils/promisebuffer.test.ts | 65 +++++++++++++------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/packages/core/test/lib/utils/promisebuffer.test.ts b/packages/core/test/lib/utils/promisebuffer.test.ts index 9c944ffd0c39..d1b4b9abc48d 100644 --- a/packages/core/test/lib/utils/promisebuffer.test.ts +++ b/packages/core/test/lib/utils/promisebuffer.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, test, vi } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { makePromiseBuffer } from '../../../src/utils/promisebuffer'; import { rejectedSyncPromise, resolvedSyncPromise } from '../../../src/utils/syncpromise'; describe('PromiseBuffer', () => { + afterEach(() => { + vi.useRealTimers(); + }); describe('add()', () => { test('enforces limit of promises', async () => { const buffer = makePromiseBuffer(5); @@ -105,20 +108,28 @@ describe('PromiseBuffer', () => { describe('drain()', () => { test('drains all promises without timeout', async () => { + vi.useFakeTimers(); + const buffer = makePromiseBuffer(); - const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); - const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); - const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); - const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); - const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); [p1, p2, p3, p4, p5].forEach(p => { void buffer.add(p); }); expect(buffer.$.length).toEqual(5); - const result = await buffer.drain(); + + const drainPromise = buffer.drain(); + + // Advance time to resolve all promises + await vi.advanceTimersByTimeAsync(10); + + const result = await drainPromise; expect(result).toEqual(true); expect(buffer.$.length).toEqual(0); @@ -130,13 +141,15 @@ describe('PromiseBuffer', () => { }); test('drains all promises with timeout', async () => { + vi.useFakeTimers(); + const buffer = makePromiseBuffer(); - const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 2))); - const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 4))); - const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 6))); - const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 8))); - const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 20))); + const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 40))); + const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 60))); + const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 80))); + const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100))); [p1, p2, p3, p4, p5].forEach(p => { void buffer.add(p); @@ -149,15 +162,29 @@ describe('PromiseBuffer', () => { expect(p5).toHaveBeenCalled(); expect(buffer.$.length).toEqual(5); - const result = await buffer.drain(6); + + // Start draining with a 50ms timeout + const drainPromise = buffer.drain(50); + + // Advance time by 50ms - this will: + // - Resolve p1 (20ms) and p2 (40ms) + // - Trigger the drain timeout (50ms) + // - p3, p4, p5 are still pending + await vi.advanceTimersByTimeAsync(50); + + const result = await drainPromise; expect(result).toEqual(false); - // p5 & p4 are still in the buffer - // Leaving some wiggle room, possibly one or two items are still in the buffer - // to avoid flakiness - expect(buffer.$.length).toBeGreaterThanOrEqual(1); - // Now drain final item - const result2 = await buffer.drain(); + // p3, p4 & p5 are still in the buffer + expect(buffer.$.length).toEqual(3); + + // Now drain remaining items without timeout + const drainPromise2 = buffer.drain(); + + // Advance time to resolve remaining promises + await vi.advanceTimersByTimeAsync(100); + + const result2 = await drainPromise2; expect(result2).toEqual(true); expect(buffer.$.length).toEqual(0); }); From 62351ca48abc29c4bd765f90f68e29bcb75589a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:25:36 +0100 Subject: [PATCH 31/76] ci(deps): bump peter-evans/create-pull-request from 7.0.9 to 8.0.0 (#18657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.9 to 8.0.0.
Release notes

Sourced from peter-evans/create-pull-request's releases.

Create Pull Request v8.0.0

What's new in v8

What's Changed

New Contributors

Full Changelog: https://github.com/peter-evans/create-pull-request/compare/v7.0.11...v8.0.0

Create Pull Request v7.0.11

What's Changed

Full Changelog: https://github.com/peter-evans/create-pull-request/compare/v7.0.10...v7.0.11

Create Pull Request v7.0.10

⚙️ Fixes an issue where updating a pull request failed when targeting a forked repository with the same owner as its parent.

What's Changed

New Contributors

Full Changelog: https://github.com/peter-evans/create-pull-request/compare/v7.0.9...v7.0.10

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=peter-evans/create-pull-request&package-manager=github_actions&previous-version=7.0.9&new-version=8.0.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 merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@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/external-contributors.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml index 1566299d67e9..b4678af2eb56 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -36,7 +36,7 @@ jobs: author_association: ${{ github.event.pull_request.author_association }} - name: Create PR with changes - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 with: # This token is scoped to Daniel Griesser # If we used the default GITHUB_TOKEN, the resulting PR would not trigger CI :( From 19d8f0d0b081839ad25219fdeeef9d8c15b216ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:26:22 +0100 Subject: [PATCH 32/76] ci(deps): Bump actions/create-github-app-token from 2.2.0 to 2.2.1 (#18656) Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 2.2.0 to 2.2.1.
Release notes

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

v2.2.1

2.2.1 (2025-12-05)

Bug Fixes

  • deps: bump the production-dependencies group with 2 updates (#311) (b212e6a)
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/create-github-app-token&package-manager=github_actions&previous-version=2.2.0&new-version=2.2.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@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 e1f22cff2f64..02a1f47b611a 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a0278ae85a4..fcb44598c722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From 0cfe3d12875a2496b5a28ccb7cf16357a79c08d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:58:43 +0100 Subject: [PATCH 33/76] ci(deps): bump actions/upload-artifact from 5 to 6 (#18655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
Release notes

Sourced from actions/upload-artifact's releases.

v6.0.0

v6 - What's new

[!IMPORTANT] actions/upload-artifact@v6 now runs on Node.js 24 (runs.using: node24) and requires a minimum Actions Runner version of 2.327.1. If you are using self-hosted runners, ensure they are updated before upgrading.

Node.js 24

This release updates the runtime to Node.js 24. v5 had preliminary support for Node.js 24, however this action was by default still running on Node.js 20. Now this action by default will run on Node.js 24.

What's Changed

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

Commits
  • b7c566a Merge pull request #745 from actions/upload-artifact-v6-release
  • e516bc8 docs: correct description of Node.js 24 support in README
  • ddc45ed docs: update README to correct action name for Node.js 24 support
  • 615b319 chore: release v6.0.0 for Node.js 24 support
  • 017748b Merge pull request #744 from actions/fix-storage-blob
  • 38d4c79 chore: rebuild dist
  • 7d27270 chore: add missing license cache files for @​actions/core, @​actions/io, and mi...
  • 5f643d3 chore: update license files for @​actions/artifact@​5.0.1 dependencies
  • 1df1684 chore: update package-lock.json with @​actions/artifact@​5.0.1
  • b5b1a91 fix: update @​actions/artifact to ^5.0.0 for Node.js 24 punycode fix
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=5&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 merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@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 | 14 +++++++------- .github/workflows/flaky-test-detector.yml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94dc3db3942d..4e34446d2792 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -181,7 +181,7 @@ jobs: run: yarn build - name: Upload build artifacts - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: build-output path: ${{ env.CACHED_BUILD_PATHS }} @@ -386,7 +386,7 @@ jobs: run: yarn build:tarball - name: Archive artifacts - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: ${{ github.sha }} retention-days: 90 @@ -629,7 +629,7 @@ jobs: format(' --shard={0}/{1}', matrix.shard, matrix.shards) || '' }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: @@ -692,7 +692,7 @@ jobs: yarn test:loader - name: Upload Playwright Traces - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: playwright-traces-job_browser_loader_tests-${{ matrix.bundle}} @@ -1009,7 +1009,7 @@ jobs: SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: playwright-traces-job_e2e_playwright_tests-${{ matrix.test-application}} @@ -1023,7 +1023,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) @@ -1135,7 +1135,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index bb3169ecb410..580fcd644216 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -71,7 +71,7 @@ jobs: TEST_RUN_COUNT: 'AUTO' - name: Upload Playwright Traces - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() && steps.test.outcome == 'failure' with: name: playwright-test-results From 1b1cf85544ee4a4650c52e9d38d9668b01b13251 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:58:53 +0100 Subject: [PATCH 34/76] ci(deps): bump actions/cache from 4 to 5 (#18654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
Release notes

Sourced from actions/cache's releases.

v5.0.0

[!IMPORTANT] actions/cache@v5 runs on the Node.js 24 runtime and requires a minimum Actions Runner version of 2.327.1.

If you are using self-hosted runners, ensure they are updated before upgrading.


What's Changed

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

v4.3.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/cache/compare/v4...v4.3.0

v4.2.4

What's Changed

New Contributors

Full Changelog: https://github.com/actions/cache/compare/v4...v4.2.4

v4.2.3

What's Changed

  • Update to use @​actions/cache 4.0.3 package & prepare for new release by @​salmanmkc in actions/cache#1577 (SAS tokens for cache entries are now masked in debug logs)

New Contributors

Full Changelog: https://github.com/actions/cache/compare/v4.2.2...v4.2.3

... (truncated)

Changelog

Sourced from actions/cache's changelog.

Releases

Changelog

5.0.1

  • Update @azure/storage-blob to ^12.29.1 via @actions/cache@5.0.1 #1685

5.0.0

[!IMPORTANT] actions/cache@v5 runs on the Node.js 24 runtime and requires a minimum Actions Runner version of 2.327.1. If you are using self-hosted runners, ensure they are updated before upgrading.

4.3.0

  • Bump @actions/cache to v4.1.0

4.2.4

  • Bump @actions/cache to v4.0.5

4.2.3

  • Bump @actions/cache to v4.0.3 (obfuscates SAS token in debug logs for cache entries)

4.2.2

  • Bump @actions/cache to v4.0.2

4.2.1

  • Bump @actions/cache to v4.0.1

4.2.0

TLDR; The cache backend service has been rewritten from the ground up for improved performance and reliability. actions/cache now integrates with the new cache service (v2) APIs.

The new service will gradually roll out as of February 1st, 2025. The legacy service will also be sunset on the same date. Changes in these release are fully backward compatible.

We are deprecating some versions of this action. We recommend upgrading to version v4 or v3 as soon as possible before February 1st, 2025. (Upgrade instructions below).

If you are using pinned SHAs, please use the SHAs of versions v4.2.0 or v3.4.0

If you do not upgrade, all workflow runs using any of the deprecated actions/cache will fail.

Upgrading to the recommended versions will not break your workflows.

4.1.2

... (truncated)

Commits
  • 9255dc7 Merge pull request #1686 from actions/cache-v5.0.1-release
  • 8ff5423 chore: release v5.0.1
  • 9233019 Merge pull request #1685 from salmanmkc/node24-storage-blob-fix
  • b975f2b fix: add peer property to package-lock.json for dependencies
  • d0a0e18 fix: update license files for @​actions/cache, fast-xml-parser, and strnum
  • 74de208 fix: update @​actions/cache to ^5.0.1 for Node.js 24 punycode fix
  • ac7f115 peer
  • b0f846b fix: update @​actions/cache with storage-blob fix for Node.js 24 punycode depr...
  • a783357 Merge pull request #1684 from actions/prepare-cache-v5-release
  • 3bb0d78 docs: highlight v5 runner requirement in releases
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/cache&package-manager=github_actions&previous-version=4&new-version=5)](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 merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@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 | 10 +++++----- .github/workflows/canary.yml | 4 ++-- .github/workflows/flaky-test-detector.yml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4e34446d2792..25797f31a008 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -159,7 +159,7 @@ jobs: head: ${{ env.HEAD_COMMIT }} - name: NX cache - uses: actions/cache@v4 + uses: actions/cache@v5 # Disable cache when: # - on release branches # - when PR has `ci-skip-cache` label or on nightly builds @@ -881,7 +881,7 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: NX cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: .nxcache key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT }} @@ -892,7 +892,7 @@ jobs: run: yarn build:tarball - name: Stores tarballs in cache - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: ${{ github.workspace }}/packages/*/*.tgz key: ${{ env.BUILD_CACHE_TARBALL_KEY }} @@ -959,7 +959,7 @@ jobs: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore tarball cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 id: restore-tarball-cache with: path: ${{ github.workspace }}/packages/*/*.tgz @@ -1084,7 +1084,7 @@ jobs: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore tarball cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 id: restore-tarball-cache with: path: ${{ github.workspace }}/packages/*/*.tgz diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 1e71125ddad2..95f6f19ae6db 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -39,7 +39,7 @@ jobs: with: node-version-file: 'package.json' - name: Check canary cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ env.CACHED_BUILD_PATHS }} key: canary-${{ env.HEAD_COMMIT }} @@ -130,7 +130,7 @@ jobs: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Restore canary cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: ${{ env.CACHED_BUILD_PATHS }} key: canary-${{ env.HEAD_COMMIT }} diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index 580fcd644216..6afed7df214b 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -40,7 +40,7 @@ jobs: run: yarn install --ignore-engines --frozen-lockfile - name: NX cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: .nxcache key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT }} From ba7f90aa8df7639e328f216acfff361edec275eb Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 2 Jan 2026 12:59:44 +0100 Subject: [PATCH 35/76] chore(nextjs): Bump next version in dev deps (#18661) We want to prevent having a vulnerable version in our deps, this change will only affect how we use types internally and is not breaking. closes https://github.com/getsentry/sentry-javascript/issues/18645 --- packages/nextjs/package.json | 2 +- yarn.lock | 162 +++++++++++++++++------------------ 2 files changed, 81 insertions(+), 83 deletions(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 893da0f71c73..d07549214eec 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -92,7 +92,7 @@ }, "devDependencies": { "eslint-plugin-react": "^7.31.11", - "next": "13.5.9", + "next": "14.2.35", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/yarn.lock b/yarn.lock index 268bb8529193..8943392b3e86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5237,55 +5237,55 @@ yargs "^17.0.0" zod "^3.23.8" -"@next/env@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/env/-/env-13.5.9.tgz#3298c57c9ad9f333774484e03cf20fba90cd79c4" - integrity sha512-h9+DconfsLkhHIw950Som5t5DC0kZReRRVhT4XO2DLo5vBK3PQK6CbFr8unxjHwvIcRdDvb8rosKleLdirfShQ== - -"@next/swc-darwin-arm64@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.9.tgz#46c3a525039171ff1a83c813d7db86fb7808a9b2" - integrity sha512-pVyd8/1y1l5atQRvOaLOvfbmRwefxLhqQOzYo/M7FQ5eaRwA1+wuCn7t39VwEgDd7Aw1+AIWwd+MURXUeXhwDw== - -"@next/swc-darwin-x64@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.9.tgz#b690452e9a6ce839f8738e27e9fd1a8567dd7554" - integrity sha512-DwdeJqP7v8wmoyTWPbPVodTwCybBZa02xjSJ6YQFIFZFZ7dFgrieKW4Eo0GoIcOJq5+JxkQyejmI+8zwDp3pwA== - -"@next/swc-linux-arm64-gnu@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.9.tgz#c3e335e2da3ba932c0b2f571f0672d1aa7af33df" - integrity sha512-wdQsKsIsGSNdFojvjW3Ozrh8Q00+GqL3wTaMjDkQxVtRbAqfFBtrLPO0IuWChVUP2UeuQcHpVeUvu0YgOP00+g== - -"@next/swc-linux-arm64-musl@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.9.tgz#54600d4917bace2508725cc963eeeb3b6432889e" - integrity sha512-6VpS+bodQqzOeCwGxoimlRoosiWlSc0C224I7SQWJZoyJuT1ChNCo+45QQH+/GtbR/s7nhaUqmiHdzZC9TXnXA== - -"@next/swc-linux-x64-gnu@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.9.tgz#f869c2066f13ff2818140e0a145dfea1ea7c0333" - integrity sha512-XxG3yj61WDd28NA8gFASIR+2viQaYZEFQagEodhI/R49gXWnYhiflTeeEmCn7Vgnxa/OfK81h1gvhUZ66lozpw== - -"@next/swc-linux-x64-musl@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.9.tgz#09295ea60a42a1b22d927802d6e543d8a8bbb186" - integrity sha512-/dnscWqfO3+U8asd+Fc6dwL2l9AZDl7eKtPNKW8mKLh4Y4wOpjJiamhe8Dx+D+Oq0GYVjuW0WwjIxYWVozt2bA== - -"@next/swc-win32-arm64-msvc@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.9.tgz#f39e3513058d7af6e9f6b1f296bf071301217159" - integrity sha512-T/iPnyurOK5a4HRUcxAlss8uzoEf5h9tkd+W2dSWAfzxv8WLKlUgbfk+DH43JY3Gc2xK5URLuXrxDZ2mGfk/jw== - -"@next/swc-win32-ia32-msvc@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.9.tgz#d567f471e182efa4ea29f47f3030613dd3fc68b5" - integrity sha512-BLiPKJomaPrTAb7ykjA0LPcuuNMLDVK177Z1xe0nAem33+9FIayU4k/OWrtSn9SAJW/U60+1hoey5z+KCHdRLQ== - -"@next/swc-win32-x64-msvc@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.9.tgz#35c53bd6d33040ec0ce1dd613c59112aac06b235" - integrity sha512-/72/dZfjXXNY/u+n8gqZDjI6rxKMpYsgBBYNZKWOQw0BpBF7WCnPflRy3ZtvQ2+IYI3ZH2bPyj7K+6a6wNk90Q== +"@next/env@14.2.35": + version "14.2.35" + resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.35.tgz#e979016d0ca8500a47d41ffd02625fe29b8df35a" + integrity sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ== + +"@next/swc-darwin-arm64@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz#9e74a4223f1e5e39ca4f9f85709e0d95b869b298" + integrity sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA== + +"@next/swc-darwin-x64@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz#fcf0c45938da9b0cc2ec86357d6aefca90bd17f3" + integrity sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA== + +"@next/swc-linux-arm64-gnu@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz#837f91a740eb4420c06f34c4677645315479d9be" + integrity sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw== + +"@next/swc-linux-arm64-musl@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz#dc8903469e5c887b25e3c2217a048bd30c58d3d4" + integrity sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg== + +"@next/swc-linux-x64-gnu@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz#344438be592b6b28cc540194274561e41f9933e5" + integrity sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg== + +"@next/swc-linux-x64-musl@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz#3379fad5e0181000b2a4fac0b80f7ca4ffe795c8" + integrity sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA== + +"@next/swc-win32-arm64-msvc@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz#bca8f4dde34656aef8e99f1e5696de255c2f00e5" + integrity sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ== + +"@next/swc-win32-ia32-msvc@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz#a69c581483ea51dd3b8907ce33bb101fe07ec1df" + integrity sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q== + +"@next/swc-win32-x64-msvc@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz#f1a40062530c17c35a86d8c430b3ae465eb7cea1" + integrity sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg== "@ngtools/webpack@14.2.13": version "14.2.13" @@ -7996,11 +7996,17 @@ svelte-hmr "^0.16.0" vitefu "^0.2.5" -"@swc/helpers@0.5.2": - version "0.5.2" - resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d" - integrity sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw== +"@swc/counter@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" + integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== + +"@swc/helpers@0.5.5": + version "0.5.5" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.5.tgz#12689df71bfc9b21c4f4ca00ae55f2f16c8b77c0" + integrity sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A== dependencies: + "@swc/counter" "^0.1.3" tslib "^2.4.0" "@tanstack/history@1.132.21": @@ -12700,10 +12706,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001669: - version "1.0.30001674" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001674.tgz#eb200a716c3e796d33d30b9c8890517a72f862c8" - integrity sha512-jOsKlZVRnzfhLojb+Ykb+gyUSp9Xb57So+fAiFlLzzTKpqg8xxSav0e40c8/4F/v9N8QSvrRRaLeVzQbLqomYw== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001669: + version "1.0.30001762" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz#e4dbfeda63d33258cdde93e53af2023a13ba27d4" + integrity sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw== capture-exit@^2.0.0: version "2.0.0" @@ -23085,28 +23091,28 @@ new-find-package-json@^2.0.0: dependencies: debug "^4.3.4" -next@13.5.9: - version "13.5.9" - resolved "https://registry.yarnpkg.com/next/-/next-13.5.9.tgz#a8c38254279eb30a264c1c640bf77340289ba6e3" - integrity sha512-h4ciD/Uxf1PwsiX0DQePCS5rMoyU5a7rQ3/Pg6HBLwpa/SefgNj1QqKSZsWluBrYyqdtEyqKrjeOszgqZlyzFQ== +next@14.2.35: + version "14.2.35" + resolved "https://registry.yarnpkg.com/next/-/next-14.2.35.tgz#7c68873a15fe5a19401f2f993fea535be3366ee9" + integrity sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig== dependencies: - "@next/env" "13.5.9" - "@swc/helpers" "0.5.2" + "@next/env" "14.2.35" + "@swc/helpers" "0.5.5" busboy "1.6.0" - caniuse-lite "^1.0.30001406" + caniuse-lite "^1.0.30001579" + graceful-fs "^4.2.11" postcss "8.4.31" styled-jsx "5.1.1" - watchpack "2.4.0" optionalDependencies: - "@next/swc-darwin-arm64" "13.5.9" - "@next/swc-darwin-x64" "13.5.9" - "@next/swc-linux-arm64-gnu" "13.5.9" - "@next/swc-linux-arm64-musl" "13.5.9" - "@next/swc-linux-x64-gnu" "13.5.9" - "@next/swc-linux-x64-musl" "13.5.9" - "@next/swc-win32-arm64-msvc" "13.5.9" - "@next/swc-win32-ia32-msvc" "13.5.9" - "@next/swc-win32-x64-msvc" "13.5.9" + "@next/swc-darwin-arm64" "14.2.33" + "@next/swc-darwin-x64" "14.2.33" + "@next/swc-linux-arm64-gnu" "14.2.33" + "@next/swc-linux-arm64-musl" "14.2.33" + "@next/swc-linux-x64-gnu" "14.2.33" + "@next/swc-linux-x64-musl" "14.2.33" + "@next/swc-win32-arm64-msvc" "14.2.33" + "@next/swc-win32-ia32-msvc" "14.2.33" + "@next/swc-win32-x64-msvc" "14.2.33" ng-packagr@^14.2.2: version "14.3.0" @@ -31399,14 +31405,6 @@ watch-detector@^1.0.0, watch-detector@^1.0.2: silent-error "^1.1.1" tmp "^0.1.0" -watchpack@2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" - integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== - dependencies: - glob-to-regexp "^0.4.1" - graceful-fs "^4.1.2" - watchpack@^2.4.0, watchpack@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.1.tgz#29308f2cac150fa8e4c92f90e0ec954a9fed7fff" From 53777ce20863b55f37c026297324da0fef710fb1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 2 Jan 2026 15:22:11 +0200 Subject: [PATCH 36/76] fix(next): Ensure inline sourcemaps are generated for wrapped modules in Dev (#18640) This fixes breakpoints for editors like VSCode/Cursor in server-side code. I have verified that breakpoints work in: - Server-side app router components - Server-side pages router functions and static params - API endpoints and server-side functions - middleware This only affects webpack, and doesn't change anything for prod builds. closes #17088 --- .../src/config/loaders/wrappingLoader.ts | 65 ++++++++++++-- packages/nextjs/src/config/webpack.ts | 1 + .../nextjs/test/config/wrappingLoader.test.ts | 86 +++++++++++++++++++ 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index 3125102e9656..4bdf841cef2c 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -43,6 +43,7 @@ export type WrappingLoaderOptions = { wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'route-handler'; vercelCronsConfig?: VercelCronsConfig; nextjsRequestAsyncStorageModulePath?: string; + isDev?: boolean; }; /** @@ -66,6 +67,7 @@ export default function wrappingLoader( wrappingTargetKind, vercelCronsConfig, nextjsRequestAsyncStorageModulePath, + isDev, } = 'getOptions' in this ? this.getOptions() : this.query; this.async(); @@ -220,7 +222,7 @@ export default function wrappingLoader( // Run the proxy module code through Rollup, in order to split the `export * from ''` out into // individual exports (which nextjs seems to require). - wrapUserCode(templateCode, userCode, userModuleSourceMap) + wrapUserCode(templateCode, userCode, userModuleSourceMap, isDev, this.resourcePath) .then(({ code: wrappedCode, map: wrappedCodeSourceMap }) => { this.callback(null, wrappedCode, wrappedCodeSourceMap); }) @@ -245,6 +247,9 @@ export default function wrappingLoader( * * @param wrapperCode The wrapper module code * @param userModuleCode The user module code + * @param userModuleSourceMap The source map for the user module + * @param isDev Whether we're in development mode (affects sourcemap generation) + * @param userModulePath The absolute path to the user's original module (for sourcemap accuracy) * @returns The wrapped user code and a source map that describes the transformations done by this function */ async function wrapUserCode( @@ -252,6 +257,8 @@ async function wrapUserCode( userModuleCode: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any userModuleSourceMap: any, + isDev?: boolean, + userModulePath?: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise<{ code: string; map?: any }> { const wrap = (withDefaultExport: boolean): Promise => @@ -267,21 +274,48 @@ async function wrapUserCode( resolveId: id => { if (id === SENTRY_WRAPPER_MODULE_NAME || id === WRAPPING_TARGET_MODULE_NAME) { return id; - } else { - return null; } + + return null; }, load(id) { if (id === SENTRY_WRAPPER_MODULE_NAME) { return withDefaultExport ? wrapperCode : wrapperCode.replace('export { default } from', 'export {} from'); - } else if (id === WRAPPING_TARGET_MODULE_NAME) { + } + + if (id !== WRAPPING_TARGET_MODULE_NAME) { + return null; + } + + // In prod/build, we should not interfere with sourcemaps + if (!isDev || !userModulePath) { + return { code: userModuleCode, map: userModuleSourceMap }; + } + + // In dev mode, we need to adjust the sourcemap to use absolute paths for the user's file. + // This ensures debugger breakpoints correctly map back to the original file. + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const userSources: string[] = userModuleSourceMap?.sources; + if (Array.isArray(userSources)) { return { code: userModuleCode, - map: userModuleSourceMap, // give rollup access to original user module source map + map: { + ...userModuleSourceMap, + sources: userSources.map((source: string, index: number) => (index === 0 ? userModulePath : source)), + }, }; - } else { - return null; } + + // If no sourcemap exists, create a simple identity mapping with the absolute path + return { + code: userModuleCode, + map: { + version: 3, + sources: [userModulePath], + sourcesContent: [userModuleCode], + mappings: '', + }, + }; }, }, @@ -352,7 +386,22 @@ async function wrapUserCode( const finalBundle = await rollupBuild.generate({ format: 'esm', - sourcemap: 'hidden', // put source map data in the bundle but don't generate a source map comment in the output + // In dev mode, use inline sourcemaps so debuggers can map breakpoints back to original source. + // In production, use hidden sourcemaps (no sourceMappingURL comment) to avoid exposing internals. + sourcemap: isDev ? 'inline' : 'hidden', + // In dev mode, preserve absolute paths in sourcemaps so debuggers can correctly resolve breakpoints. + // By default, Rollup converts absolute paths to relative paths, which breaks debugging. + // We only do this in dev mode to avoid interfering with Sentry's sourcemap upload in production. + sourcemapPathTransform: isDev + ? relativeSourcePath => { + // If we have userModulePath and this relative path matches the end of it, use the absolute path + if (userModulePath?.endsWith(relativeSourcePath)) { + return userModulePath; + } + // Keep other paths (like sentry-wrapper-module) as-is + return relativeSourcePath; + } + : undefined, }); // The module at index 0 is always the entrypoint, which in this case is the proxy module. diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 7ae5b6859330..497f170725c8 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -150,6 +150,7 @@ export function constructWebpackConfigFunction({ projectDir, rawNewConfig.resolve?.modules, ), + isDev, }; const normalizeLoaderResourcePath = (resourcePath: string): string => { diff --git a/packages/nextjs/test/config/wrappingLoader.test.ts b/packages/nextjs/test/config/wrappingLoader.test.ts index ab33450790bb..97e2b016301e 100644 --- a/packages/nextjs/test/config/wrappingLoader.test.ts +++ b/packages/nextjs/test/config/wrappingLoader.test.ts @@ -249,4 +249,90 @@ describe('wrappingLoader', () => { expect(wrappedCode).toMatch(/const proxy = userProvidedProxy \? wrappedHandler : undefined/); }); }); + + describe('sourcemap handling', () => { + it('should include inline sourcemap in dev mode', async () => { + const callback = vi.fn(); + + const userCode = ` + export function middleware(request) { + return new Response('ok'); + } + `; + const userCodeSourceMap = undefined; + + const loaderPromise = new Promise(resolve => { + const loaderThis = { + ...defaultLoaderThis, + resourcePath: '/my/src/middleware.ts', + callback: callback.mockImplementation(() => { + resolve(); + }), + getOptions() { + return { + pagesDir: '/my/pages', + appDir: '/my/app', + pageExtensionRegex: DEFAULT_PAGE_EXTENSION_REGEX, + excludeServerRoutes: [], + wrappingTargetKind: 'middleware', + vercelCronsConfig: undefined, + nextjsRequestAsyncStorageModulePath: '/my/request-async-storage.js', + isDev: true, + }; + }, + } satisfies LoaderThis; + + wrappingLoader.call(loaderThis, userCode, userCodeSourceMap); + }); + + await loaderPromise; + + const wrappedCode = callback.mock.calls[0][1] as string; + + // In dev mode, should have inline sourcemap for debugger support + expect(wrappedCode).toContain('//# sourceMappingURL=data:application/json;charset=utf-8;base64,'); + }); + + it('should not include inline sourcemap in production mode', async () => { + const callback = vi.fn(); + + const userCode = ` + export function middleware(request) { + return new Response('ok'); + } + `; + const userCodeSourceMap = undefined; + + const loaderPromise = new Promise(resolve => { + const loaderThis = { + ...defaultLoaderThis, + resourcePath: '/my/src/middleware.ts', + callback: callback.mockImplementation(() => { + resolve(); + }), + getOptions() { + return { + pagesDir: '/my/pages', + appDir: '/my/app', + pageExtensionRegex: DEFAULT_PAGE_EXTENSION_REGEX, + excludeServerRoutes: [], + wrappingTargetKind: 'middleware', + vercelCronsConfig: undefined, + nextjsRequestAsyncStorageModulePath: '/my/request-async-storage.js', + isDev: false, + }; + }, + } satisfies LoaderThis; + + wrappingLoader.call(loaderThis, userCode, userCodeSourceMap); + }); + + await loaderPromise; + + const wrappedCode = callback.mock.calls[0][1] as string; + + // In production mode, should NOT have inline sourcemap (hidden sourcemap instead) + expect(wrappedCode).not.toContain('//# sourceMappingURL=data:application/json;charset=utf-8;base64,'); + }); + }); }); From b068af1da541738ac0f2a15791cf7d7ceaaa2bb9 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:22:47 +0100 Subject: [PATCH 37/76] chore(craft): Use version templating for aws layer (#18675) Craft now supports mustach-style template variables for versions which means we no longer have to worry about manually keeping track of the major in the layer name. Related: https://github.com/getsentry/craft/pull/678 Closes: #18674 --- .craft.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.craft.yml b/.craft.yml index f2ffca132f23..331d065a2ff9 100644 --- a/.craft.yml +++ b/.craft.yml @@ -146,7 +146,7 @@ targets: # AWS Lambda Layer target - name: aws-lambda-layer includeNames: /^sentry-node-serverless-\d+.\d+.\d+(-(beta|alpha|rc)\.\d+)?\.zip$/ - layerName: SentryNodeServerlessSDKv10 + layerName: SentryNodeServerlessSDKv{{{major}}} compatibleRuntimes: - name: node versions: From 4aa907e8e127fd08fe23e10ca4ac387fcac3ea0d Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:40:46 +0100 Subject: [PATCH 38/76] ref(node): Remove duplicate function `isCjs` (#18662) While working on something else I stumbled over this unused, duplicated function. Also, there was still one implementation in Google Cloud Serverless, which checked for `typeof require !== 'undefined'`, which does not work well all the time (see this PR: https://github.com/getsentry/sentry-javascript/pull/15927). Closes #18663 (added automatically) --- packages/cloudflare/src/utils/commonjs.ts | 8 -------- packages/google-cloud-serverless/package.json | 3 ++- packages/google-cloud-serverless/src/sdk.ts | 5 +---- 3 files changed, 3 insertions(+), 13 deletions(-) delete mode 100644 packages/cloudflare/src/utils/commonjs.ts diff --git a/packages/cloudflare/src/utils/commonjs.ts b/packages/cloudflare/src/utils/commonjs.ts deleted file mode 100644 index 23a9b97f9fc1..000000000000 --- a/packages/cloudflare/src/utils/commonjs.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Detect CommonJS. */ -export function isCjs(): boolean { - try { - return typeof module !== 'undefined' && typeof module.exports !== 'undefined'; - } catch { - return false; - } -} diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index 5b2caff2b00b..5db18b752c8b 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -49,7 +49,8 @@ }, "dependencies": { "@sentry/core": "10.32.1", - "@sentry/node": "10.32.1" + "@sentry/node": "10.32.1", + "@sentry/node-core": "10.32.1" }, "devDependencies": { "@google-cloud/bigquery": "^5.3.0", diff --git a/packages/google-cloud-serverless/src/sdk.ts b/packages/google-cloud-serverless/src/sdk.ts index 2699eb4f9e2f..1161ab60300e 100644 --- a/packages/google-cloud-serverless/src/sdk.ts +++ b/packages/google-cloud-serverless/src/sdk.ts @@ -2,13 +2,10 @@ import type { Integration, Options } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrationsWithoutPerformance, init as initNode } from '@sentry/node'; +import { isCjs } from '@sentry/node-core'; import { googleCloudGrpcIntegration } from './integrations/google-cloud-grpc'; import { googleCloudHttpIntegration } from './integrations/google-cloud-http'; -function isCjs(): boolean { - return typeof require !== 'undefined'; -} - function getCjsOnlyIntegrations(): Integration[] { return isCjs() ? [ From ed0a0fa362cae3610556cb2ed2206731cfe9eedd Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Fri, 2 Jan 2026 09:11:09 -0800 Subject: [PATCH 39/76] fix(feedback): Fix cases where the outline of inputs were wrong (#18647) The integration was using `"colorScheme: "dark"` we include css only dark mode. But some properties, like `outline` were not specified, the system does a good job by default. However, adding `color-scheme: only light` into the css meant that the light-mode outlines were used in all cases, even when we asked for dark mode. This changes things so that we have the correct values for `color-scheme` if a specific value is picked. This ensures that the `outline` and `:focus` colors set by the system are correct in all cases. **Test Plan** I tested with my system set to each of: light, dark, automatic And then with the integration setting set to each of: `colorScheme: 'light'`, `colorScheme: 'dark'` and `colorScheme: 'system'`. To test i just opened up the feedback widget and clicked to focus an input box. **Screenshots** | Before | After | | --- | --- | | SCR-20251230-pleg | SCR-20251230-plch --- packages/feedback/src/core/createMainStyles.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/feedback/src/core/createMainStyles.ts b/packages/feedback/src/core/createMainStyles.ts index 6ed6e2e357d8..36d38e62740a 100644 --- a/packages/feedback/src/core/createMainStyles.ts +++ b/packages/feedback/src/core/createMainStyles.ts @@ -71,7 +71,7 @@ export function createMainStyles({ font-family: var(--font-family); font-size: var(--font-size); - ${colorScheme !== 'system' ? 'color-scheme: only light;' : ''} + ${colorScheme !== 'system' ? `color-scheme: only ${colorScheme};` : ''} ${getThemedCssVariables( colorScheme === 'dark' ? { ...DEFAULT_DARK, ...themeDark } : { ...DEFAULT_LIGHT, ...themeLight }, @@ -83,12 +83,13 @@ ${ ? ` @media (prefers-color-scheme: dark) { :host { + color-scheme: only dark; + ${getThemedCssVariables({ ...DEFAULT_DARK, ...themeDark })} } }` : '' } -} `; if (styleNonce) { From 5cd938dd5fe6879d9a9f7a9bbb3d2f8ff9fe2443 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 5 Jan 2026 11:40:24 +0100 Subject: [PATCH 40/76] test(nestjs): Add canary test for latest (#18685) Adding a canary test for nestjs using the `latest` tag. There is also a `next` tag that seems to be used for upcoming majors, but using that all the tests break so I removed it again for now. see [npm nestjs/core tags](https://www.npmjs.com/package/@nestjs/core?activeTab=versions) Closes https://github.com/getsentry/sentry-javascript/issues/18668 --- .github/workflows/canary.yml | 3 +++ .../e2e-tests/test-applications/nestjs-11/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 95f6f19ae6db..d96bb9393a75 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -114,6 +114,9 @@ jobs: - test-application: 'nuxt-4' build-command: 'test:build-canary' label: 'nuxt-4 (canary)' + - test-application: 'nestjs-11' + build-command: 'test:build-latest' + label: 'nestjs-11 (latest)' steps: - name: Check out current commit diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/package.json b/dev-packages/e2e-tests/test-applications/nestjs-11/package.json index b59ad9b2245e..48e2525de321 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/package.json @@ -11,6 +11,7 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml", "test": "playwright test", "test:build": "pnpm install", + "test:build-latest": "pnpm install && pnpm add @nestjs/common@latest @nestjs/core@latest @nestjs/platform-express@latest @nestjs/microservices@latest && pnpm add -D @nestjs/cli@latest @nestjs/testing@latest && pnpm build", "test:assert": "pnpm test" }, "dependencies": { From 22702565aa497a9f461017a8440f057346b72b8a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 5 Jan 2026 11:54:56 +0100 Subject: [PATCH 41/76] test(tanstackstart-react): Add canary test for latest (#18686) Adding a canary test for tanstackstart using the latest tag. There are also alpha and beta tags, but they don't seem to be used anymore. see [npm tanstack/react-start tags](https://www.npmjs.com/package/@tanstack/react-start?activeTab=versions) Closes https://github.com/getsentry/sentry-javascript/issues/18667 --- .github/workflows/canary.yml | 3 +++ .../test-applications/tanstackstart-react/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index d96bb9393a75..36244b3da154 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -114,6 +114,9 @@ jobs: - test-application: 'nuxt-4' build-command: 'test:build-canary' label: 'nuxt-4 (canary)' + - test-application: 'tanstackstart-react' + build-command: 'test:build-latest' + label: 'tanstackstart-react (latest)' - test-application: 'nestjs-11' build-command: 'test:build-latest' label: 'nestjs-11 (latest)' diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 0076ccf22dc8..d75ebb148639 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -9,6 +9,7 @@ "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", + "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", "test:assert": "pnpm test" }, "dependencies": { From 66f48252285f483a667e6f484a1065cdb413c3f0 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 5 Jan 2026 12:32:10 +0100 Subject: [PATCH 42/76] test(node-native): Increase worker block timeout (#18683) I saw this test flake (doesn't happen a lot but still). I had a quick look at the integration and one idea I had is that maybe sometimes the worker thread blocks before the watchdog is ready and that the thread therefore never gets detected. So here I just increase the timeout before blocking to see if that helps (let me know if that doesn't make any sense). Closes https://github.com/getsentry/sentry-javascript/issues/18688 --- .../suites/thread-blocked-native/worker-block.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs index 274a4ce9e3a9..dfd664fbf01f 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs @@ -2,4 +2,4 @@ import { longWork } from './long-work.js'; setTimeout(() => { longWork(); -}, 2000); +}, 5000); From 4fd502154c8d0d0a6e2bba5abb56da5f0d7e2d9a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 6 Jan 2026 12:35:16 +0200 Subject: [PATCH 43/76] test(vue): Added canary and latest test variants to Vue tests (#18681) This pull request adds new test scripts and Sentry test configuration variants to the `vue-3` test application. The main goal is to enable automated testing of the application against both the latest and canary (alpha) versions of Vue, improving compatibility and early detection of issues with upcoming Vue releases. Vue has several tags but no single tag is considered the "cutting-edge" of Vue releases. [The docs suggest using](https://vuejs.org/about/releases#pre-releases) `install-vue` tool but none of the suggested tags are suitable: - `canary` latest publish is 2 years ago `3.2` - `edge` covers the main branch and not the latest releases, currently sitting at `3.5.xx` - `alpha` latest alpha release, already outdated with `beta` releases are coming through - `beta` will be outdated once `rc` releases start going through - `rc` will be outdated once the stable version comes through So it doesn't feel like there is one good option here, so I opted to instead use git to query the latest tag on the Vue repo and just use that, it's not true canary and it will sometimes install other versions but I'm out of ideas. Happy to hear more thoughts and ideas here! I will reach out to Vue team members to see if they can make `canary` live again. closes #18679 --- .../test-applications/vue-3/package.json | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/vue-3/package.json b/dev-packages/e2e-tests/test-applications/vue-3/package.json index 1dc469b50ca1..603f2f0ffc31 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -12,7 +12,11 @@ "type-check": "vue-tsc --build --force", "test": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "playwright test" + "test:assert": "pnpm test:print-version && playwright test", + "test:build-canary": "pnpm install && pnpm test:install-canary && pnpm build", + "test:build-latest": "pnpm install && pnpm add vue@latest && pnpm build", + "test:install-canary": "pnpm add vue@$(git ls-remote --tags --sort='v:refname' https://github.com/vuejs/core.git | tail -n1 | awk -F'/' '{print $NF}')", + "test:print-version": "node -p \"'Vue version: ' + require('vue/package.json').version\"" }, "dependencies": { "@sentry/vue": "latest || *", @@ -36,5 +40,19 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-latest", + "label": "vue-3 (latest)" + } + ], + "optionalVariants": [ + { + "build-command": "pnpm test:build-canary", + "label": "vue-3 (canary)" + } + ] } } From 747e212c398617d6d504e285672e3e573b6a3b32 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 6 Jan 2026 14:08:58 +0000 Subject: [PATCH 44/76] doc: E2E testing documentation updates (#18649) 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`). - [ ] 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 #issue_link_here This PR documents key learnings and best practices for E2E testing within the Sentry JavaScript SDKs, focusing on common pitfalls and setup requirements. **Why these changes?** To improve the developer experience for E2E testing by providing clear guidance on Verdaccio setup, the critical `.npmrc` file, bundler-specific behaviors, and debugging strategies, thereby reducing common issues and debugging time. **What was changed?** - **`CONTRIBUTING.md`**: Added a new section "Running E2E Tests Locally" with prerequisites, step-by-step instructions, and troubleshooting. - **`CLAUDE.md` & `.cursor/rules/sdk_development.mdc`**: Added an "E2E Testing" section under "Development Guidelines," explaining Verdaccio, `.npmrc` requirements, how to run single tests, and common pitfalls. - **`dev-packages/e2e-tests/README.md`**: Expanded existing documentation with detailed `.npmrc` explanations, a comprehensive troubleshooting guide, and notes on bundler-specific environment variable handling (Webpack, Vite, Next.js) and `import.meta.env` behavior. --- Open in
Cursor Open in Web Closes #18651 (added automatically) --------- Co-authored-by: Cursor Agent --- .cursor/rules/sdk_development.mdc | 48 ++++++++++++++++++ CLAUDE.md | 48 ++++++++++++++++++ CONTRIBUTING.md | 83 +++++++++++++++++++++++++++++++ dev-packages/e2e-tests/README.md | 75 ++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+) diff --git a/.cursor/rules/sdk_development.mdc b/.cursor/rules/sdk_development.mdc index 088c94f47a23..c997b65f5482 100644 --- a/.cursor/rules/sdk_development.mdc +++ b/.cursor/rules/sdk_development.mdc @@ -121,6 +121,54 @@ Each package typically contains: - Integration tests use Playwright extensively - Never change the volta, yarn, or package manager setup in general unless explicitly asked for +### E2E Testing + +E2E tests are located in `dev-packages/e2e-tests/` and verify SDK behavior in real-world framework scenarios. + +#### How Verdaccio Registry Works + +E2E tests use [Verdaccio](https://verdaccio.org/), a lightweight npm registry running in Docker. Before tests run: + +1. SDK packages are built and packed into tarballs (`yarn build && yarn build:tarball`) +2. Tarballs are published to Verdaccio at `http://127.0.0.1:4873` +3. Test applications install packages from Verdaccio instead of public npm + +#### The `.npmrc` Requirement + +Every E2E test application needs an `.npmrc` file with: + +``` +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +``` + +Without this file, pnpm installs from the public npm registry instead of Verdaccio, so your local changes won't be tested. This is a common cause of "tests pass in CI but fail locally" or vice versa. + +#### Running a Single E2E Test + +```bash +# Build packages first +yarn build && yarn build:tarball + +# Run a specific test app +cd dev-packages/e2e-tests +yarn test:run + +# Run with a specific variant (e.g., Next.js 15) +yarn test:run --variant +``` + +#### Common Pitfalls and Debugging + +1. **Missing `.npmrc`**: Most common issue. Always verify the test app has the correct `.npmrc` file. + +2. **Stale tarballs**: After SDK changes, must re-run `yarn build:tarball`. + +3. **Debugging tips**: + - Check browser console logs for SDK initialization errors + - Use `debug: true` in Sentry config + - Verify installed package version: check `node_modules/@sentry/*/package.json` + ### Notes for Background Tasks - Make sure to use [volta](https://volta.sh/) for development. Volta is used to manage the node, yarn and pnpm version used. diff --git a/CLAUDE.md b/CLAUDE.md index e515c171303e..cae60376d964 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,54 @@ Each package typically contains: - Integration tests use Playwright extensively - Never change the volta, yarn, or package manager setup in general unless explicitly asked for +### E2E Testing + +E2E tests are located in `dev-packages/e2e-tests/` and verify SDK behavior in real-world framework scenarios. + +#### How Verdaccio Registry Works + +E2E tests use [Verdaccio](https://verdaccio.org/), a lightweight npm registry running in Docker. Before tests run: + +1. SDK packages are built and packed into tarballs (`yarn build && yarn build:tarball`) +2. Tarballs are published to Verdaccio at `http://127.0.0.1:4873` +3. Test applications install packages from Verdaccio instead of public npm + +#### The `.npmrc` Requirement + +Every E2E test application needs an `.npmrc` file with: + +``` +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +``` + +Without this file, pnpm installs from the public npm registry instead of Verdaccio, so your local changes won't be tested. This is a common cause of "tests pass in CI but fail locally" or vice versa. + +#### Running a Single E2E Test + +```bash +# Build packages first +yarn build && yarn build:tarball + +# Run a specific test app +cd dev-packages/e2e-tests +yarn test:run + +# Run with a specific variant (e.g., Next.js 15) +yarn test:run --variant +``` + +#### Common Pitfalls and Debugging + +1. **Missing `.npmrc`**: Most common issue. Always verify the test app has the correct `.npmrc` file. + +2. **Stale tarballs**: After SDK changes, must re-run `yarn build:tarball`. + +3. **Debugging tips**: + - Check browser console logs for SDK initialization errors + - Use `debug: true` in Sentry config + - Verify installed package version: check `node_modules/@sentry/*/package.json` + ### Notes for Background Tasks - Make sure to use [volta](https://volta.sh/) for development. Volta is used to manage the node, yarn and pnpm version used. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d486d6718c1..70ebd45da74f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,89 @@ the tests in each location. Check out the `scripts` entry of the corresponding ` Note: you must run `yarn build` before `yarn test` will work. +## Running E2E Tests Locally + +E2E tests verify SDK behavior in real-world framework scenarios using a local npm registry (Verdaccio). + +### Prerequisites + +1. **Docker**: Required to run the Verdaccio registry container +2. **Volta with pnpm support**: Enable pnpm in Volta by setting `VOLTA_FEATURE_PNPM=1` in your environment. See [Volta pnpm docs](https://docs.volta.sh/advanced/pnpm). + +### Step-by-Step Instructions + +1. **Build the SDK packages and create tarballs:** + + ```bash + yarn build + yarn build:tarball + ``` + + Note: You must re-run `yarn build:tarball` after any changes to packages. + +2. **Set up environment (optional):** + + ```bash + cd dev-packages/e2e-tests + cp .env.example .env + # Fill in Sentry project auth info if running tests that send data to Sentry + ``` + +3. **Run all E2E tests:** + + ```bash + yarn test:e2e + ``` + +4. **Or run a specific test application:** + + ```bash + yarn test:run + # Example: yarn test:run nextjs-app-dir + ``` + +5. **Run with a specific variant:** + ```bash + yarn test:run --variant + # Example: yarn test:run nextjs-pages-dir --variant 15 + ``` + +### Common Issues and Troubleshooting + +#### Packages install from public npm instead of Verdaccio + +Every E2E test application **must** have an `.npmrc` file with: + +``` +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +``` + +Without this, pnpm will fetch packages from the public npm registry instead of the local Verdaccio instance, causing tests to use outdated/published versions instead of your local changes. + +#### Tests fail after making SDK changes + +Make sure to rebuild tarballs: + +```bash +yarn build +yarn build:tarball +``` + +#### Docker-related issues + +- Ensure Docker daemon is running +- Check that port 4873 is not in use by another process +- Try stopping and removing existing Verdaccio containers + +#### Debugging test failures + +1. Check browser console logs for SDK initialization errors +2. Enable debug mode in the test app's Sentry config: `debug: true` +3. Verify packages are installed from Verdaccio by checking the version in `node_modules/@sentry/*/package.json` + +For more details, see [dev-packages/e2e-tests/README.md](dev-packages/e2e-tests/README.md). + ## Debug Build Flags Throughout the codebase, you will find a `__DEBUG_BUILD__` constant. This flag serves two purposes: diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 133b53268d52..ffe06dd91aaf 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -88,6 +88,81 @@ EOF Make sure to add a `test:build` and `test:assert` command to the new app's `package.json` file. +### The `.npmrc` File + +Every test application needs an `.npmrc` file (as shown above) to tell pnpm to fetch `@sentry/*` and `@sentry-internal/*` packages from the local Verdaccio registry. Without it, pnpm will install from the public npm registry and your local changes won't be tested - this is one of the most common causes of confusing test failures. + +To verify packages are being installed from Verdaccio, check the version in `node_modules/@sentry/*/package.json`. If it shows something like `0.0.0-pr.12345`, Verdaccio is working. If it shows a released version (e.g., `8.0.0`), the `.npmrc` is missing or incorrect. + +## Troubleshooting + +### Common Issues + +#### Tests fail with "Cannot find module '@sentry/...'" or use wrong package version + +1. Verify the test application has an `.npmrc` file (see above) +2. Rebuild tarballs: `yarn build && yarn build:tarball` +3. Delete `node_modules` in the test application and re-run the test + +#### Docker/Verdaccio issues + +- Ensure Docker daemon is running +- Check that port 4873 is not already in use: `lsof -i :4873` +- Stop any existing Verdaccio containers: `docker ps` and `docker stop ` +- Check Verdaccio logs for errors + +#### Tests pass locally but fail in CI (or vice versa) + +- Most likely cause: missing `.npmrc` file +- Verify all `@sentry/*` dependencies use `latest || *` version specifier +- Check if the test relies on environment-specific behavior + +### Debugging Tips + +1. **Enable Sentry debug mode**: Add `debug: true` to the Sentry init config to see detailed SDK logs +2. **Check browser console**: Look for SDK initialization errors or warnings +3. **Inspect network requests**: Verify events are being sent to the expected endpoint +4. **Check installed versions**: `cat node_modules/@sentry/browser/package.json | grep version` + +## Bundler-Specific Behavior + +Different bundlers handle environment variables and code replacement differently. This is important when writing tests or SDK code that relies on build-time constants. + +### Webpack + +- `DefinePlugin` replaces variables in your application code +- **Does NOT replace values inside `node_modules`** +- Environment variables must be explicitly defined + +### Vite + +- `define` option replaces variables in your application code +- **Does NOT replace values inside `node_modules`** +- `import.meta.env.VITE_*` variables are replaced at build time +- For replacing values in dependencies, use `@rollup/plugin-replace` + +### Next.js + +- Automatically injects `process.env` via webpack/turbopack +- Handles environment variables more seamlessly than raw webpack/Vite +- Server and client bundles may have different environment variable access + +### `import.meta.env` Considerations + +- Only available in Vite and ES modules +- Webpack and Turbopack do not have `import.meta.env` +- SDK code accessing `import.meta.env` must use try-catch to handle environments where it doesn't exist + +```typescript +// Safe pattern for SDK code +let envValue: string | undefined; +try { + envValue = import.meta.env.VITE_SOME_VAR; +} catch { + // import.meta.env not available in this bundler +} +``` + Test apps in the folder `test-applications` will be automatically picked up by CI in the job `job_e2e_tests` (in `.github/workflows/build.yml`). The test matrix for CI is generated in `dev-packages/e2e-tests/lib/getTestMatrix.ts`. From 82314e40280fb532e00246b1a091b40acfb7e7d3 Mon Sep 17 00:00:00 2001 From: Gianfranco P <899175+gianpaj@users.noreply.github.com> Date: Wed, 7 Jan 2026 09:31:24 +0100 Subject: [PATCH 45/76] chore(changelog): Fix typo (#18648) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8fdc6d9fb1d..0cb50ecff105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, and @G-R You can now set attributes on the SDK's scopes which will be applied to all logs as long as the respective scopes are active. For the time being, only `string`, `number` and `boolean` attribute values are supported. ```ts - Sentry.geGlobalScope().setAttributes({ is_admin: true, auth_provider: 'google' }); + Sentry.getGlobalScope().setAttributes({ is_admin: true, auth_provider: 'google' }); Sentry.withScope(scope => { scope.setAttribute('step', 'authentication'); From 8d946bd0d811ae3dd9637d8321f0abe700e3c84d Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Wed, 7 Jan 2026 09:37:15 +0100 Subject: [PATCH 46/76] chore: Add external contributor to CHANGELOG.md (#18706) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #18648 Co-authored-by: nicohrubec <29484629+nicohrubec@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb50ecff105..72adf7a77e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, and @G-Rath. Thank you for your contributions! +Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, @G-Rath, and @gianpaj. Thank you for your contributions! - ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) From 497a0b544485c487638638e6e8d7fc287fac2582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 7 Jan 2026 10:47:57 +0100 Subject: [PATCH 47/76] feat(core): Add recordInputs/recordOutputs options to MCP server wrapper (#18600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds granular control over capturing tool/prompt inputs and outputs in MCP server instrumentation. **NOT a breaking change:** both options default to `sendDefaultPii`, so existing behavior is preserved. ## Motivation Previously, capturing MCP tool/prompt inputs and outputs required enabling `sendDefaultPii: true`, which also enables capturing IP addresses, user data, and other sensitive information. Bbut MCP inputs/outputs are important and user will want to record them, so it shouldn't require exposing all PII. This change decouples input/output capture from the broader PII setting, giving users granular control: - Want inputs/outputs for debugging but not IP addresses? → `recordInputs: true` + `sendDefaultPii: false` - Want full PII including network info? → `sendDefaultPii: true` (same as before) ## New Options for `wrapMcpServerWithSentry` - `recordInputs`: Controls whether tool/prompt input arguments are captured in spans - `recordOutputs`: Controls whether tool/prompt output results are captured in spans ## Usage ```typescript const mcpServer = new McpServer({name: "my-server"}) // Default: inherits from sendDefaultPii const server = wrapMcpServerWithSentry(mcpServer); // Explicit control const server = wrapMcpServerWithSentry( mcpServer, { recordInputs: true, recordOutputs: false } ); ``` ### PII Simplification - `piiFiltering.ts` now only handles network PII (`client.address`, `client.port`, `mcp.resource.uri`) - Input/output capture is controlled at the source via `recordInputs`/`recordOutputs` rather than filtering after capture ## Files Changed - `types.ts` - Added `McpServerWrapperOptions` type - `index.ts` - Added options parameter with `sendDefaultPii` defaults - `attributeExtraction.ts` - Conditional input argument capture - `spans.ts` - Threading `recordInputs` through span creation - `transport.ts` - Passing options to transport wrappers - `correlation.ts` - Conditional output result capture - `piiFiltering.ts` - Simplified to network PII only - Tests updated accordingly Closes #18603 (added automatically) --- .../mcp-server/attributeExtraction.ts | 22 ++- .../integrations/mcp-server/correlation.ts | 24 ++- .../core/src/integrations/mcp-server/index.ts | 24 ++- .../integrations/mcp-server/piiFiltering.ts | 64 ++------ .../mcp-server/resultExtraction.ts | 105 +++++++------ .../core/src/integrations/mcp-server/spans.ts | 23 ++- .../src/integrations/mcp-server/transport.ts | 16 +- .../core/src/integrations/mcp-server/types.ts | 17 +++ .../mcp-server/piiFiltering.test.ts | 144 +++--------------- .../mcp-server/semanticConventions.test.ts | 84 +++++++++- .../transportInstrumentation.test.ts | 119 ++++++++++++++- 11 files changed, 386 insertions(+), 256 deletions(-) diff --git a/packages/core/src/integrations/mcp-server/attributeExtraction.ts b/packages/core/src/integrations/mcp-server/attributeExtraction.ts index 8f1e5a77d94d..75449f43ccc9 100644 --- a/packages/core/src/integrations/mcp-server/attributeExtraction.ts +++ b/packages/core/src/integrations/mcp-server/attributeExtraction.ts @@ -14,15 +14,25 @@ import { import { extractTargetInfo, getRequestArguments } from './methodConfig'; import type { JsonRpcNotification, JsonRpcRequest, McpSpanType } from './types'; +/** + * Formats logging data for span attributes + * @internal + */ +function formatLoggingData(data: unknown): string { + return typeof data === 'string' ? data : JSON.stringify(data); +} + /** * Extracts additional attributes for specific notification types * @param method - Notification method name * @param params - Notification parameters + * @param recordInputs - Whether to include actual content or just metadata * @returns Method-specific attributes for span instrumentation */ export function getNotificationAttributes( method: string, params: Record, + recordInputs?: boolean, ): Record { const attributes: Record = {}; @@ -45,10 +55,8 @@ export function getNotificationAttributes( } if (params?.data !== undefined) { attributes[MCP_LOGGING_DATA_TYPE_ATTRIBUTE] = typeof params.data; - if (typeof params.data === 'string') { - attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = params.data; - } else { - attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = JSON.stringify(params.data); + if (recordInputs) { + attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = formatLoggingData(params.data); } } break; @@ -95,12 +103,14 @@ export function getNotificationAttributes( * @param type - Span type (request or notification) * @param message - JSON-RPC message * @param params - Optional parameters for attribute extraction + * @param recordInputs - Whether to capture input arguments in spans * @returns Type-specific attributes for span instrumentation */ export function buildTypeSpecificAttributes( type: McpSpanType, message: JsonRpcRequest | JsonRpcNotification, params?: Record, + recordInputs?: boolean, ): Record { if (type === 'request') { const request = message as JsonRpcRequest; @@ -109,11 +119,11 @@ export function buildTypeSpecificAttributes( return { ...(request.id !== undefined && { [MCP_REQUEST_ID_ATTRIBUTE]: String(request.id) }), ...targetInfo.attributes, - ...getRequestArguments(request.method, params || {}), + ...(recordInputs ? getRequestArguments(request.method, params || {}) : {}), }; } - return getNotificationAttributes(message.method, params || {}); + return getNotificationAttributes(message.method, params || {}, recordInputs); } // Re-export buildTransportAttributes for spans.ts diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 0985a0927cdd..22517306c7cb 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -6,14 +6,12 @@ * request ID collisions between different MCP sessions. */ -import { getClient } from '../../currentScopes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types-hoist/span'; import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes'; -import { filterMcpPiiFromSpanData } from './piiFiltering'; import { extractPromptResultAttributes, extractToolResultAttributes } from './resultExtraction'; import { buildServerAttributesFromInfo, extractSessionDataFromInitializeResponse } from './sessionExtraction'; -import type { MCPTransport, RequestId, RequestSpanMapValue } from './types'; +import type { MCPTransport, RequestId, RequestSpanMapValue, ResolvedMcpOptions } from './types'; /** * Transport-scoped correlation system that prevents collisions between different MCP sessions @@ -57,8 +55,14 @@ export function storeSpanForRequest(transport: MCPTransport, requestId: RequestI * @param transport - MCP transport instance * @param requestId - Request identifier * @param result - Execution result for attribute extraction + * @param options - Resolved MCP options */ -export function completeSpanWithResults(transport: MCPTransport, requestId: RequestId, result: unknown): void { +export function completeSpanWithResults( + transport: MCPTransport, + requestId: RequestId, + result: unknown, + options: ResolvedMcpOptions, +): void { const spanMap = getOrCreateSpanMap(transport); const spanData = spanMap.get(requestId); if (spanData) { @@ -77,18 +81,10 @@ export function completeSpanWithResults(transport: MCPTransport, requestId: Requ span.setAttributes(initAttributes); } else if (method === 'tools/call') { - const rawToolAttributes = extractToolResultAttributes(result); - const client = getClient(); - const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); - const toolAttributes = filterMcpPiiFromSpanData(rawToolAttributes, sendDefaultPii); - + const toolAttributes = extractToolResultAttributes(result, options.recordOutputs); span.setAttributes(toolAttributes); } else if (method === 'prompts/get') { - const rawPromptAttributes = extractPromptResultAttributes(result); - const client = getClient(); - const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); - const promptAttributes = filterMcpPiiFromSpanData(rawPromptAttributes, sendDefaultPii); - + const promptAttributes = extractPromptResultAttributes(result, options.recordOutputs); span.setAttributes(promptAttributes); } diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts index a1eb8815805a..5698cd445834 100644 --- a/packages/core/src/integrations/mcp-server/index.ts +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -1,7 +1,8 @@ +import { getClient } from '../../currentScopes'; import { fill } from '../../utils/object'; import { wrapAllMCPHandlers } from './handlers'; import { wrapTransportError, wrapTransportOnClose, wrapTransportOnMessage, wrapTransportSend } from './transport'; -import type { MCPServerInstance, MCPTransport } from './types'; +import type { MCPServerInstance, McpServerWrapperOptions, MCPTransport, ResolvedMcpOptions } from './types'; import { validateMcpServerInstance } from './validation'; /** @@ -22,18 +23,26 @@ const wrappedMcpServerInstances = new WeakSet(); * import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; * import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; * + * // Default: inputs/outputs captured based on sendDefaultPii option * const server = Sentry.wrapMcpServerWithSentry( * new McpServer({ name: "my-server", version: "1.0.0" }) * ); * + * // Explicitly control input/output capture + * const server = Sentry.wrapMcpServerWithSentry( + * new McpServer({ name: "my-server", version: "1.0.0" }), + * { recordInputs: true, recordOutputs: false } + * ); + * * const transport = new StreamableHTTPServerTransport(); * await server.connect(transport); * ``` * * @param mcpServerInstance - MCP server instance to instrument + * @param options - Optional configuration for recording inputs and outputs * @returns Instrumented server instance (same reference) */ -export function wrapMcpServerWithSentry(mcpServerInstance: S): S { +export function wrapMcpServerWithSentry(mcpServerInstance: S, options?: McpServerWrapperOptions): S { if (wrappedMcpServerInstances.has(mcpServerInstance)) { return mcpServerInstance; } @@ -43,6 +52,13 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S): } const serverInstance = mcpServerInstance as MCPServerInstance; + const client = getClient(); + const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); + + const resolvedOptions: ResolvedMcpOptions = { + recordInputs: options?.recordInputs ?? sendDefaultPii, + recordOutputs: options?.recordOutputs ?? sendDefaultPii, + }; fill(serverInstance, 'connect', originalConnect => { return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) { @@ -52,8 +68,8 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S): ...restArgs, ); - wrapTransportOnMessage(transport); - wrapTransportSend(transport); + wrapTransportOnMessage(transport, resolvedOptions); + wrapTransportSend(transport, resolvedOptions); wrapTransportOnClose(transport); wrapTransportError(transport); diff --git a/packages/core/src/integrations/mcp-server/piiFiltering.ts b/packages/core/src/integrations/mcp-server/piiFiltering.ts index ff801cbf2a1e..f8715a383f00 100644 --- a/packages/core/src/integrations/mcp-server/piiFiltering.ts +++ b/packages/core/src/integrations/mcp-server/piiFiltering.ts @@ -1,71 +1,37 @@ /** * PII filtering for MCP server spans * - * Removes sensitive data when sendDefaultPii is false. - * Uses configurable attribute filtering to protect user privacy. + * Removes network-level sensitive data when sendDefaultPii is false. + * Input/output data (request arguments, tool/prompt results) is controlled + * separately via recordInputs/recordOutputs options. */ import type { SpanAttributeValue } from '../../types-hoist/span'; -import { - CLIENT_ADDRESS_ATTRIBUTE, - CLIENT_PORT_ATTRIBUTE, - MCP_LOGGING_MESSAGE_ATTRIBUTE, - MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE, - MCP_PROMPT_RESULT_MESSAGE_CONTENT_ATTRIBUTE, - MCP_PROMPT_RESULT_PREFIX, - MCP_REQUEST_ARGUMENT, - MCP_RESOURCE_URI_ATTRIBUTE, - MCP_TOOL_RESULT_CONTENT_ATTRIBUTE, - MCP_TOOL_RESULT_PREFIX, -} from './attributes'; +import { CLIENT_ADDRESS_ATTRIBUTE, CLIENT_PORT_ATTRIBUTE, MCP_RESOURCE_URI_ATTRIBUTE } from './attributes'; /** - * PII attributes that should be removed when sendDefaultPii is false + * Network PII attributes that should be removed when sendDefaultPii is false * @internal */ -const PII_ATTRIBUTES = new Set([ - CLIENT_ADDRESS_ATTRIBUTE, - CLIENT_PORT_ATTRIBUTE, - MCP_LOGGING_MESSAGE_ATTRIBUTE, - MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE, - MCP_PROMPT_RESULT_MESSAGE_CONTENT_ATTRIBUTE, - MCP_RESOURCE_URI_ATTRIBUTE, - MCP_TOOL_RESULT_CONTENT_ATTRIBUTE, -]); +const NETWORK_PII_ATTRIBUTES = new Set([CLIENT_ADDRESS_ATTRIBUTE, CLIENT_PORT_ATTRIBUTE, MCP_RESOURCE_URI_ATTRIBUTE]); /** - * Checks if an attribute key should be considered PII. + * Checks if an attribute key should be considered network PII. * * Returns true for: - * - Explicit PII attributes (client.address, client.port, mcp.logging.message, etc.) - * - All request arguments (mcp.request.argument.*) - * - Tool and prompt result content (mcp.tool.result.*, mcp.prompt.result.*) except metadata - * - * Preserves metadata attributes ending with _count, _error, or .is_error as they don't contain sensitive data. + * - client.address (IP address) + * - client.port (port number) + * - mcp.resource.uri (potentially sensitive URIs) * * @param key - Attribute key to evaluate - * @returns true if the attribute should be filtered out (is PII), false if it should be preserved + * @returns true if the attribute should be filtered out (is network PII), false if it should be preserved * @internal */ -function isPiiAttribute(key: string): boolean { - if (PII_ATTRIBUTES.has(key)) { - return true; - } - - if (key.startsWith(`${MCP_REQUEST_ARGUMENT}.`)) { - return true; - } - - if (key.startsWith(`${MCP_TOOL_RESULT_PREFIX}.`) || key.startsWith(`${MCP_PROMPT_RESULT_PREFIX}.`)) { - if (!key.endsWith('_count') && !key.endsWith('_error') && !key.endsWith('.is_error')) { - return true; - } - } - - return false; +function isNetworkPiiAttribute(key: string): boolean { + return NETWORK_PII_ATTRIBUTES.has(key); } /** - * Removes PII attributes from span data when sendDefaultPii is false + * Removes network PII attributes from span data when sendDefaultPii is false * @param spanData - Raw span attributes * @param sendDefaultPii - Whether to include PII data * @returns Filtered span attributes @@ -80,7 +46,7 @@ export function filterMcpPiiFromSpanData( return Object.entries(spanData).reduce( (acc, [key, value]) => { - if (!isPiiAttribute(key)) { + if (!isNetworkPiiAttribute(key)) { acc[key] = value as SpanAttributeValue; } return acc; diff --git a/packages/core/src/integrations/mcp-server/resultExtraction.ts b/packages/core/src/integrations/mcp-server/resultExtraction.ts index 34dc2be9d09c..58f9ad860083 100644 --- a/packages/core/src/integrations/mcp-server/resultExtraction.ts +++ b/packages/core/src/integrations/mcp-server/resultExtraction.ts @@ -15,9 +15,13 @@ import { isValidContentItem } from './validation'; /** * Build attributes for tool result content items * @param content - Array of content items from tool result - * @returns Attributes extracted from each content item including type, text, mime type, URI, and resource info + * @param includeContent - Whether to include actual content (text, URIs) or just metadata + * @returns Attributes extracted from each content item */ -function buildAllContentItemAttributes(content: unknown[]): Record { +function buildAllContentItemAttributes( + content: unknown[], + includeContent: boolean, +): Record { const attributes: Record = { [MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE]: content.length, }; @@ -29,29 +33,34 @@ function buildAllContentItemAttributes(content: unknown[]): Record { - if (typeof value === 'string') { - attributes[`${prefix}.${key}`] = value; - } - }; + if (typeof item.type === 'string') { + attributes[`${prefix}.content_type`] = item.type; + } - safeSet('content_type', item.type); - safeSet('mime_type', item.mimeType); - safeSet('uri', item.uri); - safeSet('name', item.name); + if (includeContent) { + const safeSet = (key: string, value: unknown): void => { + if (typeof value === 'string') { + attributes[`${prefix}.${key}`] = value; + } + }; - if (typeof item.text === 'string') { - attributes[`${prefix}.content`] = item.text; - } + safeSet('mime_type', item.mimeType); + safeSet('uri', item.uri); + safeSet('name', item.name); - if (typeof item.data === 'string') { - attributes[`${prefix}.data_size`] = item.data.length; - } + if (typeof item.text === 'string') { + attributes[`${prefix}.content`] = item.text; + } - const resource = item.resource; - if (isValidContentItem(resource)) { - safeSet('resource_uri', resource.uri); - safeSet('resource_mime_type', resource.mimeType); + if (typeof item.data === 'string') { + attributes[`${prefix}.data_size`] = item.data.length; + } + + const resource = item.resource; + if (isValidContentItem(resource)) { + safeSet('resource_uri', resource.uri); + safeSet('resource_mime_type', resource.mimeType); + } } } @@ -61,14 +70,18 @@ function buildAllContentItemAttributes(content: unknown[]): Record { +export function extractToolResultAttributes( + result: unknown, + recordOutputs: boolean, +): Record { if (!isValidContentItem(result)) { return {}; } - const attributes = Array.isArray(result.content) ? buildAllContentItemAttributes(result.content) : {}; + const attributes = Array.isArray(result.content) ? buildAllContentItemAttributes(result.content, recordOutputs) : {}; if (typeof result.isError === 'boolean') { attributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] = result.isError; @@ -80,43 +93,49 @@ export function extractToolResultAttributes(result: unknown): Record { +export function extractPromptResultAttributes( + result: unknown, + recordOutputs: boolean, +): Record { const attributes: Record = {}; if (!isValidContentItem(result)) { return attributes; } - if (typeof result.description === 'string') { + if (recordOutputs && typeof result.description === 'string') { attributes[MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE] = result.description; } if (Array.isArray(result.messages)) { attributes[MCP_PROMPT_RESULT_MESSAGE_COUNT_ATTRIBUTE] = result.messages.length; - const messages = result.messages; - for (const [i, message] of messages.entries()) { - if (!isValidContentItem(message)) { - continue; - } + if (recordOutputs) { + const messages = result.messages; + for (const [i, message] of messages.entries()) { + if (!isValidContentItem(message)) { + continue; + } - const prefix = messages.length === 1 ? 'mcp.prompt.result' : `mcp.prompt.result.${i}`; + const prefix = messages.length === 1 ? 'mcp.prompt.result' : `mcp.prompt.result.${i}`; - const safeSet = (key: string, value: unknown): void => { - if (typeof value === 'string') { - const attrName = messages.length === 1 ? `${prefix}.message_${key}` : `${prefix}.${key}`; - attributes[attrName] = value; - } - }; + const safeSet = (key: string, value: unknown): void => { + if (typeof value === 'string') { + const attrName = messages.length === 1 ? `${prefix}.message_${key}` : `${prefix}.${key}`; + attributes[attrName] = value; + } + }; - safeSet('role', message.role); + safeSet('role', message.role); - if (isValidContentItem(message.content)) { - const content = message.content; - if (typeof content.text === 'string') { - const attrName = messages.length === 1 ? `${prefix}.message_content` : `${prefix}.content`; - attributes[attrName] = content.text; + if (isValidContentItem(message.content)) { + const content = message.content; + if (typeof content.text === 'string') { + const attrName = messages.length === 1 ? `${prefix}.message_content` : `${prefix}.content`; + attributes[attrName] = content.text; + } } } } diff --git a/packages/core/src/integrations/mcp-server/spans.ts b/packages/core/src/integrations/mcp-server/spans.ts index fdd4c107ee30..010148faab65 100644 --- a/packages/core/src/integrations/mcp-server/spans.ts +++ b/packages/core/src/integrations/mcp-server/spans.ts @@ -24,7 +24,14 @@ import { } from './attributes'; import { extractTargetInfo } from './methodConfig'; import { filterMcpPiiFromSpanData } from './piiFiltering'; -import type { ExtraHandlerData, JsonRpcNotification, JsonRpcRequest, McpSpanConfig, MCPTransport } from './types'; +import type { + ExtraHandlerData, + JsonRpcNotification, + JsonRpcRequest, + McpSpanConfig, + MCPTransport, + ResolvedMcpOptions, +} from './types'; /** * Creates a span name based on the method and target @@ -76,7 +83,7 @@ function buildSentryAttributes(type: McpSpanConfig['type']): Record = { ...buildTransportAttributes(transport, extra), [MCP_METHOD_NAME_ATTRIBUTE]: method, - ...buildTypeSpecificAttributes(type, message, params), + ...buildTypeSpecificAttributes(type, message, params, options?.recordInputs), ...buildSentryAttributes(type), }; @@ -116,6 +123,7 @@ function createMcpSpan(config: McpSpanConfig): unknown { * @param jsonRpcMessage - Notification message * @param transport - MCP transport instance * @param extra - Extra handler data + * @param options - Resolved MCP options * @param callback - Span execution callback * @returns Span execution result */ @@ -123,6 +131,7 @@ export function createMcpNotificationSpan( jsonRpcMessage: JsonRpcNotification, transport: MCPTransport, extra: ExtraHandlerData, + options: ResolvedMcpOptions, callback: () => unknown, ): unknown { return createMcpSpan({ @@ -131,6 +140,7 @@ export function createMcpNotificationSpan( transport, extra, callback, + options, }); } @@ -138,18 +148,21 @@ export function createMcpNotificationSpan( * Creates a span for outgoing MCP notifications * @param jsonRpcMessage - Notification message * @param transport - MCP transport instance + * @param options - Resolved MCP options * @param callback - Span execution callback * @returns Span execution result */ export function createMcpOutgoingNotificationSpan( jsonRpcMessage: JsonRpcNotification, transport: MCPTransport, + options: ResolvedMcpOptions, callback: () => unknown, ): unknown { return createMcpSpan({ type: 'notification-outgoing', message: jsonRpcMessage, transport, + options, callback, }); } @@ -159,12 +172,14 @@ export function createMcpOutgoingNotificationSpan( * @param jsonRpcMessage - Request message * @param transport - MCP transport instance * @param extra - Optional extra handler data + * @param options - Resolved MCP options * @returns Span configuration object */ export function buildMcpServerSpanConfig( jsonRpcMessage: JsonRpcRequest, transport: MCPTransport, extra?: ExtraHandlerData, + options?: ResolvedMcpOptions, ): { name: string; op: string; @@ -180,7 +195,7 @@ export function buildMcpServerSpanConfig( const rawAttributes: Record = { ...buildTransportAttributes(transport, extra), [MCP_METHOD_NAME_ATTRIBUTE]: method, - ...buildTypeSpecificAttributes('request', jsonRpcMessage, params), + ...buildTypeSpecificAttributes('request', jsonRpcMessage, params, options?.recordInputs), ...buildSentryAttributes('request'), }; diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts index bb9a1b2b37d2..5e4fd6e75c23 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -22,7 +22,7 @@ import { updateSessionDataForTransport, } from './sessionManagement'; import { buildMcpServerSpanConfig, createMcpNotificationSpan, createMcpOutgoingNotificationSpan } from './spans'; -import type { ExtraHandlerData, MCPTransport, SessionData } from './types'; +import type { ExtraHandlerData, MCPTransport, ResolvedMcpOptions, SessionData } from './types'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, isValidContentItem } from './validation'; /** @@ -30,8 +30,9 @@ import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, isValidCont * For "initialize" requests, extracts and stores client info and protocol version * in the session data for the transport. * @param transport - MCP transport instance to wrap + * @param options - Resolved MCP options */ -export function wrapTransportOnMessage(transport: MCPTransport): void { +export function wrapTransportOnMessage(transport: MCPTransport, options: ResolvedMcpOptions): void { if (transport.onmessage) { fill(transport, 'onmessage', originalOnMessage => { return function (this: MCPTransport, message: unknown, extra?: unknown) { @@ -51,7 +52,7 @@ export function wrapTransportOnMessage(transport: MCPTransport): void { const isolationScope = getIsolationScope().clone(); return withIsolationScope(isolationScope, () => { - const spanConfig = buildMcpServerSpanConfig(message, this, extra as ExtraHandlerData); + const spanConfig = buildMcpServerSpanConfig(message, this, extra as ExtraHandlerData, options); const span = startInactiveSpan(spanConfig); // For initialize requests, add client info directly to span (works even for stateless transports) @@ -73,7 +74,7 @@ export function wrapTransportOnMessage(transport: MCPTransport): void { } if (isJsonRpcNotification(message)) { - return createMcpNotificationSpan(message, this, extra as ExtraHandlerData, () => { + return createMcpNotificationSpan(message, this, extra as ExtraHandlerData, options, () => { return (originalOnMessage as (...args: unknown[]) => unknown).call(this, message, extra); }); } @@ -89,15 +90,16 @@ export function wrapTransportOnMessage(transport: MCPTransport): void { * For "initialize" responses, extracts and stores protocol version and server info * in the session data for the transport. * @param transport - MCP transport instance to wrap + * @param options - Resolved MCP options */ -export function wrapTransportSend(transport: MCPTransport): void { +export function wrapTransportSend(transport: MCPTransport, options: ResolvedMcpOptions): void { if (transport.send) { fill(transport, 'send', originalSend => { return async function (this: MCPTransport, ...args: unknown[]) { const [message] = args; if (isJsonRpcNotification(message)) { - return createMcpOutgoingNotificationSpan(message, this, () => { + return createMcpOutgoingNotificationSpan(message, this, options, () => { return (originalSend as (...args: unknown[]) => unknown).call(this, ...args); }); } @@ -119,7 +121,7 @@ export function wrapTransportSend(transport: MCPTransport): void { } } - completeSpanWithResults(this, message.id, message.result); + completeSpanWithResults(this, message.id, message.result, options); } } diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts index 7c25d52167c7..35dbcffcabb0 100644 --- a/packages/core/src/integrations/mcp-server/types.ts +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -127,6 +127,7 @@ export interface McpSpanConfig { transport: MCPTransport; extra?: ExtraHandlerData; callback: () => unknown; + options?: ResolvedMcpOptions; } export type SessionId = string; @@ -183,3 +184,19 @@ export type SessionData = { protocolVersion?: string; serverInfo?: PartyInfo; }; + +/** + * Options for configuring the MCP server wrapper. + */ +export type McpServerWrapperOptions = { + /** Whether to capture tool/prompt input arguments in spans. Defaults to sendDefaultPii. */ + recordInputs?: boolean; + /** Whether to capture tool/prompt output results in spans. Defaults to sendDefaultPii. */ + recordOutputs?: boolean; +}; + +/** + * Resolved options with defaults applied. Used internally. + * @internal + */ +export type ResolvedMcpOptions = Required; diff --git a/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts b/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts index a86ccbd534d0..5cfcd5cb1bfe 100644 --- a/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts @@ -13,33 +13,31 @@ describe('MCP Server PII Filtering', () => { vi.clearAllMocks(); }); - describe('Integration Tests', () => { + describe('Integration Tests - Network PII', () => { let mockMcpServer: ReturnType; - let wrappedMcpServer: ReturnType; let mockTransport: ReturnType; beforeEach(() => { mockMcpServer = createMockMcpServer(); - wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); mockTransport = createMockTransport(); mockTransport.sessionId = 'test-session-123'; }); - it('should include PII data when sendDefaultPii is true', async () => { - // Mock client with sendDefaultPii: true + it('should include network PII when sendDefaultPii is true', async () => { getClientSpy.mockReturnValue({ getOptions: () => ({ sendDefaultPii: true }), getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), emit: vi.fn(), } as unknown as ReturnType); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); await wrappedMcpServer.connect(mockTransport); const jsonRpcRequest = { jsonrpc: '2.0', method: 'tools/call', id: 'req-pii-true', - params: { name: 'weather', arguments: { location: 'London', units: 'metric' } }, + params: { name: 'weather', arguments: { location: 'London' } }, }; const extraWithClientInfo = { @@ -51,35 +49,31 @@ describe('MCP Server PII Filtering', () => { mockTransport.onmessage?.(jsonRpcRequest, extraWithClientInfo); - expect(startInactiveSpanSpy).toHaveBeenCalledWith({ - name: 'tools/call weather', - op: 'mcp.server', - forceTransaction: true, - attributes: expect.objectContaining({ - 'client.address': '192.168.1.100', - 'client.port': 54321, - 'mcp.request.argument.location': '"London"', - 'mcp.request.argument.units': '"metric"', - 'mcp.tool.name': 'weather', + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'client.address': '192.168.1.100', + 'client.port': 54321, + }), }), - }); + ); }); - it('should exclude PII data when sendDefaultPii is false', async () => { - // Mock client with sendDefaultPii: false + it('should exclude network PII when sendDefaultPii is false', async () => { getClientSpy.mockReturnValue({ getOptions: () => ({ sendDefaultPii: false }), getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), emit: vi.fn(), } as unknown as ReturnType); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); await wrappedMcpServer.connect(mockTransport); const jsonRpcRequest = { jsonrpc: '2.0', method: 'tools/call', id: 'req-pii-false', - params: { name: 'weather', arguments: { location: 'London', units: 'metric' } }, + params: { name: 'weather', arguments: { location: 'London' } }, }; const extraWithClientInfo = { @@ -96,8 +90,6 @@ describe('MCP Server PII Filtering', () => { attributes: expect.not.objectContaining({ 'client.address': expect.anything(), 'client.port': expect.anything(), - 'mcp.request.argument.location': expect.anything(), - 'mcp.request.argument.units': expect.anything(), }), }), ); @@ -111,49 +103,6 @@ describe('MCP Server PII Filtering', () => { }), ); }); - - it('should filter tool result content when sendDefaultPii is false', async () => { - // Mock client with sendDefaultPii: false - getClientSpy.mockReturnValue({ - getOptions: () => ({ sendDefaultPii: false }), - } as ReturnType); - - await wrappedMcpServer.connect(mockTransport); - - const mockSpan = { - setAttributes: vi.fn(), - setStatus: vi.fn(), - end: vi.fn(), - } as unknown as ReturnType; - startInactiveSpanSpy.mockReturnValueOnce(mockSpan); - - const toolCallRequest = { - jsonrpc: '2.0', - method: 'tools/call', - id: 'req-tool-result-filtered', - params: { name: 'weather-lookup' }, - }; - - mockTransport.onmessage?.(toolCallRequest, {}); - - const toolResponse = { - jsonrpc: '2.0', - id: 'req-tool-result-filtered', - result: { - content: [{ type: 'text', text: 'Sensitive weather data for London' }], - isError: false, - }, - }; - - mockTransport.send?.(toolResponse); - - // Tool result content should be filtered out, but metadata should remain - const setAttributesCall = mockSpan.setAttributes.mock.calls[0]?.[0]; - expect(setAttributesCall).toBeDefined(); - expect(setAttributesCall).not.toHaveProperty('mcp.tool.result.content'); - expect(setAttributesCall).toHaveProperty('mcp.tool.result.is_error', false); - expect(setAttributesCall).toHaveProperty('mcp.tool.result.content_count', 1); - }); }); describe('filterMcpPiiFromSpanData Function', () => { @@ -161,80 +110,34 @@ describe('MCP Server PII Filtering', () => { const spanData = { 'client.address': '192.168.1.100', 'client.port': 54321, - 'mcp.request.argument.location': '"San Francisco"', - 'mcp.tool.result.content': 'Weather data: 18°C', - 'mcp.tool.result.content_count': 1, - 'mcp.prompt.result.description': 'Code review prompt for sensitive analysis', - 'mcp.prompt.result.message_content': 'Please review this confidential code.', - 'mcp.prompt.result.message_count': 1, - 'mcp.resource.result.content': 'Sensitive resource content', - 'mcp.logging.message': 'User requested weather', 'mcp.resource.uri': 'file:///private/docs/secret.txt', - 'mcp.method.name': 'tools/call', // Non-PII should remain + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'weather', }; const result = filterMcpPiiFromSpanData(spanData, true); - expect(result).toEqual(spanData); // All data preserved + expect(result).toEqual(spanData); }); - it('should remove PII data when sendDefaultPii is false', () => { + it('should only remove network PII when sendDefaultPii is false', () => { const spanData = { 'client.address': '192.168.1.100', 'client.port': 54321, - 'mcp.request.argument.location': '"San Francisco"', - 'mcp.request.argument.units': '"celsius"', - 'mcp.tool.result.content': 'Weather data: 18°C', - 'mcp.tool.result.content_count': 1, - 'mcp.prompt.result.description': 'Code review prompt for sensitive analysis', - 'mcp.prompt.result.message_count': 2, - 'mcp.prompt.result.0.role': 'user', - 'mcp.prompt.result.0.content': 'Sensitive prompt content', - 'mcp.prompt.result.1.role': 'assistant', - 'mcp.prompt.result.1.content': 'Another sensitive response', - 'mcp.resource.result.content_count': 1, - 'mcp.resource.result.uri': 'file:///private/file.txt', - 'mcp.resource.result.content': 'Sensitive resource content', - 'mcp.logging.message': 'User requested weather', 'mcp.resource.uri': 'file:///private/docs/secret.txt', - 'mcp.method.name': 'tools/call', // Non-PII should remain - 'mcp.session.id': 'test-session-123', // Non-PII should remain + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'weather', + 'mcp.session.id': 'test-session-123', }; const result = filterMcpPiiFromSpanData(spanData, false); - // Client info should be filtered expect(result).not.toHaveProperty('client.address'); expect(result).not.toHaveProperty('client.port'); - - // Request arguments should be filtered - expect(result).not.toHaveProperty('mcp.request.argument.location'); - expect(result).not.toHaveProperty('mcp.request.argument.units'); - - // Specific PII content attributes should be filtered - expect(result).not.toHaveProperty('mcp.tool.result.content'); - expect(result).not.toHaveProperty('mcp.prompt.result.description'); - - // Count attributes should remain as they don't contain sensitive content - expect(result).toHaveProperty('mcp.tool.result.content_count', 1); - expect(result).toHaveProperty('mcp.prompt.result.message_count', 2); - - // All tool and prompt result content should be filtered (including indexed attributes) - expect(result).not.toHaveProperty('mcp.prompt.result.0.role'); - expect(result).not.toHaveProperty('mcp.prompt.result.0.content'); - expect(result).not.toHaveProperty('mcp.prompt.result.1.role'); - expect(result).not.toHaveProperty('mcp.prompt.result.1.content'); - - expect(result).toHaveProperty('mcp.resource.result.content_count', 1); - expect(result).toHaveProperty('mcp.resource.result.uri', 'file:///private/file.txt'); - expect(result).toHaveProperty('mcp.resource.result.content', 'Sensitive resource content'); - - // Other PII attributes should be filtered - expect(result).not.toHaveProperty('mcp.logging.message'); expect(result).not.toHaveProperty('mcp.resource.uri'); - // Non-PII attributes should remain expect(result).toHaveProperty('mcp.method.name', 'tools/call'); + expect(result).toHaveProperty('mcp.tool.name', 'weather'); expect(result).toHaveProperty('mcp.session.id', 'test-session-123'); }); @@ -243,10 +146,11 @@ describe('MCP Server PII Filtering', () => { expect(result).toEqual({}); }); - it('should handle span data with no PII attributes', () => { + it('should handle span data with no network PII attributes', () => { const spanData = { 'mcp.method.name': 'tools/list', 'mcp.session.id': 'test-session', + 'mcp.tool.name': 'weather', }; const result = filterMcpPiiFromSpanData(spanData, false); diff --git a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts index 0ad969d5b46e..356cc4152123 100644 --- a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts @@ -26,7 +26,7 @@ describe('MCP Server Semantic Conventions', () => { beforeEach(() => { mockMcpServer = createMockMcpServer(); - wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer, { recordInputs: true, recordOutputs: true }); mockTransport = createMockTransport(); mockTransport.sessionId = 'test-session-123'; }); @@ -506,5 +506,87 @@ describe('MCP Server Semantic Conventions', () => { expect(setStatusSpy).not.toHaveBeenCalled(); expect(endSpy).toHaveBeenCalled(); }); + + it('should capture tool result metadata but not content when recordOutputs is false', async () => { + const server = wrapMcpServerWithSentry(createMockMcpServer(), { recordOutputs: false }); + const transport = createMockTransport(); + await server.connect(transport); + + const setAttributesSpy = vi.fn(); + const mockSpan = { setAttributes: setAttributesSpy, setStatus: vi.fn(), end: vi.fn() }; + startInactiveSpanSpy.mockReturnValueOnce( + mockSpan as unknown as ReturnType, + ); + + transport.onmessage?.({ jsonrpc: '2.0', method: 'tools/call', id: 'req-1', params: { name: 'tool' } }, {}); + transport.send?.({ + jsonrpc: '2.0', + id: 'req-1', + result: { + content: [{ type: 'text', text: 'sensitive', mimeType: 'text/plain', uri: 'file:///secret', name: 'file' }], + isError: false, + }, + }); + + const attrs = setAttributesSpy.mock.calls.find(c => c[0]?.['mcp.tool.result.content_count'])?.[0]; + expect(attrs).toMatchObject({ 'mcp.tool.result.is_error': false, 'mcp.tool.result.content_count': 1 }); + expect(attrs).not.toHaveProperty('mcp.tool.result.content'); + expect(attrs).not.toHaveProperty('mcp.tool.result.uri'); + }); + + it('should capture prompt result metadata but not content when recordOutputs is false', async () => { + const server = wrapMcpServerWithSentry(createMockMcpServer(), { recordOutputs: false }); + const transport = createMockTransport(); + await server.connect(transport); + + const setAttributesSpy = vi.fn(); + const mockSpan = { setAttributes: setAttributesSpy, setStatus: vi.fn(), end: vi.fn() }; + startInactiveSpanSpy.mockReturnValueOnce( + mockSpan as unknown as ReturnType, + ); + + transport.onmessage?.({ jsonrpc: '2.0', method: 'prompts/get', id: 'req-1', params: { name: 'prompt' } }, {}); + transport.send?.({ + jsonrpc: '2.0', + id: 'req-1', + result: { + description: 'sensitive description', + messages: [{ role: 'user', content: { type: 'text', text: 'sensitive' } }], + }, + }); + + const attrs = setAttributesSpy.mock.calls.find(c => c[0]?.['mcp.prompt.result.message_count'])?.[0]; + expect(attrs).toMatchObject({ 'mcp.prompt.result.message_count': 1 }); + expect(attrs).not.toHaveProperty('mcp.prompt.result.description'); + expect(attrs).not.toHaveProperty('mcp.prompt.result.message_role'); + }); + + it('should capture notification metadata but not logging message when recordInputs is false', async () => { + const server = wrapMcpServerWithSentry(createMockMcpServer(), { recordInputs: false }); + const transport = createMockTransport(); + await server.connect(transport); + + const loggingNotification = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', logger: 'test-logger', data: 'sensitive log message' }, + }; + + transport.onmessage?.(loggingNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'mcp.logging.level': 'info', + 'mcp.logging.logger': 'test-logger', + 'mcp.logging.data_type': 'string', + }), + }), + expect.any(Function), + ); + + const lastCall = startSpanSpy.mock.calls[startSpanSpy.mock.calls.length - 1]; + expect(lastCall?.[0]?.attributes).not.toHaveProperty('mcp.logging.message'); + }); }); }); diff --git a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts index d128e12d8635..e8ffb31477ad 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -190,7 +190,7 @@ describe('MCP Server Transport Instrumentation', () => { beforeEach(() => { mockMcpServer = createMockMcpServer(); - wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer, { recordInputs: true }); mockStdioTransport = createMockStdioTransport(); mockStdioTransport.sessionId = 'stdio-session-456'; }); @@ -308,7 +308,7 @@ describe('MCP Server Transport Instrumentation', () => { it('should test wrapTransportOnMessage directly', () => { const originalOnMessage = mockTransport.onmessage; - wrapTransportOnMessage(mockTransport); + wrapTransportOnMessage(mockTransport, { recordInputs: false, recordOutputs: false }); expect(mockTransport.onmessage).not.toBe(originalOnMessage); }); @@ -316,7 +316,7 @@ describe('MCP Server Transport Instrumentation', () => { it('should test wrapTransportSend directly', () => { const originalSend = mockTransport.send; - wrapTransportSend(mockTransport); + wrapTransportSend(mockTransport, { recordInputs: false, recordOutputs: false }); expect(mockTransport.send).not.toBe(originalSend); }); @@ -345,12 +345,17 @@ describe('MCP Server Transport Instrumentation', () => { params: { name: 'test-tool', arguments: { input: 'test' } }, }; - const config = buildMcpServerSpanConfig(jsonRpcRequest, mockTransport, { - requestInfo: { - remoteAddress: '127.0.0.1', - remotePort: 8080, + const config = buildMcpServerSpanConfig( + jsonRpcRequest, + mockTransport, + { + requestInfo: { + remoteAddress: '127.0.0.1', + remotePort: 8080, + }, }, - }); + { recordInputs: true, recordOutputs: true }, + ); expect(config).toEqual({ name: 'tools/call test-tool', @@ -655,4 +660,102 @@ describe('MCP Server Transport Instrumentation', () => { expect(mockSpan.end).toHaveBeenCalled(); }); }); + + describe('Wrapper Options', () => { + it('should NOT capture inputs/outputs when sendDefaultPii is false', async () => { + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: false }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as any); + + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'tools/call', + id: 'tool-1', + params: { name: 'weather', arguments: { location: 'London' } }, + }, + {}, + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'mcp.request.argument.location': expect.anything(), + }), + }), + ); + }); + + it('should capture inputs/outputs when sendDefaultPii is true', async () => { + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as any); + + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'tools/call', + id: 'tool-1', + params: { name: 'weather', arguments: { location: 'London' } }, + }, + {}, + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'mcp.request.argument.location': '"London"', + }), + }), + ); + }); + + it('should allow explicit override of defaults', async () => { + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as any); + + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer, { recordInputs: false }); + const transport = createMockTransport(); + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'tools/call', + id: 'tool-1', + params: { name: 'weather', arguments: { location: 'London' } }, + }, + {}, + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'mcp.request.argument.location': expect.anything(), + }), + }), + ); + }); + }); }); From 314babc24a9b4a383bb3ecaa2ab9acdace243dbd Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Wed, 7 Jan 2026 14:24:15 +0200 Subject: [PATCH 48/76] =?UTF-8?q?feat(core):=20Add=20gen=5Fai.conversation?= =?UTF-8?q?.id=20attribute=20to=20OpenAI=20and=20LangGr=E2=80=A6=20(#18703?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for capturing conversation/session identifiers in AI integrations to enable linking messages across API calls. ## Changes ### OpenAI - Added instrumentation for the Conversations API (`conversations.create`) - Captures `gen_ai.conversation.id` from: - `conversation` parameter in `responses.create` - `previous_response_id` parameter for response chaining - Response object from `conversations.create` ### LangGraph - Captures `gen_ai.conversation.id` from `config.configurable.thread_id` when invoking agents Closes https://github.com/getsentry/sentry-javascript/issues/18702 --- .../tracing/langgraph/scenario-thread-id.mjs | 67 +++++++++++++ .../suites/tracing/langgraph/test.ts | 68 +++++++++++++ .../tracing/openai/scenario-conversation.mjs | 95 +++++++++++++++++++ .../suites/tracing/openai/test.ts | 71 ++++++++++++++ .../core/src/tracing/ai/gen-ai-attributes.ts | 8 ++ packages/core/src/tracing/langgraph/index.ts | 10 ++ packages/core/src/tracing/openai/constants.ts | 10 +- packages/core/src/tracing/openai/index.ts | 57 +++++------ packages/core/src/tracing/openai/types.ts | 17 +++- packages/core/src/tracing/openai/utils.ts | 89 +++++++++++++++++ .../core/test/lib/utils/openai-utils.test.ts | 39 ++++++++ 11 files changed, 496 insertions(+), 35 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-thread-id.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-thread-id.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-thread-id.mjs new file mode 100644 index 000000000000..415d85215278 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-thread-id.mjs @@ -0,0 +1,67 @@ +import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; +import * as Sentry from '@sentry/node'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'langgraph-thread-id-test' }, async () => { + // Define a simple mock LLM function + const mockLlm = () => { + return { + messages: [ + { + role: 'assistant', + content: 'Mock LLM response', + response_metadata: { + model_name: 'mock-model', + finish_reason: 'stop', + tokenUsage: { + promptTokens: 20, + completionTokens: 10, + totalTokens: 30, + }, + }, + }, + ], + }; + }; + + // Create and compile the graph + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', mockLlm) + .addEdge(START, 'agent') + .addEdge('agent', END) + .compile({ name: 'thread_test_agent' }); + + // Test 1: Invoke with thread_id in config + await graph.invoke( + { + messages: [{ role: 'user', content: 'Hello with thread ID' }], + }, + { + configurable: { + thread_id: 'thread_abc123_session_1', + }, + }, + ); + + // Test 2: Invoke with different thread_id (simulating different conversation) + await graph.invoke( + { + messages: [{ role: 'user', content: 'Different conversation' }], + }, + { + configurable: { + thread_id: 'thread_xyz789_session_2', + }, + }, + ); + + // Test 3: Invoke without thread_id (should not have gen_ai.conversation.id) + await graph.invoke({ + messages: [{ role: 'user', content: 'No thread ID here' }], + }); + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 6a67b5cd1e86..bafcdf49a32c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -205,4 +205,72 @@ describe('LangGraph integration', () => { await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_WITH_TOOLS }).start().completed(); }); }); + + // Test for thread_id (conversation ID) support + const EXPECTED_TRANSACTION_THREAD_ID = { + transaction: 'langgraph-thread-id-test', + spans: expect.arrayContaining([ + // create_agent span + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'create_agent', + 'sentry.op': 'gen_ai.create_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'thread_test_agent', + }, + description: 'create_agent thread_test_agent', + op: 'gen_ai.create_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // First invoke_agent span with thread_id + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'thread_test_agent', + 'gen_ai.pipeline.name': 'thread_test_agent', + // The thread_id should be captured as conversation.id + 'gen_ai.conversation.id': 'thread_abc123_session_1', + }), + description: 'invoke_agent thread_test_agent', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // Second invoke_agent span with different thread_id + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'thread_test_agent', + 'gen_ai.pipeline.name': 'thread_test_agent', + // Different thread_id for different conversation + 'gen_ai.conversation.id': 'thread_xyz789_session_2', + }), + description: 'invoke_agent thread_test_agent', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // Third invoke_agent span without thread_id (should NOT have gen_ai.conversation.id) + expect.objectContaining({ + data: expect.not.objectContaining({ + 'gen_ai.conversation.id': expect.anything(), + }), + description: 'invoke_agent thread_test_agent', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-thread-id.mjs', 'instrument.mjs', (createRunner, test) => { + test('should capture thread_id as gen_ai.conversation.id', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_THREAD_ID }).start().completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs new file mode 100644 index 000000000000..7088a6ca9cbe --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs @@ -0,0 +1,95 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + // Conversations API endpoint - create conversation + app.post('/openai/conversations', (req, res) => { + res.send({ + id: 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + object: 'conversation', + created_at: 1704067200, + metadata: {}, + }); + }); + + // Responses API endpoint - with conversation support + app.post('/openai/responses', (req, res) => { + const { model, conversation, previous_response_id } = req.body; + + res.send({ + id: 'resp_mock_conv_123', + object: 'response', + created_at: 1704067210, + model: model, + output: [ + { + type: 'message', + id: 'msg_mock_output_1', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text: `Response with conversation: ${conversation || 'none'}, previous_response_id: ${previous_response_id || 'none'}`, + annotations: [], + }, + ], + }, + ], + output_text: `Response with conversation: ${conversation || 'none'}`, + status: 'completed', + usage: { + input_tokens: 10, + output_tokens: 15, + total_tokens: 25, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'conversation-test' }, async () => { + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + // Test 1: Create a conversation + const conversation = await client.conversations.create(); + + // Test 2: Use conversation ID in responses.create + await client.responses.create({ + model: 'gpt-4', + input: 'Hello, this is a conversation test', + conversation: conversation.id, + }); + + // Test 3: Use previous_response_id for chaining (without formal conversation) + const firstResponse = await client.responses.create({ + model: 'gpt-4', + input: 'Tell me a joke', + }); + + await client.responses.create({ + model: 'gpt-4', + input: 'Explain why that is funny', + previous_response_id: firstResponse.id, + }); + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index d56bb27f6a24..db3a592a4870 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -645,4 +645,75 @@ describe('OpenAI integration', () => { }); }, ); + + // Test for conversation ID support (Conversations API and previous_response_id) + const EXPECTED_TRANSACTION_CONVERSATION = { + transaction: 'conversation-test', + spans: expect.arrayContaining([ + // First span - conversations.create returns conversation object with id + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'conversations', + 'sentry.op': 'gen_ai.conversations', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + // The conversation ID should be captured from the response + 'gen_ai.conversation.id': 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + }), + description: 'conversations unknown', + op: 'gen_ai.conversations', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - responses.create with conversation parameter + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + // The conversation ID should be captured from the request + 'gen_ai.conversation.id': 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + }), + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Third span - responses.create without conversation (first in chain, should NOT have gen_ai.conversation.id) + expect.objectContaining({ + data: expect.not.objectContaining({ + 'gen_ai.conversation.id': expect.anything(), + }), + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Fourth span - responses.create with previous_response_id (chaining) + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + // The previous_response_id should be captured as conversation.id + 'gen_ai.conversation.id': 'resp_mock_conv_123', + }), + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-conversation.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures conversation ID from Conversations API and previous_response_id', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_CONVERSATION }) + .start() + .completed(); + }); + }); }); diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index e76b2945b497..154e90cbaec1 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -154,6 +154,13 @@ export const GEN_AI_AGENT_NAME_ATTRIBUTE = 'gen_ai.agent.name'; */ export const GEN_AI_PIPELINE_NAME_ATTRIBUTE = 'gen_ai.pipeline.name'; +/** + * The conversation ID for linking messages across API calls + * For OpenAI Assistants API: thread_id + * For LangGraph: configurable.thread_id + */ +export const GEN_AI_CONVERSATION_ID_ATTRIBUTE = 'gen_ai.conversation.id'; + /** * The number of cache creation input tokens used */ @@ -254,6 +261,7 @@ export const OPENAI_OPERATIONS = { CHAT: 'chat', RESPONSES: 'responses', EMBEDDINGS: 'embeddings', + CONVERSATIONS: 'conversations', } as const; // ============================================================================= diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index 5601cddf458b..cfbe18bc4f88 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -3,6 +3,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from ' import { SPAN_STATUS_ERROR } from '../../tracing'; import { GEN_AI_AGENT_NAME_ATTRIBUTE, + GEN_AI_CONVERSATION_ID_ATTRIBUTE, GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_PIPELINE_NAME_ATTRIBUTE, @@ -113,6 +114,15 @@ function instrumentCompiledGraphInvoke( span.updateName(`invoke_agent ${graphName}`); } + // Extract thread_id from the config (second argument) + // LangGraph uses config.configurable.thread_id for conversation/session linking + const config = args.length > 1 ? (args[1] as Record | undefined) : undefined; + const configurable = config?.configurable as Record | undefined; + const threadId = configurable?.thread_id; + if (threadId && typeof threadId === 'string') { + span.setAttribute(GEN_AI_CONVERSATION_ID_ATTRIBUTE, threadId); + } + // Extract available tools from the graph instance const tools = extractToolsFromCompiledGraph(graphInstance); if (tools) { diff --git a/packages/core/src/tracing/openai/constants.ts b/packages/core/src/tracing/openai/constants.ts index e8b5c6ddc87f..426cda443680 100644 --- a/packages/core/src/tracing/openai/constants.ts +++ b/packages/core/src/tracing/openai/constants.ts @@ -2,7 +2,15 @@ export const OPENAI_INTEGRATION_NAME = 'OpenAI'; // https://platform.openai.com/docs/quickstart?api-mode=responses // https://platform.openai.com/docs/quickstart?api-mode=chat -export const INSTRUMENTED_METHODS = ['responses.create', 'chat.completions.create', 'embeddings.create'] as const; +// https://platform.openai.com/docs/api-reference/conversations +export const INSTRUMENTED_METHODS = [ + 'responses.create', + 'chat.completions.create', + 'embeddings.create', + // Conversations API - for conversation state management + // https://platform.openai.com/docs/guides/conversation-state + 'conversations.create', +] as const; export const RESPONSES_TOOL_CALL_EVENT_TYPES = [ 'response.output_item.added', 'response.function_call_arguments.delta', diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index c68e920daf2b..031cbb8ee47e 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -7,15 +7,8 @@ import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, - GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE, - GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, - GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, - GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, - GEN_AI_REQUEST_STREAM_ATTRIBUTE, - GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, - GEN_AI_REQUEST_TOP_P_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; @@ -31,17 +24,34 @@ import type { } from './types'; import { addChatCompletionAttributes, + addConversationAttributes, addEmbeddingsAttributes, addResponsesApiAttributes, buildMethodPath, + extractRequestParameters, getOperationName, getSpanOperation, isChatCompletionResponse, + isConversationResponse, isEmbeddingsResponse, isResponsesApiResponse, shouldInstrument, } from './utils'; +/** + * Extract available tools from request parameters + */ +function extractAvailableTools(params: Record): string | undefined { + const tools = Array.isArray(params.tools) ? params.tools : []; + const hasWebSearchOptions = params.web_search_options && typeof params.web_search_options === 'object'; + const webSearchOptions = hasWebSearchOptions + ? [{ type: 'web_search_options', ...(params.web_search_options as Record) }] + : []; + + const availableTools = [...tools, ...webSearchOptions]; + return availableTools.length > 0 ? JSON.stringify(availableTools) : undefined; +} + /** * Extract request attributes from method arguments */ @@ -52,36 +62,15 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record 0 && typeof args[0] === 'object' && args[0] !== null) { const params = args[0] as Record; - const tools = Array.isArray(params.tools) ? params.tools : []; - const hasWebSearchOptions = params.web_search_options && typeof params.web_search_options === 'object'; - const webSearchOptions = hasWebSearchOptions - ? [{ type: 'web_search_options', ...(params.web_search_options as Record) }] - : []; - - const availableTools = [...tools, ...webSearchOptions]; - - if (availableTools.length > 0) { - attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = JSON.stringify(availableTools); + const availableTools = extractAvailableTools(params); + if (availableTools) { + attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = availableTools; } - } - - if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { - const params = args[0] as Record; - attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = params.model ?? 'unknown'; - if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = params.temperature; - if ('top_p' in params) attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE] = params.top_p; - if ('frequency_penalty' in params) - attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE] = params.frequency_penalty; - if ('presence_penalty' in params) attributes[GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE] = params.presence_penalty; - if ('stream' in params) attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = params.stream; - if ('encoding_format' in params) attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE] = params.encoding_format; - if ('dimensions' in params) attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] = params.dimensions; + Object.assign(attributes, extractRequestParameters(params)); } else { attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = 'unknown'; } @@ -91,7 +80,7 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record; +} + +export type OpenAiResponse = + | OpenAiChatCompletionObject + | OpenAIResponseObject + | OpenAICreateEmbeddingsObject + | OpenAIConversationObject; /** * Streaming event types for the Responses API diff --git a/packages/core/src/tracing/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts index 4dff5b4fdbb8..007dd93a91b1 100644 --- a/packages/core/src/tracing/openai/utils.ts +++ b/packages/core/src/tracing/openai/utils.ts @@ -1,5 +1,14 @@ import type { Span } from '../../types-hoist/span'; import { + GEN_AI_CONVERSATION_ID_ATTRIBUTE, + GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE, + GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, + GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_REQUEST_TOP_P_ATTRIBUTE, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_ID_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, @@ -19,6 +28,7 @@ import type { ChatCompletionChunk, InstrumentedMethod, OpenAiChatCompletionObject, + OpenAIConversationObject, OpenAICreateEmbeddingsObject, OpenAIResponseObject, ResponseStreamingEvent, @@ -37,6 +47,9 @@ export function getOperationName(methodPath: string): string { if (methodPath.includes('embeddings')) { return OPENAI_OPERATIONS.EMBEDDINGS; } + if (methodPath.includes('conversations')) { + return OPENAI_OPERATIONS.CONVERSATIONS; + } return methodPath.split('.').pop() || 'unknown'; } @@ -101,6 +114,19 @@ export function isEmbeddingsResponse(response: unknown): response is OpenAICreat ); } +/** + * Check if response is a Conversations API object + * @see https://platform.openai.com/docs/api-reference/conversations + */ +export function isConversationResponse(response: unknown): response is OpenAIConversationObject { + return ( + response !== null && + typeof response === 'object' && + 'object' in response && + (response as Record).object === 'conversation' + ); +} + /** * Check if streaming event is from the Responses API */ @@ -221,6 +247,27 @@ export function addEmbeddingsAttributes(span: Span, response: OpenAICreateEmbedd } } +/** + * Add attributes for Conversations API responses + * @see https://platform.openai.com/docs/api-reference/conversations + */ +export function addConversationAttributes(span: Span, response: OpenAIConversationObject): void { + const { id, created_at } = response; + + span.setAttributes({ + [OPENAI_RESPONSE_ID_ATTRIBUTE]: id, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: id, + // The conversation id is used to link messages across API calls + [GEN_AI_CONVERSATION_ID_ATTRIBUTE]: id, + }); + + if (created_at) { + span.setAttributes({ + [OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(created_at * 1000).toISOString(), + }); + } +} + /** * Set token usage attributes * @param span - The span to add attributes to @@ -273,3 +320,45 @@ export function setCommonResponseAttributes(span: Span, id: string, model: strin [OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(timestamp * 1000).toISOString(), }); } + +/** + * Extract conversation ID from request parameters + * Supports both Conversations API and previous_response_id chaining + * @see https://platform.openai.com/docs/guides/conversation-state + */ +function extractConversationId(params: Record): string | undefined { + // Conversations API: conversation parameter (e.g., "conv_...") + if ('conversation' in params && typeof params.conversation === 'string') { + return params.conversation; + } + // Responses chaining: previous_response_id links to parent response + if ('previous_response_id' in params && typeof params.previous_response_id === 'string') { + return params.previous_response_id; + } + return undefined; +} + +/** + * Extract request parameters including model settings and conversation context + */ +export function extractRequestParameters(params: Record): Record { + const attributes: Record = { + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: params.model ?? 'unknown', + }; + + if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = params.temperature; + if ('top_p' in params) attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE] = params.top_p; + if ('frequency_penalty' in params) attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE] = params.frequency_penalty; + if ('presence_penalty' in params) attributes[GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE] = params.presence_penalty; + if ('stream' in params) attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = params.stream; + if ('encoding_format' in params) attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE] = params.encoding_format; + if ('dimensions' in params) attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] = params.dimensions; + + // Capture conversation ID for linking messages across API calls + const conversationId = extractConversationId(params); + if (conversationId) { + attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE] = conversationId; + } + + return attributes; +} diff --git a/packages/core/test/lib/utils/openai-utils.test.ts b/packages/core/test/lib/utils/openai-utils.test.ts index c68a35e5becc..ff951e8be40b 100644 --- a/packages/core/test/lib/utils/openai-utils.test.ts +++ b/packages/core/test/lib/utils/openai-utils.test.ts @@ -5,6 +5,7 @@ import { getSpanOperation, isChatCompletionChunk, isChatCompletionResponse, + isConversationResponse, isResponsesApiResponse, isResponsesApiStreamEvent, shouldInstrument, @@ -22,6 +23,11 @@ describe('openai-utils', () => { expect(getOperationName('some.path.responses.method')).toBe('responses'); }); + it('should return conversations for conversations methods', () => { + expect(getOperationName('conversations.create')).toBe('conversations'); + expect(getOperationName('some.path.conversations.method')).toBe('conversations'); + }); + it('should return the last part of path for unknown methods', () => { expect(getOperationName('some.unknown.method')).toBe('method'); expect(getOperationName('create')).toBe('create'); @@ -44,6 +50,7 @@ describe('openai-utils', () => { it('should return true for instrumented methods', () => { expect(shouldInstrument('responses.create')).toBe(true); expect(shouldInstrument('chat.completions.create')).toBe(true); + expect(shouldInstrument('conversations.create')).toBe(true); }); it('should return false for non-instrumented methods', () => { @@ -146,4 +153,36 @@ describe('openai-utils', () => { expect(isChatCompletionChunk({ object: null })).toBe(false); }); }); + + describe('isConversationResponse', () => { + it('should return true for valid conversation responses', () => { + const validConversation = { + object: 'conversation', + id: 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + created_at: 1704067200, + }; + expect(isConversationResponse(validConversation)).toBe(true); + }); + + it('should return true for conversation with metadata', () => { + const conversationWithMetadata = { + object: 'conversation', + id: 'conv_123', + created_at: 1704067200, + metadata: { user_id: 'user_123' }, + }; + expect(isConversationResponse(conversationWithMetadata)).toBe(true); + }); + + it('should return false for invalid responses', () => { + expect(isConversationResponse(null)).toBe(false); + expect(isConversationResponse(undefined)).toBe(false); + expect(isConversationResponse('string')).toBe(false); + expect(isConversationResponse(123)).toBe(false); + expect(isConversationResponse({})).toBe(false); + expect(isConversationResponse({ object: 'thread' })).toBe(false); + expect(isConversationResponse({ object: 'response' })).toBe(false); + expect(isConversationResponse({ object: null })).toBe(false); + }); + }); }); From a6a4f7b80085d34bfc4ea67d9058685eb13bd8ac Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 7 Jan 2026 14:27:45 +0100 Subject: [PATCH 49/76] ref(core): Streamline and test `browserPerformanceTimeOrigin` (#18708) - Remove a couple of bytes, by no longer storing the source of the timeOrigin. While this would be arguably valuable, we never used this information, so right now it was just wasted space. If we ever need it, we can bring it back of course) - Add tests for the time origin determination, reliability testing and fallback logic - Add TODOs for future improvements Blocked on major: We can remove an entire branch of this code, if we decide to drop Safari 14 support (opened #18707 to track) --- packages/core/src/utils/time.ts | 36 +++--- packages/core/test/lib/utils/time.test.ts | 142 ++++++++++++++++++++++ 2 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 packages/core/test/lib/utils/time.test.ts diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index ff858a15b0ac..bfed9386f8bb 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -74,22 +74,23 @@ export function timestampInSeconds(): number { /** * Cached result of getBrowserTimeOrigin. */ -let cachedTimeOrigin: [number | undefined, string] | undefined; +let cachedTimeOrigin: number | null | undefined = null; /** * Gets the time origin and the mode used to determine it. + * TODO: move to `@sentry/browser-utils` package. */ -function getBrowserTimeOrigin(): [number | undefined, string] { +function getBrowserTimeOrigin(): number | undefined { // Unfortunately browsers may report an inaccurate time origin data, through either performance.timeOrigin or // performance.timing.navigationStart, which results in poor results in performance data. We only treat time origin // data as reliable if they are within a reasonable threshold of the current time. - const { performance } = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; if (!performance?.now) { - return [undefined, 'none']; + return undefined; } - const threshold = 3600 * 1000; + // TOOD: We should probably set a much tighter threshold here as skew can already happen within just a few minutes. + const threshold = 3_600_000; // 1 hour in milliseconds const performanceNow = performance.now(); const dateNow = Date.now(); @@ -99,6 +100,10 @@ function getBrowserTimeOrigin(): [number | undefined, string] { : threshold; const timeOriginIsReliable = timeOriginDelta < threshold; + // TODO: Remove all code related to `performance.timing.navigationStart` once we drop support for Safari 14. + // `performance.timeSince` is available in Safari 15. + // see: https://caniuse.com/mdn-api_performance_timeorigin + // While performance.timing.navigationStart is deprecated in favor of performance.timeOrigin, performance.timeOrigin // is not as widely supported. Namely, performance.timeOrigin is undefined in Safari as of writing. // Also as of writing, performance.timing is not available in Web Workers in mainstream browsers, so it is not always @@ -111,17 +116,18 @@ function getBrowserTimeOrigin(): [number | undefined, string] { const navigationStartDelta = hasNavigationStart ? Math.abs(navigationStart + performanceNow - dateNow) : threshold; const navigationStartIsReliable = navigationStartDelta < threshold; - if (timeOriginIsReliable || navigationStartIsReliable) { - // Use the more reliable time origin - if (timeOriginDelta <= navigationStartDelta) { - return [performance.timeOrigin, 'timeOrigin']; - } else { - return [navigationStart, 'navigationStart']; - } + // TODO: Since timeOrigin explicitly replaces navigationStart, we should probably remove the navigationStartIsReliable check. + if (timeOriginIsReliable && timeOriginDelta <= navigationStartDelta) { + return performance.timeOrigin; + } + + if (navigationStartIsReliable) { + return navigationStart; } + // TODO: We should probably fall back to Date.now() - performance.now(), since this is still more accurate than just Date.now() (?) // Either both timeOrigin and navigationStart are skewed or neither is available, fallback to Date. - return [dateNow, 'dateNow']; + return dateNow; } /** @@ -129,9 +135,9 @@ function getBrowserTimeOrigin(): [number | undefined, string] { * performance API is available. */ export function browserPerformanceTimeOrigin(): number | undefined { - if (!cachedTimeOrigin) { + if (cachedTimeOrigin === null) { cachedTimeOrigin = getBrowserTimeOrigin(); } - return cachedTimeOrigin[0]; + return cachedTimeOrigin; } diff --git a/packages/core/test/lib/utils/time.test.ts b/packages/core/test/lib/utils/time.test.ts new file mode 100644 index 000000000000..e40c607cb409 --- /dev/null +++ b/packages/core/test/lib/utils/time.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest'; + +async function getFreshPerformanceTimeOrigin() { + // Adding the query param with the date, forces a fresh import each time this is called + // otherwise, the dynamic import would be cached and thus fall back to the cached value. + const timeModule = await import(`../../../src/utils/time?update=${Date.now()}`); + return timeModule.browserPerformanceTimeOrigin(); +} + +const RELIABLE_THRESHOLD_MS = 3_600_000; + +describe('browserPerformanceTimeOrigin', () => { + it('returns `performance.timeOrigin` if it is available and reliable', async () => { + const timeOrigin = await getFreshPerformanceTimeOrigin(); + expect(timeOrigin).toBeDefined(); + expect(timeOrigin).toBeGreaterThan(0); + expect(timeOrigin).toBeLessThan(Date.now()); + expect(timeOrigin).toBe(performance.timeOrigin); + }); + + it('returns `undefined` if `performance.now` is not available', async () => { + vi.stubGlobal('performance', undefined); + + const timeOrigin = await getFreshPerformanceTimeOrigin(); + expect(timeOrigin).toBeUndefined(); + + vi.unstubAllGlobals(); + }); + + it('returns `Date.now()` if `performance.timeOrigin` is not reliable', async () => { + const currentTimeMs = 1767778040866; + + const unreliableTime = currentTimeMs - RELIABLE_THRESHOLD_MS - 2_000; + + const timeSincePageloadMs = 1_234.789; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + + vi.stubGlobal('performance', { + timeOrigin: unreliableTime, + timing: { + navigationStart: unreliableTime, + }, + now: () => timeSincePageloadMs, + }); + + const timeOrigin = await getFreshPerformanceTimeOrigin(); + expect(timeOrigin).toBe(1767778040866); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('returns `performance.timing.navigationStart` if `performance.timeOrigin` is not available', async () => { + const currentTimeMs = 1767778040870; + + const navigationStartMs = currentTimeMs - 2_000; + + const timeSincePageloadMs = 1_234.789; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + + vi.stubGlobal('performance', { + timeOrigin: undefined, + timing: { + navigationStart: navigationStartMs, + }, + now: () => timeSincePageloadMs, + }); + + const timeOrigin = await getFreshPerformanceTimeOrigin(); + expect(timeOrigin).toBe(navigationStartMs); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('returns `performance.timing.navigationStart` if `performance.timeOrigin` is less reliable', async () => { + const currentTimeMs = 1767778040874; + + const navigationStartMs = currentTimeMs - 2_000; + + const timeSincePageloadMs = 1_234.789; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + + vi.stubGlobal('performance', { + timeOrigin: navigationStartMs - 1, + timing: { + navigationStart: navigationStartMs, + }, + now: () => timeSincePageloadMs, + }); + + const timeOrigin = await getFreshPerformanceTimeOrigin(); + expect(timeOrigin).toBe(navigationStartMs); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + describe('caching', () => { + it('caches `undefined` result', async () => { + vi.stubGlobal('performance', undefined); + + const timeModule = await import(`../../../src/utils/time?update=${Date.now()}`); + + const result1 = timeModule.browserPerformanceTimeOrigin(); + + expect(result1).toBeUndefined(); + + vi.stubGlobal('performance', { + timeOrigin: 1000, + now: () => 100, + }); + + const result2 = timeModule.browserPerformanceTimeOrigin(); + expect(result2).toBeUndefined(); // Should still be undefined due to caching + + vi.unstubAllGlobals(); + }); + + it('caches `number` result', async () => { + const timeModule = await import(`../../../src/utils/time?update=${Date.now()}`); + const result = timeModule.browserPerformanceTimeOrigin(); + const timeOrigin = performance.timeOrigin; + expect(result).toBe(timeOrigin); + + vi.stubGlobal('performance', { + now: undefined, + }); + + const result2 = timeModule.browserPerformanceTimeOrigin(); + expect(result2).toBe(timeOrigin); + + vi.unstubAllGlobals(); + }); + }); +}); From d0840e92a553dfd7d91a07d3978ed3294de97a4b Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:44:03 +0100 Subject: [PATCH 50/76] feat(nuxt): Detect development environment and add dev E2E test (#18671) Nuxt shows if it is running in a dev environment in the variable `import.meta.dev` ([docs](https://nuxt.com/docs/4.x/api/advanced/import-meta#runtime-app-properties)) - the Vite variable `import.meta.env.DEV` ([docs](https://vite.dev/guide/env-and-mode#built-in-constants)) is not available. To test this, a dev E2E test environment has been added. This includes a bash script which runs `nuxt dev` first to generate the necessary files and then starts the dev command again with the `NODE_OPTIONS` env. Right now, the development environment tests only test this specific feature (detecting the dev environment) because a bunch of existing tests are failing in dev mode. This is probably because the dev test setup needs to be a bit different (like waiting for `networkidle`) and a lot of tests would need some adaption (can be done in another PR). Added an entry to contributors as there was already a PR for that a while ago: https://github.com/getsentry/sentry-javascript/pull/15179 Closes https://github.com/getsentry/sentry-javascript/issues/15623 Closes https://github.com/getsentry/sentry-javascript/issues/15147 --- CHANGELOG.md | 2 +- .../nuxt-4/nuxt-start-dev-server.bash | 49 ++++++++++++ .../test-applications/nuxt-4/package.json | 4 +- .../nuxt-4/playwright.config.ts | 20 ++++- .../nuxt-4/sentry.client.config.ts | 1 - .../nuxt-4/sentry.server.config.ts | 1 - .../nuxt-4/tests/environment.test.ts | 77 +++++++++++++++++++ .../nuxt-4/tests/isDevMode.ts | 1 + packages/core/src/constants.ts | 1 + packages/core/src/index.ts | 2 +- packages/nuxt/package.json | 1 + packages/nuxt/src/client/sdk.ts | 3 +- packages/nuxt/src/server/sdk.ts | 12 ++- 13 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/isDevMode.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 72adf7a77e0e..42e61ce31050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, @G-Rath, and @gianpaj. Thank you for your contributions! +Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, @G-Rath, @maximepvrt, and @gianpaj. Thank you for your contributions! - ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash new file mode 100644 index 000000000000..1204eabb7c87 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash @@ -0,0 +1,49 @@ +#!/bin/bash +# To enable Sentry in Nuxt dev, it needs the sentry.server.config.mjs file from the .nuxt folder. +# First, we need to start 'nuxt dev' to generate the file, and then start 'nuxt dev' again with the NODE_OPTIONS to have Sentry enabled. + +# Using a different port to avoid playwright already starting with the tests for port 3030 +TEMP_PORT=3035 + +# 1. Start dev in background - this generates .nuxt folder +pnpm dev -p $TEMP_PORT & +DEV_PID=$! + +# 2. Wait for the sentry.server.config.mjs file to appear +echo "Waiting for .nuxt/dev/sentry.server.config.mjs file..." +COUNTER=0 +while [ ! -f ".nuxt/dev/sentry.server.config.mjs" ] && [ $COUNTER -lt 30 ]; do + sleep 1 + ((COUNTER++)) +done + +if [ ! -f ".nuxt/dev/sentry.server.config.mjs" ]; then + echo "ERROR: .nuxt/dev/sentry.server.config.mjs file never appeared!" + echo "This usually means the Nuxt dev server failed to start or generate the file. Try to rerun the test." + pkill -P $DEV_PID || kill $DEV_PID + exit 1 +fi + +# 3. Cleanup +echo "Found .nuxt/dev/sentry.server.config.mjs, stopping 'nuxt dev' process..." +pkill -P $DEV_PID || kill $DEV_PID + +# Wait for port to be released +echo "Waiting for port $TEMP_PORT to be released..." +COUNTER=0 +# Check if port is still in use +while lsof -i :$TEMP_PORT > /dev/null 2>&1 && [ $COUNTER -lt 10 ]; do + sleep 1 + ((COUNTER++)) +done + +if lsof -i :$TEMP_PORT > /dev/null 2>&1; then + echo "WARNING: Port $TEMP_PORT still in use after 10 seconds, proceeding anyway..." +else + echo "Port $TEMP_PORT released successfully" +fi + +echo "Starting nuxt dev with Sentry server config..." + +# 4. Start the actual dev command which should be used for the tests +NODE_OPTIONS='--import ./.nuxt/dev/sentry.server.config.mjs' nuxt dev diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index eb28e69b0633..ebd383e48eb9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -11,9 +11,11 @@ "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", "clean": "npx nuxi cleanup", "test": "playwright test", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test environment", "test:build": "pnpm install && pnpm build", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test:prod && pnpm test:dev" }, "dependencies": { "@pinia/nuxt": "^0.5.5", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts index e07fb02e5218..d3618f176d02 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts @@ -1,7 +1,25 @@ 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') { + return 'bash ./nuxt-start-dev-server.bash'; + } + + if (testEnv === 'production') { + return 'pnpm start:import'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + const config = getPlaywrightConfig({ - startCommand: `pnpm start:import`, + startCommand: getStartCommand(), }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts index 3cbea64827cb..7b5d97ff0b09 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/nuxt'; import { usePinia, useRuntimeConfig } from '#imports'; Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions dsn: useRuntimeConfig().public.sentry.dsn, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index 729b2296c683..26519911072b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions tunnel: 'http://localhost:3031/', // proxy server }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts new file mode 100644 index 000000000000..b59e4560165b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; + +test.describe('environment detection', async () => { + test('sets correct environment for client-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-4', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-4 E2E test app'; + }); + + // We have to wait for networkidle in dev mode because clicking the button is a no-op otherwise (network requests are blocked during page load) + await page.goto(`/client-error`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for client-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const transaction = await transactionPromise; + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-4', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toBe('GET /api/server-error'); + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace.op).toBe('http.server'); + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 38475b857ace..7fdc380faf0d 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -1 +1,2 @@ export const DEFAULT_ENVIRONMENT = 'production'; +export const DEV_ENVIRONMENT = 'development'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c4884edf939b..30e24c3b35c7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,7 +101,7 @@ export { headersToDict, httpHeadersToSpanAttributes, } from './utils/request'; -export { DEFAULT_ENVIRONMENT } from './constants'; +export { DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; export { functionToStringIntegration } from './integrations/functiontostring'; // eslint-disable-next-line deprecation/deprecation diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index bbb4086d3dc2..37e6e52c1477 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -53,6 +53,7 @@ "@sentry/cloudflare": "10.32.1", "@sentry/core": "10.32.1", "@sentry/node": "10.32.1", + "@sentry/node-core": "10.32.1", "@sentry/rollup-plugin": "^4.6.1", "@sentry/vite-plugin": "^4.6.1", "@sentry/vue": "10.32.1" diff --git a/packages/nuxt/src/client/sdk.ts b/packages/nuxt/src/client/sdk.ts index 5db856dae689..f0654a97c201 100644 --- a/packages/nuxt/src/client/sdk.ts +++ b/packages/nuxt/src/client/sdk.ts @@ -1,6 +1,6 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowser } from '@sentry/browser'; import type { Client } from '@sentry/core'; -import { applySdkMetadata } from '@sentry/core'; +import { applySdkMetadata, DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from '@sentry/core'; import type { SentryNuxtClientOptions } from '../common/types'; /** @@ -12,6 +12,7 @@ export function init(options: SentryNuxtClientOptions): Client | undefined { const sentryOptions = { /* BrowserTracing is added later with the Nuxt client plugin */ defaultIntegrations: [...getBrowserDefaultIntegrations(options)], + environment: import.meta.dev ? DEV_ENVIRONMENT : DEFAULT_ENVIRONMENT, ...options, }; diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index 2b492b1249ac..2621f3f77f9a 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -1,12 +1,21 @@ import * as path from 'node:path'; import type { Client, Event, EventProcessor, Integration } from '@sentry/core'; -import { applySdkMetadata, debug, flush, getGlobalScope, vercelWaitUntil } from '@sentry/core'; +import { + applySdkMetadata, + debug, + DEFAULT_ENVIRONMENT, + DEV_ENVIRONMENT, + flush, + getGlobalScope, + vercelWaitUntil, +} from '@sentry/core'; import { getDefaultIntegrations as getDefaultNodeIntegrations, httpIntegration, init as initNode, type NodeOptions, } from '@sentry/node'; +import { isCjs } from '@sentry/node-core'; import { DEBUG_BUILD } from '../common/debug-build'; import type { SentryNuxtServerOptions } from '../common/types'; @@ -17,6 +26,7 @@ import type { SentryNuxtServerOptions } from '../common/types'; */ export function init(options: SentryNuxtServerOptions): Client | undefined { const sentryOptions = { + environment: !isCjs() && import.meta.dev ? DEV_ENVIRONMENT : DEFAULT_ENVIRONMENT, ...options, defaultIntegrations: getNuxtDefaultIntegrations(options), }; From a68ac9076dabbeecebca5b455944e45ba35870d4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 7 Jan 2026 16:41:17 +0100 Subject: [PATCH 51/76] fix(core): Subtract `performance.now()` from `browserPerformanceTimeOrigin` fallback (#18715) This patch fixes a "bug" where we previously returned just `Date.now()` as a fallback for getting the timeOrigin if more precise `performance.timeOrigin` or `performance.timing.navigationStart` were not available or unreliable. This fix now subtracts `performance.now()` from `Date.now()` which should make the fallback more accurate. Closes #18716 (added automatically) --- packages/core/src/utils/time.ts | 6 ++--- packages/core/test/lib/utils/time.test.ts | 28 ++++++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index bfed9386f8bb..1f1b4af1a434 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -125,9 +125,9 @@ function getBrowserTimeOrigin(): number | undefined { return navigationStart; } - // TODO: We should probably fall back to Date.now() - performance.now(), since this is still more accurate than just Date.now() (?) - // Either both timeOrigin and navigationStart are skewed or neither is available, fallback to Date. - return dateNow; + // Either both timeOrigin and navigationStart are skewed or neither is available, fallback to subtracting + // `performance.now()` from `Date.now()`. + return dateNow - performanceNow; } /** diff --git a/packages/core/test/lib/utils/time.test.ts b/packages/core/test/lib/utils/time.test.ts index e40c607cb409..585e0d5387d4 100644 --- a/packages/core/test/lib/utils/time.test.ts +++ b/packages/core/test/lib/utils/time.test.ts @@ -27,12 +27,12 @@ describe('browserPerformanceTimeOrigin', () => { vi.unstubAllGlobals(); }); - it('returns `Date.now()` if `performance.timeOrigin` is not reliable', async () => { + it('returns `Date.now() - performance.now()` if `performance.timeOrigin` is not reliable', async () => { const currentTimeMs = 1767778040866; const unreliableTime = currentTimeMs - RELIABLE_THRESHOLD_MS - 2_000; - const timeSincePageloadMs = 1_234.789; + const timeSincePageloadMs = 1_234.56789; vi.useFakeTimers(); vi.setSystemTime(new Date(currentTimeMs)); @@ -46,7 +46,29 @@ describe('browserPerformanceTimeOrigin', () => { }); const timeOrigin = await getFreshPerformanceTimeOrigin(); - expect(timeOrigin).toBe(1767778040866); + expect(timeOrigin).toBe(currentTimeMs - timeSincePageloadMs); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('returns `Date.now() - performance.now()` if neither `performance.timeOrigin` nor `performance.timing.navigationStart` are available', async () => { + const currentTimeMs = 1767778040866; + + const timeSincePageloadMs = 1_234.56789; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: undefined, + timing: { + navigationStart: undefined, + }, + now: () => timeSincePageloadMs, + }); + + const timeOrigin = await getFreshPerformanceTimeOrigin(); + expect(timeOrigin).toBe(currentTimeMs - timeSincePageloadMs); vi.useRealTimers(); vi.unstubAllGlobals(); From 107e2b090816b67c0df6031ab66349df583f688a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 7 Jan 2026 16:43:00 +0100 Subject: [PATCH 52/76] ref(core): Remove dependence between `performance.timeOrigin` and `performance.timing.navigationStart` (#18710) The `performance.timeOrigin` HighResTimestamp [replaced `performance.timing.navigationStart`](https://www.w3.org/TR/navigation-timing-2/#dom-performancetiming-navigationstart) a while ago. With this patch we can simplify our timeOrigin determination logic by fully decoupling both values. Previously, we'd only take `timeOrigin` if `navigationStart` was "more reliable" (less delta to `Date.now()`). Since `timeOrigin` has sub-millisecond precision, this leads to cases where we'd incorrectly take the older, less precise `navigationStart` entry, simply because `timeOrigin` had a couple of decimal places. --- packages/core/src/utils/time.ts | 37 +++++++++++------------ packages/core/test/lib/utils/time.test.ts | 25 --------------- 2 files changed, 17 insertions(+), 45 deletions(-) diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index 1f1b4af1a434..b46c569433d0 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -78,12 +78,14 @@ let cachedTimeOrigin: number | null | undefined = null; /** * Gets the time origin and the mode used to determine it. + * + * Unfortunately browsers may report an inaccurate time origin data, through either performance.timeOrigin or + * performance.timing.navigationStart, which results in poor results in performance data. We only treat time origin + * data as reliable if they are within a reasonable threshold of the current time. + * * TODO: move to `@sentry/browser-utils` package. */ function getBrowserTimeOrigin(): number | undefined { - // Unfortunately browsers may report an inaccurate time origin data, through either performance.timeOrigin or - // performance.timing.navigationStart, which results in poor results in performance data. We only treat time origin - // data as reliable if they are within a reasonable threshold of the current time. const { performance } = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; if (!performance?.now) { return undefined; @@ -94,11 +96,13 @@ function getBrowserTimeOrigin(): number | undefined { const performanceNow = performance.now(); const dateNow = Date.now(); - // if timeOrigin isn't available set delta to threshold so it isn't used - const timeOriginDelta = performance.timeOrigin - ? Math.abs(performance.timeOrigin + performanceNow - dateNow) - : threshold; - const timeOriginIsReliable = timeOriginDelta < threshold; + const timeOrigin = performance.timeOrigin; + if (typeof timeOrigin === 'number') { + const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow); + if (timeOriginDelta < threshold) { + return timeOrigin; + } + } // TODO: Remove all code related to `performance.timing.navigationStart` once we drop support for Safari 14. // `performance.timeSince` is available in Safari 15. @@ -111,18 +115,11 @@ function getBrowserTimeOrigin(): number | undefined { // Date API. // eslint-disable-next-line deprecation/deprecation const navigationStart = performance.timing?.navigationStart; - const hasNavigationStart = typeof navigationStart === 'number'; - // if navigationStart isn't available set delta to threshold so it isn't used - const navigationStartDelta = hasNavigationStart ? Math.abs(navigationStart + performanceNow - dateNow) : threshold; - const navigationStartIsReliable = navigationStartDelta < threshold; - - // TODO: Since timeOrigin explicitly replaces navigationStart, we should probably remove the navigationStartIsReliable check. - if (timeOriginIsReliable && timeOriginDelta <= navigationStartDelta) { - return performance.timeOrigin; - } - - if (navigationStartIsReliable) { - return navigationStart; + if (typeof navigationStart === 'number') { + const navigationStartDelta = Math.abs(navigationStart + performanceNow - dateNow); + if (navigationStartDelta < threshold) { + return navigationStart; + } } // Either both timeOrigin and navigationStart are skewed or neither is available, fallback to subtracting diff --git a/packages/core/test/lib/utils/time.test.ts b/packages/core/test/lib/utils/time.test.ts index 585e0d5387d4..7f3f7f2d6c19 100644 --- a/packages/core/test/lib/utils/time.test.ts +++ b/packages/core/test/lib/utils/time.test.ts @@ -99,31 +99,6 @@ describe('browserPerformanceTimeOrigin', () => { vi.unstubAllGlobals(); }); - it('returns `performance.timing.navigationStart` if `performance.timeOrigin` is less reliable', async () => { - const currentTimeMs = 1767778040874; - - const navigationStartMs = currentTimeMs - 2_000; - - const timeSincePageloadMs = 1_234.789; - - vi.useFakeTimers(); - vi.setSystemTime(new Date(currentTimeMs)); - - vi.stubGlobal('performance', { - timeOrigin: navigationStartMs - 1, - timing: { - navigationStart: navigationStartMs, - }, - now: () => timeSincePageloadMs, - }); - - const timeOrigin = await getFreshPerformanceTimeOrigin(); - expect(timeOrigin).toBe(navigationStartMs); - - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - describe('caching', () => { it('caches `undefined` result', async () => { vi.stubGlobal('performance', undefined); From 40ec7f8c6e152212f272133d38410d0761d03049 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:39:03 +0100 Subject: [PATCH 53/76] chore(e2e): Unpin react-router-7-framework-spa to ^7.11.0 (#18551) This unpins the React Router version from 7.10.1 to ^7.11.0 to allow automatic updates when [the SPA mode vite preview bug](https://github.com/remix-run/react-router/issues/14672) is fixed upstream. **Background**: React Router 7.11.0 introduced a bug where vite preview attempts to load the deleted server build in SPA mode (ssr: false). We [pinned to 7.10.1 as a workaround](https://github.com/getsentry/sentry-javascript/pull/18548), but this branch prepares for future unpinning once the issue is resolved. --------- Co-authored-by: Charly Gomez --- .../react-router-7-framework-spa/app/entry.client.tsx | 2 +- .../react-router-7-framework-spa/package.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx index 223c8e6129dd..7448ebe7bfe2 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx @@ -9,7 +9,7 @@ Sentry.init({ dsn: 'https://username@domain/123', integrations: [Sentry.reactRouterTracingIntegration()], tracesSampleRate: 1.0, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: `http://localhost:3031/`, tracePropagationTargets: [/^\//], }); 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 3421b6e913c3..3d102291ff55 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 @@ -18,16 +18,16 @@ }, "dependencies": { "@sentry/react-router": "latest || *", - "@react-router/node": "7.10.1", - "@react-router/serve": "7.10.1", + "@react-router/node": "^7.11.0", + "@react-router/serve": "^7.11.0", "isbot": "^5.1.27", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router": "7.10.1" + "react-router": "^7.11.0" }, "devDependencies": { "@playwright/test": "~1.56.0", - "@react-router/dev": "7.10.1", + "@react-router/dev": "^7.11.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@tailwindcss/vite": "^4.1.4", "@types/node": "^20", From 0d0fd782646e102aff8993624059c4268170408d Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:39:31 +0100 Subject: [PATCH 54/76] fix(replay): Ensure replays contain canvas rendering when resumed after inactivity (#18714) Replays of apps that use canvas elements that are resumed after a long period of inactivity (for example when navigating away and back to a tab after 5 minutes) were previously broken. Replays contained all DOM elements, including the canvas, but the canvas would not have any of its rendering captured. This happens because before resuming from inactivity, `getCanvasManager` creates a new `CanvasManager` that is then passed to a promise resolve function that was already resolved beforehand. That leads to the new canvas manager not actually being used when returning from inactivity and thus having all rendering attempted to be captured from the previous canvas manager instead of the new one. For backwards compatibility, I kept the promise based approach around and added a second storage variable for the canvas manager. I attempted to create integration tests but was not able to reproduce this issue in an integration test so I opted for just a basic unit test. I did reproduce this issue in a sample app locally and captured two replays: 1) The [first replay](https://sentry-sdks.sentry.io/explore/replays/26cd46702dc448148c0c887edaa10aec/?playlistEnd=2026-01-07T13%3A05%3A52&playlistStart=2026-01-07T12%3A05%3A52&project=4507937458552832&query=&referrer=replayList) uses our CDN bundles and shows canvas rendering captured at first but missing towards the end of the replay. 2) The [second replay](https://sentry-sdks.sentry.io/explore/replays/765c4b98474242b0a0e690e16b59ab7f/?playlistEnd=2026-01-07T13%3A13%3A23&playlistStart=2026-01-07T12%3A13%3A23&project=4507937458552832&query=&referrer=replayList) uses bundles built from this PR and shows canvas rendering continues towards the end of the replay. Closes: #18682 --- packages/replay-canvas/src/canvas.ts | 8 ++++++- packages/replay-canvas/test/canvas.test.ts | 28 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/replay-canvas/src/canvas.ts b/packages/replay-canvas/src/canvas.ts index 7861572b190f..0ed2b49d237e 100644 --- a/packages/replay-canvas/src/canvas.ts +++ b/packages/replay-canvas/src/canvas.ts @@ -77,6 +77,7 @@ export const _replayCanvasIntegration = ((options: Partial ] as [number, number], }; + let currentCanvasManager: CanvasManager | undefined; let canvasManagerResolve: (value: CanvasManager) => void; const _canvasManager: Promise = new Promise(resolve => (canvasManagerResolve = resolve)); @@ -104,14 +105,19 @@ export const _replayCanvasIntegration = ((options: Partial } }, }); + + currentCanvasManager = manager; + + // Resolve promise on first call for backward compatibility canvasManagerResolve(manager); + return manager; }, ...(CANVAS_QUALITY[quality || 'medium'] || CANVAS_QUALITY.medium), }; }, async snapshot(canvasElement?: HTMLCanvasElement, options?: SnapshotOptions) { - const canvasManager = await _canvasManager; + const canvasManager = currentCanvasManager || (await _canvasManager); canvasManager.snapshot(canvasElement, options); }, diff --git a/packages/replay-canvas/test/canvas.test.ts b/packages/replay-canvas/test/canvas.test.ts index 1acfeab69d21..ee51c91ce47a 100644 --- a/packages/replay-canvas/test/canvas.test.ts +++ b/packages/replay-canvas/test/canvas.test.ts @@ -103,3 +103,31 @@ it('has correct types', () => { const res2 = rc.snapshot(document.createElement('canvas')); expect(res2).toBeInstanceOf(Promise); }); + +it('tracks current canvas manager across multiple getCanvasManager calls', async () => { + const rc = _replayCanvasIntegration({ enableManualSnapshot: true }); + const options = rc.getOptions(); + + // First call - simulates initial recording session + // @ts-expect-error don't care about the normal options we need to call this with + options.getCanvasManager({}); + expect(CanvasManager).toHaveBeenCalledTimes(1); + + const mockManager1 = vi.mocked(CanvasManager).mock.results[0].value; + mockManager1.snapshot = vi.fn(); + + // Second call - simulates session refresh after inactivity or max age + // @ts-expect-error don't care about the normal options we need to call this with + options.getCanvasManager({}); + expect(CanvasManager).toHaveBeenCalledTimes(2); + + const mockManager2 = vi.mocked(CanvasManager).mock.results[1].value; + mockManager2.snapshot = vi.fn(); + + void rc.snapshot(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockManager1.snapshot).toHaveBeenCalledTimes(0); + expect(mockManager2.snapshot).toHaveBeenCalledTimes(1); +}); From b8556641477f92ef4ab8addc053a79e29b58d3e0 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 22 Dec 2025 10:40:13 -0800 Subject: [PATCH 55/76] fix(tracing): add gen_ai.request.messages.original_length attributes (#18608) Whenever the `gen_ai.request.messages` is potentially truncated, add a `gen_ai.request.messages.original_length` attribute indicating the initial length of the array, so that the truncation is evident. Closes JS-1350 --- .../tracing/openai/openai-tool-calls/test.ts | 4 ++ .../suites/tracing/openai/test.ts | 4 ++ .../suites/tracing/openai/v6/test.ts | 6 +++ .../suites/tracing/vercelai/test.ts | 8 +++ .../suites/tracing/vercelai/v5/test.ts | 8 +++ .../core/src/tracing/ai/gen-ai-attributes.ts | 5 ++ .../core/src/tracing/anthropic-ai/index.ts | 21 ++------ .../core/src/tracing/anthropic-ai/utils.ts | 26 ++++++++- .../core/src/tracing/google-genai/index.ts | 4 +- packages/core/src/tracing/langchain/utils.ts | 3 ++ packages/core/src/tracing/langgraph/index.ts | 6 ++- packages/core/src/tracing/openai/index.ts | 17 +++--- packages/core/src/tracing/vercel-ai/utils.ts | 20 ++++++- .../test/lib/utils/anthropic-utils.test.ts | 54 ++++++++++++++++++- 14 files changed, 155 insertions(+), 31 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts index 98dab6e77b86..ac40fbe94249 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts @@ -182,6 +182,7 @@ describe('OpenAI Tool Calls integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', @@ -212,6 +213,7 @@ describe('OpenAI Tool Calls integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', @@ -241,6 +243,7 @@ describe('OpenAI Tool Calls integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', @@ -270,6 +273,7 @@ describe('OpenAI Tool Calls integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index db3a592a4870..4d41b34b8c31 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -159,6 +159,7 @@ describe('OpenAI integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"What is the capital of France?"}]', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -214,6 +215,7 @@ describe('OpenAI integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', }, description: 'chat error-model', @@ -231,6 +233,7 @@ describe('OpenAI integration', () => { 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.temperature': 0.8, 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Tell me about streaming"}]', 'gen_ai.response.text': 'Hello from OpenAI streaming!', @@ -287,6 +290,7 @@ describe('OpenAI integration', () => { 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'error-model', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', 'gen_ai.system': 'openai', 'sentry.op': 'gen_ai.chat', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts index 23520852f070..3784fb7e4631 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts @@ -159,6 +159,7 @@ describe('OpenAI integration (V6)', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"What is the capital of France?"}]', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -214,6 +215,7 @@ describe('OpenAI integration (V6)', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', }, description: 'chat error-model', @@ -231,6 +233,7 @@ describe('OpenAI integration (V6)', () => { 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.temperature': 0.8, 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Tell me about streaming"}]', 'gen_ai.response.text': 'Hello from OpenAI streaming!', @@ -287,6 +290,7 @@ describe('OpenAI integration (V6)', () => { 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'error-model', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', 'gen_ai.system': 'openai', 'sentry.op': 'gen_ai.chat', @@ -306,6 +310,7 @@ describe('OpenAI integration (V6)', () => { // Check that custom options are respected expect.objectContaining({ data: expect.objectContaining({ + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true }), @@ -313,6 +318,7 @@ describe('OpenAI integration (V6)', () => { // Check that custom options are respected for streaming expect.objectContaining({ data: expect.objectContaining({ + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true 'gen_ai.request.stream': true, // Should be marked as stream 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 2ccf8a1dc212..8112bcadd5f5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -67,6 +67,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -95,6 +96,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.request.messages': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.response.id': expect.any(String), @@ -205,6 +207,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"Where is the first span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the first span?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -237,6 +240,7 @@ describe('Vercel AI integration', () => { // Second span - doGenerate for first call, should also include input/output fields when sendDefaultPii: true expect.objectContaining({ data: { + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['stop'], @@ -275,6 +279,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -308,6 +313,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.request.messages': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.response.id': expect.any(String), @@ -345,6 +351,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather in San Francisco?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -380,6 +387,7 @@ describe('Vercel AI integration', () => { data: { 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, 'gen_ai.request.messages': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['tool-calls'], 'gen_ai.response.id': expect.any(String), diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts index 01aa715bdc77..179644bbcd73 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts @@ -75,6 +75,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.settings.maxRetries': 2, 'vercel.ai.streaming': false, 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'gen_ai.response.model': 'mock-model-id', 'gen_ai.usage.input_tokens': 10, @@ -107,6 +108,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.response.id': expect.any(String), 'gen_ai.response.text': expect.any(String), 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.usage.input_tokens': 10, @@ -205,6 +207,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.operationId': 'ai.generateText', 'vercel.ai.pipeline.name': 'generateText', 'vercel.ai.prompt': '{"prompt":"Where is the first span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the first span?"}]', 'vercel.ai.response.finishReason': 'stop', 'gen_ai.response.text': 'First span here!', @@ -231,6 +234,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.model.provider': 'mock-provider', 'vercel.ai.operationId': 'ai.generateText.doGenerate', 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', 'vercel.ai.response.finishReason': 'stop', 'vercel.ai.response.id': expect.any(String), @@ -263,6 +267,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.operationId': 'ai.generateText', 'vercel.ai.pipeline.name': 'generateText', 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'vercel.ai.response.finishReason': 'stop', 'gen_ai.response.text': expect.any(String), @@ -300,6 +305,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.response.id': expect.any(String), 'gen_ai.response.text': expect.any(String), 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.usage.input_tokens': 10, @@ -321,6 +327,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.operationId': 'ai.generateText', 'vercel.ai.pipeline.name': 'generateText', 'vercel.ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather in San Francisco?"}]', 'vercel.ai.response.finishReason': 'tool-calls', 'gen_ai.response.tool_calls': expect.any(String), @@ -347,6 +354,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.model.provider': 'mock-provider', 'vercel.ai.operationId': 'ai.generateText.doGenerate', 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), 'vercel.ai.prompt.toolChoice': expect.any(String), 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index 154e90cbaec1..7959ee05bcdf 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -115,6 +115,11 @@ export const GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE = 'gen_ai.usage.total_tokens'; */ export const GEN_AI_OPERATION_NAME_ATTRIBUTE = 'gen_ai.operation.name'; +/** + * Original length of messages array, used to indicate truncations had occured + */ +export const GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE = 'gen_ai.request.messages.original_length'; + /** * The prompt messages * Only recorded when recordInputs is enabled diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index d8d06efdc9e5..49ed1c3b3354 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -12,7 +12,6 @@ import { GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, - GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, @@ -24,13 +23,7 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { - buildMethodPath, - getFinalOperationName, - getSpanOperation, - getTruncatedJsonString, - setTokenUsageAttributes, -} from '../ai/utils'; +import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { AnthropicAiInstrumentedMethod, @@ -39,7 +32,7 @@ import type { AnthropicAiStreamingEvent, ContentBlock, } from './types'; -import { handleResponseError, messagesFromParams, shouldInstrument } from './utils'; +import { handleResponseError, messagesFromParams, setMessagesAttribute, shouldInstrument } from './utils'; /** * Extract request attributes from method arguments @@ -83,15 +76,7 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record): void { const messages = messagesFromParams(params); - if (messages.length) { - const truncatedMessages = getTruncatedJsonString(messages); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); - } - - if ('input' in params) { - const truncatedInput = getTruncatedJsonString(params.input); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); - } + setMessagesAttribute(span, messages); if ('prompt' in params) { span.setAttributes({ [GEN_AI_PROMPT_ATTRIBUTE]: JSON.stringify(params.prompt) }); diff --git a/packages/core/src/tracing/anthropic-ai/utils.ts b/packages/core/src/tracing/anthropic-ai/utils.ts index 01f86b41adfc..f10b3ebe6358 100644 --- a/packages/core/src/tracing/anthropic-ai/utils.ts +++ b/packages/core/src/tracing/anthropic-ai/utils.ts @@ -1,6 +1,11 @@ import { captureException } from '../../exports'; import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types-hoist/span'; +import { + GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { getTruncatedJsonString } from '../ai/utils'; import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; import type { AnthropicAiInstrumentedMethod, AnthropicAiResponse } from './types'; @@ -11,6 +16,19 @@ export function shouldInstrument(methodPath: string): methodPath is AnthropicAiI return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod); } +/** + * Set the messages and messages original length attributes. + */ +export function setMessagesAttribute(span: Span, messages: unknown): void { + const length = Array.isArray(messages) ? messages.length : undefined; + if (length !== 0) { + span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(messages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: length, + }); + } +} + /** * Capture error information from the response * @see https://docs.anthropic.com/en/api/errors#error-shapes @@ -32,11 +50,15 @@ export function handleResponseError(span: Span, response: AnthropicAiResponse): * Include the system prompt in the messages list, if available */ export function messagesFromParams(params: Record): unknown[] { - const { system, messages } = params; + const { system, messages, input } = params; const systemMessages = typeof system === 'string' ? [{ role: 'system', content: params.system }] : []; - const userMessages = Array.isArray(messages) ? messages : messages != null ? [messages] : []; + const inputParamMessages = Array.isArray(input) ? input : input != null ? [input] : undefined; + + const messagesParamMessages = Array.isArray(messages) ? messages : messages != null ? [messages] : []; + + const userMessages = inputParamMessages ?? messagesParamMessages; return [...systemMessages, ...userMessages]; } diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index 9c53e09fd1ca..53af7a9632cb 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -11,6 +11,7 @@ import { GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, @@ -165,8 +166,9 @@ function addPrivateRequestAttributes(span: Span, params: Record messages.push(...contentUnionToMessages(params.message as PartListUnion, 'user')); } - if (messages.length) { + if (Array.isArray(messages) && messages.length) { span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: messages.length, [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncateGenAiMessages(messages)), }); } diff --git a/packages/core/src/tracing/langchain/utils.ts b/packages/core/src/tracing/langchain/utils.ts index 9a8fa9aed26d..0a07ae8df370 100644 --- a/packages/core/src/tracing/langchain/utils.ts +++ b/packages/core/src/tracing/langchain/utils.ts @@ -5,6 +5,7 @@ import { GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, @@ -253,6 +254,7 @@ export function extractLLMRequestAttributes( const attrs = baseRequestAttributes(system, modelName, 'pipeline', llm, invocationParams, langSmithMetadata); if (recordInputs && Array.isArray(prompts) && prompts.length > 0) { + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, prompts.length); const messages = prompts.map(p => ({ role: 'user', content: p })); setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(messages)); } @@ -282,6 +284,7 @@ export function extractChatModelRequestAttributes( if (recordInputs && Array.isArray(langChainMessages) && langChainMessages.length > 0) { const normalized = normalizeLangChainMessages(langChainMessages.flat()); + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, normalized.length); const truncated = truncateGenAiMessages(normalized); setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(truncated)); } diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index cfbe18bc4f88..c0800e05e6da 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -9,6 +9,7 @@ import { GEN_AI_PIPELINE_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { truncateGenAiMessages } from '../ai/messageTruncation'; import type { LangChainMessage } from '../langchain/types'; @@ -138,7 +139,10 @@ function instrumentCompiledGraphInvoke( if (inputMessages && recordInputs) { const normalizedMessages = normalizeLangChainMessages(inputMessages); const truncatedMessages = truncateGenAiMessages(normalizedMessages); - span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, JSON.stringify(truncatedMessages)); + span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncatedMessages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: normalizedMessages.length, + }); } // Call original invoke diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index 031cbb8ee47e..6789f5fca3ce 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -8,6 +8,7 @@ import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, @@ -107,13 +108,15 @@ function addResponseAttributes(span: Span, result: unknown, recordOutputs?: bool // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. function addRequestAttributes(span: Span, params: Record): void { - if ('messages' in params) { - const truncatedMessages = getTruncatedJsonString(params.messages); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); - } - if ('input' in params) { - const truncatedInput = getTruncatedJsonString(params.input); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); + const src = 'input' in params ? params.input : 'messages' in params ? params.messages : undefined; + // typically an array, but can be other types. skip if an empty array. + const length = Array.isArray(src) ? src.length : undefined; + if (src && length !== 0) { + const truncatedInput = getTruncatedJsonString(src); + span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, truncatedInput); + if (length) { + span.setAttribute(GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, length); + } } } diff --git a/packages/core/src/tracing/vercel-ai/utils.ts b/packages/core/src/tracing/vercel-ai/utils.ts index b6c5b0ad5aab..05dcc1f43817 100644 --- a/packages/core/src/tracing/vercel-ai/utils.ts +++ b/packages/core/src/tracing/vercel-ai/utils.ts @@ -8,6 +8,7 @@ import { GEN_AI_GENERATE_TEXT_DO_GENERATE_OPERATION_ATTRIBUTE, GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_STREAM_OBJECT_DO_STREAM_OPERATION_ATTRIBUTE, GEN_AI_STREAM_TEXT_DO_STREAM_OPERATION_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, @@ -142,7 +143,24 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes !attributes[AI_PROMPT_MESSAGES_ATTRIBUTE] ) { const messages = convertPromptToMessages(prompt); - if (messages.length) span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, getTruncatedJsonString(messages)); + if (messages.length) { + span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(messages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: messages.length, + }); + } + } else if (typeof attributes[AI_PROMPT_MESSAGES_ATTRIBUTE] === 'string') { + try { + const messages = JSON.parse(attributes[AI_PROMPT_MESSAGES_ATTRIBUTE]); + if (Array.isArray(messages)) { + span.setAttributes({ + [AI_PROMPT_MESSAGES_ATTRIBUTE]: undefined, + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(messages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: messages.length, + }); + } + // eslint-disable-next-line no-empty + } catch {} } } diff --git a/packages/core/test/lib/utils/anthropic-utils.test.ts b/packages/core/test/lib/utils/anthropic-utils.test.ts index 0be295b85813..74d4e6b85c17 100644 --- a/packages/core/test/lib/utils/anthropic-utils.test.ts +++ b/packages/core/test/lib/utils/anthropic-utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { messagesFromParams, shouldInstrument } from '../../../src/tracing/anthropic-ai/utils'; +import { messagesFromParams, setMessagesAttribute, shouldInstrument } from '../../../src/tracing/anthropic-ai/utils'; +import type { Span } from '../../../src/types-hoist/span'; describe('anthropic-ai-utils', () => { describe('shouldInstrument', () => { @@ -25,6 +26,19 @@ describe('anthropic-ai-utils', () => { ]); }); + it('looks to params.input ahead of params.messages', () => { + expect( + messagesFromParams({ + input: [{ role: 'user', content: 'input' }], + messages: [{ role: 'user', content: 'hello' }], + system: 'You are a friendly robot awaiting a greeting.', + }), + ).toStrictEqual([ + { role: 'system', content: 'You are a friendly robot awaiting a greeting.' }, + { role: 'user', content: 'input' }, + ]); + }); + it('includes system message along with non-array messages', () => { expect( messagesFromParams({ @@ -53,4 +67,42 @@ describe('anthropic-ai-utils', () => { ).toStrictEqual([{ role: 'user', content: 'hello' }]); }); }); + + describe('setMessagesAtribute', () => { + const mock = { + attributes: {} as Record, + setAttributes(kv: Record) { + for (const [key, val] of Object.entries(kv)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + if (val === undefined) delete this.attributes[key]; + else this.attributes[key] = val; + } + }, + }; + const span = mock as unknown as Span; + + it('sets length along with truncated value', () => { + const content = 'A'.repeat(200_000); + setMessagesAttribute(span, [{ role: 'user', content }]); + const result = [{ role: 'user', content: 'A'.repeat(19972) }]; + expect(mock.attributes).toStrictEqual({ + 'gen_ai.request.messages.original_length': 1, + 'gen_ai.request.messages': JSON.stringify(result), + }); + }); + + it('removes length when setting new value ', () => { + setMessagesAttribute(span, { content: 'hello, world' }); + expect(mock.attributes).toStrictEqual({ + 'gen_ai.request.messages': '{"content":"hello, world"}', + }); + }); + + it('ignores empty array', () => { + setMessagesAttribute(span, []); + expect(mock.attributes).toStrictEqual({ + 'gen_ai.request.messages': '{"content":"hello, world"}', + }); + }); + }); }); From 2a2e8cbccbaeba9ed67dfa5e72933048ea22b2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 8 Jan 2026 09:07:14 +0100 Subject: [PATCH 56/76] fix(node-core): Ignore worker threads in OnUncaughtException (#18689) closes #18592 closes [JS-1347](https://linear.app/getsentry/issue/JS-1347/sentry-changing-worker-thread-error-behavior) The [onUncaughtException integration](https://docs.sentry.io/platforms/javascript/guides/express/configuration/integrations/onuncaughtexception/) triggered for errors inside workers, which caused a wrong error code overall. Since we already have a [Child Process integration](https://docs.sentry.io/platforms/javascript/guides/express/configuration/integrations/childProcess/) which handles errors it would make most sense to disable the onUncaughtException integration entirely for workers. Another option would also be to only ignore `shouldApplyFatalHandlingLogic` for workers, which was the main reason to exit the entire process, even though the error was handled - but I don't like this approach, since the child processes errors will be handled anyways in the other integration. **Interesting finding:** When using `--import` or `--require` CLI flags, these are propagated to worker threads, so `Sentry.init()` runs twice: once in the main thread (`isMainThread === true`) and once in the worker (`isMainThread === false`). However, when using inline `require()` inside a file, it is NOT propagated to workers, so it initializes only once with `isMainThread === true`. This means the bug primarily manifests with ESM (`--import`) or when CJS uses `--require` (which may be less common). The `caught-worker-inline.js` test always passes (inline require), while `caught-worker.js` with `--require` only passes after the fix is applied, demonstrating this behavior. --- .../public-api/OnUncaughtException/test.ts | 126 ++++++++++++++++++ .../worker-thread/caught-worker-inline.js | 4 + .../worker-thread/caught-worker.js | 23 ++++ .../worker-thread/caught-worker.mjs | 24 ++++ .../worker-thread/instrument.js | 9 ++ .../worker-thread/instrument.mjs | 9 ++ .../OnUncaughtException/worker-thread/job.js | 1 + .../worker-thread/uncaught-worker.mjs | 17 +++ .../src/integrations/onuncaughtexception.ts | 7 + 9 files changed, 220 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker-inline.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.mjs create mode 100644 dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/job.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/uncaught-worker.mjs diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts index 5a35991bfd4b..10981a84d103 100644 --- a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts @@ -1,6 +1,7 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import { describe, expect, test } from 'vitest'; +import { conditionalTest } from '../../../utils'; import { createRunner } from '../../../utils/runner'; describe('OnUncaughtException integration', () => { @@ -101,4 +102,129 @@ describe('OnUncaughtException integration', () => { .start() .completed(); }); + + conditionalTest({ max: 18 })('Worker thread error handling Node 18', () => { + test('should capture uncaught worker thread errors - without childProcess integration', async () => { + await createRunner(__dirname, 'worker-thread/uncaught-worker.mjs') + .withInstrument(path.join(__dirname, 'worker-thread/instrument.mjs')) + .expect({ + event: { + level: 'fatal', + exception: { + values: [ + { + type: 'Error', + value: 'job failed', + mechanism: { + type: 'auto.node.onuncaughtexception', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); + }); + + // childProcessIntegration only exists in Node 20+ + conditionalTest({ min: 20 })('Worker thread error handling Node 20+', () => { + test.each(['mjs', 'js'])('should not interfere with worker thread error handling ".%s"', async extension => { + const runner = createRunner(__dirname, `worker-thread/caught-worker.${extension}`) + .withFlags( + extension === 'mjs' ? '--import' : '--require', + path.join(__dirname, `worker-thread/instrument.${extension}`), + ) + .expect({ + event: { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'job failed', + mechanism: { + type: 'auto.child_process.worker_thread', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start(); + + await runner.completed(); + + const logs = runner.getLogs(); + + expect(logs).toEqual(expect.arrayContaining([expect.stringMatching(/^caught Error: job failed/)])); + }); + + test('should not interfere with worker thread error handling when required inline', async () => { + const runner = createRunner(__dirname, 'worker-thread/caught-worker-inline.js') + .expect({ + event: { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'job failed', + mechanism: { + type: 'auto.child_process.worker_thread', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start(); + + await runner.completed(); + + const logs = runner.getLogs(); + + expect(logs).toEqual(expect.arrayContaining([expect.stringMatching(/^caught Error: job failed/)])); + }); + + test('should capture uncaught worker thread errors', async () => { + await createRunner(__dirname, 'worker-thread/uncaught-worker.mjs') + .withInstrument(path.join(__dirname, 'worker-thread/instrument.mjs')) + .expect({ + event: { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'job failed', + mechanism: { + type: 'auto.child_process.worker_thread', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker-inline.js b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker-inline.js new file mode 100644 index 000000000000..798d6725308b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker-inline.js @@ -0,0 +1,4 @@ +// reuse the same worker script as the other tests +// just now in one file +require('./instrument.js'); +require('./caught-worker.js'); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.js b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.js new file mode 100644 index 000000000000..a13112e06d92 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.js @@ -0,0 +1,23 @@ +const path = require('path'); +const { Worker } = require('worker_threads'); + +function runJob() { + const worker = new Worker(path.join(__dirname, 'job.js')); + return new Promise((resolve, reject) => { + worker.once('error', reject); + worker.once('exit', code => { + if (code) reject(new Error(`Worker exited with code ${code}`)); + else resolve(); + }); + }); +} + +runJob() + .then(() => { + // eslint-disable-next-line no-console + console.log('Job completed successfully'); + }) + .catch(err => { + // eslint-disable-next-line no-console + console.error('caught', err); + }); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.mjs b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.mjs new file mode 100644 index 000000000000..4e1750e36e71 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.mjs @@ -0,0 +1,24 @@ +import path from 'path'; +import { Worker } from 'worker_threads'; + +const __dirname = new URL('.', import.meta.url).pathname; + +function runJob() { + const worker = new Worker(path.join(__dirname, 'job.js')); + return new Promise((resolve, reject) => { + worker.once('error', reject); + worker.once('exit', code => { + if (code) reject(new Error(`Worker exited with code ${code}`)); + else resolve(); + }); + }); +} + +try { + await runJob(); + // eslint-disable-next-line no-console + console.log('Job completed successfully'); +} catch (err) { + // eslint-disable-next-line no-console + console.error('caught', err); +} diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.js b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.js new file mode 100644 index 000000000000..a2b13b91bce6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.js @@ -0,0 +1,9 @@ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 0, + debug: false, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.mjs b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.mjs new file mode 100644 index 000000000000..9263fe27bce1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 0, + debug: false, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/job.js b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/job.js new file mode 100644 index 000000000000..b904a77813ac --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/job.js @@ -0,0 +1 @@ +throw new Error('job failed'); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/uncaught-worker.mjs b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/uncaught-worker.mjs new file mode 100644 index 000000000000..eceff3cffa77 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/uncaught-worker.mjs @@ -0,0 +1,17 @@ +import path from 'path'; +import { Worker } from 'worker_threads'; + +const __dirname = new URL('.', import.meta.url).pathname; + +function runJob() { + const worker = new Worker(path.join(__dirname, 'job.js')); + return new Promise((resolve, reject) => { + worker.once('error', reject); + worker.once('exit', code => { + if (code) reject(new Error(`Worker exited with code ${code}`)); + else resolve(); + }); + }); +} + +await runJob(); diff --git a/packages/node-core/src/integrations/onuncaughtexception.ts b/packages/node-core/src/integrations/onuncaughtexception.ts index 41c4bf96917d..8afa70787a5c 100644 --- a/packages/node-core/src/integrations/onuncaughtexception.ts +++ b/packages/node-core/src/integrations/onuncaughtexception.ts @@ -1,4 +1,5 @@ import { captureException, debug, defineIntegration, getClient } from '@sentry/core'; +import { isMainThread } from 'worker_threads'; import { DEBUG_BUILD } from '../debug-build'; import type { NodeClient } from '../sdk/client'; import { logAndExitProcess } from '../utils/errorhandling'; @@ -44,6 +45,12 @@ export const onUncaughtExceptionIntegration = defineIntegration((options: Partia return { name: INTEGRATION_NAME, setup(client: NodeClient) { + // errors in worker threads are already handled by the childProcessIntegration + // also we don't want to exit the Node process on worker thread errors + if (!isMainThread) { + return; + } + global.process.on('uncaughtException', makeErrorHandler(client, optionsWithDefaults)); }, }; From ce62e84a4634ea8bd738fe9cb0ea7197b41f83a1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 8 Jan 2026 13:44:06 +0100 Subject: [PATCH 57/76] ref(core): Strengthen `browserPerformanceTimeOrigin` reliability check (#18719) In `browserPerformanceTimeOrigin`, we test how reliable `performance.timeOrigin` (or its predecessor) is by comparing its timestamp against `performance.now() - Date.now()`. If the delta is larger than 1h, we take the fallback rather than `performance.timeOrigin`. This PR now makes the reliability check more strict by decreasing the time window from 1h to just 5 minutes. This _should_ catch time drift more often. Now that we improved the fallback via #18715, I think we can give this a shot. --- packages/core/src/utils/time.ts | 3 +-- packages/core/test/lib/utils/time.test.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index b46c569433d0..ecaca1ea9e9b 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -91,8 +91,7 @@ function getBrowserTimeOrigin(): number | undefined { return undefined; } - // TOOD: We should probably set a much tighter threshold here as skew can already happen within just a few minutes. - const threshold = 3_600_000; // 1 hour in milliseconds + const threshold = 300_000; // 5 minutes in milliseconds const performanceNow = performance.now(); const dateNow = Date.now(); diff --git a/packages/core/test/lib/utils/time.test.ts b/packages/core/test/lib/utils/time.test.ts index 7f3f7f2d6c19..a1d537df5862 100644 --- a/packages/core/test/lib/utils/time.test.ts +++ b/packages/core/test/lib/utils/time.test.ts @@ -7,7 +7,7 @@ async function getFreshPerformanceTimeOrigin() { return timeModule.browserPerformanceTimeOrigin(); } -const RELIABLE_THRESHOLD_MS = 3_600_000; +const RELIABLE_THRESHOLD_MS = 300_000; describe('browserPerformanceTimeOrigin', () => { it('returns `performance.timeOrigin` if it is available and reliable', async () => { From 31cc183951e2b4b43543032d7b2bb5f3eb489d2c Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 8 Jan 2026 13:57:05 +0100 Subject: [PATCH 58/76] fix(nextjs): Remove polynomial regular expression (#18725) Check out the regex here: https://regex101.com/r/Tw2fuQ/1 Closes https://github.com/getsentry/sentry-javascript/security/code-scanning/419 Closes #18726 (added automatically) --- .../config/manifest/createRouteManifest.ts | 7 +++-- .../app/(api_internal)/api/page.tsx | 1 + .../route-groups/app/(auth-v2)/login/page.tsx | 1 + .../app/(v2.0.beta)/features/page.tsx | 1 + .../suites/route-groups/route-groups.test.ts | 28 +++++++++++++++++++ 5 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(api_internal)/api/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(auth-v2)/login/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(v2.0.beta)/features/page.tsx diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index d37285983d31..487ab05c55d8 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -28,9 +28,10 @@ function isRouteGroup(name: string): boolean { return name.startsWith('(') && name.endsWith(')'); } -function normalizeRoutePath(routePath: string): string { +function normalizeRouteGroupPath(routePath: string): string { // Remove route group segments from the path - return routePath.replace(/\/\([^)]+\)/g, ''); + // Using positive lookahead with (?=[^)\/]*\)) to avoid polynomial matching + return routePath.replace(/\/\((?=[^)/]*\))[^)/]+\)/g, ''); } function getDynamicRouteSegment(name: string): string { @@ -140,7 +141,7 @@ function scanAppDirectory(dir: string, basePath: string = '', includeRouteGroups if (pageFile) { // Conditionally normalize the path based on includeRouteGroups option - const routePath = includeRouteGroups ? basePath || '/' : normalizeRoutePath(basePath || '/'); + const routePath = includeRouteGroups ? basePath || '/' : normalizeRouteGroupPath(basePath || '/'); const isDynamic = routePath.includes(':'); // Check if this page has generateStaticParams (ISR/SSG indicator) diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(api_internal)/api/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(api_internal)/api/page.tsx new file mode 100644 index 000000000000..39c826b4bf16 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(api_internal)/api/page.tsx @@ -0,0 +1 @@ +// API Internal Page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth-v2)/login/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth-v2)/login/page.tsx new file mode 100644 index 000000000000..3776b7545439 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth-v2)/login/page.tsx @@ -0,0 +1 @@ +// Login V2 Page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(v2.0.beta)/features/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(v2.0.beta)/features/page.tsx new file mode 100644 index 000000000000..66c18edfd787 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(v2.0.beta)/features/page.tsx @@ -0,0 +1 @@ +// Features Beta Page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index c2d455361c4c..32ac315b3571 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -12,11 +12,14 @@ describe('route-groups', () => { expect(manifest).toEqual({ staticRoutes: [ { path: '/' }, + { path: '/api' }, { path: '/login' }, { path: '/signup' }, + { path: '/login' }, // from (auth-v2) { path: '/dashboard' }, { path: '/settings/profile' }, { path: '/public/about' }, + { path: '/features' }, ], dynamicRoutes: [ { @@ -28,6 +31,8 @@ describe('route-groups', () => { ], isrRoutes: [], }); + // Verify we have 9 static routes total (including duplicates from special chars) + expect(manifest.staticRoutes).toHaveLength(9); }); test('should handle dynamic routes within route groups', () => { @@ -37,6 +42,17 @@ describe('route-groups', () => { expect(regex.test('/dashboard/abc')).toBe(true); expect(regex.test('/dashboard/123/456')).toBe(false); }); + + test.each([ + { routeGroup: '(auth-v2)', strippedPath: '/login', description: 'hyphens' }, + { routeGroup: '(api_internal)', strippedPath: '/api', description: 'underscores' }, + { routeGroup: '(v2.0.beta)', strippedPath: '/features', description: 'dots' }, + ])('should strip route groups with $description', ({ routeGroup, strippedPath }) => { + // Verify the stripped path exists + expect(manifest.staticRoutes.find(route => route.path === strippedPath)).toBeDefined(); + // Verify the route group was stripped, not included + expect(manifest.staticRoutes.find(route => route.path.includes(routeGroup))).toBeUndefined(); + }); }); describe('includeRouteGroups: true', () => { @@ -46,11 +62,14 @@ describe('route-groups', () => { expect(manifest).toEqual({ staticRoutes: [ { path: '/' }, + { path: '/(api_internal)/api' }, { path: '/(auth)/login' }, { path: '/(auth)/signup' }, + { path: '/(auth-v2)/login' }, { path: '/(dashboard)/dashboard' }, { path: '/(dashboard)/settings/profile' }, { path: '/(marketing)/public/about' }, + { path: '/(v2.0.beta)/features' }, ], dynamicRoutes: [ { @@ -62,6 +81,7 @@ describe('route-groups', () => { ], isrRoutes: [], }); + expect(manifest.staticRoutes).toHaveLength(9); }); test('should handle dynamic routes within route groups with proper regex escaping', () => { @@ -92,5 +112,13 @@ describe('route-groups', () => { expect(authSignup).toBeDefined(); expect(marketingPublic).toBeDefined(); }); + + test.each([ + { fullPath: '/(auth-v2)/login', description: 'hyphens' }, + { fullPath: '/(api_internal)/api', description: 'underscores' }, + { fullPath: '/(v2.0.beta)/features', description: 'dots' }, + ])('should preserve route groups with $description when includeRouteGroups is true', ({ fullPath }) => { + expect(manifest.staticRoutes.find(route => route.path === fullPath)).toBeDefined(); + }); }); }); From 5901e709721ba3b0eac07267b19380eda8d2804e Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:21:25 +0100 Subject: [PATCH 59/76] fix(react,solid,vue): Fix parametrization behavior for non-matched routes (#18735) Our e2e tests started breaking around the `1.142.x` release of tanstack router, we ended up hard-pinning the version to `1.141.8`. The failures revealed a real behavioral change of matches tanstack router returns when attempting to match a non-existing route. Previously the matches would be an empty array, but we now get a `__root__` match. The fix involves checking if the last match is `__root__` and falling back to `url` for `sentry.source` attributes. Closes: #18672 --- .../solid-tanstack-router/package.json | 2 +- .../solid-tanstack-router/src/main.tsx | 2 +- .../tanstack-router/package.json | 2 +- .../tanstack-router/src/main.tsx | 2 +- .../vue-tanstack-router/package.json | 2 +- .../vue-tanstack-router/src/main.ts | 2 +- packages/react/src/tanstackrouter.ts | 31 ++++++++++++------- packages/solid/src/tanstackrouter.ts | 31 ++++++++++++------- packages/vue/src/tanstackrouter.ts | 26 ++++++++++------ 9 files changed, 60 insertions(+), 40 deletions(-) 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 973ab3c5b921..8082add342d1 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 @@ -15,7 +15,7 @@ "dependencies": { "@sentry/solid": "latest || *", "@tailwindcss/vite": "^4.0.6", - "@tanstack/solid-router": "1.141.8", + "@tanstack/solid-router": "^1.141.8", "@tanstack/solid-router-devtools": "^1.132.25", "@tanstack/solid-start": "^1.132.25", "solid-js": "^1.9.5", diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx index 4580fa6e8a90..6561d96ce6b1 100644 --- a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx @@ -39,7 +39,7 @@ const indexRoute = createRoute({ const postsRoute = createRoute({ getParentRoute: () => rootRoute, - path: 'posts/', + path: 'posts', }); const postIdRoute = createRoute({ diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/tanstack-router/package.json index 64f92e662ae0..edb4a6cd6707 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@sentry/react": "latest || *", - "@tanstack/react-router": "1.64.0", + "@tanstack/react-router": "^1.64.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx b/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx index 3574d4ffb81a..01d867f63c65 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx @@ -41,7 +41,7 @@ const indexRoute = createRoute({ const postsRoute = createRoute({ getParentRoute: () => rootRoute, - path: 'posts/', + path: 'posts', }); const postIdRoute = createRoute({ diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json index 1e3d436e101c..448876ec6d2b 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@sentry/vue": "latest || *", - "@tanstack/vue-router": "1.141.8", + "@tanstack/vue-router": "^1.141.8", "vue": "^3.4.15" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts index cdeec524fb50..2b44a6297ca7 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts @@ -19,7 +19,7 @@ const indexRoute = createRoute({ const postsRoute = createRoute({ getParentRoute: () => rootRoute, - path: 'posts/', + path: 'posts', }); const postIdRoute = createRoute({ diff --git a/packages/react/src/tanstackrouter.ts b/packages/react/src/tanstackrouter.ts index 0eba31722819..d2424697d9d5 100644 --- a/packages/react/src/tanstackrouter.ts +++ b/packages/react/src/tanstackrouter.ts @@ -49,14 +49,17 @@ export function tanstackRouterBrowserTracingIntegration( ); const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + // If we only match __root__, we ended up not matching any route at all, so + // we fall back to the pathname. + const routeMatch = lastMatch?.routeId !== '__root__' ? lastMatch : undefined; startBrowserTracingPageLoadSpan(client, { - name: lastMatch ? lastMatch.routeId : initialWindowLocation.pathname, + name: routeMatch ? routeMatch.routeId : initialWindowLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: lastMatch ? 'route' : 'url', - ...routeMatchToParamSpanAttributes(lastMatch), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...routeMatchToParamSpanAttributes(routeMatch), }, }); } @@ -74,21 +77,23 @@ export function tanstackRouterBrowserTracingIntegration( return; } - const onResolvedMatchedRoutes = castRouterInstance.matchRoutes( + const matchedRoutesOnBeforeNavigate = castRouterInstance.matchRoutes( onBeforeNavigateArgs.toLocation.pathname, onBeforeNavigateArgs.toLocation.search, { preload: false, throwOnError: false }, ); - const onBeforeNavigateLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onBeforeNavigateLastMatch = matchedRoutesOnBeforeNavigate[matchedRoutesOnBeforeNavigate.length - 1]; + const onBeforeNavigateRouteMatch = + onBeforeNavigateLastMatch?.routeId !== '__root__' ? onBeforeNavigateLastMatch : undefined; const navigationLocation = WINDOW.location; const navigationSpan = startBrowserTracingNavigationSpan(client, { - name: onBeforeNavigateLastMatch ? onBeforeNavigateLastMatch.routeId : navigationLocation.pathname, + name: onBeforeNavigateRouteMatch ? onBeforeNavigateRouteMatch.routeId : navigationLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateLastMatch ? 'route' : 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateRouteMatch ? 'route' : 'url', }, }); @@ -96,18 +101,20 @@ export function tanstackRouterBrowserTracingIntegration( const unsubscribeOnResolved = castRouterInstance.subscribe('onResolved', onResolvedArgs => { unsubscribeOnResolved(); if (navigationSpan) { - const onResolvedMatchedRoutes = castRouterInstance.matchRoutes( + const matchedRoutesOnResolved = castRouterInstance.matchRoutes( onResolvedArgs.toLocation.pathname, onResolvedArgs.toLocation.search, { preload: false, throwOnError: false }, ); - const onResolvedLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onResolvedLastMatch = matchedRoutesOnResolved[matchedRoutesOnResolved.length - 1]; + const onResolvedRouteMatch = + onResolvedLastMatch?.routeId !== '__root__' ? onResolvedLastMatch : undefined; - if (onResolvedLastMatch) { - navigationSpan.updateName(onResolvedLastMatch.routeId); + if (onResolvedRouteMatch) { + navigationSpan.updateName(onResolvedRouteMatch.routeId); navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedLastMatch)); + navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedRouteMatch)); } } }); diff --git a/packages/solid/src/tanstackrouter.ts b/packages/solid/src/tanstackrouter.ts index 389ce9bb6e10..c589430eb615 100644 --- a/packages/solid/src/tanstackrouter.ts +++ b/packages/solid/src/tanstackrouter.ts @@ -48,14 +48,17 @@ export function tanstackRouterBrowserTracingIntegration( ); const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + // If we only match __root__, we ended up not matching any route at all, so + // we fall back to the pathname. + const routeMatch = lastMatch?.routeId !== '__root__' ? lastMatch : undefined; startBrowserTracingPageLoadSpan(client, { - name: lastMatch ? lastMatch.routeId : initialWindowLocation.pathname, + name: routeMatch ? routeMatch.routeId : initialWindowLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.solid.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: lastMatch ? 'route' : 'url', - ...routeMatchToParamSpanAttributes(lastMatch), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...routeMatchToParamSpanAttributes(routeMatch), }, }); } @@ -73,21 +76,23 @@ export function tanstackRouterBrowserTracingIntegration( return; } - const onResolvedMatchedRoutes = router.matchRoutes( + const matchedRoutesOnBeforeNavigate = router.matchRoutes( onBeforeNavigateArgs.toLocation.pathname, onBeforeNavigateArgs.toLocation.search, { preload: false, throwOnError: false }, ); - const onBeforeNavigateLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onBeforeNavigateLastMatch = matchedRoutesOnBeforeNavigate[matchedRoutesOnBeforeNavigate.length - 1]; + const onBeforeNavigateRouteMatch = + onBeforeNavigateLastMatch?.routeId !== '__root__' ? onBeforeNavigateLastMatch : undefined; const navigationLocation = WINDOW.location; const navigationSpan = startBrowserTracingNavigationSpan(client, { - name: onBeforeNavigateLastMatch ? onBeforeNavigateLastMatch.routeId : navigationLocation.pathname, + name: onBeforeNavigateRouteMatch ? onBeforeNavigateRouteMatch.routeId : navigationLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateLastMatch ? 'route' : 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateRouteMatch ? 'route' : 'url', }, }); @@ -95,18 +100,20 @@ export function tanstackRouterBrowserTracingIntegration( const unsubscribeOnResolved = router.subscribe('onResolved', onResolvedArgs => { unsubscribeOnResolved(); if (navigationSpan) { - const onResolvedMatchedRoutes = router.matchRoutes( + const matchedRoutesOnResolved = router.matchRoutes( onResolvedArgs.toLocation.pathname, onResolvedArgs.toLocation.search, { preload: false, throwOnError: false }, ); - const onResolvedLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onResolvedLastMatch = matchedRoutesOnResolved[matchedRoutesOnResolved.length - 1]; + const onResolvedRouteMatch = + onResolvedLastMatch?.routeId !== '__root__' ? onResolvedLastMatch : undefined; - if (onResolvedLastMatch) { - navigationSpan.updateName(onResolvedLastMatch.routeId); + if (onResolvedRouteMatch) { + navigationSpan.updateName(onResolvedRouteMatch.routeId); navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedLastMatch)); + navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedRouteMatch)); } } }); diff --git a/packages/vue/src/tanstackrouter.ts b/packages/vue/src/tanstackrouter.ts index a0ae76814c0c..d46f2a8c40c6 100644 --- a/packages/vue/src/tanstackrouter.ts +++ b/packages/vue/src/tanstackrouter.ts @@ -43,20 +43,22 @@ export function tanstackRouterBrowserTracingIntegration( if (instrumentPageLoad && initialWindowLocation) { const matchedRoutes = router.matchRoutes( initialWindowLocation.pathname, - router.options.parseSearch(initialWindowLocation.search), { preload: false, throwOnError: false }, ); const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + // If we only match __root__, we ended up not matching any route at all, so + // we fall back to the pathname. + const routeMatch = lastMatch?.routeId !== '__root__' ? lastMatch : undefined; startBrowserTracingPageLoadSpan(client, { - name: lastMatch ? lastMatch.routeId : initialWindowLocation.pathname, + name: routeMatch ? routeMatch.routeId : initialWindowLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: lastMatch ? 'route' : 'url', - ...routeMatchToParamSpanAttributes(lastMatch), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...routeMatchToParamSpanAttributes(routeMatch), }, }); } @@ -87,18 +89,20 @@ export function tanstackRouterBrowserTracingIntegration( ); const onBeforeNavigateLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onBeforeNavigateRouteMatch = + onBeforeNavigateLastMatch?.routeId !== '__root__' ? onBeforeNavigateLastMatch : undefined; const navigationLocation = WINDOW.location; const navigationSpan = startBrowserTracingNavigationSpan(client, { - name: onBeforeNavigateLastMatch - ? onBeforeNavigateLastMatch.routeId + name: onBeforeNavigateRouteMatch + ? onBeforeNavigateRouteMatch.routeId : // In SSR/non-browser contexts, WINDOW.location may be undefined, so fall back to the router's location // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access navigationLocation?.pathname || onBeforeNavigateArgs.toLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateLastMatch ? 'route' : 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateRouteMatch ? 'route' : 'url', }, }); @@ -116,11 +120,13 @@ export function tanstackRouterBrowserTracingIntegration( ); const onResolvedLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onResolvedRouteMatch = + onResolvedLastMatch?.routeId !== '__root__' ? onResolvedLastMatch : undefined; - if (onResolvedLastMatch) { - navigationSpan.updateName(onResolvedLastMatch.routeId); + if (onResolvedRouteMatch) { + navigationSpan.updateName(onResolvedRouteMatch.routeId); navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedLastMatch)); + navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedRouteMatch)); } } }); From c6b3fa1b48bc0d6c201853a61cfd3d331d23c383 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:58:44 +0100 Subject: [PATCH 60/76] test(nuxt): Fix nuxt-4 dev E2E test (#18737) I was under the assumption that playwright will wait for the port `3030` to start testing (as it is configured like this). But it seems like playwright already starts testing when only the temporary port `3035` is available. This fix runs the bash script first and only then starts playwright. Closes https://github.com/getsentry/sentry-javascript/issues/18728 --- .../test-applications/nuxt-4/nuxt-start-dev-server.bash | 5 +---- dev-packages/e2e-tests/test-applications/nuxt-4/package.json | 2 +- .../e2e-tests/test-applications/nuxt-4/playwright.config.ts | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash index 1204eabb7c87..a1831f1e8e76 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash @@ -43,7 +43,4 @@ else echo "Port $TEMP_PORT released successfully" fi -echo "Starting nuxt dev with Sentry server config..." - -# 4. Start the actual dev command which should be used for the tests -NODE_OPTIONS='--import ./.nuxt/dev/sentry.server.config.mjs' nuxt dev +echo "Nuxt dev server can now be started with '--import ./.nuxt/dev/sentry.server.config.mjs'" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index ebd383e48eb9..3f25ef7df0e4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -12,7 +12,7 @@ "clean": "npx nuxi cleanup", "test": "playwright test", "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development playwright test environment", + "test:dev": "bash ./nuxt-start-dev-server.bash && TEST_ENV=development playwright test environment", "test:build": "pnpm install && pnpm build", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts index d3618f176d02..b86690ca086c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts @@ -8,7 +8,7 @@ if (!testEnv) { const getStartCommand = () => { if (testEnv === 'development') { - return 'bash ./nuxt-start-dev-server.bash'; + return "NODE_OPTIONS='--import ./.nuxt/dev/sentry.server.config.mjs' nuxt dev -p 3030"; } if (testEnv === 'production') { From 7d0a81b22dc2e6f94286df7a229f60548e67065b Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:04:06 +0100 Subject: [PATCH 61/76] chore(e2e-tests): Upgrade `@trpc/server` and `@trpc/client` (#18722) Closes https://github.com/getsentry/sentry-javascript/security/dependabot/900 Closes https://github.com/getsentry/sentry-javascript/security/dependabot/899 Closes https://github.com/getsentry/sentry-javascript/security/dependabot/897 btw: those are all different links :D Closes #18724 (added automatically) --- .../node-express-incorrect-instrumentation/package.json | 4 ++-- .../e2e-tests/test-applications/node-express-v5/package.json | 4 ++-- .../e2e-tests/test-applications/node-express/package.json | 4 ++-- .../e2e-tests/test-applications/tsx-express/package.json | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-express-incorrect-instrumentation/package.json b/dev-packages/e2e-tests/test-applications/node-express-incorrect-instrumentation/package.json index d60da152faf0..994100e5d7b9 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-incorrect-instrumentation/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-incorrect-instrumentation/package.json @@ -12,8 +12,8 @@ }, "dependencies": { "@sentry/node": "latest || *", - "@trpc/server": "10.45.3", - "@trpc/client": "10.45.2", + "@trpc/server": "10.45.4", + "@trpc/client": "10.45.4", "@types/express": "4.17.17", "@types/node": "^18.19.1", "express": "4.20.0", diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/package.json b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json index e6ca810047a6..f29feda5eea8 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json @@ -13,8 +13,8 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.10.2", "@sentry/node": "latest || *", - "@trpc/server": "10.45.3", - "@trpc/client": "10.45.2", + "@trpc/server": "10.45.4", + "@trpc/client": "10.45.4", "@types/express": "^4.17.21", "@types/node": "^18.19.1", "express": "^5.1.0", diff --git a/dev-packages/e2e-tests/test-applications/node-express/package.json b/dev-packages/e2e-tests/test-applications/node-express/package.json index d0fbe7df5ff0..4305e8593a76 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express/package.json @@ -13,8 +13,8 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.10.2", "@sentry/node": "latest || *", - "@trpc/server": "10.45.2", - "@trpc/client": "10.45.2", + "@trpc/server": "10.45.4", + "@trpc/client": "10.45.4", "@types/express": "^4.17.21", "@types/node": "^18.19.1", "express": "^4.21.2", diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/package.json b/dev-packages/e2e-tests/test-applications/tsx-express/package.json index 32c8a5668f63..cd6eeba3f19b 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/package.json +++ b/dev-packages/e2e-tests/test-applications/tsx-express/package.json @@ -13,8 +13,8 @@ "@modelcontextprotocol/sdk": "^1.10.2", "@sentry/core": "latest || *", "@sentry/node": "latest || *", - "@trpc/server": "10.45.2", - "@trpc/client": "10.45.2", + "@trpc/server": "10.45.4", + "@trpc/client": "10.45.4", "@types/express": "^4.17.21", "@types/node": "^18.19.1", "express": "^4.21.2", From a516745dae1a844c5fd411d9895dab87bd734994 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:04:19 +0100 Subject: [PATCH 62/76] chore(node-tests): Upgrade `@langchain/core` (#18720) Closes https://github.com/getsentry/sentry-javascript/security/dependabot/901 Closes #18721 (added automatically) --- 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 ed25bcfa1f59..f761c6b7c458 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -30,7 +30,7 @@ "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.4", "@langchain/anthropic": "^0.3.10", - "@langchain/core": "^0.3.28", + "@langchain/core": "^0.3.80", "@langchain/langgraph": "^0.2.32", "@nestjs/common": "^11", "@nestjs/core": "^11", diff --git a/yarn.lock b/yarn.lock index 8943392b3e86..35c1b6941b0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4922,10 +4922,10 @@ "@anthropic-ai/sdk" "^0.65.0" fast-xml-parser "^4.4.1" -"@langchain/core@^0.3.28": - version "0.3.78" - resolved "https://registry.npmjs.org/@langchain/core/-/core-0.3.78.tgz#40e69fba6688858edbcab4473358ec7affc685fd" - integrity sha512-Nn0x9erQlK3zgtRU1Z8NUjLuyW0gzdclMsvLQ6wwLeDqV91pE+YKl6uQb+L2NUDs4F0N7c2Zncgz46HxrvPzuA== +"@langchain/core@^0.3.80": + version "0.3.80" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-0.3.80.tgz#c494a6944e53ab28bf32dc531e257b17cfc8f797" + integrity sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA== dependencies: "@cfworker/json-schema" "^4.0.2" ansi-styles "^5.0.0" From 29ed4da11f194390257b3e5e0d447ef85acc3ac9 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:04:34 +0100 Subject: [PATCH 63/76] fix(nuxt): Allow overwriting server-side `defaultIntegrations` (#18717) There is no reason for spreading the user-provided options before adding the `defaultIntegrations`. This changes the order to allow overriding and allowing passing `defaultIntegrations: false`. Also added tests for that Came up in this review comment of another PR: https://github.com/getsentry/sentry-javascript/pull/18671#discussion_r2667565723 Closes #18718 (added automatically) --- packages/nuxt/src/server/sdk.ts | 2 +- packages/nuxt/test/client/sdk.test.ts | 36 +++++++++++++++++++++++++++ packages/nuxt/test/server/sdk.test.ts | 36 +++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index 2621f3f77f9a..3be024815b4c 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -27,8 +27,8 @@ import type { SentryNuxtServerOptions } from '../common/types'; export function init(options: SentryNuxtServerOptions): Client | undefined { const sentryOptions = { environment: !isCjs() && import.meta.dev ? DEV_ENVIRONMENT : DEFAULT_ENVIRONMENT, - ...options, defaultIntegrations: getNuxtDefaultIntegrations(options), + ...options, }; applySdkMetadata(sentryOptions, 'nuxt', ['nuxt', 'node']); diff --git a/packages/nuxt/test/client/sdk.test.ts b/packages/nuxt/test/client/sdk.test.ts index 29448c720ea4..833872069e4e 100644 --- a/packages/nuxt/test/client/sdk.test.ts +++ b/packages/nuxt/test/client/sdk.test.ts @@ -41,5 +41,41 @@ describe('Nuxt Client SDK', () => { it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); + + it('uses default integrations when not provided in options', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(browserInit).toHaveBeenCalledTimes(1); + const callArgs = browserInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBeDefined(); + expect(Array.isArray(callArgs?.defaultIntegrations)).toBe(true); + }); + + it('allows options.defaultIntegrations to override default integrations', () => { + const customIntegrations = [{ name: 'CustomIntegration' }]; + + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + defaultIntegrations: customIntegrations as any, + }); + + expect(browserInit).toHaveBeenCalledTimes(1); + const callArgs = browserInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBe(customIntegrations); + }); + + it('allows options.defaultIntegrations to be set to false', () => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + defaultIntegrations: false, + }); + + expect(browserInit).toHaveBeenCalledTimes(1); + const callArgs = browserInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBe(false); + }); }); }); diff --git a/packages/nuxt/test/server/sdk.test.ts b/packages/nuxt/test/server/sdk.test.ts index 626b574612b0..c2565217d138 100644 --- a/packages/nuxt/test/server/sdk.test.ts +++ b/packages/nuxt/test/server/sdk.test.ts @@ -41,6 +41,42 @@ describe('Nuxt Server SDK', () => { expect(init({})).not.toBeUndefined(); }); + it('uses default integrations when not provided in options', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const callArgs = nodeInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBeDefined(); + expect(Array.isArray(callArgs?.defaultIntegrations)).toBe(true); + }); + + it('allows options.defaultIntegrations to override default integrations', () => { + const customIntegrations = [{ name: 'CustomIntegration' }]; + + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + defaultIntegrations: customIntegrations as any, + }); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const callArgs = nodeInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBe(customIntegrations); + }); + + it('allows options.defaultIntegrations to be set to false', () => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + defaultIntegrations: false, + }); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const callArgs = nodeInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBe(false); + }); + describe('lowQualityTransactionsFilter', () => { const options = { debug: false }; const filter = lowQualityTransactionsFilter(options); From 1535106feea34ac2bdc0b737d674ee315a60d839 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 9 Jan 2026 11:41:28 +0100 Subject: [PATCH 64/76] ref(core): Use `serializeAttributes` for metric attribute serialization (#18582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre-work for #18160 Use the same attibute serialization logic we already use in logs. Bundle size impact: - size decreases for users with logs and metrics - size increases for metrics-only users. I think this is because previously the separate serialization logic could be inlined, while now it has to inline the slightly larger logic. Since it's not used in any non-treeshakeable default behavior (yet), this results in higher bundle size. Given we'll use this in our span serialization logic in v11, it will become part of the minimum SDK anyway (without metrics), so it _should_ be fine to do this now. - Because we include metrics (but not logs) in all our CDN bundles by accident, this now also increases CDN bundle size 😬. We're tracking removal of this for most bundles in v11 (#18583) I think the largest positive long-term aspect of this refactor is that we'll re-use this logic more and more going forward (scope attributes on metrics, spansv2, and other telemetry items in the future), so I'd like to have it unified. --- packages/core/src/attributes.ts | 11 +- packages/core/src/index.ts | 1 + packages/core/src/metrics/internal.ts | 57 +------ packages/core/src/types-hoist/metric.ts | 13 +- packages/core/test/lib/attributes.test.ts | 152 +++++++++++++++++- .../core/test/lib/metrics/internal.test.ts | 69 -------- 6 files changed, 169 insertions(+), 134 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index f33241be2a1e..d3255d76b0e9 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -75,7 +75,7 @@ export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObjec */ export function attributeValueToTypedAttributeValue( rawValue: unknown, - useFallback?: boolean, + useFallback?: boolean | 'skip-undefined', ): TypedAttributeValue | void { const { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined }; const attributeValue = getTypedAttributeValue(value); @@ -84,7 +84,7 @@ export function attributeValueToTypedAttributeValue( return { ...attributeValue, ...checkedUnit }; } - if (!useFallback) { + if (!useFallback || (useFallback === 'skip-undefined' && value === undefined)) { return; } @@ -113,9 +113,12 @@ export function attributeValueToTypedAttributeValue( * * @returns The serialized attributes. */ -export function serializeAttributes(attributes: RawAttributes, fallback: boolean = false): Attributes { +export function serializeAttributes( + attributes: RawAttributes | undefined, + fallback: boolean | 'skip-undefined' = false, +): Attributes { const serializedAttributes: Attributes = {}; - for (const [key, value] of Object.entries(attributes)) { + for (const [key, value] of Object.entries(attributes ?? {})) { const typedValue = attributeValueToTypedAttributeValue(value, fallback); if (typedValue) { serializedAttributes[key] = typedValue; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 30e24c3b35c7..e4b48f24de2f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -449,6 +449,7 @@ export type { MetricType, SerializedMetric, SerializedMetricContainer, + // eslint-disable-next-line deprecation/deprecation SerializedMetricAttributeValue, } from './types-hoist/metric'; export type { TimedEvent } from './types-hoist/timedEvent'; diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index db98c476fff7..846ecf89d6e5 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -1,10 +1,11 @@ +import { serializeAttributes } from '../attributes'; import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; import { getClient, getCurrentScope, getIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import type { Scope } from '../scope'; import type { Integration } from '../types-hoist/integration'; -import type { Metric, SerializedMetric, SerializedMetricAttributeValue } from '../types-hoist/metric'; +import type { Metric, SerializedMetric } from '../types-hoist/metric'; import { debug } from '../utils/debug-logger'; import { getCombinedScopeData } from '../utils/scopeData'; import { _getSpanForScope } from '../utils/spanOnScope'; @@ -14,50 +15,6 @@ import { createMetricEnvelope } from './envelope'; const MAX_METRIC_BUFFER_SIZE = 1000; -/** - * Converts a metric attribute to a serialized metric attribute. - * - * @param value - The value of the metric attribute. - * @returns The serialized metric attribute. - */ -export function metricAttributeToSerializedMetricAttribute(value: unknown): SerializedMetricAttributeValue { - switch (typeof value) { - case 'number': - if (Number.isInteger(value)) { - return { - value, - type: 'integer', - }; - } - return { - value, - type: 'double', - }; - case 'boolean': - return { - value, - type: 'boolean', - }; - case 'string': - return { - value, - type: 'string', - }; - default: { - let stringValue = ''; - try { - stringValue = JSON.stringify(value) ?? ''; - } catch { - // Do nothing - } - return { - value: stringValue, - type: 'string', - }; - } - } -} - /** * Sets a metric attribute if the value exists and the attribute key is not already present. * @@ -169,14 +126,6 @@ function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentSc * Creates a serialized metric ready to be sent to Sentry. */ function _buildSerializedMetric(metric: Metric, client: Client, currentScope: Scope): SerializedMetric { - // Serialize attributes - const serializedAttributes: Record = {}; - for (const key in metric.attributes) { - if (metric.attributes[key] !== undefined) { - serializedAttributes[key] = metricAttributeToSerializedMetricAttribute(metric.attributes[key]); - } - } - // Get trace context const [, traceContext] = _getTraceInfoFromScope(client, currentScope); const span = _getSpanForScope(currentScope); @@ -191,7 +140,7 @@ function _buildSerializedMetric(metric: Metric, client: Client, currentScope: Sc type: metric.type, unit: metric.unit, value: metric.value, - attributes: serializedAttributes, + attributes: serializeAttributes(metric.attributes, 'skip-undefined'), }; } diff --git a/packages/core/src/types-hoist/metric.ts b/packages/core/src/types-hoist/metric.ts index 6ac63da6032b..976fc9fe863f 100644 --- a/packages/core/src/types-hoist/metric.ts +++ b/packages/core/src/types-hoist/metric.ts @@ -1,3 +1,5 @@ +import type { Attributes, TypedAttributeValue } from '../attributes'; + export type MetricType = 'counter' | 'gauge' | 'distribution'; export interface Metric { @@ -27,11 +29,10 @@ export interface Metric { attributes?: Record; } -export type SerializedMetricAttributeValue = - | { value: string; type: 'string' } - | { value: number; type: 'integer' } - | { value: number; type: 'double' } - | { value: boolean; type: 'boolean' }; +/** + * @deprecated this was not intended for public consumption + */ +export type SerializedMetricAttributeValue = TypedAttributeValue; export interface SerializedMetric { /** @@ -72,7 +73,7 @@ export interface SerializedMetric { /** * Arbitrary structured data that stores information about the metric. */ - attributes?: Record; + attributes?: Attributes; } export type SerializedMetricContainer = { diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index 9d9b2d5c1e9a..13b9e026e6e9 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { attributeValueToTypedAttributeValue, isAttributeObject } from '../../src/attributes'; +import { attributeValueToTypedAttributeValue, isAttributeObject, serializeAttributes } from '../../src/attributes'; describe('attributeValueToTypedAttributeValue', () => { describe('without fallback (default behavior)', () => { @@ -267,8 +267,43 @@ describe('attributeValueToTypedAttributeValue', () => { type: 'string', }); }); + + it.each([null, { value: null }, { value: null, unit: 'byte' }])('stringifies %s values', value => { + const result = attributeValueToTypedAttributeValue(value, true); + expect(result).toMatchObject({ + value: 'null', + type: 'string', + }); + }); + + it.each([undefined, { value: undefined }])('stringifies %s values to ""', value => { + const result = attributeValueToTypedAttributeValue(value, true); + expect(result).toEqual({ + value: '', + type: 'string', + }); + }); + + it('stringifies undefined values with unit to ""', () => { + const result = attributeValueToTypedAttributeValue({ value: undefined, unit: 'byte' }, true); + expect(result).toEqual({ + value: '', + unit: 'byte', + type: 'string', + }); + }); }); }); + + describe('with fallback="skip-undefined"', () => { + it.each([undefined, { value: undefined }, { value: undefined, unit: 'byte' }])( + 'ignores undefined values (%s)', + value => { + const result = attributeValueToTypedAttributeValue(value, 'skip-undefined'); + expect(result).toBeUndefined(); + }, + ); + }); }); describe('isAttributeObject', () => { @@ -297,3 +332,118 @@ describe('isAttributeObject', () => { }, ); }); + +describe('serializeAttributes', () => { + it('returns an empty object for undefined attributes', () => { + const result = serializeAttributes(undefined); + expect(result).toStrictEqual({}); + }); + + it('returns an empty object for an empty object', () => { + const result = serializeAttributes({}); + expect(result).toStrictEqual({}); + }); + + it('serializes valid, non-primitive values', () => { + const result = serializeAttributes({ foo: 'bar', bar: { value: 123 }, baz: { value: 456, unit: 'byte' } }); + expect(result).toStrictEqual({ + bar: { + type: 'integer', + value: 123, + }, + baz: { + type: 'integer', + unit: 'byte', + value: 456, + }, + foo: { + type: 'string', + value: 'bar', + }, + }); + }); + + it('ignores undefined values if fallback is false', () => { + const result = serializeAttributes( + { foo: undefined, bar: { value: undefined }, baz: { value: undefined, unit: 'byte' } }, + false, + ); + expect(result).toStrictEqual({}); + }); + + it('ignores undefined values if fallback is "skip-undefined"', () => { + const result = serializeAttributes( + { foo: undefined, bar: { value: undefined }, baz: { value: undefined, unit: 'byte' } }, + 'skip-undefined', + ); + expect(result).toStrictEqual({}); + }); + + it('stringifies undefined values to "" if fallback is true', () => { + const result = serializeAttributes( + { foo: undefined, bar: { value: undefined }, baz: { value: undefined, unit: 'byte' } }, + true, + ); + expect(result).toStrictEqual({ + bar: { + type: 'string', + value: '', + }, + baz: { + type: 'string', + unit: 'byte', + value: '', + }, + foo: { type: 'string', value: '' }, + }); + }); + + it('ignores null values by default', () => { + const result = serializeAttributes({ foo: null, bar: { value: null }, baz: { value: null, unit: 'byte' } }); + expect(result).toStrictEqual({}); + }); + + it('stringifies to `"null"` if fallback is true', () => { + const result = serializeAttributes({ foo: null, bar: { value: null }, baz: { value: null, unit: 'byte' } }, true); + expect(result).toStrictEqual({ + foo: { + type: 'string', + value: 'null', + }, + bar: { + type: 'string', + value: 'null', + }, + baz: { + type: 'string', + unit: 'byte', + value: 'null', + }, + }); + }); + + describe('invalid (non-primitive) values', () => { + it("doesn't fall back to stringification by default", () => { + const result = serializeAttributes({ foo: { some: 'object' }, bar: [1, 2, 3], baz: () => {} }); + expect(result).toStrictEqual({}); + }); + + it('falls back to stringification of unsupported non-primitive values if fallback is true', () => { + const result = serializeAttributes({ foo: { some: 'object' }, bar: [1, 2, 3], baz: () => {} }, true); + expect(result).toStrictEqual({ + bar: { + type: 'string', + value: '[1,2,3]', + }, + baz: { + type: 'string', + value: '', + }, + foo: { + type: 'string', + value: '{"some":"object"}', + }, + }); + }); + }); +}); diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts index 3e479e282a0c..55753082d7ff 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -4,7 +4,6 @@ import { _INTERNAL_captureMetric, _INTERNAL_flushMetricsBuffer, _INTERNAL_getMetricBuffer, - metricAttributeToSerializedMetricAttribute, } from '../../../src/metrics/internal'; import type { Metric } from '../../../src/types-hoist/metric'; import * as loggerModule from '../../../src/utils/debug-logger'; @@ -12,74 +11,6 @@ import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; const PUBLIC_DSN = 'https://username@domain/123'; -describe('metricAttributeToSerializedMetricAttribute', () => { - it('serializes integer values', () => { - const result = metricAttributeToSerializedMetricAttribute(42); - expect(result).toEqual({ - value: 42, - type: 'integer', - }); - }); - - it('serializes double values', () => { - const result = metricAttributeToSerializedMetricAttribute(42.34); - expect(result).toEqual({ - value: 42.34, - type: 'double', - }); - }); - - it('serializes boolean values', () => { - const result = metricAttributeToSerializedMetricAttribute(true); - expect(result).toEqual({ - value: true, - type: 'boolean', - }); - }); - - it('serializes string values', () => { - const result = metricAttributeToSerializedMetricAttribute('endpoint'); - expect(result).toEqual({ - value: 'endpoint', - type: 'string', - }); - }); - - it('serializes object values as JSON strings', () => { - const obj = { name: 'John', age: 30 }; - const result = metricAttributeToSerializedMetricAttribute(obj); - expect(result).toEqual({ - value: JSON.stringify(obj), - type: 'string', - }); - }); - - it('serializes array values as JSON strings', () => { - const array = [1, 2, 3, 'test']; - const result = metricAttributeToSerializedMetricAttribute(array); - expect(result).toEqual({ - value: JSON.stringify(array), - type: 'string', - }); - }); - - it('serializes undefined values as empty strings', () => { - const result = metricAttributeToSerializedMetricAttribute(undefined); - expect(result).toEqual({ - value: '', - type: 'string', - }); - }); - - it('serializes null values as JSON strings', () => { - const result = metricAttributeToSerializedMetricAttribute(null); - expect(result).toEqual({ - value: 'null', - type: 'string', - }); - }); -}); - describe('_INTERNAL_captureMetric', () => { it('captures and sends metrics', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); From fae3a77d8d96dc8be648aac3b055f818858febe9 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 9 Jan 2026 14:57:59 +0100 Subject: [PATCH 65/76] fix(nextjs): Avoid Edge build warning from OpenTelemetry `process.argv0` (#18759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix Next.js Edge build warnings caused by OpenTelemetry’s process.argv0 usage by ensuring OTEL deps are bundled/rewritten in @sentry/vercel-edge and by removing unnecessary @sentry/opentelemetry imports from @sentry/nextjs Edge/shared utilities. closes https://github.com/getsentry/sentry-javascript/issues/18755 --- .../utils/dropMiddlewareTunnelRequests.ts | 40 +++- packages/nextjs/src/edge/index.ts | 30 ++- packages/opentelemetry/src/constants.ts | 4 + .../opentelemetry/src/utils/contextData.ts | 4 + .../src/utils/isSentryRequest.ts | 4 + packages/vercel-edge/rollup.npm.config.mjs | 201 +++++++++++------- .../vercel-edge/test/build-artifacts.test.ts | 32 +++ 7 files changed, 240 insertions(+), 75 deletions(-) create mode 100644 packages/vercel-edge/test/build-artifacts.test.ts diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index e8cde6e94baf..29e2ee55e45e 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,6 +1,12 @@ import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; -import { GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, type Span, type SpanAttributes } from '@sentry/core'; -import { isSentryRequestSpan } from '@sentry/opentelemetry'; +import { + getClient, + GLOBAL_OBJ, + isSentryRequestUrl, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + type Span, + type SpanAttributes, +} from '@sentry/core'; import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; @@ -36,6 +42,36 @@ export function dropMiddlewareTunnelRequests(span: Span, attrs: SpanAttributes | } } +/** + * Local copy of `@sentry/opentelemetry`'s `isSentryRequestSpan`, to avoid pulling the whole package into Edge bundles. + */ +function isSentryRequestSpan(span: Span): boolean { + const attributes = spanToAttributes(span); + if (!attributes) { + return false; + } + + const httpUrl = attributes['http.url'] || attributes['url.full']; + if (!httpUrl) { + return false; + } + + return isSentryRequestUrl(httpUrl.toString(), getClient()); +} + +function spanToAttributes(span: Span): Record | undefined { + // OTEL spans expose attributes in different shapes depending on implementation. + // We only need best-effort read access. + type MaybeSpanAttributes = { + attributes?: Record; + _attributes?: Record; + }; + + const maybeSpan = span as unknown as MaybeSpanAttributes; + const attrs = maybeSpan.attributes || maybeSpan._attributes; + return attrs; +} + /** * Checks if a span's HTTP target matches the tunnel route. */ diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 9fa05c94e978..94c71a52c483 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,7 +1,7 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ -import { context } from '@opentelemetry/api'; +import { context, createContextKey } from '@opentelemetry/api'; import { applySdkMetadata, type EventProcessor, @@ -12,6 +12,7 @@ import { getRootSpan, GLOBAL_OBJ, registerSpanErrorInstrumentation, + type Scope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -19,7 +20,6 @@ import { spanToJSON, stripUrlQueryAndFragment, } from '@sentry/core'; -import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -42,6 +42,32 @@ export { wrapApiHandlerWithSentry } from './wrapApiHandlerWithSentry'; export type EdgeOptions = VercelEdgeOptions; +type CurrentScopes = { + scope: Scope; + isolationScope: Scope; +}; + +// This key must match `@sentry/opentelemetry`'s `SENTRY_SCOPES_CONTEXT_KEY`. +// We duplicate it here so the Edge bundle does not need to import the full `@sentry/opentelemetry` package. +const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes'); + +type ContextWithGetValue = { + getValue(key: unknown): unknown; +}; + +function getScopesFromContext(otelContext: unknown): CurrentScopes | undefined { + if (!otelContext || typeof otelContext !== 'object') { + return undefined; + } + + const maybeContext = otelContext as Partial; + if (typeof maybeContext.getValue !== 'function') { + return undefined; + } + + return maybeContext.getValue(SENTRY_SCOPES_CONTEXT_KEY) as CurrentScopes | undefined; +} + const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; diff --git a/packages/opentelemetry/src/constants.ts b/packages/opentelemetry/src/constants.ts index 375e42dfdd00..3500ad6c4782 100644 --- a/packages/opentelemetry/src/constants.ts +++ b/packages/opentelemetry/src/constants.ts @@ -9,6 +9,10 @@ export const SENTRY_TRACE_STATE_URL = 'sentry.url'; export const SENTRY_TRACE_STATE_SAMPLE_RAND = 'sentry.sample_rand'; export const SENTRY_TRACE_STATE_SAMPLE_RATE = 'sentry.sample_rate'; +// NOTE: `@sentry/nextjs` has a local copy of this context key for Edge bundles: +// - `packages/nextjs/src/edge/index.ts` (`SENTRY_SCOPES_CONTEXT_KEY`) +// +// If you change the key name passed to `createContextKey(...)`, update that file too. export const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes'); export const SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY = createContextKey('sentry_fork_isolation_scope'); diff --git a/packages/opentelemetry/src/utils/contextData.ts b/packages/opentelemetry/src/utils/contextData.ts index 468b377f9ccd..78577131d0c7 100644 --- a/packages/opentelemetry/src/utils/contextData.ts +++ b/packages/opentelemetry/src/utils/contextData.ts @@ -11,6 +11,10 @@ const SCOPE_CONTEXT_FIELD = '_scopeContext'; * This requires a Context Manager that was wrapped with getWrappedContextManager. */ export function getScopesFromContext(context: Context): CurrentScopes | undefined { + // NOTE: `@sentry/nextjs` has a local copy of this helper for Edge bundles: + // - `packages/nextjs/src/edge/index.ts` (`getScopesFromContext`) + // + // If you change how scopes are stored/read (key or retrieval), update that file too. return context.getValue(SENTRY_SCOPES_CONTEXT_KEY) as CurrentScopes | undefined; } diff --git a/packages/opentelemetry/src/utils/isSentryRequest.ts b/packages/opentelemetry/src/utils/isSentryRequest.ts index d6b59880137b..6e06bcf5ab2e 100644 --- a/packages/opentelemetry/src/utils/isSentryRequest.ts +++ b/packages/opentelemetry/src/utils/isSentryRequest.ts @@ -9,6 +9,10 @@ import { spanHasAttributes } from './spanTypes'; * @returns boolean */ export function isSentryRequestSpan(span: AbstractSpan): boolean { + // NOTE: `@sentry/nextjs` has a local copy of this helper for Edge bundles: + // - `packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts` (`isSentryRequestSpan`) + // + // If you change supported OTEL attribute keys or request detection logic, update that file too. if (!spanHasAttributes(span)) { return false; } diff --git a/packages/vercel-edge/rollup.npm.config.mjs b/packages/vercel-edge/rollup.npm.config.mjs index ae01f43703d0..d8f1704e2f8a 100644 --- a/packages/vercel-edge/rollup.npm.config.mjs +++ b/packages/vercel-edge/rollup.npm.config.mjs @@ -1,79 +1,138 @@ import replace from '@rollup/plugin-replace'; import { makeBaseNPMConfig, makeNPMConfigVariants, plugins } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants( - makeBaseNPMConfig({ - entrypoints: ['src/index.ts'], - bundledBuiltins: ['perf_hooks', 'util'], - packageSpecificConfig: { - context: 'globalThis', - output: { - preserveModules: false, - }, - plugins: [ - plugins.makeCommonJSPlugin({ transformMixedEsModules: true }), // Needed because various modules in the OTEL toolchain use CJS (require-in-the-middle, shimmer, etc..) - plugins.makeJsonPlugin(), // Needed because `require-in-the-middle` imports json via require - replace({ - preventAssignment: true, - values: { - 'process.argv0': JSON.stringify(''), // needed because otel relies on process.argv0 for the default service name, but that api is not available in the edge runtime. - }, - }), - { - // This plugin is needed because otel imports `performance` from `perf_hooks` and also uses it via the `performance` global. - // It also imports `inspect` and `promisify` from node's `util` which are not available in the edge runtime so we need to define a polyfill. - // Both of these APIs are not available in the edge runtime so we need to define a polyfill. - // Vercel does something similar in the `@vercel/otel` package: https://github.com/vercel/otel/blob/087601ae585cb116bb2b46c211d014520de76c71/packages/otel/build.ts#L62 - name: 'edge-runtime-polyfills', - banner: ` - { - if (globalThis.performance === undefined) { - globalThis.performance = { - timeOrigin: 0, - now: () => Date.now() - }; - } - } - `, - resolveId: source => { - if (source === 'perf_hooks') { - return '\0perf_hooks_sentry_shim'; - } else if (source === 'util') { - return '\0util_sentry_shim'; - } else { - return null; +const downlevelLogicalAssignmentsPlugin = { + name: 'downlevel-logical-assignments', + renderChunk(code) { + // ES2021 logical assignment operators (`||=`, `&&=`, `??=`) are not allowed by our ES2020 compatibility check. + // OTEL currently ships some of these, so we downlevel them in the final output. + // + // Note: This is intentionally conservative (only matches property access-like LHS) to avoid duplicating side effects. + // IMPORTANT: Use regex literals (not `String.raw` + `RegExp(...)`) to avoid accidental double-escaping. + let out = code; + + // ??= + out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*\?\?=\s*([^;]+);/g, (_m, left, right) => { + return `${left} = ${left} ?? ${right};`; + }); + + // ||= + out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*\|\|=\s*([^;]+);/g, (_m, left, right) => { + return `${left} = ${left} || ${right};`; + }); + + // &&= + out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*&&=\s*([^;]+);/g, (_m, left, right) => { + return `${left} = ${left} && ${right};`; + }); + + return { code: out, map: null }; + }, +}; + +const baseConfig = makeBaseNPMConfig({ + entrypoints: ['src/index.ts'], + bundledBuiltins: ['perf_hooks', 'util'], + packageSpecificConfig: { + context: 'globalThis', + output: { + preserveModules: false, + }, + plugins: [ + plugins.makeCommonJSPlugin({ transformMixedEsModules: true }), // Needed because various modules in the OTEL toolchain use CJS (require-in-the-middle, shimmer, etc..) + plugins.makeJsonPlugin(), // Needed because `require-in-the-middle` imports json via require + replace({ + preventAssignment: true, + values: { + 'process.argv0': JSON.stringify(''), // needed because otel relies on process.argv0 for the default service name, but that api is not available in the edge runtime. + }, + }), + { + // This plugin is needed because otel imports `performance` from `perf_hooks` and also uses it via the `performance` global. + // It also imports `inspect` and `promisify` from node's `util` which are not available in the edge runtime so we need to define a polyfill. + // Both of these APIs are not available in the edge runtime so we need to define a polyfill. + // Vercel does something similar in the `@vercel/otel` package: https://github.com/vercel/otel/blob/087601ae585cb116bb2b46c211d014520de76c71/packages/otel/build.ts#L62 + name: 'edge-runtime-polyfills', + banner: ` + { + if (globalThis.performance === undefined) { + globalThis.performance = { + timeOrigin: 0, + now: () => Date.now() + }; } - }, - load: id => { - if (id === '\0perf_hooks_sentry_shim') { - return ` - export const performance = { - timeOrigin: 0, - now: () => Date.now() - } - `; - } else if (id === '\0util_sentry_shim') { - return ` - export const inspect = (object) => - JSON.stringify(object, null, 2); + } + `, + resolveId: source => { + if (source === 'perf_hooks') { + return '\0perf_hooks_sentry_shim'; + } else if (source === 'util') { + return '\0util_sentry_shim'; + } else { + return null; + } + }, + load: id => { + if (id === '\0perf_hooks_sentry_shim') { + return ` + export const performance = { + timeOrigin: 0, + now: () => Date.now() + } + `; + } else if (id === '\0util_sentry_shim') { + return ` + export const inspect = (object) => + JSON.stringify(object, null, 2); - export const promisify = (fn) => { - return (...args) => { - return new Promise((resolve, reject) => { - fn(...args, (err, result) => { - if (err) reject(err); - else resolve(result); - }); + export const promisify = (fn) => { + return (...args) => { + return new Promise((resolve, reject) => { + fn(...args, (err, result) => { + if (err) reject(err); + else resolve(result); }); - }; + }); }; - `; - } else { - return null; - } - }, + }; + `; + } else { + return null; + } }, - ], - }, - }), -); + }, + downlevelLogicalAssignmentsPlugin, + ], + }, +}); + +// `makeBaseNPMConfig` marks dependencies/peers as external by default. +// For Edge, we must ensure the OTEL SDK bits which reference `process.argv0` are bundled so our replace() plugin applies. +const baseExternal = baseConfig.external; +baseConfig.external = (source, importer, isResolved) => { + // Never treat these as external - they need to be inlined so `process.argv0` can be replaced. + if ( + source === '@opentelemetry/resources' || + source.startsWith('@opentelemetry/resources/') || + source === '@opentelemetry/sdk-trace-base' || + source.startsWith('@opentelemetry/sdk-trace-base/') + ) { + return false; + } + + if (typeof baseExternal === 'function') { + return baseExternal(source, importer, isResolved); + } + + if (Array.isArray(baseExternal)) { + return baseExternal.includes(source); + } + + if (baseExternal instanceof RegExp) { + return baseExternal.test(source); + } + + return false; +}; + +export default makeNPMConfigVariants(baseConfig); diff --git a/packages/vercel-edge/test/build-artifacts.test.ts b/packages/vercel-edge/test/build-artifacts.test.ts new file mode 100644 index 000000000000..c4994f4f8b29 --- /dev/null +++ b/packages/vercel-edge/test/build-artifacts.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +function readBuildFile(relativePathFromPackageRoot: string): string { + const filePath = join(process.cwd(), relativePathFromPackageRoot); + return readFileSync(filePath, 'utf8'); +} + +describe('build artifacts', () => { + it('does not contain Node-only `process.argv0` usage (Edge compatibility)', () => { + const cjs = readBuildFile('build/cjs/index.js'); + const esm = readBuildFile('build/esm/index.js'); + + expect(cjs).not.toContain('process.argv0'); + expect(esm).not.toContain('process.argv0'); + }); + + it('does not contain ES2021 logical assignment operators (ES2020 compatibility)', () => { + const cjs = readBuildFile('build/cjs/index.js'); + const esm = readBuildFile('build/esm/index.js'); + + // ES2021 operators which `es-check es2020` rejects + expect(cjs).not.toContain('??='); + expect(cjs).not.toContain('||='); + expect(cjs).not.toContain('&&='); + + expect(esm).not.toContain('??='); + expect(esm).not.toContain('||='); + expect(esm).not.toContain('&&='); + }); +}); From dbe652052d282789d722dcf1643b28d53590df18 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Fri, 9 Jan 2026 17:13:45 +0200 Subject: [PATCH 66/76] feat(Tracing): Add Vercel AI SDK v6 support (#18741) This PR adds support for Vercel AI SDK v6 telemetry changes. ### Changes **Provider Metadata Updates** - Added `azure` key to `ProviderMetadata` interface for Azure Responses API (v6 uses `azure` instead of `openai` for Azure provider) - Added `vertex` key for Google Vertex provider (v6 uses `vertex` instead of `google`) - Updated `addProviderMetadataToAttributes` to check both old and new keys for backward compatibility **V6 Test Suite** - Added integration tests for Vercel AI SDK v6 using `MockLanguageModelV3` - Updated mock scenarios to match v6's new data structures: - `usage` now uses object format: `{ total, noCache, cached }` - `finishReason` now uses object format: `{ unified, raw }` - Added `vercel.ai.request.headers.user-agent` attribute to test expectations Closes https://github.com/getsentry/sentry-javascript/issues/18691 --- .../vercelai/v6/instrument-with-pii.mjs | 11 + .../suites/tracing/vercelai/v6/instrument.mjs | 10 + .../vercelai/v6/scenario-error-in-tool.mjs | 41 ++ .../suites/tracing/vercelai/v6/scenario.mjs | 92 +++ .../suites/tracing/vercelai/v6/test.ts | 577 ++++++++++++++++++ packages/core/src/tracing/vercel-ai/index.ts | 22 +- .../tracing/vercel-ai/vercel-ai-attributes.ts | 4 +- .../tracing/vercelai/instrumentation.ts | 4 +- 8 files changed, 748 insertions(+), 13 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-error-in-tool.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument-with-pii.mjs new file mode 100644 index 000000000000..b798e21228f5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument-with-pii.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument.mjs new file mode 100644 index 000000000000..5e898ee1949d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-error-in-tool.mjs new file mode 100644 index 000000000000..9ea18401ac35 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-error-in-tool.mjs @@ -0,0 +1,41 @@ +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: '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?', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario.mjs new file mode 100644 index 000000000000..66233d1dabe5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario.mjs @@ -0,0 +1,92 @@ +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({ + 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?', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts new file mode 100644 index 000000000000..98a16618d77d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts @@ -0,0 +1,577 @@ +import type { Event } from '@sentry/node'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('Vercel AI integration (V6)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - no telemetry config, should enable telemetry but not record inputs/outputs when sendDefaultPii: false + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Second span - explicitly enabled telemetry but recordInputs/recordOutputs not set, should not record when sendDefaultPii: false + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'gen_ai.generate_text', + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Third span - explicit telemetry enabled, should record inputs/outputs regardless of sendDefaultPii + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.response.finishReason': 'stop', + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fourth span - doGenerate for explicit telemetry enabled call + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'gen_ai.generate_text', + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fifth span - tool call generateText span + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Sixth span - tool call doGenerate span + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Seventh span - tool call execution span + expect.objectContaining({ + data: expect.objectContaining({ + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.type': 'function', + 'gen_ai.operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + ]), + }; + + const EXPECTED_AVAILABLE_TOOLS_JSON = + '[{"type":"function","name":"getWeather","inputSchema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"location":{"type":"string"}},"required":["location"],"additionalProperties":false}}]'; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - no telemetry config, should enable telemetry AND record inputs/outputs when sendDefaultPii: true + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the first span?"}', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.messages': '[{"role":"user","content":"Where is the first span?"}]', + 'vercel.ai.response.finishReason': 'stop', + 'gen_ai.response.text': 'First span here!', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the first span?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Second span - doGenerate for first call, should also include input/output fields when sendDefaultPii: true + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'gen_ai.response.text': 'First span here!', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Third span - explicitly enabled telemetry, should record inputs/outputs regardless of sendDefaultPii + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', + 'vercel.ai.response.finishReason': 'stop', + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fourth span - doGenerate for explicitly enabled telemetry call + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'gen_ai.generate_text', + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fifth span - tool call generateText span (should include prompts when sendDefaultPii: true) + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather in San Francisco?"}]', + 'vercel.ai.response.finishReason': 'tool-calls', + 'gen_ai.response.tool_calls': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Sixth span - tool call doGenerate span (should include prompts when sendDefaultPii: true) + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.messages': expect.any(String), + 'vercel.ai.prompt.toolChoice': expect.any(String), + 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + // 'gen_ai.response.text': 'Tool call completed!', // TODO: look into why this is not being set + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.response.tool_calls': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Seventh span - tool call execution span + expect.objectContaining({ + data: expect.objectContaining({ + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.input': expect.any(String), + 'gen_ai.tool.output': expect.any(String), + 'gen_ai.tool.type': 'function', + 'gen_ai.operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates ai related spans with sendDefaultPii: false', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_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 with sendDefaultPii: true', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-error-in-tool.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('captures error in tool', async () => { + const expectedTransaction = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + 'vercel.ai.response.finishReason': 'tool-calls', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.type': 'function', + 'gen_ai.operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'internal_error', + }), + ]), + }; + + const expectedError = { + level: 'error', + tags: expect.objectContaining({ + 'vercel.ai.tool.name': 'getWeather', + 'vercel.ai.tool.callId': 'call-1', + }), + }; + + let transactionEvent: Event | undefined; + let errorEvent: Event | undefined; + + await createRunner() + .expect({ + transaction: transaction => { + transactionEvent = transaction; + }, + }) + .expect({ + event: event => { + errorEvent = event; + }, + }) + .start() + .completed(); + + expect(transactionEvent).toBeDefined(); + expect(transactionEvent).toMatchObject(expectedTransaction); + + expect(errorEvent).toBeDefined(); + expect(errorEvent).toMatchObject(expectedError); + + // Trace id should be the same for the transaction and error event + expect(transactionEvent!.contexts!.trace!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates ai related spans with v6', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); +}); diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 6b59feb7a0ec..3415852ac4f3 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -22,7 +22,7 @@ import { getSpanOpFromName, requestMessagesFromPrompt, } from './utils'; -import type { ProviderMetadata } from './vercel-ai-attributes'; +import type { OpenAiProviderMetadata, ProviderMetadata } from './vercel-ai-attributes'; import { AI_MODEL_ID_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE, @@ -270,28 +270,28 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { if (providerMetadata) { try { const providerMetadataObject = JSON.parse(providerMetadata) as ProviderMetadata; - if (providerMetadataObject.openai) { + + // Handle OpenAI metadata (v5 uses 'openai', v6 Azure Responses API uses 'azure') + const openaiMetadata: OpenAiProviderMetadata | undefined = + providerMetadataObject.openai ?? providerMetadataObject.azure; + if (openaiMetadata) { setAttributeIfDefined( attributes, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, - providerMetadataObject.openai.cachedPromptTokens, - ); - setAttributeIfDefined( - attributes, - 'gen_ai.usage.output_tokens.reasoning', - providerMetadataObject.openai.reasoningTokens, + openaiMetadata.cachedPromptTokens, ); + setAttributeIfDefined(attributes, 'gen_ai.usage.output_tokens.reasoning', openaiMetadata.reasoningTokens); setAttributeIfDefined( attributes, 'gen_ai.usage.output_tokens.prediction_accepted', - providerMetadataObject.openai.acceptedPredictionTokens, + openaiMetadata.acceptedPredictionTokens, ); setAttributeIfDefined( attributes, 'gen_ai.usage.output_tokens.prediction_rejected', - providerMetadataObject.openai.rejectedPredictionTokens, + openaiMetadata.rejectedPredictionTokens, ); - setAttributeIfDefined(attributes, 'gen_ai.conversation.id', providerMetadataObject.openai.responseId); + setAttributeIfDefined(attributes, 'gen_ai.conversation.id', openaiMetadata.responseId); } if (providerMetadataObject.anthropic) { diff --git a/packages/core/src/tracing/vercel-ai/vercel-ai-attributes.ts b/packages/core/src/tracing/vercel-ai/vercel-ai-attributes.ts index 95052fc1265a..3bb37e6a429a 100644 --- a/packages/core/src/tracing/vercel-ai/vercel-ai-attributes.ts +++ b/packages/core/src/tracing/vercel-ai/vercel-ai-attributes.ts @@ -821,7 +821,7 @@ export const AI_TOOL_CALL_SPAN_ATTRIBUTES = { * @see https://github.com/vercel/ai/blob/65e042afde6aad4da9d7a62526ece839eb34f9a5/packages/openai/src/openai-chat-language-model.ts#L397-L416 * @see https://github.com/vercel/ai/blob/65e042afde6aad4da9d7a62526ece839eb34f9a5/packages/openai/src/responses/openai-responses-language-model.ts#L377C7-L384 */ -interface OpenAiProviderMetadata { +export interface OpenAiProviderMetadata { /** * The number of predicted output tokens that were accepted. * @see https://ai-sdk.dev/providers/ai-sdk-providers/openai#predicted-outputs @@ -1041,9 +1041,11 @@ interface PerplexityProviderMetadata { export interface ProviderMetadata { openai?: OpenAiProviderMetadata; + azure?: OpenAiProviderMetadata; // v6: Azure Responses API uses 'azure' key instead of 'openai' anthropic?: AnthropicProviderMetadata; bedrock?: AmazonBedrockProviderMetadata; google?: GoogleGenerativeAIProviderMetadata; + vertex?: GoogleGenerativeAIProviderMetadata; // v6: Google Vertex uses 'vertex' key instead of 'google' deepseek?: DeepSeekProviderMetadata; perplexity?: PerplexityProviderMetadata; } diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 872e0153edba..792032a7314e 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -15,6 +15,8 @@ import { import { INTEGRATION_NAME } from './constants'; import type { TelemetrySettings, VercelAiIntegration } from './types'; +const SUPPORTED_VERSIONS = ['>=3.0.0 <7']; + // List of patched methods // From: https://sdk.vercel.ai/docs/ai-sdk-core/telemetry#collected-data const INSTRUMENTED_METHODS = [ @@ -186,7 +188,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { * Initializes the instrumentation by defining the modules to be patched. */ public init(): InstrumentationModuleDefinition { - const module = new InstrumentationNodeModuleDefinition('ai', ['>=3.0.0 <6'], this._patch.bind(this)); + const module = new InstrumentationNodeModuleDefinition('ai', SUPPORTED_VERSIONS, this._patch.bind(this)); return module; } From 7868fe5c8e4df1afc27e14f2367734022f39bed0 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:40:54 +0100 Subject: [PATCH 67/76] fix(browser): Forward worker metadata for third-party error filtering (#18756) The `thirdPartyErrorFilterIntegration` was not able to identify first-party worker code as because module metadata stayed in the worker's separate global scope and wasn't accessible to the main thread. We now forward the metadata the same way we forward debug ids to the main thread which allows first-party worker code to be identified as such. Closes: #18705 --- .../browser-webworker-vite/src/main.ts | 8 +- .../tests/errors.test.ts | 16 ++ .../browser-webworker-vite/vite.config.ts | 2 + .../browser/src/integrations/webWorker.ts | 36 ++++- .../test/integrations/webWorker.test.ts | 147 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/metadata.ts | 31 ++++ packages/core/test/lib/metadata.test.ts | 81 +++++++++- 8 files changed, 315 insertions(+), 7 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts index b017c1bfdc4d..238ec062663a 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts @@ -7,7 +7,13 @@ Sentry.init({ environment: import.meta.env.MODE || 'development', tracesSampleRate: 1.0, debug: true, - integrations: [Sentry.browserTracingIntegration()], + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-contains-third-party-frames', + filterKeys: ['browser-webworker-vite'], + }), + ], tunnel: 'http://localhost:3031/', // proxy server }); diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts index e298fa525efb..d12e61111c85 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts @@ -171,3 +171,19 @@ test('captures an error from the third lazily added worker', async ({ page }) => ], }); }); + +test('worker errors are not tagged as third-party when module metadata is present', async ({ page }) => { + const errorEventPromise = waitForError('browser-webworker-vite', async event => { + return !event.type && event.exception?.values?.[0]?.value === 'Uncaught Error: Uncaught error in worker'; + }); + + await page.goto('/'); + + await page.locator('#trigger-error').click(); + + await page.waitForTimeout(1000); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.tags?.third_party_code).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts index df010d9b426c..190aa3749e3f 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ org: process.env.E2E_TEST_SENTRY_ORG_SLUG, project: process.env.E2E_TEST_SENTRY_PROJECT, authToken: process.env.E2E_TEST_AUTH_TOKEN, + applicationKey: 'browser-webworker-vite', }), ], @@ -21,6 +22,7 @@ export default defineConfig({ org: process.env.E2E_TEST_SENTRY_ORG_SLUG, project: process.env.E2E_TEST_SENTRY_PROJECT, authToken: process.env.E2E_TEST_AUTH_TOKEN, + applicationKey: 'browser-webworker-vite', }), ], }, diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index e95e161e703c..5af6c3b2553a 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -10,6 +10,7 @@ export const INTEGRATION_NAME = 'WebWorker'; interface WebWorkerMessage { _sentryMessage: boolean; _sentryDebugIds?: Record; + _sentryModuleMetadata?: Record; // eslint-disable-line @typescript-eslint/no-explicit-any _sentryWorkerError?: SerializedWorkerError; } @@ -122,6 +123,18 @@ function listenForSentryMessages(worker: Worker): void { }; } + // Handle module metadata + if (event.data._sentryModuleMetadata) { + DEBUG_BUILD && debug.log('Sentry module metadata web worker message received', event.data); + // Merge worker's raw metadata into the global object + // It will be parsed lazily when needed by getMetadataForUrl + WINDOW._sentryModuleMetadata = { + ...event.data._sentryModuleMetadata, + // Module metadata of the main thread have precedence over the worker's in case of a collision. + ...WINDOW._sentryModuleMetadata, + }; + } + // Handle unhandled rejections forwarded from worker if (event.data._sentryWorkerError) { DEBUG_BUILD && debug.log('Sentry worker rejection message received', event.data._sentryWorkerError); @@ -187,7 +200,10 @@ interface MinimalDedicatedWorkerGlobalScope { } interface RegisterWebWorkerOptions { - self: MinimalDedicatedWorkerGlobalScope & { _sentryDebugIds?: Record }; + self: MinimalDedicatedWorkerGlobalScope & { + _sentryDebugIds?: Record; + _sentryModuleMetadata?: Record; // eslint-disable-line @typescript-eslint/no-explicit-any + }; } /** @@ -195,6 +211,7 @@ interface RegisterWebWorkerOptions { * * This function will: * - Send debug IDs to the parent thread + * - Send module metadata to the parent thread (for thirdPartyErrorFilterIntegration) * - Set up a handler for unhandled rejections in the worker * - Forward unhandled rejections to the parent thread for capture * @@ -215,10 +232,12 @@ interface RegisterWebWorkerOptions { * - `self`: The worker instance you're calling this function from (self). */ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { - // Send debug IDs to parent thread + // Send debug IDs and raw module metadata to parent thread + // The metadata will be parsed lazily on the main thread when needed self.postMessage({ _sentryMessage: true, _sentryDebugIds: self._sentryDebugIds ?? undefined, + _sentryModuleMetadata: self._sentryModuleMetadata ?? undefined, }); // Set up unhandledrejection handler inside the worker @@ -251,11 +270,12 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { return false; } - // Must have at least one of: debug IDs or worker error + // Must have at least one of: debug IDs, module metadata, or worker error const hasDebugIds = '_sentryDebugIds' in eventData; + const hasModuleMetadata = '_sentryModuleMetadata' in eventData; const hasWorkerError = '_sentryWorkerError' in eventData; - if (!hasDebugIds && !hasWorkerError) { + if (!hasDebugIds && !hasModuleMetadata && !hasWorkerError) { return false; } @@ -264,6 +284,14 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { return false; } + // Validate module metadata if present + if ( + hasModuleMetadata && + !(isPlainObject(eventData._sentryModuleMetadata) || eventData._sentryModuleMetadata === undefined) + ) { + return false; + } + // Validate worker error if present if (hasWorkerError && !isPlainObject(eventData._sentryWorkerError)) { return false; diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index b72895621339..584f18ee9a75 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -209,6 +209,97 @@ describe('webWorkerIntegration', () => { 'main.js': 'main-debug', }); }); + + it('processes module metadata from worker', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; + const moduleMetadata = { + 'Error\n at worker-file1.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + 'Error\n at worker-file2.js:2:2': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: moduleMetadata, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect(mockDebugLog).toHaveBeenCalledWith('Sentry module metadata web worker message received', mockEvent.data); + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata); + }); + + it('handles message with both debug IDs and module metadata', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; + const moduleMetadata = { + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryDebugIds: { 'worker-file.js': 'debug-id-1' }, + _sentryModuleMetadata: moduleMetadata, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata); + expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ + 'worker-file.js': 'debug-id-1', + }); + }); + + it('accepts message with only module metadata', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; + const moduleMetadata = { + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: moduleMetadata, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata); + }); + + it('ignores invalid module metadata', () => { + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: 'not-an-object', + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); + }); + + it('gives main thread precedence over worker for conflicting module metadata', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = { + 'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true, source: 'main' }, + 'Error\n at main-only.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: { + 'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true, source: 'worker' }, + 'Error\n at worker-only.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true }, + }, + }; + + messageHandler(mockEvent); + + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual({ + 'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true, source: 'main' }, // Main thread wins + 'Error\n at main-only.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true }, // Main thread preserved + 'Error\n at worker-only.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true }, // Worker added + }); + }); }); }); }); @@ -218,6 +309,7 @@ describe('registerWebWorker', () => { postMessage: ReturnType; addEventListener: ReturnType; _sentryDebugIds?: Record; + _sentryModuleMetadata?: Record; }; beforeEach(() => { @@ -236,6 +328,7 @@ describe('registerWebWorker', () => { expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, }); }); @@ -254,6 +347,7 @@ describe('registerWebWorker', () => { 'worker-file1.js': 'debug-id-1', 'worker-file2.js': 'debug-id-2', }, + _sentryModuleMetadata: undefined, }); }); @@ -266,6 +360,57 @@ describe('registerWebWorker', () => { expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, + }); + }); + + it('includes raw module metadata when available', () => { + const rawMetadata = { + 'Error\n at worker-file1.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + 'Error\n at worker-file2.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockWorkerSelf._sentryModuleMetadata = rawMetadata; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: undefined, + _sentryModuleMetadata: rawMetadata, + }); + }); + + it('sends undefined module metadata when not available', () => { + mockWorkerSelf._sentryModuleMetadata = undefined; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, + }); + }); + + it('includes both debug IDs and module metadata when both available', () => { + const rawMetadata = { + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockWorkerSelf._sentryDebugIds = { + 'worker-file.js': 'debug-id-1', + }; + mockWorkerSelf._sentryModuleMetadata = rawMetadata; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: { + 'worker-file.js': 'debug-id-1', + }, + _sentryModuleMetadata: rawMetadata, }); }); }); @@ -335,6 +480,7 @@ describe('registerWebWorker and webWorkerIntegration', () => { expect(mockWorker.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: mockWorker._sentryDebugIds, + _sentryModuleMetadata: undefined, }); expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ @@ -355,6 +501,7 @@ describe('registerWebWorker and webWorkerIntegration', () => { expect(mockWorker3.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: mockWorker3._sentryDebugIds, + _sentryModuleMetadata: undefined, }); expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e4b48f24de2f..dffa16a130bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -322,6 +322,7 @@ export { vercelWaitUntil } from './utils/vercelWaitUntil'; export { flushIfServerless } from './utils/flushIfServerless'; export { SDK_VERSION } from './utils/version'; export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids'; +export { getFilenameToMetadataMap } from './metadata'; export { escapeStringForRegex } from './vendor/escapeStringForRegex'; export type { Attachment } from './types-hoist/attachment'; diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 1ee93e8dcd5a..54ee4a1e1eb4 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -8,6 +8,37 @@ const filenameMetadataMap = new Map(); /** Set of stack strings that have already been parsed. */ const parsedStacks = new Set(); +/** + * Builds a map of filenames to module metadata from the global _sentryModuleMetadata object. + * This is useful for forwarding metadata from web workers to the main thread. + * + * @param parser - Stack parser to use for extracting filenames from stack traces + * @returns A map of filename to metadata object + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getFilenameToMetadataMap(parser: StackParser): Record { + if (!GLOBAL_OBJ._sentryModuleMetadata) { + return {}; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filenameMap: Record = {}; + + for (const stack of Object.keys(GLOBAL_OBJ._sentryModuleMetadata)) { + const metadata = GLOBAL_OBJ._sentryModuleMetadata[stack]; + const frames = parser(stack); + + for (const frame of frames.reverse()) { + if (frame.filename) { + filenameMap[frame.filename] = metadata; + break; + } + } + } + + return filenameMap; +} + function ensureMetadataStacksAreParsed(parser: StackParser): void { if (!GLOBAL_OBJ._sentryModuleMetadata) { return; diff --git a/packages/core/test/lib/metadata.test.ts b/packages/core/test/lib/metadata.test.ts index bedf4cdcf7e9..e312698a6cf8 100644 --- a/packages/core/test/lib/metadata.test.ts +++ b/packages/core/test/lib/metadata.test.ts @@ -1,5 +1,10 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { addMetadataToStackFrames, getMetadataForUrl, stripMetadataFromStackFrames } from '../../src/metadata'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + addMetadataToStackFrames, + getFilenameToMetadataMap, + getMetadataForUrl, + stripMetadataFromStackFrames, +} from '../../src/metadata'; import type { Event } from '../../src/types-hoist/event'; import { nodeStackLineParser } from '../../src/utils/node-stack-trace'; import { createStackParser } from '../../src/utils/stacktrace'; @@ -44,6 +49,10 @@ describe('Metadata', () => { GLOBAL_OBJ._sentryModuleMetadata[stack] = { team: 'frontend' }; }); + afterEach(() => { + delete GLOBAL_OBJ._sentryModuleMetadata; + }); + it('is parsed', () => { const metadata = getMetadataForUrl(parser, __filename); @@ -97,3 +106,71 @@ describe('Metadata', () => { ]); }); }); + +describe('getFilenameToMetadataMap', () => { + afterEach(() => { + delete GLOBAL_OBJ._sentryModuleMetadata; + }); + + it('returns empty object when no metadata is available', () => { + delete GLOBAL_OBJ._sentryModuleMetadata; + + const result = getFilenameToMetadataMap(parser); + + expect(result).toEqual({}); + }); + + it('extracts filenames from stack traces and maps to metadata', () => { + const stack1 = `Error + at Object. (/path/to/file1.js:10:15) + at Module._compile (internal/modules/cjs/loader.js:1063:30)`; + + const stack2 = `Error + at processTicksAndRejections (/path/to/file2.js:20:25)`; + + GLOBAL_OBJ._sentryModuleMetadata = { + [stack1]: { '_sentryBundlerPluginAppKey:my-app': true, team: 'frontend' }, + [stack2]: { '_sentryBundlerPluginAppKey:my-app': true, team: 'backend' }, + }; + + const result = getFilenameToMetadataMap(parser); + + expect(result).toEqual({ + '/path/to/file1.js': { '_sentryBundlerPluginAppKey:my-app': true, team: 'frontend' }, + '/path/to/file2.js': { '_sentryBundlerPluginAppKey:my-app': true, team: 'backend' }, + }); + }); + + it('handles stack traces with native code frames', () => { + const stackNoFilename = `Error + at [native code]`; + + GLOBAL_OBJ._sentryModuleMetadata = { + [stackNoFilename]: { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + const result = getFilenameToMetadataMap(parser); + + // Native code may be parsed as a filename by the parser + // This is acceptable behavior as long as we don't error + expect(result).toBeDefined(); + }); + + it('handles multiple stacks with the same filename', () => { + const stack1 = `Error + at functionA (/path/to/same-file.js:10:15)`; + + const stack2 = `Error + at functionB (/path/to/same-file.js:20:25)`; + + GLOBAL_OBJ._sentryModuleMetadata = { + [stack1]: { '_sentryBundlerPluginAppKey:app1': true }, + [stack2]: { '_sentryBundlerPluginAppKey:app2': true }, + }; + + const result = getFilenameToMetadataMap(parser); + + // Last one wins (based on iteration order) + expect(result['/path/to/same-file.js']).toBeDefined(); + }); +}); From 8eb1d4404626f0a46c0aba581e38f760aeab82e3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 9 Jan 2026 16:47:18 +0100 Subject: [PATCH 68/76] feat(core): Apply scope attributes to metrics (#18738) Applies scope attributes to metrics, analogously to how we apply them to logs (see https://github.com/getsentry/sentry-javascript/pull/18184). Added unit and integration tests. --- CHANGELOG.md | 21 ++++++- .../public-api/metrics/simple/subject.js | 6 ++ .../suites/public-api/metrics/simple/test.ts | 56 ++++++++++++++++++- .../suites/public-api/metrics/scenario.ts | 6 ++ .../suites/public-api/metrics/test.ts | 54 ++++++++++++++++++ .../suites/public-api/metrics/scenario.ts | 6 ++ .../suites/public-api/metrics/test.ts | 54 ++++++++++++++++++ packages/core/src/metrics/internal.ts | 31 ++++++---- packages/core/src/scope.ts | 8 +-- .../core/test/lib/metrics/internal.test.ts | 46 +++++++++++++++ 10 files changed, 270 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42e61ce31050..fdea17ff2e44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,24 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, @G-Rath, @maximepvrt, and @gianpaj. Thank you for your contributions! +- **feat(core): Apply scope attributes to metrics ([#18738](https://github.com/getsentry/sentry-javascript/pull/18738))** + + You can now set attributes on the SDK's scopes which will be applied to all metrics as long as the respective scopes are active. For the time being, only `string`, `number` and `boolean` attribute values are supported. + + ```ts + Sentry.getGlobalScope().setAttributes({ is_admin: true, auth_provider: 'google' }); + + Sentry.withScope(scope => { + scope.setAttribute('step', 'authentication'); + + // scope attributes `is_admin`, `auth_provider` and `step` are added + Sentry.metrics.count('clicks', 1, { attributes: { activeSince: 100 } }); + Sentry.metrics.gauge('timeSinceRefresh', 4, { unit: 'hour' }); + }); + + // scope attributes `is_admin` and `auth_provider` are added + Sentry.metrics.count('response_time', 283.33, { unit: 'millisecond' }); + ``` - ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) @@ -22,6 +39,8 @@ Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, @G-Rath, +Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, @G-Rath, @maximepvrt, and @gianpaj. Thank you for your contributions! + ## 10.32.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js index 0b8fced8d6e3..6e1a35009e5f 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js @@ -9,4 +9,10 @@ Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); +Sentry.withScope(scope => { + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + Sentry.metrics.count('test.scope.attributes.counter', 1, { attributes: { action: 'click' } }); +}); + Sentry.flush(); 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 3a8ac97f8408..41eb00a90bc8 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 @@ -17,7 +17,7 @@ sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) expect(envelopeItems[0]).toEqual([ { type: 'trace_metric', - item_count: 5, + item_count: 6, content_type: 'application/vnd.sentry.items.trace-metric+json', }, { @@ -98,6 +98,60 @@ sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.scope.attributes.counter', + type: 'counter', + value: 1, + attributes: { + action: { + type: 'string', + value: 'click', + }, + scope_attribute_1: { + type: 'integer', + value: 1, + }, + scope_attribute_2: { + type: 'string', + value: 'test', + }, + scope_attribute_3: { + type: 'integer', + unit: 'gigabyte', + value: 38, + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: '1.0.0', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: '10.32.1', + }, + 'user.email': { + type: 'string', + value: 'test@example.com', + }, + 'user.id': { + type: 'string', + value: 'user-123', + }, + 'user.name': { + type: 'string', + value: 'testuser', + }, + }, + }, ], }, ]); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts index 77adfae79802..86905bae1066 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts @@ -25,6 +25,12 @@ async function run(): Promise { Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); + Sentry.withScope(scope => { + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + Sentry.metrics.count('test.scope.attributes.counter', 1, { attributes: { action: 'click' } }); + }); + await Sentry.flush(); } 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 c89c8fb59e55..83715375f2cc 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 @@ -87,6 +87,60 @@ describe('metrics', () => { 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.scope.attributes.counter', + type: 'counter', + value: 1, + attributes: { + action: { + type: 'string', + value: 'click', + }, + scope_attribute_1: { + type: 'integer', + value: 1, + }, + scope_attribute_2: { + type: 'string', + value: 'test', + }, + scope_attribute_3: { + type: 'integer', + unit: 'gigabyte', + value: 38, + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: '1.0.0', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.node-core', + }, + 'sentry.sdk.version': { + type: 'string', + value: '10.32.1', + }, + 'user.email': { + type: 'string', + value: 'test@example.com', + }, + 'user.id': { + type: 'string', + value: 'user-123', + }, + 'user.name': { + type: 'string', + value: 'testuser', + }, + }, + }, ], }, }) diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts index 8d02a1fcd17c..170d4b96e279 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts @@ -22,6 +22,12 @@ async function run(): Promise { Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); + Sentry.withScope(scope => { + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + Sentry.metrics.count('test.scope.attributes.counter', 1, { attributes: { action: 'click' } }); + }); + await Sentry.flush(); } 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 471fe114fa1e..7e4b71c9ad28 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 @@ -86,6 +86,60 @@ describe('metrics', () => { 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.scope.attributes.counter', + type: 'counter', + value: 1, + attributes: { + action: { + type: 'string', + value: 'click', + }, + scope_attribute_1: { + type: 'integer', + value: 1, + }, + scope_attribute_2: { + type: 'string', + value: 'test', + }, + scope_attribute_3: { + type: 'integer', + unit: 'gigabyte', + value: 38, + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: '1.0.0', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.node', + }, + 'sentry.sdk.version': { + type: 'string', + value: '10.32.1', + }, + 'user.email': { + type: 'string', + value: 'test@example.com', + }, + 'user.id': { + type: 'string', + value: 'user-123', + }, + 'user.name': { + type: 'string', + value: 'testuser', + }, + }, + }, ], }, }) diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 846ecf89d6e5..bdd13d884967 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -1,4 +1,4 @@ -import { serializeAttributes } from '../attributes'; +import { type RawAttributes, serializeAttributes } from '../attributes'; import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; import { getClient, getCurrentScope, getIsolationScope } from '../currentScopes'; @@ -6,6 +6,7 @@ import { DEBUG_BUILD } from '../debug-build'; import type { Scope } from '../scope'; import type { Integration } from '../types-hoist/integration'; import type { Metric, SerializedMetric } from '../types-hoist/metric'; +import type { User } from '../types-hoist/user'; import { debug } from '../utils/debug-logger'; import { getCombinedScopeData } from '../utils/scopeData'; import { _getSpanForScope } from '../utils/spanOnScope'; @@ -77,7 +78,7 @@ export interface InternalCaptureMetricOptions { /** * Enriches metric with all contextual attributes (user, SDK metadata, replay, etc.) */ -function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentScope: Scope): Metric { +function _enrichMetricAttributes(beforeMetric: Metric, client: Client, user: User): Metric { const { release, environment } = client.getOptions(); const processedMetricAttributes = { @@ -85,12 +86,9 @@ function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentSc }; // Add user attributes - const { - user: { id, email, username }, - } = getCombinedScopeData(getIsolationScope(), currentScope); - setMetricAttribute(processedMetricAttributes, 'user.id', id, false); - setMetricAttribute(processedMetricAttributes, 'user.email', email, false); - setMetricAttribute(processedMetricAttributes, 'user.name', username, false); + setMetricAttribute(processedMetricAttributes, 'user.id', user.id, false); + setMetricAttribute(processedMetricAttributes, 'user.email', user.email, false); + setMetricAttribute(processedMetricAttributes, 'user.name', user.username, false); // Add Sentry metadata setMetricAttribute(processedMetricAttributes, 'sentry.release', release); @@ -125,7 +123,12 @@ function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentSc /** * Creates a serialized metric ready to be sent to Sentry. */ -function _buildSerializedMetric(metric: Metric, client: Client, currentScope: Scope): SerializedMetric { +function _buildSerializedMetric( + metric: Metric, + client: Client, + currentScope: Scope, + scopeAttributes: RawAttributes> | undefined, +): SerializedMetric { // Get trace context const [, traceContext] = _getTraceInfoFromScope(client, currentScope); const span = _getSpanForScope(currentScope); @@ -140,7 +143,10 @@ function _buildSerializedMetric(metric: Metric, client: Client, currentScope: Sc type: metric.type, unit: metric.unit, value: metric.value, - attributes: serializeAttributes(metric.attributes, 'skip-undefined'), + attributes: { + ...serializeAttributes(scopeAttributes), + ...serializeAttributes(metric.attributes, 'skip-undefined'), + }, }; } @@ -174,7 +180,8 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal } // Enrich metric with contextual attributes - const enrichedMetric = _enrichMetricAttributes(beforeMetric, client, currentScope); + const { user, attributes: scopeAttributes } = getCombinedScopeData(getIsolationScope(), currentScope); + const enrichedMetric = _enrichMetricAttributes(beforeMetric, client, user); client.emit('processMetric', enrichedMetric); @@ -188,7 +195,7 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal return; } - const serializedMetric = _buildSerializedMetric(processedMetric, client, currentScope); + const serializedMetric = _buildSerializedMetric(processedMetric, client, currentScope, scopeAttributes); DEBUG_BUILD && debug.log('[Metric]', serializedMetric); diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 0639cdb845f1..e6de9b1f27ef 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -306,8 +306,8 @@ export class Scope { /** * Sets attributes onto the scope. * - * These attributes are currently only applied to logs. - * In the future, they will also be applied to metrics and spans. + * These attributes are currently applied to logs and metrics. + * In the future, they will also be applied to spans. * * Important: For now, only strings, numbers and boolean attributes are supported, despite types allowing for * more complex attribute types. We'll add this support in the future but already specify the wider type to @@ -338,8 +338,8 @@ export class Scope { /** * Sets an attribute onto the scope. * - * These attributes are currently only applied to logs. - * In the future, they will also be applied to metrics and spans. + * These attributes are currently applied to logs and metrics. + * In the future, they will also be applied to spans. * * Important: For now, only strings, numbers and boolean attributes are supported, despite types allowing for * more complex attribute types. We'll add this support in the future but already specify the wider type to diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts index 55753082d7ff..434f4b6c8289 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -171,6 +171,52 @@ describe('_INTERNAL_captureMetric', () => { }); }); + it('includes scope attributes in metric attributes', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + scope_attribute_1: { + value: 1, + type: 'integer', + }, + scope_attribute_2: { + value: 'test', + type: 'string', + }, + scope_attribute_3: { + value: 38, + unit: 'gigabyte', + type: 'integer', + }, + }); + }); + + it('prefers metric attributes over scope attributes', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setAttribute('my-attribute', 42); + + _INTERNAL_captureMetric( + { type: 'counter', name: 'test.metric', value: 1, attributes: { 'my-attribute': 43 } }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'my-attribute': { value: 43, type: 'integer' }, + }); + }); + it('flushes metrics buffer when it reaches max size', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); From fd42f3b65eca92b691896380f85804d9aa11fc88 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 9 Jan 2026 21:26:49 +0200 Subject: [PATCH 69/76] fix(next): Wrap all Random APIs with a safe runner (#18700) Currently the cache components feature in Next.js prevents us from using any random value APIs like: - `Date.now` - `performance.now` - `Math.random` - `crypto.*` We tried resolving this by patching several span methods, but then we have plenty of other instances where we use those APIs, like in trace propagation, timestamp generation for logs, and more. Running around and patching them one by one in the Next.js SDK isn't a viable solution since most of those functionalities are strictly internal and cannot be patched from the outside, and adding escape hatches for each of them is not maintainable. So I'm testing out the other way around, by hunting those APIs down and wrapping them with a safe runner that acts as an escape hatch. Some of the Vercel engineers suggested doing that, but we need to do it for ~almost every call~ (see Josh [comment](https://github.com/getsentry/sentry-javascript/pull/18700#issuecomment-3719913841) below). The idea is an SDK can "turn on" the safe runner by injecting a global function that executes a callback and returns its results. I ### How does this fix it for Next.js? The Next.js SDK case, a safe runner would be an `AsyncLocalStorage` snapshot which is captured at the server runtime init, way before any rendering is done. ```ts const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: SafeRandomContextRunner } = GLOBAL_OBJ; globalWithSymbol[sym] = AsyncLocalStorage.snapshot(); // core SDK then offers a fn to run any random gen function export function withRandomSafeContext(cb: () => T): T { // Looks for the global symbol and if it is set it uses the runner // otherwise just runs the callback normally. } ``` I kept the API internal as much as possible to avoid users messing up with it, but the `@sentry/opentelemetry` SDK also needed this functionality so I exported the API with `_INTERNAL` prefix as we already do. --- I tested this in a simple Next.js app and it no longer errors out, and all current tests pass. I still need to take a look at the traces and see how would they look in cached component cases. Charly is already working on this and may have a proper solution, but I thought to just see if we can ship a stopgap until then. On the bright side, this seems to fix it as well for Webpack. closes #18392 closes #18340 --- .size-limit.js | 6 +- .../app/metadata-async/page.tsx | 37 +++++ .../app/metadata/page.tsx | 35 +++++ .../tests/cacheComponents.spec.ts | 26 ++++ packages/core/.eslintrc.js | 11 ++ packages/core/src/client.ts | 3 +- packages/core/src/index.ts | 6 + .../integrations/mcp-server/correlation.ts | 1 + packages/core/src/scope.ts | 8 +- packages/core/src/tracing/trace.ts | 3 +- packages/core/src/utils/misc.ts | 6 +- packages/core/src/utils/randomSafeContext.ts | 43 +++++ packages/core/src/utils/ratelimit.ts | 7 +- packages/core/src/utils/time.ts | 9 +- packages/core/src/utils/tracing.ts | 9 +- packages/eslint-plugin-sdk/src/index.js | 1 + .../src/rules/no-unsafe-random-apis.js | 147 ++++++++++++++++++ .../lib/rules/no-unsafe-random-apis.test.ts | 146 +++++++++++++++++ packages/nextjs/.eslintrc.js | 9 ++ .../wrapApiHandlerWithSentryVercelCrons.ts | 10 +- .../nextjs/src/config/polyfills/perf_hooks.js | 1 + .../nextjs/src/config/withSentryConfig.ts | 1 + packages/nextjs/src/server/index.ts | 2 + .../server/prepareSafeIdGeneratorContext.ts | 49 ++++++ packages/node-core/.eslintrc.js | 9 ++ .../node-core/src/integrations/context.ts | 2 + packages/node/.eslintrc.js | 9 ++ packages/opentelemetry/.eslintrc.js | 11 ++ packages/opentelemetry/src/sampler.ts | 3 +- packages/opentelemetry/src/spanExporter.ts | 9 +- packages/vercel-edge/.eslintrc.js | 9 ++ 31 files changed, 598 insertions(+), 30 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx create mode 100644 packages/core/src/utils/randomSafeContext.ts create mode 100644 packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js create mode 100644 packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts create mode 100644 packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts diff --git a/.size-limit.js b/.size-limit.js index 24772d8380f5..215a40d1bf17 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -82,7 +82,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '85 KB', + limit: '85.5 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -243,7 +243,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '42 KB', + limit: '42.5 KB', }, // Node-Core SDK (ESM) { @@ -261,7 +261,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '162 KB', + limit: '162.5 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx new file mode 100644 index 000000000000..03201cdccf60 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/nextjs'; + +function fetchPost() { + return Promise.resolve({ id: '1', title: 'Post 1' }); +} + +export async function generateMetadata() { + const { id } = await fetchPost(); + const product = `Product: ${id}`; + + return { + title: product, + }; +} + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx new file mode 100644 index 000000000000..7bcdbd0474e0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/nextjs'; + +/** + * Tests generateMetadata function with cache components, this calls the propagation context to be set + * Which will generate and set a trace id in the propagation context, which should trigger the random API error if unpatched + * See: https://github.com/getsentry/sentry-javascript/issues/18392 + */ +export function generateMetadata() { + return { + title: 'Cache Components Metadata Test', + }; +} + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts index 9f7b0ca559be..9a60ac59cd8f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts @@ -26,3 +26,29 @@ test('Should render suspense component', async ({ page }) => { expect(serverTx.spans?.filter(span => span.op === 'get.todos').length).toBeGreaterThan(0); await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); }); + +test('Should generate metadata', async ({ page }) => { + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto('/metadata'); + const serverTx = await serverTxPromise; + + expect(serverTx.spans?.filter(span => span.op === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + await expect(page).toHaveTitle('Cache Components Metadata Test'); +}); + +test('Should generate metadata async', async ({ page }) => { + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto('/metadata-async'); + const serverTx = await serverTxPromise; + + expect(serverTx.spans?.filter(span => span.op === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + await expect(page).toHaveTitle('Product: 1'); +}); diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js index 5a021c016763..5ce5d0f72cd2 100644 --- a/packages/core/.eslintrc.js +++ b/packages/core/.eslintrc.js @@ -1,4 +1,15 @@ module.exports = { extends: ['../../.eslintrc.js'], ignorePatterns: ['rollup.npm.config.mjs'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index aad363905a68..56b382a2860e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -45,6 +45,7 @@ import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer'; +import { safeMathRandom } from './utils/randomSafeContext'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning } from './utils/spanUtils'; import { rejectedSyncPromise } from './utils/syncpromise'; @@ -1288,7 +1289,7 @@ export abstract class Client { // 0.0 === 0% events are sent // Sampling for transaction happens somewhere else const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate); - if (isError && typeof parsedSampleRate === 'number' && Math.random() > parsedSampleRate) { + if (isError && typeof parsedSampleRate === 'number' && safeMathRandom() > parsedSampleRate) { this.recordDroppedEvent('sample_rate', 'error'); return rejectedSyncPromise( _makeDoNotSendEventError( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dffa16a130bf..28495fed10a4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -515,3 +515,9 @@ export type { UnstableRollupPluginOptions, UnstableWebpackPluginOptions, } from './build-time-plugins/buildTimeOptionsBase'; +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/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 22517306c7cb..3567ec382cdf 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -46,6 +46,7 @@ export function storeSpanForRequest(transport: MCPTransport, requestId: RequestI spanMap.set(requestId, { span, method, + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis startTime: Date.now(), }); } diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index e6de9b1f27ef..b5a64bb8818a 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -22,6 +22,7 @@ import { isPlainObject } from './utils/is'; import { merge } from './utils/merge'; import { uuid4 } from './utils/misc'; import { generateTraceId } from './utils/propagationContext'; +import { safeMathRandom } from './utils/randomSafeContext'; import { _getSpanForScope, _setSpanForScope } from './utils/spanOnScope'; import { truncate } from './utils/string'; import { dateTimestampInSeconds } from './utils/time'; @@ -168,7 +169,7 @@ export class Scope { this._sdkProcessingMetadata = {}; this._propagationContext = { traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }; } @@ -550,7 +551,10 @@ export class Scope { this._session = undefined; _setSpanForScope(this, undefined); this._attachments = []; - this.setPropagationContext({ traceId: generateTraceId(), sampleRand: Math.random() }); + this.setPropagationContext({ + traceId: generateTraceId(), + sampleRand: safeMathRandom(), + }); this._notifyScopeListeners(); return this; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index b147bb92fa63..28a5bccd4147 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -17,6 +17,7 @@ import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { parseSampleRate } from '../utils/parseSampleRate'; import { generateTraceId } from '../utils/propagationContext'; +import { safeMathRandom } from '../utils/randomSafeContext'; import { _getSpanForScope, _setSpanForScope } from '../utils/spanOnScope'; import { addChildSpanToSpan, getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; import { propagationContextFromHeaders, shouldContinueTrace } from '../utils/tracing'; @@ -293,7 +294,7 @@ export function startNewTrace(callback: () => T): T { return withScope(scope => { scope.setPropagationContext({ traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }); DEBUG_BUILD && debug.log(`Starting a new trace with id ${scope.getPropagationContext().traceId}`); return withActiveSpan(null, callback); diff --git a/packages/core/src/utils/misc.ts b/packages/core/src/utils/misc.ts index 69cd217345b8..86ddd52b05c3 100644 --- a/packages/core/src/utils/misc.ts +++ b/packages/core/src/utils/misc.ts @@ -3,6 +3,7 @@ import type { Exception } from '../types-hoist/exception'; import type { Mechanism } from '../types-hoist/mechanism'; import type { StackFrame } from '../types-hoist/stackframe'; import { addNonEnumerableProperty } from './object'; +import { safeMathRandom, withRandomSafeContext } from './randomSafeContext'; import { snipLine } from './string'; import { GLOBAL_OBJ } from './worldwide'; @@ -24,7 +25,7 @@ function getCrypto(): CryptoInternal | undefined { let emptyUuid: string | undefined; function getRandomByte(): number { - return Math.random() * 16; + return safeMathRandom() * 16; } /** @@ -35,7 +36,8 @@ function getRandomByte(): number { export function uuid4(crypto = getCrypto()): string { try { if (crypto?.randomUUID) { - return crypto.randomUUID().replace(/-/g, ''); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return withRandomSafeContext(() => crypto.randomUUID!()).replace(/-/g, ''); } } catch { // some runtimes can crash invoking crypto diff --git a/packages/core/src/utils/randomSafeContext.ts b/packages/core/src/utils/randomSafeContext.ts new file mode 100644 index 000000000000..ce4bf5a8f16d --- /dev/null +++ b/packages/core/src/utils/randomSafeContext.ts @@ -0,0 +1,43 @@ +import { GLOBAL_OBJ } from './worldwide'; + +export type RandomSafeContextRunner = (callback: () => T) => T; + +// undefined = not yet resolved, null = no runner found, function = runner found +let RESOLVED_RUNNER: RandomSafeContextRunner | null | undefined; + +/** + * Simple wrapper that allows SDKs to *secretly* set context wrapper to generate safe random IDs in cache components contexts + */ +export function withRandomSafeContext(cb: () => T): T { + // Skips future symbol lookups if we've already resolved (or attempted to resolve) the runner once + if (RESOLVED_RUNNER !== undefined) { + return RESOLVED_RUNNER ? RESOLVED_RUNNER(cb) : cb(); + } + + const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); + const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ; + + if (sym in globalWithSymbol && typeof globalWithSymbol[sym] === 'function') { + RESOLVED_RUNNER = globalWithSymbol[sym]; + return RESOLVED_RUNNER(cb); + } + + RESOLVED_RUNNER = null; + return cb(); +} + +/** + * Identical to Math.random() but wrapped in withRandomSafeContext + * to ensure safe random number generation in certain contexts (e.g., Next.js Cache Components). + */ +export function safeMathRandom(): number { + return withRandomSafeContext(() => Math.random()); +} + +/** + * Identical to Date.now() but wrapped in withRandomSafeContext + * to ensure safe time value generation in certain contexts (e.g., Next.js Cache Components). + */ +export function safeDateNow(): number { + return withRandomSafeContext(() => Date.now()); +} diff --git a/packages/core/src/utils/ratelimit.ts b/packages/core/src/utils/ratelimit.ts index 4cb8cb9d07a5..606969d88858 100644 --- a/packages/core/src/utils/ratelimit.ts +++ b/packages/core/src/utils/ratelimit.ts @@ -1,5 +1,6 @@ import type { DataCategory } from '../types-hoist/datacategory'; import type { TransportMakeRequestResponse } from '../types-hoist/transport'; +import { safeDateNow } from './randomSafeContext'; // Intentionally keeping the key broad, as we don't know for sure what rate limit headers get returned from backend export type RateLimits = Record; @@ -12,7 +13,7 @@ export const DEFAULT_RETRY_AFTER = 60 * 1000; // 60 seconds * @param now current unix timestamp * */ -export function parseRetryAfterHeader(header: string, now: number = Date.now()): number { +export function parseRetryAfterHeader(header: string, now: number = safeDateNow()): number { const headerDelay = parseInt(`${header}`, 10); if (!isNaN(headerDelay)) { return headerDelay * 1000; @@ -40,7 +41,7 @@ export function disabledUntil(limits: RateLimits, dataCategory: DataCategory): n /** * Checks if a category is rate limited */ -export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = Date.now()): boolean { +export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = safeDateNow()): boolean { return disabledUntil(limits, dataCategory) > now; } @@ -52,7 +53,7 @@ export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, no export function updateRateLimits( limits: RateLimits, { statusCode, headers }: TransportMakeRequestResponse, - now: number = Date.now(), + now: number = safeDateNow(), ): RateLimits { const updatedRateLimits: RateLimits = { ...limits, diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index ecaca1ea9e9b..10a5103b2fc1 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -1,3 +1,4 @@ +import { safeDateNow, withRandomSafeContext } from './randomSafeContext'; import { GLOBAL_OBJ } from './worldwide'; const ONE_SECOND_IN_MS = 1000; @@ -21,7 +22,7 @@ interface Performance { * Returns a timestamp in seconds since the UNIX epoch using the Date API. */ export function dateTimestampInSeconds(): number { - return Date.now() / ONE_SECOND_IN_MS; + return safeDateNow() / ONE_SECOND_IN_MS; } /** @@ -50,7 +51,7 @@ function createUnixTimestampInSecondsFunc(): () => number { // See: https://github.com/mdn/content/issues/4713 // See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6 return () => { - return (timeOrigin + performance.now()) / ONE_SECOND_IN_MS; + return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS; }; } @@ -92,8 +93,8 @@ function getBrowserTimeOrigin(): number | undefined { } const threshold = 300_000; // 5 minutes in milliseconds - const performanceNow = performance.now(); - const dateNow = Date.now(); + const performanceNow = withRandomSafeContext(() => performance.now()); + const dateNow = safeDateNow(); const timeOrigin = performance.timeOrigin; if (typeof timeOrigin === 'number') { diff --git a/packages/core/src/utils/tracing.ts b/packages/core/src/utils/tracing.ts index aa5a15153674..25e3295118f8 100644 --- a/packages/core/src/utils/tracing.ts +++ b/packages/core/src/utils/tracing.ts @@ -7,6 +7,7 @@ import { baggageHeaderToDynamicSamplingContext } from './baggage'; import { extractOrgIdFromClient } from './dsn'; import { parseSampleRate } from './parseSampleRate'; import { generateSpanId, generateTraceId } from './propagationContext'; +import { safeMathRandom } from './randomSafeContext'; // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- RegExp is used for readability here export const TRACEPARENT_REGEXP = new RegExp( @@ -65,7 +66,7 @@ export function propagationContextFromHeaders( if (!traceparentData?.traceId) { return { traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }; } @@ -133,12 +134,12 @@ function getSampleRandFromTraceparentAndDsc( if (parsedSampleRate && traceparentData?.parentSampled !== undefined) { return traceparentData.parentSampled ? // Returns a sample rand with positive sampling decision [0, sampleRate) - Math.random() * parsedSampleRate + safeMathRandom() * parsedSampleRate : // Returns a sample rand with negative sampling decision [sampleRate, 1) - parsedSampleRate + Math.random() * (1 - parsedSampleRate); + parsedSampleRate + safeMathRandom() * (1 - parsedSampleRate); } else { // If nothing applies, return a random sample rand. - return Math.random(); + return safeMathRandom(); } } diff --git a/packages/eslint-plugin-sdk/src/index.js b/packages/eslint-plugin-sdk/src/index.js index 24cc9c4cc00c..c23a1afcd373 100644 --- a/packages/eslint-plugin-sdk/src/index.js +++ b/packages/eslint-plugin-sdk/src/index.js @@ -15,5 +15,6 @@ module.exports = { 'no-regexp-constructor': require('./rules/no-regexp-constructor'), 'no-focused-tests': require('./rules/no-focused-tests'), 'no-skipped-tests': require('./rules/no-skipped-tests'), + 'no-unsafe-random-apis': require('./rules/no-unsafe-random-apis'), }, }; diff --git a/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js new file mode 100644 index 000000000000..8a9a27795481 --- /dev/null +++ b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js @@ -0,0 +1,147 @@ +'use strict'; + +/** + * @fileoverview Rule to enforce wrapping random/time APIs with withRandomSafeContext + * + * This rule detects uses of APIs that generate random values or time-based values + * and ensures they are wrapped with `withRandomSafeContext()` to ensure safe + * random number generation in certain contexts (e.g., React Server Components with caching). + */ + +// APIs that should be wrapped with withRandomSafeContext, with their specific messages +const UNSAFE_MEMBER_CALLS = [ + { + object: 'Date', + property: 'now', + messageId: 'unsafeDateNow', + }, + { + object: 'Math', + property: 'random', + messageId: 'unsafeMathRandom', + }, + { + object: 'performance', + property: 'now', + messageId: 'unsafePerformanceNow', + }, + { + object: 'crypto', + property: 'randomUUID', + messageId: 'unsafeCryptoRandomUUID', + }, + { + object: 'crypto', + property: 'getRandomValues', + messageId: 'unsafeCryptoGetRandomValues', + }, +]; + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Enforce wrapping random/time APIs (Date.now, Math.random, performance.now, crypto.randomUUID) with withRandomSafeContext', + category: 'Best Practices', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + unsafeDateNow: + '`Date.now()` should be replaced with `safeDateNow()` from `@sentry/core` to ensure safe time value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeMathRandom: + '`Math.random()` should be replaced with `safeMathRandom()` from `@sentry/core` to ensure safe random value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafePerformanceNow: + '`performance.now()` should be wrapped with `withRandomSafeContext()` to ensure safe time value generation. Use: `withRandomSafeContext(() => performance.now())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeCryptoRandomUUID: + '`crypto.randomUUID()` should be wrapped with `withRandomSafeContext()` to ensure safe random value generation. Use: `withRandomSafeContext(() => crypto.randomUUID())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeCryptoGetRandomValues: + '`crypto.getRandomValues()` should be wrapped with `withRandomSafeContext()` to ensure safe random value generation. Use: `withRandomSafeContext(() => crypto.getRandomValues(...))`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + }, + }, + create: function (context) { + /** + * Check if a node is inside a withRandomSafeContext call + */ + function isInsidewithRandomSafeContext(node) { + let current = node.parent; + + while (current) { + // Check if we're inside a callback passed to withRandomSafeContext + if ( + current.type === 'CallExpression' && + current.callee.type === 'Identifier' && + current.callee.name === 'withRandomSafeContext' + ) { + return true; + } + + // Also check for arrow functions or regular functions passed to withRandomSafeContext + if ( + (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') && + current.parent?.type === 'CallExpression' && + current.parent.callee.type === 'Identifier' && + current.parent.callee.name === 'withRandomSafeContext' + ) { + return true; + } + + current = current.parent; + } + + return false; + } + + /** + * Check if a node is inside the safeRandomGeneratorRunner.ts file (the definition file) + */ + function isInSafeRandomGeneratorRunner(_node) { + const filename = context.getFilename(); + return filename.includes('safeRandomGeneratorRunner'); + } + + return { + CallExpression(node) { + // Skip if we're in the safeRandomGeneratorRunner.ts file itself + if (isInSafeRandomGeneratorRunner(node)) { + return; + } + + // Check for member expression calls like Date.now(), Math.random(), etc. + if (node.callee.type === 'MemberExpression') { + const callee = node.callee; + + // Get the object name (e.g., 'Date', 'Math', 'performance', 'crypto') + let objectName = null; + if (callee.object.type === 'Identifier') { + objectName = callee.object.name; + } + + // Get the property name (e.g., 'now', 'random', 'randomUUID') + let propertyName = null; + if (callee.property.type === 'Identifier') { + propertyName = callee.property.name; + } else if (callee.computed && callee.property.type === 'Literal') { + propertyName = callee.property.value; + } + + if (!objectName || !propertyName) { + return; + } + + // Check if this is one of the unsafe APIs + const unsafeApi = UNSAFE_MEMBER_CALLS.find(api => api.object === objectName && api.property === propertyName); + + if (unsafeApi && !isInsidewithRandomSafeContext(node)) { + context.report({ + node, + messageId: unsafeApi.messageId, + }); + } + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts new file mode 100644 index 000000000000..e145336d6c3e --- /dev/null +++ b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts @@ -0,0 +1,146 @@ +import { RuleTester } from 'eslint'; +import { describe, test } from 'vitest'; +// @ts-expect-error untyped module +import rule from '../../../src/rules/no-unsafe-random-apis'; + +describe('no-unsafe-random-apis', () => { + test('ruleTester', () => { + const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + }, + }); + + ruleTester.run('no-unsafe-random-apis', rule, { + valid: [ + // Wrapped with withRandomSafeContext - arrow function + { + code: 'withRandomSafeContext(() => Date.now())', + }, + { + code: 'withRandomSafeContext(() => Math.random())', + }, + { + code: 'withRandomSafeContext(() => performance.now())', + }, + { + code: 'withRandomSafeContext(() => crypto.randomUUID())', + }, + { + code: 'withRandomSafeContext(() => crypto.getRandomValues(new Uint8Array(16)))', + }, + // Wrapped with withRandomSafeContext - regular function + { + code: 'withRandomSafeContext(function() { return Date.now(); })', + }, + // Nested inside withRandomSafeContext + { + code: 'withRandomSafeContext(() => { const x = Date.now(); return x + Math.random(); })', + }, + // Expression inside withRandomSafeContext + { + code: 'withRandomSafeContext(() => Date.now() / 1000)', + }, + // Other unrelated calls should be fine + { + code: 'const x = someObject.now()', + }, + { + code: 'const x = Date.parse("2021-01-01")', + }, + { + code: 'const x = Math.floor(5.5)', + }, + { + code: 'const x = performance.mark("test")', + }, + ], + invalid: [ + // Direct Date.now() calls + { + code: 'const time = Date.now()', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Direct Math.random() calls + { + code: 'const random = Math.random()', + errors: [ + { + messageId: 'unsafeMathRandom', + }, + ], + }, + // Direct performance.now() calls + { + code: 'const perf = performance.now()', + errors: [ + { + messageId: 'unsafePerformanceNow', + }, + ], + }, + // Direct crypto.randomUUID() calls + { + code: 'const uuid = crypto.randomUUID()', + errors: [ + { + messageId: 'unsafeCryptoRandomUUID', + }, + ], + }, + // Direct crypto.getRandomValues() calls + { + code: 'const bytes = crypto.getRandomValues(new Uint8Array(16))', + errors: [ + { + messageId: 'unsafeCryptoGetRandomValues', + }, + ], + }, + // Inside a function but not wrapped + { + code: 'function getTime() { return Date.now(); }', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Inside an arrow function but not wrapped with withRandomSafeContext + { + code: 'const getTime = () => Date.now()', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Inside someOtherWrapper + { + code: 'someOtherWrapper(() => Date.now())', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Multiple violations + { + code: 'const a = Date.now(); const b = Math.random();', + errors: [ + { + messageId: 'unsafeDateNow', + }, + { + messageId: 'unsafeMathRandom', + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/nextjs/.eslintrc.js b/packages/nextjs/.eslintrc.js index 1f0ae547d4e0..4a5bdd17795e 100644 --- a/packages/nextjs/.eslintrc.js +++ b/packages/nextjs/.eslintrc.js @@ -7,6 +7,9 @@ module.exports = { jsx: true, }, extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, overrides: [ { files: ['scripts/**/*.ts'], @@ -27,5 +30,11 @@ module.exports = { globalThis: 'readonly', }, }, + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, ], }; diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts index c85bdc4f2ad3..8cd0c016d0fb 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts @@ -1,4 +1,4 @@ -import { captureCheckIn } from '@sentry/core'; +import { _INTERNAL_safeDateNow, captureCheckIn } from '@sentry/core'; import type { NextApiRequest } from 'next'; import type { VercelCronsConfig } from '../types'; @@ -57,14 +57,14 @@ export function wrapApiHandlerWithSentryVercelCrons { captureCheckIn({ checkInId, monitorSlug, status: 'error', - duration: Date.now() / 1000 - startTime, + duration: _INTERNAL_safeDateNow() / 1000 - startTime, }); }; @@ -82,7 +82,7 @@ export function wrapApiHandlerWithSentryVercelCrons { @@ -98,7 +98,7 @@ export function wrapApiHandlerWithSentryVercelCrons(nextConfig?: C, sentryBuildOptions: SentryBu */ function generateRandomTunnelRoute(): string { // Generate a random 8-character alphanumeric string + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis const randomString = Math.random().toString(36).substring(2, 10); return `/${randomString}`; } diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 18f3db003177..91d1dd65ca06 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -34,6 +34,7 @@ import { isBuild } from '../common/utils/isBuild'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { handleOnSpanStart } from './handleOnSpanStart'; +import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; export * from '@sentry/node'; @@ -92,6 +93,7 @@ export function showReportDialog(): void { /** Inits the Sentry NextJS SDK on node. */ export function init(options: NodeOptions): NodeClient | undefined { + prepareSafeIdGeneratorContext(); if (isBuild()) { return; } diff --git a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts new file mode 100644 index 000000000000..bd262eb736e1 --- /dev/null +++ b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts @@ -0,0 +1,49 @@ +import { + type _INTERNAL_RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, + debug, + GLOBAL_OBJ, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../common/debug-build'; + +// Inline AsyncLocalStorage interface from current types +// Avoids conflict with resolving it from getBuiltinModule +type OriginalAsyncLocalStorage = typeof AsyncLocalStorage; + +/** + * Prepares the global object to generate safe random IDs in cache components contexts + * See: https://github.com/getsentry/sentry-javascript/blob/ceb003c15973c2d8f437dfb7025eedffbc8bc8b0/packages/core/src/utils/propagationContext.ts#L1 + */ +export function prepareSafeIdGeneratorContext(): void { + const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); + const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: _INTERNAL_RandomSafeContextRunner } = GLOBAL_OBJ; + const als = getAsyncLocalStorage(); + if (!als || typeof als.snapshot !== 'function') { + DEBUG_BUILD && + debug.warn( + '[@sentry/nextjs] No AsyncLocalStorage found in the runtime or AsyncLocalStorage.snapshot() is not available, skipping safe random ID generator context preparation, you may see some errors with cache components.', + ); + return; + } + + globalWithSymbol[sym] = als.snapshot(); + DEBUG_BUILD && debug.log('[@sentry/nextjs] Prepared safe random ID generator context'); +} + +function getAsyncLocalStorage(): OriginalAsyncLocalStorage | undefined { + // May exist in the Next.js runtime globals + // Doesn't exist in some of our tests + if (typeof AsyncLocalStorage !== 'undefined') { + return AsyncLocalStorage; + } + + // Try to resolve it dynamically without synchronously importing the module + // This is done to avoid importing the module synchronously at the top + // which means this is safe across runtimes + if ('getBuiltinModule' in process && typeof process.getBuiltinModule === 'function') { + const { AsyncLocalStorage } = process.getBuiltinModule('async_hooks') ?? {}; + + return AsyncLocalStorage as OriginalAsyncLocalStorage; + } + + return undefined; +} diff --git a/packages/node-core/.eslintrc.js b/packages/node-core/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/node-core/.eslintrc.js +++ b/packages/node-core/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index cad8a1c4a443..6584640935ee 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -204,6 +204,7 @@ function getCultureContext(): CultureContext | undefined { */ export function getAppContext(): AppContext { const app_memory = process.memoryUsage().rss; + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis const app_start_time = new Date(Date.now() - process.uptime() * 1000).toISOString(); // https://nodejs.org/api/process.html#processavailablememory const appContext: AppContext = { app_start_time, app_memory }; @@ -236,6 +237,7 @@ export function getDeviceContext(deviceOpt: DeviceContextOptions | true): Device // Hence, we only set boot time, if we get a valid uptime value. // @see https://github.com/getsentry/sentry-javascript/issues/5856 if (typeof uptime === 'number') { + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis device.boot_time = new Date(Date.now() - uptime * 1000).toISOString(); } diff --git a/packages/node/.eslintrc.js b/packages/node/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/node/.eslintrc.js +++ b/packages/node/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/opentelemetry/.eslintrc.js b/packages/opentelemetry/.eslintrc.js index fdb9952bae52..4b5e6310c8ee 100644 --- a/packages/opentelemetry/.eslintrc.js +++ b/packages/opentelemetry/.eslintrc.js @@ -3,4 +3,15 @@ module.exports = { node: true, }, extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index e06fe51bfd2a..7f7edd441612 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -12,6 +12,7 @@ import { } from '@opentelemetry/semantic-conventions'; import type { Client, SpanAttributes } from '@sentry/core'; import { + _INTERNAL_safeMathRandom, baggageHeaderToDynamicSamplingContext, debug, hasSpansEnabled, @@ -121,7 +122,7 @@ export class SentrySampler implements Sampler { const dscString = parentContext?.traceState ? parentContext.traceState.get(SENTRY_TRACE_STATE_DSC) : undefined; const dsc = dscString ? baggageHeaderToDynamicSamplingContext(dscString) : undefined; - const sampleRand = parseSampleRate(dsc?.sample_rand) ?? Math.random(); + const sampleRand = parseSampleRate(dsc?.sample_rand) ?? _INTERNAL_safeMathRandom(); const [sampled, sampleRate, localSampleRateWasApplied] = sampleSpan( options, diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index ea85641387a5..f02df1d9d56c 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -12,6 +12,7 @@ import type { TransactionSource, } from '@sentry/core'; import { + _INTERNAL_safeDateNow, captureEvent, convertSpanLinksForEnvelope, debounce, @@ -82,7 +83,7 @@ export class SentrySpanExporter { }) { this._finishedSpanBucketSize = options?.timeout || DEFAULT_TIMEOUT; this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined); - this._lastCleanupTimestampInS = Math.floor(Date.now() / 1000); + this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); this._spansToBucketEntry = new WeakMap(); this._sentSpans = new Map(); this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 }); @@ -93,7 +94,7 @@ export class SentrySpanExporter { * This is called by the span processor whenever a span is ended. */ public export(span: ReadableSpan): void { - const currentTimestampInS = Math.floor(Date.now() / 1000); + const currentTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); if (this._lastCleanupTimestampInS !== currentTimestampInS) { let droppedSpanCount = 0; @@ -146,7 +147,7 @@ export class SentrySpanExporter { `SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`, ); - const expirationDate = Date.now() + DEFAULT_TIMEOUT * 1000; + const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000; for (const span of sentSpans) { this._sentSpans.set(span.spanContext().spanId, expirationDate); @@ -226,7 +227,7 @@ export class SentrySpanExporter { /** Remove "expired" span id entries from the _sentSpans cache. */ private _flushSentSpanCache(): void { - const currentTimestamp = Date.now(); + const currentTimestamp = _INTERNAL_safeDateNow(); // Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297 for (const [spanId, expirationTime] of this._sentSpans.entries()) { if (expirationTime <= currentTimestamp) { diff --git a/packages/vercel-edge/.eslintrc.js b/packages/vercel-edge/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/vercel-edge/.eslintrc.js +++ b/packages/vercel-edge/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; From 46225eb02894e97c92f1480d75cef1f89385fde8 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:56:48 +0100 Subject: [PATCH 70/76] feat(wasm): Add applicationKey option for third-party error filtering (#18762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for applying an application key to WASM stack frames that can be then used in the `thirdPartyErrorFilterIntegration` for detection of first-party code. This changes how `thirdPartyErrorFilterIntegration` deals with native frames to also check for a `instruction_addr` for WASM-native code. Usage: ```js Sentry.init({ integrations: [ wasmIntegration({ applicationKey: 'your-custom-application-key' }), ←───┐ thirdPartyErrorFilterIntegration({ │ behaviour: 'drop-error-if-exclusively-contains-third-party-frames', ├─ matching keys filterKeys: ['your-custom-application-key'] ←─────────────────────────┘ }), ], }); ``` Closes: #18705 --- CHANGELOG.md | 19 ++++++ .../suites/wasm/thirdPartyFilter/init.js | 36 +++++++++++ .../suites/wasm/thirdPartyFilter/subject.js | 35 +++++++++++ .../suites/wasm/thirdPartyFilter/test.ts | 56 +++++++++++++++++ .../integrations/third-party-errors-filter.ts | 10 +++- packages/wasm/src/index.ts | 26 +++++++- packages/wasm/test/stacktrace-parsing.test.ts | 60 +++++++++++++++++++ 7 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/init.js create mode 100644 dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fdea17ff2e44..3a3a8a0ac38d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,25 @@ Sentry.metrics.count('response_time', 283.33, { unit: 'millisecond' }); ``` +- **feat(wasm): Add applicationKey option for third-party error filtering ([#18762])(https://github.com/getsentry/sentry-javascript/pull/18762)** + + Adds support for applying an application key to WASM stack frames that can be then used in the `thirdPartyErrorFilterIntegration` for detection of first-party code. + + Usage: + + ```js + Sentry.init({ + integrations: [ + // Integration order matters: wasmIntegration needs to be before thirdPartyErrorFilterIntegration + wasmIntegration({ applicationKey: 'your-custom-application-key' }), ←───┐ + thirdPartyErrorFilterIntegration({ │ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', ├─ matching keys + filterKeys: ['your-custom-application-key'] ←─────────────────────────┘ + }), + ], + }); + ``` + - ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) ## 10.32.1 diff --git a/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/init.js b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/init.js new file mode 100644 index 000000000000..912a4aafd728 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/init.js @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/browser'; +import { thirdPartyErrorFilterIntegration } from '@sentry/browser'; +import { wasmIntegration } from '@sentry/wasm'; + +// Simulate what the bundler plugin would inject to mark JS code as first-party +var _sentryModuleMetadataGlobal = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : typeof self !== 'undefined' + ? self + : {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata = _sentryModuleMetadataGlobal._sentryModuleMetadata || {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack] = Object.assign( + {}, + _sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack], + { + '_sentryBundlerPluginAppKey:wasm-test-app': true, + }, +); + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + wasmIntegration({ applicationKey: 'wasm-test-app' }), + thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-contains-third-party-frames', + filterKeys: ['wasm-test-app'], + }), + ], +}); diff --git a/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/subject.js b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/subject.js new file mode 100644 index 000000000000..74d9e73aa6f3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/subject.js @@ -0,0 +1,35 @@ +// Simulate what the bundler plugin would inject to mark this JS file as first-party +var _sentryModuleMetadataGlobal = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : typeof self !== 'undefined' + ? self + : {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata = _sentryModuleMetadataGlobal._sentryModuleMetadata || {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack] = Object.assign( + {}, + _sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack], + { + '_sentryBundlerPluginAppKey:wasm-test-app': true, + }, +); + +async function runWasm() { + function crash() { + throw new Error('WASM triggered error'); + } + + const { instance } = await WebAssembly.instantiateStreaming(fetch('https://localhost:5887/simple.wasm'), { + env: { + external_func: crash, + }, + }); + + instance.exports.internal_func(); +} + +runWasm(); diff --git a/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/test.ts b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/test.ts new file mode 100644 index 000000000000..f0ebf27d6aef --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/test.ts @@ -0,0 +1,56 @@ +import { expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; +import { shouldSkipWASMTests } from '../../../utils/wasmHelpers'; + +const bundle = process.env.PW_BUNDLE || ''; +// We only want to run this in non-CDN bundle mode because both +// wasmIntegration and thirdPartyErrorFilterIntegration are only available in NPM packages +if (bundle.startsWith('bundle')) { + sentryTest.skip(); +} + +sentryTest( + 'WASM frames should be recognized as first-party when applicationKey is configured', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/simple.wasm', route => { + const wasmModule = fs.readFileSync(path.resolve(__dirname, '../simple.wasm')); + + return route.fulfill({ + status: 200, + body: wasmModule, + headers: { + 'Content-Type': 'application/wasm', + }, + }); + }); + + const errorEventPromise = waitForErrorRequest(page, e => { + return e.exception?.values?.[0]?.value === 'WASM triggered error'; + }); + + await page.goto(url); + + const errorEvent = envelopeRequestParser(await errorEventPromise); + + expect(errorEvent.tags?.third_party_code).toBeUndefined(); + + // Verify we have WASM frames in the stacktrace + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + filename: expect.stringMatching(/simple\.wasm$/), + platform: 'native', + }), + ]), + ); + }, +); diff --git a/packages/core/src/integrations/third-party-errors-filter.ts b/packages/core/src/integrations/third-party-errors-filter.ts index 36c88105a283..f5d4c087eeab 100644 --- a/packages/core/src/integrations/third-party-errors-filter.ts +++ b/packages/core/src/integrations/third-party-errors-filter.ts @@ -153,9 +153,13 @@ function getBundleKeysForAllFramesWithFilenames( return frames .filter((frame, index) => { - // Exclude frames without a filename or without lineno and colno, - // since these are likely native code or built-ins - if (!frame.filename || (frame.lineno == null && frame.colno == null)) { + // Exclude frames without a filename + if (!frame.filename) { + return false; + } + // Exclude frames without location info, since these are likely native code or built-ins. + // JS frames have lineno/colno, WASM frames have instruction_addr instead. + if (frame.lineno == null && frame.colno == null && frame.instruction_addr == null) { return false; } // Optionally ignore Sentry internal frames diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index 5aa3888f1e4c..84076285fcdd 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -5,7 +5,16 @@ import { getImage, getImages } from './registry'; const INTEGRATION_NAME = 'Wasm'; -const _wasmIntegration = (() => { +interface WasmIntegrationOptions { + /** + * Key to identify this application for third-party error filtering. + * This key should match one of the keys provided to the `filterKeys` option + * of the `thirdPartyErrorFilterIntegration`. + */ + applicationKey?: string; +} + +const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, setupOnce() { @@ -18,7 +27,7 @@ const _wasmIntegration = (() => { event.exception.values.forEach(exception => { if (exception.stacktrace?.frames) { hasAtLeastOneWasmFrameWithImage = - hasAtLeastOneWasmFrameWithImage || patchFrames(exception.stacktrace.frames); + hasAtLeastOneWasmFrameWithImage || patchFrames(exception.stacktrace.frames, options.applicationKey); } }); } @@ -37,13 +46,17 @@ export const wasmIntegration = defineIntegration(_wasmIntegration); const PARSER_REGEX = /^(.*?):wasm-function\[\d+\]:(0x[a-fA-F0-9]+)$/; +// We use the same prefix as bundler plugins so that thirdPartyErrorFilterIntegration +// recognizes WASM frames as first-party code without needing modifications. +const BUNDLER_PLUGIN_APP_KEY_PREFIX = '_sentryBundlerPluginAppKey:'; + /** * Patches a list of stackframes with wasm data needed for server-side symbolication * if applicable. Returns true if the provided list of stack frames had at least one * matching registered image. */ // Only exported for tests -export function patchFrames(frames: Array): boolean { +export function patchFrames(frames: Array, applicationKey?: string): boolean { let hasAtLeastOneWasmFrameWithImage = false; frames.forEach(frame => { if (!frame.filename) { @@ -71,6 +84,13 @@ export function patchFrames(frames: Array): boolean { frame.filename = match[1]; frame.platform = 'native'; + if (applicationKey) { + frame.module_metadata = { + ...frame.module_metadata, + [`${BUNDLER_PLUGIN_APP_KEY_PREFIX}${applicationKey}`]: true, + }; + } + if (index >= 0) { frame.addr_mode = `rel:${index}`; hasAtLeastOneWasmFrameWithImage = true; diff --git a/packages/wasm/test/stacktrace-parsing.test.ts b/packages/wasm/test/stacktrace-parsing.test.ts index f1f03c247fa8..658d02847305 100644 --- a/packages/wasm/test/stacktrace-parsing.test.ts +++ b/packages/wasm/test/stacktrace-parsing.test.ts @@ -1,7 +1,67 @@ +import type { StackFrame } from '@sentry/core'; import { describe, expect, it } from 'vitest'; import { patchFrames } from '../src/index'; describe('patchFrames()', () => { + it('should add module_metadata with applicationKey when provided', () => { + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/main.js', + function: 'run', + in_app: true, + }, + { + filename: 'http://localhost:8001/main.wasm:wasm-function[190]:0x5aeb', + function: 'MyClass::bar', + in_app: true, + }, + ]; + + patchFrames(frames, 'my-app'); + + // Non-WASM frame should not have module_metadata + expect(frames[0]?.module_metadata).toBeUndefined(); + + // WASM frame should have module_metadata with the application key + expect(frames[1]?.module_metadata).toEqual({ + '_sentryBundlerPluginAppKey:my-app': true, + }); + }); + + it('should preserve existing module_metadata when adding applicationKey', () => { + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/main.wasm:wasm-function[190]:0x5aeb', + function: 'MyClass::bar', + in_app: true, + module_metadata: { + existingKey: 'existingValue', + }, + }, + ]; + + patchFrames(frames, 'my-app'); + + expect(frames[0]?.module_metadata).toEqual({ + existingKey: 'existingValue', + '_sentryBundlerPluginAppKey:my-app': true, + }); + }); + + it('should not add module_metadata when applicationKey is not provided', () => { + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/main.wasm:wasm-function[190]:0x5aeb', + function: 'MyClass::bar', + in_app: true, + }, + ]; + + patchFrames(frames); + + expect(frames[0]?.module_metadata).toBeUndefined(); + }); + it('should correctly extract instruction addresses', () => { const frames = [ { From cc93c682f133eb401c1576d5d56c2539960440e1 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 12 Jan 2026 11:15:51 +0100 Subject: [PATCH 71/76] feat(cloudflare): Support `propagateTraceparent` (#18569) - Closes #18565 --- .../cloudflare-integration-tests/runner.ts | 7 +++ .../suites/tracing/headers/index.ts | 19 ++++++ .../suites/tracing/headers/test.ts | 59 +++++++++++++++++++ .../suites/tracing/headers/wrangler.jsonc | 6 ++ .../tracing/dsc-txn-name-update/test.ts | 2 +- .../requests/fetch-breadcrumbs/test.ts | 2 +- .../fetch-no-tracing-no-spans/test.ts | 2 +- .../tracing/requests/fetch-no-tracing/test.ts | 2 +- .../fetch-sampled-no-active-span/test.ts | 2 +- .../tracing/requests/fetch-unsampled/test.ts | 2 +- .../tracing/requests/http-breadcrumbs/test.ts | 2 +- .../requests/http-no-tracing-no-spans/test.ts | 2 +- .../tracing/requests/http-no-tracing/test.ts | 2 +- .../http-sampled-no-active-span/test.ts | 2 +- .../tracing/requests/http-sampled/test.ts | 2 +- .../tracing/requests/http-unsampled/test.ts | 2 +- .../tracing/requests/traceparent/test.ts | 2 +- .../tracing/tracePropagationTargets/test.ts | 2 +- .../utils/server.ts | 46 --------------- .../tracing/dsc-txn-name-update/test.ts | 2 +- .../http-client-spans/fetch-basic/test.ts | 2 +- .../fetch-forward-request-hook/test.ts | 2 +- .../fetch-strip-query/test.ts | 2 +- .../http-client-spans/http-basic/test.ts | 2 +- .../http-strip-query/test.ts | 2 +- .../suites/tracing/httpIntegration/test.ts | 2 +- .../requests/fetch-breadcrumbs/test.ts | 2 +- .../fetch-no-tracing-no-spans/test.ts | 2 +- .../tracing/requests/fetch-no-tracing/test.ts | 2 +- .../fetch-sampled-no-active-span/test.ts | 2 +- .../tracing/requests/fetch-unsampled/test.ts | 2 +- .../tracing/requests/http-breadcrumbs/test.ts | 2 +- .../requests/http-no-tracing-no-spans/test.ts | 2 +- .../tracing/requests/http-no-tracing/test.ts | 2 +- .../http-sampled-no-active-span/test.ts | 2 +- .../tracing/requests/http-sampled/test.ts | 2 +- .../tracing/requests/http-unsampled/test.ts | 2 +- .../tracing/requests/traceparent/test.ts | 2 +- .../tracing/tracePropagationTargets/test.ts | 2 +- .../node-integration-tests/utils/server.ts | 48 --------------- dev-packages/test-utils/src/index.ts | 2 +- dev-packages/test-utils/src/server.ts | 46 +++++++++++++++ packages/cloudflare/src/integrations/fetch.ts | 2 + 43 files changed, 174 insertions(+), 129 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/headers/wrangler.jsonc delete mode 100644 dev-packages/node-integration-tests/utils/server.ts diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index 90990369743c..a9fb96b59505 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -67,8 +67,13 @@ export function createRunner(...paths: string[]) { const expectedEnvelopes: Expected[] = []; // By default, we ignore session & sessions const ignored: Set = new Set(['session', 'sessions', 'client_report']); + let serverUrl: string | undefined; return { + withServerUrl: function (url: string) { + serverUrl = url; + return this; + }, expect: function (expected: Expected) { expectedEnvelopes.push(expected); return this; @@ -186,6 +191,8 @@ export function createRunner(...paths: string[]) { 'false', '--var', `SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`, + '--var', + `SERVER_URL:${serverUrl}`, ], { stdio, signal }, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts new file mode 100644 index 000000000000..973f54053571 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SERVER_URL: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + propagateTraceparent: true, + }), + { + async fetch(_request, env, _ctx) { + await fetch(env.SERVER_URL); + throw new Error('Test error to capture trace headers'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts new file mode 100644 index 000000000000..d92fde438eb8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts @@ -0,0 +1,59 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, it } from 'vitest'; +import { eventEnvelope } from '../../../expect'; +import { createRunner } from '../../../runner'; + +it('Tracing headers', async ({ signal }) => { + expect.assertions(5); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + expect(headers['traceparent']).toEqual(expect.stringMatching(/^00-([a-f\d]{32})-([a-f\d]{16})-00$/)); + }) + .start(); + + const runner = createRunner(__dirname) + .withServerUrl(SERVER_URL) + .expect( + eventEnvelope({ + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'Test error to capture trace headers', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, + }, + ], + }, + breadcrumbs: [ + { + category: 'fetch', + data: { + method: 'GET', + status_code: 200, + url: expect.stringContaining('http://localhost:'), + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.any(String), + }, + }), + ) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); + closeTestServer(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/wrangler.jsonc new file mode 100644 index 000000000000..24fb2861023d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"] +} diff --git a/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/test.ts index b9b2327497f5..4592221c286b 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { conditionalTest } from '../../../utils'; import { createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; // This test requires Node.js 22+ because it depends on the 'http.client.request.created' // diagnostic channel for baggage header propagation, which only exists since Node 22.12.0+ and 23.2.0+ diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts index 0d1d33bb5fc9..531d66b3f2e6 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts index 61dfe4c4ba88..7781f01f4605 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts index 046763a0b55a..2f0bfd410663 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts index acc1d6c89a25..702a2febd61d 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts index 4507a360006c..8458d25728d0 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts index 318d4628453b..96892353d2dd 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { 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 1cad4abf9a99..17393f21a8a4 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,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http requests with tracing & spans disabled', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/test.ts index 55882d18830a..7d863d27ce6e 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts index 8cf07571fe24..e2af51920b0b 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/test.ts index a1ac7ca292e4..4aecd4c8dfa1 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/test.ts index 63ae25f32a0c..bf6f3fb6e316 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/traceparent/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/traceparent/test.ts index 2cdb4cfd1aa7..517ea314dfc5 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/traceparent/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/traceparent/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing traceparent', () => { createEsmAndCjsTests(__dirname, 'scenario-fetch.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/test.ts index a136eb770a8d..b97f64adace5 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { conditionalTest } from '../../../utils'; import { createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; // This test requires Node.js 22+ because it depends on the 'http.client.request.created' // diagnostic channel for baggage header propagation, which only exists since Node 22.12.0+ and 23.2.0+ diff --git a/dev-packages/node-core-integration-tests/utils/server.ts b/dev-packages/node-core-integration-tests/utils/server.ts index 92e0477c845c..b8941b4b0c32 100644 --- a/dev-packages/node-core-integration-tests/utils/server.ts +++ b/dev-packages/node-core-integration-tests/utils/server.ts @@ -37,49 +37,3 @@ export function createBasicSentryServer(onEnvelope: (env: Envelope) => void): Pr }); }); } - -type HeaderAssertCallback = (headers: Record) => void; - -/** Creates a test server that can be used to check headers */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function createTestServer() { - const gets: Array<[string, HeaderAssertCallback, number]> = []; - let error: unknown | undefined; - - return { - get: function (path: string, callback: HeaderAssertCallback, result = 200) { - gets.push([path, callback, result]); - return this; - }, - start: async (): Promise<[string, () => void]> => { - const app = express(); - - for (const [path, callback, result] of gets) { - app.get(path, (req, res) => { - try { - callback(req.headers); - } catch (e) { - error = e; - } - - res.status(result).send(); - }); - } - - return new Promise(resolve => { - const server = app.listen(0, () => { - const address = server.address() as AddressInfo; - resolve([ - `http://localhost:${address.port}`, - () => { - server.close(); - if (error) { - throw error; - } - }, - ]); - }); - }); - }, - }; -} diff --git a/dev-packages/node-integration-tests/suites/tracing/dsc-txn-name-update/test.ts b/dev-packages/node-integration-tests/suites/tracing/dsc-txn-name-update/test.ts index aa74bea7d79e..ddac08fe1b21 100644 --- a/dev-packages/node-integration-tests/suites/tracing/dsc-txn-name-update/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/dsc-txn-name-update/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; test('adds current transaction name to baggage when the txn name is high-quality', async () => { expect.assertions(5); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts index 1b599def6be6..1cc06ba6f21e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; test('captures spans for outgoing fetch requests', async () => { expect.assertions(3); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts index 8d0a35a43d05..6092e212df08 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; test('adds requestHook and responseHook attributes to spans of outgoing fetch requests', async () => { expect.assertions(3); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts index 580a63a52e90..8eea877dc72e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; test('strips and handles query params in spans of outgoing fetch requests', async () => { expect.assertions(4); 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 bb21f7def8f0..0549d7e914c0 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 @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; test('captures spans for outgoing http requests', async () => { expect.assertions(3); 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 edfac9fe2081..94ccd6c9702a 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 @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; test('strips and handles query params in spans of outgoing http requests', async () => { expect.assertions(4); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 15c354e45533..ac0ac3780a38 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; function getCommonHttpRequestHeaders(): Record { return { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts index d3315ae86ece..2691d10294a5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts index 61dfe4c4ba88..7781f01f4605 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts index 046763a0b55a..2f0bfd410663 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts index acc1d6c89a25..702a2febd61d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts index 4507a360006c..8458d25728d0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts index 318d4628453b..96892353d2dd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { 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 1cad4abf9a99..17393f21a8a4 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,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http requests with tracing & spans disabled', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts index d0b13513d1de..4f6593f82e34 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts index 932f379ec23e..b00148c26142 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts index 9a7b13a34332..ebd6198bfd4e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts index 28fb877d0425..1b40af0b6ec3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/traceparent/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/traceparent/test.ts index 2cdb4cfd1aa7..517ea314dfc5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/traceparent/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/traceparent/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing traceparent', () => { createEsmAndCjsTests(__dirname, 'scenario-fetch.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts index 9fb39a1ec8f2..dc5105c9d1bb 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; test('HttpIntegration should instrument correct requests when tracePropagationTargets option is provided', async () => { expect.assertions(11); diff --git a/dev-packages/node-integration-tests/utils/server.ts b/dev-packages/node-integration-tests/utils/server.ts deleted file mode 100644 index a1ba3f522fb1..000000000000 --- a/dev-packages/node-integration-tests/utils/server.ts +++ /dev/null @@ -1,48 +0,0 @@ -import express from 'express'; -import type { AddressInfo } from 'net'; - -type HeaderAssertCallback = (headers: Record) => void; - -/** Creates a test server that can be used to check headers */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function createTestServer() { - const gets: Array<[string, HeaderAssertCallback, number]> = []; - let error: unknown | undefined; - - return { - get: function (path: string, callback: HeaderAssertCallback, result = 200) { - gets.push([path, callback, result]); - return this; - }, - start: async (): Promise<[string, () => void]> => { - const app = express(); - - for (const [path, callback, result] of gets) { - app.get(path, (req, res) => { - try { - callback(req.headers); - } catch (e) { - error = e; - } - - res.status(result).send(); - }); - } - - return new Promise(resolve => { - const server = app.listen(0, () => { - const address = server.address() as AddressInfo; - resolve([ - `http://localhost:${address.port}`, - () => { - server.close(); - if (error) { - throw error; - } - }, - ]); - }); - }); - }, - }; -} diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index b14248aabd95..4a3dfcfaa4c8 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -11,4 +11,4 @@ export { } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; -export { createBasicSentryServer } from './server'; +export { createBasicSentryServer, createTestServer } from './server'; diff --git a/dev-packages/test-utils/src/server.ts b/dev-packages/test-utils/src/server.ts index b8941b4b0c32..92e0477c845c 100644 --- a/dev-packages/test-utils/src/server.ts +++ b/dev-packages/test-utils/src/server.ts @@ -37,3 +37,49 @@ export function createBasicSentryServer(onEnvelope: (env: Envelope) => void): Pr }); }); } + +type HeaderAssertCallback = (headers: Record) => void; + +/** Creates a test server that can be used to check headers */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createTestServer() { + const gets: Array<[string, HeaderAssertCallback, number]> = []; + let error: unknown | undefined; + + return { + get: function (path: string, callback: HeaderAssertCallback, result = 200) { + gets.push([path, callback, result]); + return this; + }, + start: async (): Promise<[string, () => void]> => { + const app = express(); + + for (const [path, callback, result] of gets) { + app.get(path, (req, res) => { + try { + callback(req.headers); + } catch (e) { + error = e; + } + + res.status(result).send(); + }); + } + + return new Promise(resolve => { + const server = app.listen(0, () => { + const address = server.address() as AddressInfo; + resolve([ + `http://localhost:${address.port}`, + () => { + server.close(); + if (error) { + throw error; + } + }, + ]); + }); + }); + }, + }; +} diff --git a/packages/cloudflare/src/integrations/fetch.ts b/packages/cloudflare/src/integrations/fetch.ts index 66c9f559f29c..8dfff417ff27 100644 --- a/packages/cloudflare/src/integrations/fetch.ts +++ b/packages/cloudflare/src/integrations/fetch.ts @@ -90,6 +90,7 @@ const _fetchIntegration = ((options: Partial = {}) => { setupOnce() { addFetchInstrumentationHandler(handlerData => { const client = getClient(); + const { propagateTraceparent } = client?.getOptions() || {}; if (!client || !HAS_CLIENT_MAP.get(client)) { return; } @@ -100,6 +101,7 @@ const _fetchIntegration = ((options: Partial = {}) => { instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { spanOrigin: 'auto.http.fetch', + propagateTraceparent, }); if (breadcrumbs) { From ac45e57bdf1085af3fb88171e7cd83d655622db6 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 12 Jan 2026 11:49:47 +0100 Subject: [PATCH 72/76] feat(nextjs): Print Turbopack note for deprecated webpack options (#18769) closes https://github.com/getsentry/sentry-javascript/issues/18758 Prints a note if a user is using deprecated webpack options with turbopack. --- packages/nextjs/src/config/withSentryConfig.ts | 12 ++++++++++-- .../nextjs/test/config/withSentryConfig.test.ts | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 5291176b354b..df203edad29e 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -122,8 +122,16 @@ function migrateDeprecatedWebpackOptions(userSentryOptions: SentryBuildOptions): return newValue ?? deprecatedValue; }; - const deprecatedMessage = (deprecatedPath: string, newPath: string): string => - `[@sentry/nextjs] DEPRECATION WARNING: ${deprecatedPath} is deprecated and will be removed in a future version. Use ${newPath} instead.`; + const deprecatedMessage = (deprecatedPath: string, newPath: string): string => { + const message = `[@sentry/nextjs] DEPRECATION WARNING: ${deprecatedPath} is deprecated and will be removed in a future version. Use ${newPath} instead.`; + + // In Turbopack builds, webpack configuration is not applied, so webpack-scoped options won't have any effect. + if (detectActiveBundler() === 'turbopack' && newPath.startsWith('webpack.')) { + return `${message} (Not supported with Turbopack.)`; + } + + return message; + }; /* eslint-disable deprecation/deprecation */ // Migrate each deprecated option to the new path, but only if the new path isn't already set diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index ed4b96a78125..7dfa68ccbcde 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -390,6 +390,21 @@ describe('withSentryConfig', () => { ); }); + it('adds a turbopack note when the deprecated option only applies to webpack', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); + + const sentryOptions = { + disableLogger: true, + }; + + materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Use webpack.treeshake.removeDebugLogging instead. (Not supported with Turbopack.)'), + ); + }); + it('does not warn when using new webpack path', () => { delete process.env.TURBOPACK; From 6f723e07eafe603e6365733fd91061e53a3ba0f2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 12 Jan 2026 12:13:10 +0100 Subject: [PATCH 73/76] fix(test): Remove hard-coded SDK version assertion (#18771) I accidentally left a hard-coded SDK version in a test recently added by me. This PR fixes that so that CI doesn't break on the next release Closes #18772 (added automatically) --- .../suites/public-api/metrics/simple/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 41eb00a90bc8..655458c008a1 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 @@ -136,7 +136,7 @@ sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) }, 'sentry.sdk.version': { type: 'string', - value: '10.32.1', + value: expect.any(String), }, 'user.email': { type: 'string', From 03546d27181ea42d8a812b54863ff81aa54ae1a2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 12 Jan 2026 12:20:34 +0100 Subject: [PATCH 74/76] test(node-core): Fix wrong import in node core IPv6 integration test (#18773) Accidentally added a wrong import when finishing work in #17708 --- .../node-core-integration-tests/suites/ipv6/scenario.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/node-core-integration-tests/suites/ipv6/scenario.ts b/dev-packages/node-core-integration-tests/suites/ipv6/scenario.ts index 0023a1bc4b48..076e0ca02643 100644 --- a/dev-packages/node-core-integration-tests/suites/ipv6/scenario.ts +++ b/dev-packages/node-core-integration-tests/suites/ipv6/scenario.ts @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/node'; +import * as Sentry from '@sentry/node-core'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ From 3ff89c6270b8cc3ae3748d67c8c09d03f7502773 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 12 Jan 2026 12:37:11 +0100 Subject: [PATCH 75/76] test(node-integration): Remove hardcoded SDK version assertions (#18775) two like in #18771 --- .../suites/public-api/metrics/test.ts | 2 +- .../node-integration-tests/suites/public-api/metrics/test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 83715375f2cc..9494ce2a99ca 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 @@ -125,7 +125,7 @@ describe('metrics', () => { }, 'sentry.sdk.version': { type: 'string', - value: '10.32.1', + value: expect.any(String), }, 'user.email': { type: 'string', 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 7e4b71c9ad28..ff67b73e9ad3 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 @@ -124,7 +124,7 @@ describe('metrics', () => { }, 'sentry.sdk.version': { type: 'string', - value: '10.32.1', + value: expect.any(String), }, 'user.email': { type: 'string', From 1630389fa60ef91fe165dd04e6eb9c7820a375ea Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 12 Jan 2026 11:17:08 +0100 Subject: [PATCH 76/76] meta(changelog): Update changelog for 10.33.0 Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3a8a0ac38d..4dc2613d9ddc 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 +## 10.33.0 + +### Important Changes + - **feat(core): Apply scope attributes to metrics ([#18738](https://github.com/getsentry/sentry-javascript/pull/18738))** You can now set attributes on the SDK's scopes which will be applied to all metrics as long as the respective scopes are active. For the time being, only `string`, `number` and `boolean` attribute values are supported. @@ -23,7 +27,11 @@ Sentry.metrics.count('response_time', 283.33, { unit: 'millisecond' }); ``` -- **feat(wasm): Add applicationKey option for third-party error filtering ([#18762])(https://github.com/getsentry/sentry-javascript/pull/18762)** +- **feat(tracing): Add Vercel AI SDK v6 support ([#18741](https://github.com/getsentry/sentry-javascript/pull/18741))** + + The Sentry SDK now supports the Vercel AI SDK v6. Tracing and error monitoring will work automatically with the new version. + +- **feat(wasm): Add applicationKey option for third-party error filtering ([#18762](https://github.com/getsentry/sentry-javascript/pull/18762))** Adds support for applying an application key to WASM stack frames that can be then used in the `thirdPartyErrorFilterIntegration` for detection of first-party code. @@ -42,7 +50,80 @@ }); ``` -- ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) +### Other Changes + +- feat(cloudflare): Support `propagateTraceparent` ([#18569](https://github.com/getsentry/sentry-javascript/pull/18569)) +- feat(core): Add `ignoreSentryInternalFrames` option to `thirdPartyErrorFilterIntegration` ([#18632](https://github.com/getsentry/sentry-javascript/pull/18632)) +- feat(core): Add gen_ai.conversation.id attribute to OpenAI and LangGr… ([#18703](https://github.com/getsentry/sentry-javascript/pull/18703)) +- feat(core): Add recordInputs/recordOutputs options to MCP server wrapper ([#18600](https://github.com/getsentry/sentry-javascript/pull/18600)) +- feat(core): Support IPv6 hosts in the DSN ([#2996](https://github.com/getsentry/sentry-javascript/pull/2996)) (#17708) +- feat(deps): Bump bundler plugins to ^4.6.1 ([#17980](https://github.com/getsentry/sentry-javascript/pull/17980)) +- feat(nextjs): Emit warning for conflicting treeshaking / debug settings ([#18638](https://github.com/getsentry/sentry-javascript/pull/18638)) +- feat(nextjs): Print Turbopack note for deprecated webpack options ([#18769](https://github.com/getsentry/sentry-javascript/pull/18769)) +- feat(node-core): Add `isolateTrace` option to `node-cron` instrumentation ([#18416](https://github.com/getsentry/sentry-javascript/pull/18416)) +- feat(node): Use `process.on('SIGTERM')` for flushing in Vercel functions ([#17583](https://github.com/getsentry/sentry-javascript/pull/17583)) +- feat(nuxt): Detect development environment and add dev E2E test ([#18671](https://github.com/getsentry/sentry-javascript/pull/18671)) +- fix(browser): Forward worker metadata for third-party error filtering ([#18756](https://github.com/getsentry/sentry-javascript/pull/18756)) +- fix(browser): Reduce number of `visibilitystate` and `pagehide` listeners ([#18581](https://github.com/getsentry/sentry-javascript/pull/18581)) +- fix(browser): Respect `tunnel` in `diagnoseSdkConnectivity` ([#18616](https://github.com/getsentry/sentry-javascript/pull/18616)) +- fix(cloudflare): Consume body of fetch in the Cloudflare transport ([#18545](https://github.com/getsentry/sentry-javascript/pull/18545)) +- fix(core): Set op on ended Vercel AI spans ([#18601](https://github.com/getsentry/sentry-javascript/pull/18601)) +- fix(core): Subtract `performance.now()` from `browserPerformanceTimeOrigin` fallback ([#18715](https://github.com/getsentry/sentry-javascript/pull/18715)) +- fix(core): Update client options to allow explicit `undefined` ([#18024](https://github.com/getsentry/sentry-javascript/pull/18024)) +- fix(feedback): Fix cases where the outline of inputs were wrong ([#18647](https://github.com/getsentry/sentry-javascript/pull/18647)) +- fix(next): Ensure inline sourcemaps are generated for wrapped modules in Dev ([#18640](https://github.com/getsentry/sentry-javascript/pull/18640)) +- fix(next): Wrap all Random APIs with a safe runner ([#18700](https://github.com/getsentry/sentry-javascript/pull/18700)) +- fix(nextjs): Avoid Edge build warning from OpenTelemetry `process.argv0` ([#18759](https://github.com/getsentry/sentry-javascript/pull/18759)) +- fix(nextjs): Remove polynomial regular expression ([#18725](https://github.com/getsentry/sentry-javascript/pull/18725)) +- fix(node-core): Ignore worker threads in OnUncaughtException ([#18689](https://github.com/getsentry/sentry-javascript/pull/18689)) +- fix(node): relax Fastify's `setupFastifyErrorHandler` argument type ([#18620](https://github.com/getsentry/sentry-javascript/pull/18620)) +- fix(nuxt): Allow overwriting server-side `defaultIntegrations` ([#18717](https://github.com/getsentry/sentry-javascript/pull/18717)) +- fix(pino): Allow custom namespaces for `msg` and `err` ([#18597](https://github.com/getsentry/sentry-javascript/pull/18597)) +- fix(react,solid,vue): Fix parametrization behavior for non-matched routes ([#18735](https://github.com/getsentry/sentry-javascript/pull/18735)) +- fix(replay): Ensure replays contain canvas rendering when resumed after inactivity ([#18714](https://github.com/getsentry/sentry-javascript/pull/18714)) +- fix(tracing): add gen_ai.request.messages.original_length attributes ([#18608](https://github.com/getsentry/sentry-javascript/pull/18608)) +- ref(nextjs): Drop `resolve` dependency ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) +- ref(react-router): Use snake_case for span op names ([#18617](https://github.com/getsentry/sentry-javascript/pull/18617)) + +
+ Internal Changes + +- chore(bun): Fix `install-bun.js` version check and improve upgrade feedback ([#18492](https://github.com/getsentry/sentry-javascript/pull/18492)) +- chore(changelog): Fix typo ([#18648](https://github.com/getsentry/sentry-javascript/pull/18648)) +- chore(craft): Use version templating for aws layer ([#18675](https://github.com/getsentry/sentry-javascript/pull/18675)) +- chore(deps): Bump IITM to ^2.0.1 ([#18599](https://github.com/getsentry/sentry-javascript/pull/18599)) +- chore(e2e-tests): Upgrade `@trpc/server` and `@trpc/client` ([#18722](https://github.com/getsentry/sentry-javascript/pull/18722)) +- chore(e2e): Unpin react-router-7-framework-spa to ^7.11.0 ([#18551](https://github.com/getsentry/sentry-javascript/pull/18551)) +- chore(nextjs): Bump next version in dev deps ([#18661](https://github.com/getsentry/sentry-javascript/pull/18661)) +- chore(node-tests): Upgrade `@langchain/core` ([#18720](https://github.com/getsentry/sentry-javascript/pull/18720)) +- chore(react): Inline `hoist-non-react-statics` package ([#18102](https://github.com/getsentry/sentry-javascript/pull/18102)) +- chore(size-limit): Add size checks for metrics and logs ([#18573](https://github.com/getsentry/sentry-javascript/pull/18573)) +- chore(tests): Add unordered mode to cloudflare test runner ([#18596](https://github.com/getsentry/sentry-javascript/pull/18596)) +- ci(deps): bump actions/cache from 4 to 5 ([#18654](https://github.com/getsentry/sentry-javascript/pull/18654)) +- ci(deps): Bump actions/create-github-app-token from 2.2.0 to 2.2.1 ([#18656](https://github.com/getsentry/sentry-javascript/pull/18656)) +- ci(deps): bump actions/upload-artifact from 5 to 6 ([#18655](https://github.com/getsentry/sentry-javascript/pull/18655)) +- ci(deps): bump peter-evans/create-pull-request from 7.0.9 to 8.0.0 ([#18657](https://github.com/getsentry/sentry-javascript/pull/18657)) +- doc: E2E testing documentation updates ([#18649](https://github.com/getsentry/sentry-javascript/pull/18649)) +- ref(core): Extract and reuse `getCombinedScopeData` helper ([#18585](https://github.com/getsentry/sentry-javascript/pull/18585)) +- ref(core): Remove dependence between `performance.timeOrigin` and `performance.timing.navigationStart` ([#18710](https://github.com/getsentry/sentry-javascript/pull/18710)) +- ref(core): Streamline and test `browserPerformanceTimeOrigin` ([#18708](https://github.com/getsentry/sentry-javascript/pull/18708)) +- ref(core): Strengthen `browserPerformanceTimeOrigin` reliability check ([#18719](https://github.com/getsentry/sentry-javascript/pull/18719)) +- ref(core): Use `serializeAttributes` for metric attribute serialization ([#18582](https://github.com/getsentry/sentry-javascript/pull/18582)) +- ref(node): Remove duplicate function `isCjs` ([#18662](https://github.com/getsentry/sentry-javascript/pull/18662)) +- test(core): Improve unit test performance for offline transport tests ([#18628](https://github.com/getsentry/sentry-javascript/pull/18628)) +- test(core): Use fake timers in promisebuffer tests to ensure deterministic behavior ([#18659](https://github.com/getsentry/sentry-javascript/pull/18659)) +- test(e2e): Add e2e metrics tests in Next.js 16 ([#18643](https://github.com/getsentry/sentry-javascript/pull/18643)) +- test(e2e): Pin agents package in cloudflare-mcp test ([#18609](https://github.com/getsentry/sentry-javascript/pull/18609)) +- test(e2e): Pin solid/vue tanstack router to 1.41.8 ([#18610](https://github.com/getsentry/sentry-javascript/pull/18610)) +- test(nestjs): Add canary test for latest ([#18685](https://github.com/getsentry/sentry-javascript/pull/18685)) +- test(node-native): Increase worker block timeout ([#18683](https://github.com/getsentry/sentry-javascript/pull/18683)) +- test(nuxt): Fix nuxt-4 dev E2E test ([#18737](https://github.com/getsentry/sentry-javascript/pull/18737)) +- test(tanstackstart-react): Add canary test for latest ([#18686](https://github.com/getsentry/sentry-javascript/pull/18686)) +- test(vue): Added canary and latest test variants to Vue tests ([#18681](https://github.com/getsentry/sentry-javascript/pull/18681)) + +
+ +Work in this release was contributed by @G-Rath, @gianpaj, @maximepvrt, @Mohataseem89, @sebws, and @xgedev. Thank you for your contributions! ## 10.32.1 @@ -58,8 +139,6 @@ -Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, @G-Rath, @maximepvrt, and @gianpaj. Thank you for your contributions! - ## 10.32.0 ### Important Changes