From e7093dc92b6840ccf6193a89ca6cc862762db82a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 28 Sep 2026 18:58:15 +0200 Subject: [PATCH 01/65] ci: shard Deno shared integration tests (#24772) Similar to #24676 and #24726, split the shared Node integration suites running on Deno across two CI jobs as coverage grows. The native Deno suites still run once, on shard 1; only the Vitest suites are sharded. Co-authored-by: GPT-6 --- .github/workflows/build.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 908dfc211966..924ccf458fe9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -931,11 +931,15 @@ jobs: run: yarn test job_deno_integration_tests: - name: Deno Integration Tests + name: Deno Integration Tests (${{ matrix.shard }}/2) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_deno_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -956,9 +960,16 @@ jobs: - name: Build @sentry/deno working-directory: packages/deno run: yarn build - - name: Run integration tests + - name: Run native Deno integration tests + if: matrix.shard == 1 working-directory: dev-packages/deno-integration-tests - run: yarn test + run: | + yarn install:deno + yarn deno-types + yarn test:unit + - name: Run shared Node integration tests + working-directory: dev-packages/deno-integration-tests + run: yarn test:node-suites --shard=${{ matrix.shard }}/2 job_build_tarballs: name: Build tarballs From db2de15b931aae0b702fb73b4145b06923e9773c Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 28 Sep 2026 19:04:48 +0200 Subject: [PATCH 02/65] ci: shard Bun integration tests (#24771) Similar to #24676 and #24726, split Bun integration tests across two CI jobs as coverage grows. Bun on develop currently only takes around 2 minutes and with this change 1 minute, so this is not yet really necessary but also doesn't hurt. Co-authored-by: GPT-6 --- .github/workflows/build.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 924ccf458fe9..9f7d0181bf1c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -903,11 +903,15 @@ jobs: run: yarn test job_bun_integration_tests: - name: Bun Integration Tests + name: Bun Integration Tests (${{ matrix.shard }}/2) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_bun_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -928,7 +932,7 @@ jobs: - name: Run integration tests working-directory: dev-packages/bun-integration-tests - run: yarn test + run: yarn test --shard=${{ matrix.shard }}/2 job_deno_integration_tests: name: Deno Integration Tests (${{ matrix.shard }}/2) From 545d1e1adef0616797fdf4a964e315c03e77a0e9 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 29 Sep 2026 08:58:35 +0200 Subject: [PATCH 03/65] fix(node): Flush buffered metrics on process exit (#24806) Add the missing `beforeExit` flush for metrics, matching the existing behavior for logs and client reports. This ensures buffered metrics are sent when a Node process exits naturally, even if the timer or size threshold has not triggered a flush yet. --- packages/node/src/sdk/client.ts | 11 ++++++++ packages/node/test/sdk/client.test.ts | 38 +++++++++++++++------------ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/packages/node/src/sdk/client.ts b/packages/node/src/sdk/client.ts index 01de1b58fd82..4936a92f0ef6 100644 --- a/packages/node/src/sdk/client.ts +++ b/packages/node/src/sdk/client.ts @@ -5,6 +5,7 @@ import type { ServerRuntimeClientOptions } from '@sentry/core/server'; import { _INTERNAL_clearAiProviderSkips, _INTERNAL_flushLogsBuffer, + _INTERNAL_flushMetricsBuffer, _INTERNAL_setDeferSegmentSpanCapture, applySdkMetadata, debug, @@ -38,6 +39,7 @@ export class NodeClient extends ServerRuntimeClient { private _clientReportInterval: NodeJS.Timeout | undefined; private _clientReportOnExitFlushListener: (() => void) | undefined; private _logOnExitFlushListener: (() => void) | undefined; + private _metricsOnExitFlushListener: (() => void) | undefined; public constructor(options: NodeClientOptions) { const serverName = @@ -63,6 +65,10 @@ export class NodeClient extends ServerRuntimeClient { _INTERNAL_flushLogsBuffer(this); }; + this._metricsOnExitFlushListener = () => { + _INTERNAL_flushMetricsBuffer(this); + }; + if (serverName) { this.on('beforeCaptureLog', log => { log.attributes = { @@ -73,6 +79,7 @@ export class NodeClient extends ServerRuntimeClient { } process.on('beforeExit', this._logOnExitFlushListener); + process.on('beforeExit', this._metricsOnExitFlushListener); // Enable deferred segment-span transaction capture here, in the constructor, rather than in // `initOtel`. Every client runs its constructor exactly once, whereas `initOtel` only runs on @@ -158,6 +165,10 @@ export class NodeClient extends ServerRuntimeClient { process.off('beforeExit', this._logOnExitFlushListener); } + if (this._metricsOnExitFlushListener) { + process.off('beforeExit', this._metricsOnExitFlushListener); + } + const allEventsSent = await super.close(timeout); if (this.traceProvider) { await this.traceProvider.shutdown(); diff --git a/packages/node/test/sdk/client.test.ts b/packages/node/test/sdk/client.test.ts index 1792dd6da0eb..03994b43c73d 100644 --- a/packages/node/test/sdk/client.test.ts +++ b/packages/node/test/sdk/client.test.ts @@ -397,35 +397,39 @@ describe('NodeClient', () => { expect(forceFlushSpy).toHaveBeenCalledTimes(1); }); - it('stops client report tracking if it was started', async () => { - const processOffSpy = vi.spyOn(process, 'off'); - const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval'); - + it('stops client report tracking when closed', async () => { + const originalListeners = process.listeners('beforeExit'); const client = new NodeClient(getDefaultNodeClientOptions({ sendClientReports: true })); + const listenersBeforeTracking = process.listeners('beforeExit'); + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval'); + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval'); client.startClientReportTracking(); - const result = await client.close(); + const addedListeners = process + .listeners('beforeExit') + .filter(listener => !listenersBeforeTracking.includes(listener)); + const [intervalResult] = setIntervalSpy.mock.results; - expect(result).toBe(true); + await client.close(); + expect(addedListeners).toHaveLength(1); + expect(process.listeners('beforeExit')).toEqual(originalListeners); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); expect(clearIntervalSpy).toHaveBeenCalledTimes(1); - - // removes `_clientReportOnExitFlushListener` - expect(processOffSpy).toHaveBeenNthCalledWith(1, 'beforeExit', expect.any(Function)); + expect(clearIntervalSpy).toHaveBeenCalledWith(intervalResult?.value); }); - it('stops log capture if it was started', async () => { - const processOffSpy = vi.spyOn(process, 'off'); + it('removes log and metric exit listeners when closed', async () => { + const originalListeners = process.listeners('beforeExit'); + const client = new NodeClient(getDefaultNodeClientOptions({ sendClientReports: false })); - const client = new NodeClient(getDefaultNodeClientOptions()); + const addedListeners = process.listeners('beforeExit').filter(listener => !originalListeners.includes(listener)); - const result = await client.close(); - - expect(result).toBe(true); + await client.close(); - // removes `_logOnExitFlushListener` - expect(processOffSpy).toHaveBeenNthCalledWith(1, 'beforeExit', expect.any(Function)); + expect(addedListeners).toHaveLength(2); + expect(process.listeners('beforeExit')).toEqual(originalListeners); }); }); From 87c4eede12f31816716a859b87239e8abc7985f9 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:25:24 +0200 Subject: [PATCH 04/65] test(nuxt): Remove version pin for @nuxt/cli in Nuxt 5 E2E (#24798) Nuxt fixed it in: https://github.com/nuxt/nuxt/commit/7758942be14d86d1f949be3ade3119378f27ca61 Follow-up for this PR (just reverting the commit): https://github.com/getsentry/sentry-javascript/pull/24788 --- .../e2e-tests/test-applications/nuxt-5/package.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index 36aea2e0b856..cdf4885a9703 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -19,9 +19,6 @@ }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", - "@nuxt/cli is pinned. The CLI nightlies register nitro runtime plugins that live inside the CLI package: https://github.com/nuxt/cli/pull/1570", - "Nuxt's impound guard blocks imports from that package in the dev server runtime, so every SSR request fails in dev mode.", - "TODO: remove the @nuxt/cli override when impound allows the CLI runtime plugins", "TODO: remove nitro from dependencies", "Nuxt generates `fetch.server.mjs` as a virtual module that imports `ofetch`, and since a virtual id is neither absolute nor on disk, Vite resolves from its `root` (the srcDir) instead of the importer's folder.", "This fails because `ofetch` is a dependency of `nuxt` rather than the app, so pnpm leaves it in `.pnpm/`", @@ -39,11 +36,6 @@ "@playwright/test": "~1.63.0", "@sentry-internal/test-utils": "link:../../../test-utils" }, - "pnpm": { - "overrides": { - "@nuxt/cli": "npm:@nuxt/cli-nightly@4.0.0-20260927-110353-6eaa0eb" - } - }, "sentryTest": { "optional": true }, From df2dc5de374272c445ebcc464c697ad6de706f58 Mon Sep 17 00:00:00 2001 From: Noor Nabi Date: Tue, 29 Sep 2026 14:54:12 +0500 Subject: [PATCH 05/65] test(e2e): Add Bun Express test app (#24306) Adds a dedicated Bun + Express E2E application covering automatic request tracing and error capture. The app exercises both a parameterized success route and an exception route, asserting Express handler spans, route naming, status propagation, the Bun runtime context, and trace correlation. The app is built with `sentryBunPlugin` because Express instrumentation under Bun relies on build-time diagnostics-channel injection. The SDK and Express remain external so the app validates the normal package boundary, while CI now installs its pinned Bun version for this matrix entry. The targeted `bun-express` E2E run passes both Playwright cases on Bun 1.3.14. `yarn format`, `yarn build:dev`, and `yarn build:tarball` also complete successfully. The full local lint and unit-suite runs still surface pre-existing environment/baseline failures outside the files changed here; CI will provide the authoritative full-suite result. Fixes #12732 Co-authored-by: OpenAI Codex --- .github/workflows/build.yml | 2 +- .../test-applications/bun-express/.gitignore | 1 + .../test-applications/bun-express/build.ts | 20 +++++++++++ .../bun-express/package.json | 26 +++++++++++++++ .../bun-express/playwright.config.mjs | 5 +++ .../test-applications/bun-express/src/app.ts | 28 ++++++++++++++++ .../bun-express/start-event-proxy.mjs | 6 ++++ .../bun-express/tests/errors.test.ts | 33 +++++++++++++++++++ .../bun-express/tests/spans.test.ts | 30 +++++++++++++++++ .../bun-express/tsconfig.json | 11 +++++++ 10 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/bun-express/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/bun-express/build.ts create mode 100644 dev-packages/e2e-tests/test-applications/bun-express/package.json create mode 100644 dev-packages/e2e-tests/test-applications/bun-express/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/bun-express/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/bun-express/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/bun-express/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/bun-express/tests/spans.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/bun-express/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9f7d0181bf1c..fdaafc4b6e89 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1056,7 +1056,7 @@ jobs: - name: Set up Bun if: contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "elysia-bun-static", "hono-4", - "bun-bytecode", "bun-mysql"]'), matrix.test-application) + "bun-bytecode", "bun-express", "bun-mysql"]'), matrix.test-application) uses: oven-sh/setup-bun@v2 with: bun-version: '1.3.14' diff --git a/dev-packages/e2e-tests/test-applications/bun-express/.gitignore b/dev-packages/e2e-tests/test-applications/bun-express/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/bun-express/build.ts b/dev-packages/e2e-tests/test-applications/bun-express/build.ts new file mode 100644 index 000000000000..a2c2eaefd2a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/build.ts @@ -0,0 +1,20 @@ +// @ts-ignore -- subpath export resolved by Bun at runtime; the package +// tsconfig's node module resolution can't see `exports` subpaths. +import { sentryBunPlugin } from '@sentry/bun/plugin'; +import { join } from 'path'; + +void (async () => { + const result = await Bun.build({ + entrypoints: [join(__dirname, 'src/app.ts')], + target: 'bun', + outdir: join(__dirname, 'dist'), + external: ['@sentry/bun', 'express'], + plugins: [sentryBunPlugin()], + }); + + if (!result.success) { + // oxlint-disable-next-line no-console + console.error('BUILD_FAILED', result.logs); + process.exit(1); + } +})(); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/package.json b/dev-packages/e2e-tests/test-applications/bun-express/package.json new file mode 100644 index 000000000000..cd2634fe22f4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/package.json @@ -0,0 +1,26 @@ +{ + "name": "bun-express", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun run dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install && bun run build.ts", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/bun": "latest || *", + "express": "^5.1.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/express": "^5.0.0", + "bun-types": "^1.2.9" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/bun-express/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/bun-express/playwright.config.mjs new file mode 100644 index 000000000000..ce719004da38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/playwright.config.mjs @@ -0,0 +1,5 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +export default getPlaywrightConfig({ + startCommand: 'pnpm start', +}); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/src/app.ts b/dev-packages/e2e-tests/test-applications/bun-express/src/app.ts new file mode 100644 index 000000000000..97c8b53a6c26 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/src/app.ts @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/bun'; +import express from 'express'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1, +}); + +const app = express(); + +app.get('/test-param/:id', (request, response) => { + response.json({ id: request.params.id }); +}); + +app.get('/test-exception/:id', request => { + throw new Error(`This is an exception with id ${request.params.id}`); +}); + +app.use((_error, _request, response, _next) => { + response.status(500).send('Internal Server Error'); +}); + +app.listen(3030, () => { + // oxlint-disable-next-line no-console + console.log('Bun Express app listening on port 3030'); +}); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/bun-express/start-event-proxy.mjs new file mode 100644 index 000000000000..61525a11957e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'bun-express', +}); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/bun-express/tests/errors.test.ts new file mode 100644 index 000000000000..1fd5c04cbf06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/tests/errors.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment, waitForError } from '@sentry-internal/test-utils'; + +test('captures an error thrown in an Express route', async ({ baseURL }) => { + const errorEventPromise = waitForError('bun-express', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + const spansPromise = collectStreamedSpansUntilSegment('bun-express', 'GET /test-exception/:id'); + + const response = await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + const spans = await spansPromise; + + expect(response.status).toBe(500); + expect(errorEvent.exception?.values).toEqual([ + expect.objectContaining({ + value: 'This is an exception with id 123', + mechanism: { + type: 'auto.http.express', + handled: false, + }, + }), + ]); + expect(errorEvent.transaction).toBe('GET /test-exception/:id'); + expect(errorEvent.contexts?.runtime?.name).toBe('bun'); + + const rootSpan = spans.find(span => span.is_segment); + expect(rootSpan?.name).toBe('GET /test-exception/:id'); + expect(rootSpan?.status).toBe('error'); + expect(rootSpan?.attributes['http.response.status_code']?.value).toBe(500); + expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/bun-express/tests/spans.test.ts new file mode 100644 index 000000000000..050e42ab35a8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/tests/spans.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; + +test('instruments an Express route under Bun', async ({ baseURL }) => { + const spansPromise = collectStreamedSpansUntilSegment('bun-express', 'GET /test-param/:id'); + + const response = await fetch(`${baseURL}/test-param/123`); + const spans = await spansPromise; + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ id: '123' }); + + const rootSpan = spans.find(span => span.is_segment); + expect(rootSpan?.name).toBe('GET /test-param/:id'); + expect(rootSpan && getSpanOp(rootSpan)).toBe('http.server'); + expect(rootSpan?.status).toBe('ok'); + expect(rootSpan?.attributes['sentry.segment.name.source']?.value).toBe('route'); + + expect(spans).toContainEqual( + expect.objectContaining({ + name: '/test-param/:id', + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.http.express', type: 'string' }, + 'sentry.op': { value: 'handler', type: 'string' }, + 'http.route': { value: '/test-param/:id', type: 'string' }, + 'express.type': { value: 'request_handler', type: 'string' }, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/tsconfig.json b/dev-packages/e2e-tests/test-applications/bun-express/tsconfig.json new file mode 100644 index 000000000000..ef784cd1e0b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["bun-types"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "build.ts"] +} From e703accf5f3ad1b50ab5b266d83665a449b95fdb Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:59:11 +0200 Subject: [PATCH 06/65] Merge pull request #24813 from getsentry/ab/fix-lru-map-set-eviction fix(core): Stop LRUMap.set from evicting when updating an existing key --- packages/core/src/utils/lru.ts | 2 ++ packages/core/test/lib/utils/lru.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/core/src/utils/lru.ts b/packages/core/src/utils/lru.ts index 3158dff7d413..e206a5a7b1bd 100644 --- a/packages/core/src/utils/lru.ts +++ b/packages/core/src/utils/lru.ts @@ -25,6 +25,8 @@ export class LRUMap { /** Insert an entry and evict an older entry if we've reached maxSize */ public set(key: K, value: V): void { + // Delete first so updating an existing key refreshes its order and does not evict another entry + this._cache.delete(key); if (this._cache.size >= this._maxSize) { // keys() returns an iterator in insertion order so keys().next() gives us the oldest key // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/packages/core/test/lib/utils/lru.test.ts b/packages/core/test/lib/utils/lru.test.ts index 5940f10684e1..485145a6f206 100644 --- a/packages/core/test/lib/utils/lru.test.ts +++ b/packages/core/test/lib/utils/lru.test.ts @@ -27,6 +27,28 @@ describe('LRUMap', () => { expect(map.keys()).toEqual(['a', 'd', 'e']); }); + test('does not evict when updating an existing key', () => { + const map = new LRUMap(2); + map.set('a', '1'); + map.set('b', '2'); + map.set('b', '22'); + + expect(map.keys()).toEqual(['a', 'b']); + expect(map.get('b')).toEqual('22'); + }); + + test('updates last used when calling set on an existing key', () => { + const map = new LRUMap(3); + map.set('a', '1'); + map.set('b', '2'); + map.set('c', '3'); + + map.set('a', '11'); + map.set('d', '4'); + + expect(map.keys()).toEqual(['c', 'a', 'd']); + }); + test('removes and returns entry', () => { const map = new LRUMap(3); map.set('a', '1'); From 05604aa385582f07b306f6a41155ad9c4dbce1bb Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:02:57 +0200 Subject: [PATCH 07/65] chore: Add external contributor to CHANGELOG.md (#24822) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #24306 Co-authored-by: nicohrubec <29484629+nicohrubec@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b52a3163c26..f09f1015a9c2 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 @nabi-noor. Thank you for your contribution! + ## 11.1.0 ### Important Changes From 1c81b5ebd05e207a6f99450db67a40d6e7abf001 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:03:56 +0200 Subject: [PATCH 08/65] feat(deps): bump ip-address from 10.4.0 to 10.7.2 (#24810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.4.0 to 10.7.2.
Release notes

Sourced from ip-address's releases.

v10.7.2

What's Changed

Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.7.1...v10.7.2

v10.7.1

What's Changed

Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.7.0...v10.7.1

v10.7.0

What's Changed

Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.6.0...v10.7.0

v10.6.0

What's Changed

Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.5.1...v10.6.0

v10.5.1

Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.5.0...v10.5.1

v10.5.0

What's Changed

Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.4.0...v10.5.0

Commits
  • 974b48d 10.7.2
  • 4dfe8e5 Accept an arpa suffix in any case and without the root dot in fromArpa (#227)
  • f0c25df 10.7.1
  • 8b34a21 Merge commit from fork
  • 13b6155 Merge commit from fork
  • 469ead1 Reject an address longer than the family allows before parsing it
  • 1343629 Report an address of the other family as not contained
  • 4c2184a Bump js-yaml and brace-expansion in the lockfile (#226)
  • 2b7cab5 10.7.0
  • 87fae23 Add offset() and nextNetwork(), accept prefix-length ip6.arpa names, correct ...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ip-address&package-manager=npm_and_yarn&previous-version=10.4.0&new-version=10.7.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2796b4ef923e..0155f7ec0349 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17757,9 +17757,9 @@ ioredis@5.10.1, ioredis@^5.10.1: standard-as-callback "^2.1.0" ip-address@^10.0.1: - version "10.4.0" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.4.0.tgz#c5910bc541b6eae287765d1e4846be0308a05d93" - integrity sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ== + version "10.7.2" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.7.2.tgz#5b3b2b7d46c6293861b82dfcd49cc203aa8f2c5c" + integrity sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w== ipaddr.js@1.9.1: version "1.9.1" From adf0747513bfe4cfe68c440ad669e86279819172 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 29 Sep 2026 13:07:20 +0200 Subject: [PATCH 09/65] test(browser): Fix flaky reportPageLoaded duration assertion (#24814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `reportPageLoaded` default tests flaked in CI with a pageload span duration of ~3.16s against a `< 3` upper bound. _Root cause:_ the pageload span starts at navigation start, but the 2.5s `setTimeout` before `Sentry.reportPageLoaded()` only begins once the test bundle has executed, so page load time on a slow runner is added on top. Bumping the upper bound to 4s gives enough headroom; the `idle_span_finish_reason: 'reportPageLoaded'` assertion already guarantees the span wasn't ended by a timeout. Applied to both the static and streamed variants. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../reportPageLoaded-streamed/default/test.ts | 6 +++--- .../reportPageLoaded/default/test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts index d19476885c36..706166ef0dc4 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts @@ -33,9 +33,9 @@ sentryTest( [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'reportPageLoaded' }, }); - // We wait for 2.5 seconds before calling Sentry.reportPageLoaded() - // the margins are to account for timing weirdness in CI to avoid flakes + // We wait for 2.5 seconds before calling Sentry.reportPageLoaded(). The span starts at navigation start, + // but the timeout only starts once the bundle has executed, so allow generous upper headroom for slow CI. expect(spanDurationSeconds).toBeGreaterThan(2); - expect(spanDurationSeconds).toBeLessThan(3); + expect(spanDurationSeconds).toBeLessThan(4); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts index ce539cbd6d4b..9c8c0b48fb69 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts @@ -34,9 +34,9 @@ sentryTest( ['sentry.idle_span_finish_reason']: 'reportPageLoaded', }); - // We wait for 2.5 seconds before calling Sentry.reportPageLoaded() - // the margins are to account for timing weirdness in CI to avoid flakes + // We wait for 2.5 seconds before calling Sentry.reportPageLoaded(). The span starts at navigation start, + // but the timeout only starts once the bundle has executed, so allow generous upper headroom for slow CI. expect(spanDurationSeconds).toBeGreaterThan(2); - expect(spanDurationSeconds).toBeLessThan(3); + expect(spanDurationSeconds).toBeLessThan(4); }, ); From 841b43d9b5da88e4f0af8f3c0f2bc55ca6f9b918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 29 Sep 2026 14:58:26 +0300 Subject: [PATCH 10/65] test(test-utils): Add fetchSpanAttributes to read span attributes via the sentry CLI (#24514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is helpful for the stacked PR where we add a test for workers-ai Adds `fetchSpanAttributes(traceId, spanId)` to `@sentry-internal/test-utils/cli`, so send-to-sentry E2E tests can assert span attributes and not only the span op. `sentry trace view --json` looks like it returns span attributes, but it silently drops all of them for most spans: the trace-items endpoint sends `int` attribute values as strings, the CLI's schema (0.44.1 and 0.45.0) expects numbers, and a failed schema check only logs `Could not fetch details for span`. The helper calls the same endpoint through `sentry api`, which does no schema check. The spawn and credential handling moved into a shared runner so both helpers use the E2E token the same way. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 --- dev-packages/test-utils/src/cli.ts | 48 +++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/dev-packages/test-utils/src/cli.ts b/dev-packages/test-utils/src/cli.ts index d45c80232239..1feffa3761f2 100644 --- a/dev-packages/test-utils/src/cli.ts +++ b/dev-packages/test-utils/src/cli.ts @@ -1,4 +1,5 @@ import { spawnSync } from 'node:child_process'; +import type { SpawnSyncReturns } from 'node:child_process'; /** * Spans only become queryable once they have made it through to EAP, which takes @@ -30,13 +31,8 @@ export function traceTarget(traceId: string): string { return `${process.env['E2E_TEST_SENTRY_ORG_SLUG']}/${process.env['E2E_TEST_SENTRY_PROJECT']}/${traceId}`; } -/** - * Fetch a trace of the E2E test project through the `sentry` CLI, which the calling test app has to - * list as a dev dependency. Returns an empty list while the trace has not landed yet. - */ -export function fetchTrace(traceId: string): TraceItem[] { - const target = traceTarget(traceId); - const result = spawnSync('pnpm', ['exec', 'sentry', 'trace', 'view', target, '--json', '--fresh'], { +function runSentryCli(args: string[]): SpawnSyncReturns { + const result = spawnSync('pnpm', ['exec', 'sentry', ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { @@ -50,11 +46,22 @@ export function fetchTrace(traceId: string): TraceItem[] { if (result.error) { throw new Error( - `Could not run \`pnpm exec sentry trace view\`: ${result.error.message}. ` + + `Could not run \`pnpm exec sentry ${args[0]}\`: ${result.error.message}. ` + 'The test app needs `sentry` as a dev dependency.', ); } + return result; +} + +/** + * Fetch a trace of the E2E test project through the `sentry` CLI, which the calling test app has to + * list as a dev dependency. Returns an empty list while the trace has not landed yet. + */ +export function fetchTrace(traceId: string): TraceItem[] { + const target = traceTarget(traceId); + const result = runSentryCli(['trace', 'view', target, '--json', '--fresh']); + if (result.status === 0) { return (JSON.parse(result.stdout) as { spans?: TraceItem[] }).spans ?? []; } @@ -80,6 +87,31 @@ export function fetchTrace(traceId: string): TraceItem[] { throw new Error(`sentry trace view ${target} exited with ${result.status}: ${result.stderr}`); } +/** + * Fetch all attributes of a span in the E2E test project, keyed by attribute name. Returns + * `undefined` while the span is not queryable yet. + * + * `sentry trace view --json` cannot be used for this: the trace-items endpoint sends `int` attribute + * values as strings, the CLI's schema rejects that, and the CLI then drops all attributes of the span. + */ +export function fetchSpanAttributes(traceId: string, spanId: string): Record | undefined { + const path = + `/projects/${process.env['E2E_TEST_SENTRY_ORG_SLUG']}/${process.env['E2E_TEST_SENTRY_PROJECT']}` + + `/trace-items/${spanId}/?trace_id=${traceId}&item_type=spans`; + const result = runSentryCli(['api', path]); + + if (result.status === 0) { + const { attributes } = JSON.parse(result.stdout) as { attributes: { name: string; value: unknown }[] }; + return Object.fromEntries(attributes.map(({ name, value }) => [name, value])); + } + + if (result.stdout.includes('"Not found."')) { + return undefined; + } + + throw new Error(`sentry api ${path} exited with ${result.status}: ${result.stdout}${result.stderr}`); +} + /** * Errors attach to whichever span was active when they were captured, and relocate from the * top level into that span once it lands, so a given event can surface at any depth. From db901797058750b321f74a06061c0f4a73bda24f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 29 Sep 2026 14:58:26 +0300 Subject: [PATCH 11/65] test(cloudflare): Assert Workers AI gen_ai spans in the send-to-sentry E2E test (#24515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #24759 Adds a Workers AI call to the real-Worker E2E app and checks that the `gen_ai.chat` span reaches Sentry with `gen_ai.input.messages` and `gen_ai.output.messages`. The unit and integration tests only see the envelope, so this is the first check that the Workers AI span and its attributes survive ingestion. The test expects `gen_ai.output.messages` and not `gen_ai.response.text`, because Sentry stores the SDK's `gen_ai.response.text` under that name. It uses `@cf/meta/llama-3.2-1b-instruct` with `max_tokens: 5` to keep the Workers AI cost of each run small. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 --- .../src/env.d.ts | 1 + .../src/index.ts | 4 ++++ .../tests/send-to-sentry.test.ts | 20 +++++++++++++++++++ .../wrangler.jsonc | 1 + 4 files changed, 26 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts index aaf72be7640b..15557b010a50 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts @@ -1,4 +1,5 @@ interface Env { E2E_TEST_DSN: string; SLEEP_WORKFLOW: Workflow; + AI: Ai; } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts index 93f1c24f124e..324c9161cf69 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts @@ -27,6 +27,10 @@ export default { } case '/test-unhandled-error': throw new Error('E2E test unhandled error'); + case '/test-workers-ai': { + await env.AI.run('@cf/meta/llama-3.2-1b-instruct', { prompt: 'Say hi', max_tokens: 5 }); + return Response.json({ traceId: spanContext?.traceId }); + } case '/test-span': return Response.json({ spanId: spanContext?.spanId, traceId: spanContext?.traceId }); case '/test-workflow-sleep': { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts index 8578ecb7bee7..044883dea3b0 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto'; import { expect, test } from '@playwright/test'; import { EVENT_POLLING_OPTIONS, + fetchSpanAttributes, fetchTrace, findErrorInTrace, findSpanInTrace, @@ -73,3 +74,22 @@ test('Sends the spans of Workflow steps before the Workflow goes to sleep', asyn ); expect(['running', 'waiting']).toContain(status); }); + +test('Sends a Workers AI gen_ai span to Sentry', async () => { + const { traceId }: { traceId: string } = JSON.parse(await fetchFromWorker(`${workerUrl}/test-workers-ai`, 200)); + + console.log(`Polling for gen_ai.chat span: sentry trace view ${traceTarget(traceId)}`); + + let spanId: string | undefined; + await expect + .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.chat')?.event_id), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + // Sentry stores `gen_ai.response.text` as `gen_ai.output.messages`. + await expect + .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + 'gen_ai.input.messages': expect.stringContaining('Say hi'), + 'gen_ai.output.messages': expect.stringContaining('"role":"assistant"'), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc index d6b15b1dbf5e..f1c0857ea5dd 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc @@ -6,6 +6,7 @@ "compatibility_date": "2026-05-20", "compatibility_flags": ["nodejs_compat"], "workers_dev": true, + "ai": { "binding": "AI" }, // Workers Logs keep the invocations of the last 7 days, so a failed CI run can still be inspected. "observability": { "enabled": true }, // Workflow names are unique per account, so deployWorker() renames it to the worker name. From 06b14f67b25f13353b9fd323cae3834f8c51eca9 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Tue, 29 Sep 2026 14:28:11 +0200 Subject: [PATCH 12/65] fix(aws-serverless): Keep Lambda extension polling past 300s (#24811) Invocations longer than 300 seconds can hit fetch's headers timeout and leave the Lambda extension registered but no longer polling, hanging subsequent invocations. Use timeout-free HTTP polling, with three retries for transient connection errors after 100, 200, and 400 milliseconds. Successful polls reset the retry budget; HTTP errors and exhausted retries log and exit so the environment cannot remain silently stuck. Based on @LuccaRebelloToledo's diagnosis and initial fix in #24219. Verified with the Node 22 Lambda emulator: after a 310-second invocation, the baseline's next invocation hangs while the patched version returns in 0.14 seconds. The built extension also recovers from injected connection resets and exits after exhausting consecutive retries. Fixes #24218 --------- Co-authored-by: LuccaRebelloToledo Co-authored-by: OpenAI GPT-6 --- .../lambda-extension/aws-lambda-extension.ts | 36 +++++-- .../src/lambda-extension/index.ts | 31 +++++- .../test/aws-lambda-extension.test.ts | 90 ++++++++++++++++- .../test/lambda-extension-index.test.ts | 99 +++++++++++++++++++ 4 files changed, 241 insertions(+), 15 deletions(-) create mode 100644 packages/aws-serverless/test/lambda-extension-index.test.ts diff --git a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts index 8ecbfe2510ad..163054aae0b3 100644 --- a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts +++ b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts @@ -48,20 +48,36 @@ export class AwsLambdaExtension { * Advances the extension to the next event. */ public async next(): Promise { - if (!this._extensionId) { + const extensionId = this._extensionId; + if (!extensionId) { throw new Error('Extension ID is not set'); } - const res = await fetch(`${this._baseUrl}/event/next`, { - headers: { - 'Lambda-Extension-Identifier': this._extensionId, - 'Content-Type': 'application/json', - }, - }); + // The Extensions API long poll must not time out. fetch applies a 300s headers timeout. + await new Promise((resolve, reject) => { + const req = http.get( + `${this._baseUrl}/event/next`, + { + agent: false, + timeout: 0, + headers: { + 'Lambda-Extension-Identifier': extensionId, + 'Content-Type': 'application/json', + }, + }, + res => { + buffer(res).then(body => { + if (res.statusCode !== 200) { + reject(new Error(`Failed to advance to next event: ${body.toString()}`)); + } else { + resolve(); + } + }, reject); + }, + ); - if (!res.ok) { - throw new Error(`Failed to advance to next event: ${await res.text()}`); - } + req.on('error', reject); + }); } /** diff --git a/packages/aws-serverless/src/lambda-extension/index.ts b/packages/aws-serverless/src/lambda-extension/index.ts index f465dae9741d..19a72a961b19 100644 --- a/packages/aws-serverless/src/lambda-extension/index.ts +++ b/packages/aws-serverless/src/lambda-extension/index.ts @@ -1,7 +1,8 @@ #!/usr/bin/env node -import { debug } from '@sentry/core'; +import { consoleSandbox } from '@sentry/core'; import { AwsLambdaExtension } from './aws-lambda-extension'; -import { DEBUG_BUILD } from './debug-build'; + +export const POLL_RETRY_DELAYS = [100, 200, 400] as const; async function main(): Promise { const extension = new AwsLambdaExtension(); @@ -10,12 +11,34 @@ async function main(): Promise { extension.startSentryTunnel(); + let failures = 0; + // eslint-disable-next-line no-constant-condition while (true) { - await extension.next(); + try { + await extension.next(); + failures = 0; + } catch (err) { + const delay = POLL_RETRY_DELAYS[failures++]; + const code = (err as NodeJS.ErrnoException | undefined)?.code; + + if ( + delay === undefined || + !code || + !['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN'].includes(code) + ) { + throw err; + } + + await new Promise(resolve => setTimeout(resolve, delay)); + } } } main().catch(err => { - DEBUG_BUILD && debug.error('Error in Lambda Extension', err); + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.error('Error in Lambda Extension', err); + }); + process.exit(1); }); diff --git a/packages/aws-serverless/test/aws-lambda-extension.test.ts b/packages/aws-serverless/test/aws-lambda-extension.test.ts index 4c3143eea442..f96d211d2e53 100644 --- a/packages/aws-serverless/test/aws-lambda-extension.test.ts +++ b/packages/aws-serverless/test/aws-lambda-extension.test.ts @@ -1,5 +1,93 @@ +import { once } from 'node:events'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { getSentryDSNFromEnv } from '../src/lambda-extension/aws-lambda-extension'; +import { AwsLambdaExtension, getSentryDSNFromEnv } from '../src/lambda-extension/aws-lambda-extension'; + +vi.mock('node:http', async () => { + const original = await vi.importActual('node:http'); + return { ...original }; +}); + +describe('AwsLambdaExtension.next', () => { + let server: http.Server; + let extension: AwsLambdaExtension; + + beforeEach(async () => { + server = http.createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + vi.stubEnv('AWS_LAMBDA_RUNTIME_API', `127.0.0.1:${(server.address() as AddressInfo).port}`); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('{}', { headers: { 'lambda-extension-identifier': 'test-extension-id' } }), + ); + extension = new AwsLambdaExtension(); + await extension.register(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + }); + + test('uses timeout-free HTTP requests for successive events', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('fetch headers timeout')); + const get = vi.spyOn(http, 'get'); + const requests: Array<{ method: string | undefined; url: string | undefined; extensionId: unknown }> = []; + server.on('request', (req, res) => { + requests.push({ method: req.method, url: req.url, extensionId: req.headers['lambda-extension-identifier'] }); + res.end(JSON.stringify({ eventType: 'INVOKE' })); + }); + + await expect(extension.next()).resolves.toBeUndefined(); + await expect(extension.next()).resolves.toBeUndefined(); + + expect(get).toHaveBeenCalledWith( + `http://${process.env.AWS_LAMBDA_RUNTIME_API}/2020-01-01/extension/event/next`, + { + agent: false, + timeout: 0, + headers: { 'Lambda-Extension-Identifier': 'test-extension-id', 'Content-Type': 'application/json' }, + }, + expect.any(Function), + ); + expect(requests).toEqual([ + { method: 'GET', url: '/2020-01-01/extension/event/next', extensionId: 'test-extension-id' }, + { method: 'GET', url: '/2020-01-01/extension/event/next', extensionId: 'test-extension-id' }, + ]); + }); + + test.each([403, 500])('rejects a %i response with the error body', async status => { + server.once('request', (_req, res) => { + res.writeHead(status); + res.end('Extension API error'); + }); + + await expect(extension.next()).rejects.toThrow('Failed to advance to next event: Extension API error'); + }); + + test('rejects when the connection closes before headers arrive', async () => { + server.once('request', req => req.socket.destroy()); + + await expect(extension.next()).rejects.toThrow('socket hang up'); + }); + + test('rejects when the response body is interrupted', async () => { + server.once('request', (_req, res) => { + res.writeHead(200, { 'Content-Length': '100' }); + res.flushHeaders(); + res.end('{'); + }); + + await expect(extension.next()).rejects.toThrow(); + }); + + test('rejects before registration', async () => { + await expect(new AwsLambdaExtension().next()).rejects.toThrow('Extension ID is not set'); + }); +}); describe('getSentryDSNFromEnv', () => { afterEach(() => { diff --git a/packages/aws-serverless/test/lambda-extension-index.test.ts b/packages/aws-serverless/test/lambda-extension-index.test.ts new file mode 100644 index 000000000000..0927b1c75469 --- /dev/null +++ b/packages/aws-serverless/test/lambda-extension-index.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + vi.setSystemTime(0); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +async function setupExtension() { + const { AwsLambdaExtension } = await import('../src/lambda-extension/aws-lambda-extension'); + vi.spyOn(AwsLambdaExtension.prototype, 'register').mockResolvedValue(); + vi.spyOn(AwsLambdaExtension.prototype, 'startSentryTunnel').mockImplementation(() => {}); + const next = vi.spyOn(AwsLambdaExtension.prototype, 'next').mockImplementation(() => new Promise(() => {})); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + return { next, log, exit }; +} + +test.each(['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN'])( + 'recovers from %s without exiting', + async code => { + const { next, exit, log } = await setupExtension(); + next.mockRejectedValueOnce(Object.assign(new Error('Connection failed'), { code })).mockResolvedValueOnce(); + + const { POLL_RETRY_DELAYS } = await import('../src/lambda-extension/index'); + await vi.advanceTimersByTimeAsync(0); + expect(next).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(POLL_RETRY_DELAYS[0] - 1); + expect(next).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + + expect(next).toHaveBeenCalledTimes(3); + expect(exit).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }, +); + +test('logs and exits after exhausting consecutive connection retries', async () => { + const { next, exit, log } = await setupExtension(); + const error = Object.assign(new Error('Connection reset'), { code: 'ECONNRESET' }); + const attempts: number[] = []; + next.mockImplementation(async () => { + attempts.push(Date.now()); + throw error; + }); + + const { POLL_RETRY_DELAYS } = await import('../src/lambda-extension/index'); + const retryBudget = POLL_RETRY_DELAYS.reduce((total, delay) => total + delay, 0); + await vi.advanceTimersByTimeAsync(retryBudget - 1); + expect(exit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + expect(attempts).toEqual([0, POLL_RETRY_DELAYS[0], POLL_RETRY_DELAYS[0] + POLL_RETRY_DELAYS[1], retryBudget]); + expect(exit).toHaveBeenCalledExactlyOnceWith(1); + expect(log).toHaveBeenCalledExactlyOnceWith('Error in Lambda Extension', error); + expect(vi.getTimerCount()).toBe(0); +}); + +test('resets the retry budget after a successful poll', async () => { + const { next, exit } = await setupExtension(); + const error = Object.assign(new Error('Connection reset'), { code: 'ECONNRESET' }); + next + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce() + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(); + + const { POLL_RETRY_DELAYS } = await import('../src/lambda-extension/index'); + const retryBudget = POLL_RETRY_DELAYS.reduce((total, delay) => total + delay, 0); + await vi.advanceTimersByTimeAsync(POLL_RETRY_DELAYS[0] + POLL_RETRY_DELAYS[1] + retryBudget); + + expect(next).toHaveBeenCalledTimes(8); + expect(exit).not.toHaveBeenCalled(); +}); + +test.each([ + new Error('Failed to advance to next event: Forbidden'), + new Error('Failed to advance to next event: Container error'), + Object.assign(new Error('Invalid URL'), { code: 'ERR_INVALID_URL' }), +])('logs and exits immediately for $message', async error => { + const { next, log, exit } = await setupExtension(); + next.mockRejectedValue(error); + + await import('../src/lambda-extension/index'); + await vi.advanceTimersByTimeAsync(0); + + expect(next).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledExactlyOnceWith(1); + expect(log).toHaveBeenCalledExactlyOnceWith('Error in Lambda Extension', error); + expect(vi.getTimerCount()).toBe(0); +}); From f48e9388ece7f55603791af29912dabac372fe20 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 29 Sep 2026 14:31:40 +0200 Subject: [PATCH 13/65] fix(deno): Flush buffered metrics and logs before process exit (#24807) Add the missing metrics exit on flush in Deno similar to https://github.com/getsentry/sentry-javascript/pull/24806. --------- Co-authored-by: GPT-6 --- packages/deno/src/client.ts | 18 +++++++++++++++++- packages/deno/test/mod.test.ts | 17 +++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/deno/src/client.ts b/packages/deno/src/client.ts index 160396df57af..295b7757b9fd 100644 --- a/packages/deno/src/client.ts +++ b/packages/deno/src/client.ts @@ -1,7 +1,8 @@ import type { ServerRuntimeClientOptions } from '@sentry/core/server'; -import { _INTERNAL_flushLogsBuffer, SDK_VERSION } from '@sentry/core'; +import { _INTERNAL_flushLogsBuffer, _INTERNAL_flushMetricsBuffer, SDK_VERSION } from '@sentry/core'; import { ServerRuntimeClient } from '@sentry/core/server'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; +import process from 'node:process'; import type { DenoClientOptions } from './types'; function getHostName(): string | undefined { @@ -22,6 +23,7 @@ function getHostName(): string | undefined { */ export class DenoClient extends ServerRuntimeClient { private _logOnExitFlushListener: (() => void) | undefined; + private _metricsOnExitFlushListener: (() => void) | undefined; /** * Creates a new Deno SDK instance. @@ -55,6 +57,10 @@ export class DenoClient extends ServerRuntimeClient { _INTERNAL_flushLogsBuffer(this); }; + this._metricsOnExitFlushListener = () => { + _INTERNAL_flushMetricsBuffer(this); + }; + if (serverName) { this.on('beforeCaptureLog', log => { log.attributes = { @@ -64,7 +70,11 @@ export class DenoClient extends ServerRuntimeClient { }); } + // Unlike unload, beforeExit lets the transport finish asynchronous sends. + process.on('beforeExit', this._logOnExitFlushListener); + process.on('beforeExit', this._metricsOnExitFlushListener); globalThis.addEventListener('unload', this._logOnExitFlushListener); + globalThis.addEventListener('unload', this._metricsOnExitFlushListener); } /** @inheritDoc */ @@ -81,9 +91,15 @@ export class DenoClient extends ServerRuntimeClient { // @ts-expect-error - PromiseLike is a subset of Promise public async close(timeout?: number | undefined): PromiseLike { if (this._logOnExitFlushListener) { + process.off('beforeExit', this._logOnExitFlushListener); globalThis.removeEventListener('unload', this._logOnExitFlushListener); } + if (this._metricsOnExitFlushListener) { + process.off('beforeExit', this._metricsOnExitFlushListener); + globalThis.removeEventListener('unload', this._metricsOnExitFlushListener); + } + return super.close(timeout); } } diff --git a/packages/deno/test/mod.test.ts b/packages/deno/test/mod.test.ts index f769ad0ff2d2..ad5e226b9661 100644 --- a/packages/deno/test/mod.test.ts +++ b/packages/deno/test/mod.test.ts @@ -1,3 +1,4 @@ +import process from 'node:process'; import type { Envelope, Event, Log } from '@sentry/core'; import { createStackParser, forEachEnvelopeItem } from '@sentry/core'; import { nodeStackLineParser } from '@sentry/core/server'; @@ -69,7 +70,7 @@ function expectCaptureExceptionEvent(event: Event | undefined): void { filename: 'app:///test/mod.test.ts', function: '?', in_app: true, - lineno: 45, + lineno: 46, }, { colno: 12, @@ -77,7 +78,7 @@ function expectCaptureExceptionEvent(event: Event | undefined): void { filename: 'app:///test/mod.test.ts', function: 'something', in_app: true, - lineno: 42, + lineno: 43, }, ], ); @@ -231,7 +232,8 @@ Deno.test('preserves existing log attributes when adding server.address', () => assertEquals(log.attributes?.['server.address'], 'test-server'); }); -Deno.test('close() removes unload listener', async () => { +Deno.test('close() removes log and metric exit listeners', async () => { + const originalListeners = process.listeners('beforeExit'); const removeEventListenerCalls: Array = []; const originalRemoveEventListener = globalThis.removeEventListener; globalThis.removeEventListener = ((event: string, ...args: unknown[]) => { @@ -248,9 +250,16 @@ Deno.test('close() removes unload listener', async () => { transport: makeTestTransport(() => {}), }); + const addedListeners = process.listeners('beforeExit').filter(listener => !originalListeners.includes(listener)); + await client.close(); - assertEquals(removeEventListenerCalls.includes('unload'), true); + assertEquals(addedListeners.length, 2); + assertEquals(process.listeners('beforeExit'), originalListeners); + assertEquals( + removeEventListenerCalls.filter(event => event === 'unload'), + ['unload', 'unload'], + ); } finally { globalThis.removeEventListener = originalRemoveEventListener; } From a3f92d5aa002deb07dc6ac3f1d5a7fa76e74a48e Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Tue, 29 Sep 2026 14:41:46 +0200 Subject: [PATCH 14/65] chore: Add external contributor to CHANGELOG.md (#24827) Credit @LuccaRebelloToledo in the unreleased changelog for the Lambda polling diagnosis and original fix in #24219, incorporated in #24811. The external-contributor workflow skips member-authored PRs and does not inspect commit co-authors. Co-authored-by: OpenAI GPT-6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f09f1015a9c2..e1896e87fea2 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 @nabi-noor. Thank you for your contribution! +Work in this release was contributed by @nabi-noor and @LuccaRebelloToledo. Thank you for your contributions! ## 11.1.0 From 48dfb5f6dc79ce9b0c6810787a4e5d6624c2289d Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:53:34 +0200 Subject: [PATCH 15/65] chore(github): Add `external` label on PRs of external contributors (#24825) --- .github/workflows/label-external-prs.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/label-external-prs.yml diff --git a/.github/workflows/label-external-prs.yml b/.github/workflows/label-external-prs.yml new file mode 100644 index 000000000000..e6a73aaae5a7 --- /dev/null +++ b/.github/workflows/label-external-prs.yml @@ -0,0 +1,22 @@ +name: 'Automation: Label external PRs' +on: + pull_request_target: + types: + - opened + +jobs: + label_external_pr: + name: Add external label + permissions: + pull-requests: write + runs-on: ubuntu-24.04 + if: | + github.event.pull_request.author_association != 'COLLABORATOR' + && github.event.pull_request.author_association != 'MEMBER' + && github.event.pull_request.author_association != 'OWNER' + && endsWith(github.event.pull_request.user.login, '[bot]') == false + steps: + - name: Add external label + uses: actions-ecosystem/action-add-labels@18f1af5e3544586314bbe15c0273249c770b2daf # v1.1.3 + with: + labels: external From e99805e380a86783b9798607adc142f7a351df98 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 29 Sep 2026 15:34:08 +0200 Subject: [PATCH 16/65] test(replay): De-flake mutationLimit large-mutations test (#24784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `replay.mutations` breadcrumb is created asynchronously — only once rrweb's `MutationObserver` fires — whereas the incremental snapshots and the `ui.click` breadcrumb are buffered synchronously on the click. Because the test's `forceFlushReplay()` races the force-flush triggered by `stop({ reason: 'mutationLimit' })`, those events can be split across two separate replay envelopes. The test waited for the *first* request containing snapshots and asserted that single request held both `replay.mutations` and `ui.click`. Whenever the mutation breadcrumb landed in the second flush, that first request only carried `['ui.click']`, producing the CI flake. The fix aggregates recording snapshots across requests via the existing `collectReplayRequests` helper, resolving once the mutation breadcrumb has arrived, so the assertions no longer depend on the flush split. Since `ui.click` is always buffered before the async mutation breadcrumb, it's guaranteed present in the aggregated set by then. _Root cause_: flush-ordering race between the test's manual flush and replay's stop-triggered force-flush. --------- Co-authored-by: Claude Opus 4.8 --- .../largeMutations/mutationLimit/test.ts | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts b/dev-packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts index a6ba46a1d758..764baebb2ad9 100644 --- a/dev-packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts @@ -1,6 +1,9 @@ import { expect } from '@playwright/test'; +import { EventType } from '@sentry/rrweb'; import { sentryTest } from '../../../../utils/fixtures'; import { + collectReplayRequests, + getReplayBreadcrumbs, getReplayRecordingContent, getReplaySnapshot, shouldSkipReplayTest, @@ -25,14 +28,20 @@ sentryTest( const [res0] = await Promise.all([waitForReplayRequest(page, 0), gotoPageAndClick()]); await forceFlushReplay(); - const [res1] = await Promise.all([ - waitForReplayRequest(page, (_event, res) => { - const parsed = getReplayRecordingContent(res); - return !!parsed.incrementalSnapshots.length || !!parsed.fullSnapshots.length; - }), - page.locator('#button-add').click(), - forceFlushReplay(), - ]); + // The `replay.mutations` breadcrumb is added asynchronously (once rrweb's + // MutationObserver fires) and can land in a different flush than the + // incremental snapshots and the `ui.click` breadcrumb. Collect across + // requests until the mutation breadcrumb has arrived rather than betting on + // a single request containing everything. + const requestsPromise = collectReplayRequests( + page, + recordingSnapshots => getReplayBreadcrumbs(recordingSnapshots, 'replay.mutations').length > 0, + ); + + await page.locator('#button-add').click(); + await forceFlushReplay(); + + const { replayRecordingSnapshots } = await requestsPromise; // replay should be stopped due to mutation limit let replay = await getReplaySnapshot(page); @@ -48,11 +57,21 @@ sentryTest( const replayData0 = getReplayRecordingContent(res0); expect(replayData0.fullSnapshots.length).toBe(1); + const fullSnapshots = replayRecordingSnapshots.filter(snapshot => snapshot.type === EventType.FullSnapshot); + const incrementalSnapshots = replayRecordingSnapshots.filter( + snapshot => snapshot.type === EventType.IncrementalSnapshot, + ); + // A still-in-flight envelope from the setup flush can re-deliver the + // `#noop` `ui.click` breadcrumb into the collected set, so assert on the + // unique categories rather than the raw (potentially duplicated) list. + const breadcrumbCategories = [ + ...new Set(getReplayBreadcrumbs(replayRecordingSnapshots).map(({ category }) => category)), + ].sort(); + // Breadcrumbs (click and mutation); - const replayData1 = getReplayRecordingContent(res1); - expect(replayData1.fullSnapshots.length).toBe(0); - expect(replayData1.incrementalSnapshots.length).toBeGreaterThan(0); - expect(replayData1.breadcrumbs.map(({ category }) => category).sort()).toEqual(['replay.mutations', 'ui.click']); + expect(fullSnapshots.length).toBe(0); + expect(incrementalSnapshots.length).toBeGreaterThan(0); + expect(breadcrumbCategories).toEqual(['replay.mutations', 'ui.click']); replay = await getReplaySnapshot(page); expect(replay.session).toBe(undefined); From 343ca075385bb469667b39a14a2d11e956255d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 29 Sep 2026 16:42:15 +0300 Subject: [PATCH 17/65] test(bun): Run the auto-instrumentation suites with bundled scenarios (#24612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `bun run` the SDK cannot inject diagnostics channels into libraries (#23882), so a Bun app only gets framework, database and AI spans when it is built with `@sentry/bun/plugin`. That path had no integration coverage. The new `node-suites-bun-build` project bundles each auto-instrumentation scenario with the plugin right before it starts and runs it with `@sentry/bun`. Express, Fastify, Hapi, Koa, Apollo, most AI suites and the Docker database suites now run on Bun. `pg-native` and the CommonJS `postgresjs` tests are skipped on Bun: the `libpq` addon needs a Node symbol that Bun does not have, and through the `bun` export condition `require('postgres')` returns the ES module namespace. Closes #23889. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../node-suites/bun-build.ts | 38 ++++++++ .../node-suites/excludes.ts | 55 +++++++++++- .../bun-integration-tests/vite.config.mts | 26 +++++- .../suites/tracing/ioredis-dc/test.ts | 3 +- .../suites/tracing/postgres-streamed/test.ts | 7 +- .../suites/tracing/postgres/test.ts | 3 +- .../tracing/postgresjs-streamed/test.ts | 90 +++++++++++-------- .../suites/tracing/postgresjs/test.ts | 70 +++++++++------ .../suites/tracing/redis-cache/test.ts | 3 +- .../suites/tracing/redis-dc/test.ts | 3 +- .../suites/tracing/redis/test.ts | 3 +- .../utils/runner/createRunner.ts | 26 +++++- 12 files changed, 255 insertions(+), 72 deletions(-) create mode 100644 dev-packages/bun-integration-tests/node-suites/bun-build.ts diff --git a/dev-packages/bun-integration-tests/node-suites/bun-build.ts b/dev-packages/bun-integration-tests/node-suites/bun-build.ts new file mode 100644 index 000000000000..3c86daba1baf --- /dev/null +++ b/dev-packages/bun-integration-tests/node-suites/bun-build.ts @@ -0,0 +1,38 @@ +// Bundles one Node suite scenario with `@sentry/bun/plugin`, so the plugin can inject the +// diagnostics channels into the libraries the scenario uses. The runner calls this script with the +// scenario path when `RUNTIME_BUILD_SCRIPT` points to it, and runs the printed output file instead. +import { sentryBunPlugin } from '@sentry/bun/plugin'; +import { dirname, join, relative } from 'node:path'; + +const NODE_SUITES_ROOT = join(import.meta.dir, '..', '..', 'node-integration-tests'); +const BUILD_ROOT = join(import.meta.dir, '..', 'build'); + +const entry = process.argv[2]; +if (!entry) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED no scenario path'); + process.exit(1); +} + +const result = await Bun.build({ + entrypoints: [entry], + target: 'bun', + // Mirrors the scenario's folder, so relative paths between scenarios keep their shape. + outdir: join(BUILD_ROOT, relative(NODE_SUITES_ROOT, dirname(entry))), + sourcemap: 'linked', + // The instrument file is preloaded unbundled, so the bundle must share its `@sentry/*` packages. + // knex requires the drivers of all its dialects, and the ones that are not installed must stay + // external so the bundle still builds. + external: ['@sentry/*', '@sentry-internal/*', 'better-sqlite3', 'oracledb', 'pg-query-stream', 'sqlite3'], + plugins: [sentryBunPlugin()], +}); + +const output = result.outputs.find(file => file.kind === 'entry-point'); +if (!result.success || !output) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED', result.logs); + process.exit(1); +} + +// eslint-disable-next-line no-console +console.log(`BUILD_OK ${output.path}`); diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index c06bd54b0c4c..7bf45d467d15 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -75,7 +75,7 @@ const NO_OUTGOING_HTTP_INSTRUMENTATION = [ // `bun run` cannot inject the diagnostics channels into libraries, so framework, database and AI // instrumentation creates no spans. Apps must be built with `@sentry/bun/plugin`. // See https://github.com/getsentry/sentry-javascript/issues/23882 -const NO_AUTO_INSTRUMENTATION = [ +export const NO_AUTO_INSTRUMENTATION = [ 'suites/express/**', 'suites/fs-instrumentation/test.ts', 'suites/hono-sdk/test.ts', @@ -216,3 +216,56 @@ export const SENTRY_BUN_EXCLUDE = [ ...NO_NATIVE_NODE_FETCH_INTEGRATION, ...FETCH_INTEGRATION_DIFFERS, ]; + +// The build project (`node-suites-bun-build`) runs the suites of `NO_AUTO_INSTRUMENTATION` with the +// scenarios bundled by `@sentry/bun/plugin`. These do not run there. + +// On Bun the channel integrations subscribe at `init()` by design, and this suite checks that +// they wait until their module loads. +const BUN_BUILD_EAGER_SUBSCRIPTION = ['suites/tracing/orchestrion-lazy-registration/test.ts']; + +// The first `init()` has no DSN, so `bunHttpServerIntegration` is not set up, and the suite then +// adds only `httpIntegration`. On Bun that does not isolate requests, because Bun does not publish +// `http.server.request.start`. +const BUN_BUILD_NO_HTTP_SERVER_INTEGRATION = ['suites/express/multiple-init/test.ts']; + +// Some or all tests fail with the bundled scenarios, cause not investigated yet. In +// `express/tracing` only the request data tests fail: they set `httpIntegration` options, and with +// `@sentry/bun` the request body comes from `bunHttpServerIntegration`. +const BUN_BUILD_NOT_TRIAGED = [ + 'suites/express/sentry-trace/test.ts', + 'suites/express/tracing/test.ts', + 'suites/express/with-http/**', + 'suites/hono-sdk/test.ts', + 'suites/pino/test.ts', + 'suites/tracing/google-genai-v2/test.ts', + 'suites/tracing/google-genai/test.ts', + 'suites/tracing/langchain/v1/test.ts', + 'suites/tracing/mastra/test.ts', + 'suites/tracing/mcp-handler-exact-once/test.ts', + 'suites/tracing/mcp-server-streamed/test.ts', + 'suites/tracing/mongodb-v4/test.ts', + 'suites/tracing/mongodb-v5/test.ts', + 'suites/tracing/mongodb-v6/test.ts', + 'suites/tracing/mongodb-v7/test.ts', + 'suites/tracing/mongodb/test.ts', + 'suites/tracing/mongoose-tracing-channel/test.ts', + 'suites/tracing/mongoose-v5/test.ts', + 'suites/tracing/mongoose-v7/test.ts', + 'suites/tracing/mongoose-v8/test.ts', + 'suites/tracing/mongoose-v9/test.ts', + 'suites/tracing/mongoose/test.ts', + 'suites/tracing/mysql/test.ts', + 'suites/tracing/openai/test.ts', + 'suites/tracing/prisma-orm-v8/test.ts', + 'suites/tracing/together-ai/test.ts', + 'suites/tracing/vercelai/test.ts', + 'suites/tracing/vercelai/v6_v7/test.ts', +]; + +export const BUN_BUILD_EXCLUDE = [ + '**/node_modules/**', + ...BUN_BUILD_EAGER_SUBSCRIPTION, + ...BUN_BUILD_NO_HTTP_SERVER_INTEGRATION, + ...BUN_BUILD_NOT_TRIAGED, +]; diff --git a/dev-packages/bun-integration-tests/vite.config.mts b/dev-packages/bun-integration-tests/vite.config.mts index e2ed86d0affb..51df09e0ce40 100644 --- a/dev-packages/bun-integration-tests/vite.config.mts +++ b/dev-packages/bun-integration-tests/vite.config.mts @@ -1,7 +1,13 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; import baseConfig from '../../vite/vite.config'; -import { NODE_SUITES_EXCLUDE, SENTRY_BUN_EXCLUDE, SENTRY_NODE_EXCLUDE } from './node-suites/excludes'; +import { + BUN_BUILD_EXCLUDE, + NO_AUTO_INSTRUMENTATION, + NODE_SUITES_EXCLUDE, + SENTRY_BUN_EXCLUDE, + SENTRY_NODE_EXCLUDE, +} from './node-suites/excludes'; const NODE_SUITES_ROOT = fileURLToPath(new URL('../node-integration-tests', import.meta.url)); @@ -82,6 +88,24 @@ export default defineConfig({ }, }, }, + { + extends: true, + test: { + ...nodeSuitesTest, + // The auto-instrumentation suites, with each scenario bundled by `@sentry/bun/plugin` before + // it starts, as Bun apps must be built to get these spans. + // See https://github.com/getsentry/sentry-javascript/issues/23882 + name: 'node-suites-bun-build', + include: NO_AUTO_INSTRUMENTATION.map(glob => (glob.endsWith('/**') ? `${glob}/test.ts` : glob)), + exclude: BUN_BUILD_EXCLUDE, + env: { + RUNTIME: 'bun', + RUNTIME_PRELOAD: fileURLToPath(new URL('./node-suites/alias-sentry-bun.ts', import.meta.url)), + RUNTIME_BUILD_SCRIPT: fileURLToPath(new URL('./node-suites/bun-build.ts', import.meta.url)), + EXPECTED_SDK_NAME: 'sentry.javascript.bun', + }, + }, + }, ], }, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts index e5332df32f76..e6866955ca5c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts @@ -1,6 +1,7 @@ import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { EXPECTED_SDK_NAME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose( @@ -135,7 +136,7 @@ describeWithDockerCompose( 'sentry.op': op, 'sentry.origin': ORIGIN, 'sentry.release': '1.0', - 'sentry.sdk.name': 'sentry.javascript.node', + 'sentry.sdk.name': EXPECTED_SDK_NAME, 'sentry.segment.name': SEGMENT_NAME, 'server.address': HOST, 'server.port': PORT, diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts index 92a48d809282..3a2cea4a4c74 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts @@ -2,7 +2,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; import { afterAll, describe, expect } from 'vitest'; -import { conditionalTest } from '../../../utils'; +import { conditionalTest, EXPECTED_SDK_NAME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; // Query-span origin depends on which instrumentation is active. Blocks driving the SDK's default @@ -58,7 +58,7 @@ const COMMON_DB_ATTRIBUTES = { }, 'sentry.sdk.name': { type: 'string', - value: 'sentry.javascript.node', + value: EXPECTED_SDK_NAME, }, 'sentry.sdk.version': { type: 'string', @@ -228,7 +228,8 @@ describeWithDockerCompose('postgres auto instrumentation (streamed)', { workingD }); // Deno: with a module load hook installed, Deno compiles a native addon (`libpq`) as JavaScript. - conditionalTest({ max: 25, skipRuntimes: ['deno'] })('pg-native', () => { + // Bun: the `libpq` addon needs the Node symbol `node::EmitAsyncInit`, which Bun does not provide. + conditionalTest({ max: 25, skipRuntimes: ['bun', 'deno'] })('pg-native', () => { createEsmAndCjsTests( __dirname, 'scenario-native.mjs', diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts index 304af1aaffb0..6533e2cca588 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts @@ -277,7 +277,8 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ }); // Deno: with a module load hook installed, Deno compiles a native addon (`libpq`) as JavaScript. - conditionalTest({ max: 25, skipRuntimes: ['deno'] })('pg-native', () => { + // Bun: the `libpq` addon needs the Node symbol `node::EmitAsyncInit`, which Bun does not provide. + conditionalTest({ max: 25, skipRuntimes: ['bun', 'deno'] })('pg-native', () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/test.ts index e77702f8242b..5acbaba41e8f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/test.ts @@ -1,7 +1,11 @@ import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { RUNTIME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; +// On Bun, `postgres` resolves to its ESM build through the `bun` export condition, so +// `require('postgres')` returns the module namespace instead of the `postgres` function. + /** * Streamed span attributes are `{ value, type }` objects, unlike transaction span `data`, * which stores values directly. @@ -168,30 +172,34 @@ describeWithDockerCompose('postgresjs auto instrumentation (streamed)', { workin }, }; - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { - test('should auto-instrument `postgres` package', { timeout: 90_000 }, async () => { - await createTestRunner() - .expect({ - span: container => { - expect(container).toMatchObject(EXPECTED_SPANS); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test, mode) => { + test.skipIf(RUNTIME === 'bun' && mode === 'cjs')( + 'should auto-instrument `postgres` package', + { timeout: 90_000 }, + async () => { + await createTestRunner() + .expect({ + span: container => { + expect(container).toMatchObject(EXPECTED_SPANS); - // The assertions above only cover the queries the scenario issues itself. postgres.js - // also runs internal ones (e.g. the `pg_catalog` type lookup), so guard the invariant - // across every query span: the name is the summary, never the statement. - const dbSpans = getDbSpans(container); - expect(dbSpans.length).toBeGreaterThan(0); - for (const span of dbSpans) { - expect(span.name).toBe(span.attributes['db.query.summary']?.value); - } - }, - }) - .expect({ event: EXPECTED_ERROR_EVENT }) - // The error event is captured via an unhandled rejection processed on a later tick than - // the spans, so the two envelopes can reach the transport in either order. - .unordered() - .start() - .completed(); - }); + // The assertions above only cover the queries the scenario issues itself. postgres.js + // also runs internal ones (e.g. the `pg_catalog` type lookup), so guard the invariant + // across every query span: the name is the summary, never the statement. + const dbSpans = getDbSpans(container); + expect(dbSpans.length).toBeGreaterThan(0); + for (const span of dbSpans) { + expect(span.name).toBe(span.attributes['db.query.summary']?.value); + } + }, + }) + .expect({ event: EXPECTED_ERROR_EVENT }) + // The error event is captured via an unhandled rejection processed on a later tick than + // the spans, so the two envelopes can reach the transport in either order. + .unordered() + .start() + .completed(); + }, + ); }); }); @@ -228,10 +236,14 @@ describeWithDockerCompose('postgresjs auto instrumentation (streamed)', { workin __dirname, 'scenario-requestHook.mjs', 'instrument-requestHook.mjs', - (createTestRunner, test) => { - test('should call requestHook when provided', { timeout: 90_000 }, async () => { - await createTestRunner().expect({ span: EXPECTED_SPANS }).start().completed(); - }); + (createTestRunner, test, mode) => { + test.skipIf(RUNTIME === 'bun' && mode === 'cjs')( + 'should call requestHook when provided', + { timeout: 90_000 }, + async () => { + await createTestRunner().expect({ span: EXPECTED_SPANS }).start().completed(); + }, + ); }, ); }); @@ -262,10 +274,14 @@ describeWithDockerCompose('postgresjs auto instrumentation (streamed)', { workin ]), }; - createEsmAndCjsTests(__dirname, 'scenario-url.mjs', 'instrument.mjs', (createTestRunner, test) => { - test('should instrument postgres package with URL initialization', { timeout: 90_000 }, async () => { - await createTestRunner().ignore('event').expect({ span: EXPECTED_SPANS }).start().completed(); - }); + createEsmAndCjsTests(__dirname, 'scenario-url.mjs', 'instrument.mjs', (createTestRunner, test, mode) => { + test.skipIf(RUNTIME === 'bun' && mode === 'cjs')( + 'should instrument postgres package with URL initialization', + { timeout: 90_000 }, + async () => { + await createTestRunner().ignore('event').expect({ span: EXPECTED_SPANS }).start().completed(); + }, + ); }); }); @@ -296,10 +312,14 @@ describeWithDockerCompose('postgresjs auto instrumentation (streamed)', { workin ]), }; - createEsmAndCjsTests(__dirname, 'scenario-unsafe.mjs', 'instrument.mjs', (createTestRunner, test) => { - test('should instrument sql.unsafe() queries', { timeout: 90_000 }, async () => { - await createTestRunner().ignore('event').expect({ span: EXPECTED_SPANS }).start().completed(); - }); + createEsmAndCjsTests(__dirname, 'scenario-unsafe.mjs', 'instrument.mjs', (createTestRunner, test, mode) => { + test.skipIf(RUNTIME === 'bun' && mode === 'cjs')( + 'should instrument sql.unsafe() queries', + { timeout: 90_000 }, + async () => { + await createTestRunner().ignore('event').expect({ span: EXPECTED_SPANS }).start().completed(); + }, + ); }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts index d893e5200004..a0fffe40d453 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts @@ -1,6 +1,10 @@ import { afterAll, describe, expect } from 'vitest'; +import { RUNTIME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; +// On Bun, `postgres` resolves to its ESM build through the `bun` export condition, so +// `require('postgres')` returns the module namespace instead of the `postgres` function. + describeWithDockerCompose('postgresjs auto instrumentation', { workingDirectory: [__dirname] }, () => { afterAll(() => { cleanupChildProcesses(); @@ -223,17 +227,21 @@ describeWithDockerCompose('postgresjs auto instrumentation', { workingDirectory: }, }; - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { - test('should auto-instrument `postgres` package', { timeout: 60_000 }, async () => { - await createTestRunner() - .expect({ transaction: EXPECTED_TRANSACTION }) - .expect({ event: EXPECTED_ERROR_EVENT }) - // The error event is captured via an unhandled rejection processed on a later tick than - // the transaction, so the two envelopes can reach the transport in either order. - .unordered() - .start() - .completed(); - }); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test, mode) => { + test.skipIf(RUNTIME === 'bun' && mode === 'cjs')( + 'should auto-instrument `postgres` package', + { timeout: 60_000 }, + async () => { + await createTestRunner() + .expect({ transaction: EXPECTED_TRANSACTION }) + .expect({ event: EXPECTED_ERROR_EVENT }) + // The error event is captured via an unhandled rejection processed on a later tick than + // the transaction, so the two envelopes can reach the transport in either order. + .unordered() + .start() + .completed(); + }, + ); }); }); @@ -326,10 +334,14 @@ describeWithDockerCompose('postgresjs auto instrumentation', { workingDirectory: __dirname, 'scenario-requestHook.mjs', 'instrument-requestHook.mjs', - (createTestRunner, test) => { - test('should call requestHook when provided', { timeout: 60_000 }, async () => { - await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); - }); + (createTestRunner, test, mode) => { + test.skipIf(RUNTIME === 'bun' && mode === 'cjs')( + 'should call requestHook when provided', + { timeout: 60_000 }, + async () => { + await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }, + ); }, ); }); @@ -407,10 +419,14 @@ describeWithDockerCompose('postgresjs auto instrumentation', { workingDirectory: ]), }; - createEsmAndCjsTests(__dirname, 'scenario-url.mjs', 'instrument.mjs', (createTestRunner, test) => { - test('should instrument postgres package with URL initialization', { timeout: 90_000 }, async () => { - await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); - }); + createEsmAndCjsTests(__dirname, 'scenario-url.mjs', 'instrument.mjs', (createTestRunner, test, mode) => { + test.skipIf(RUNTIME === 'bun' && mode === 'cjs')( + 'should instrument postgres package with URL initialization', + { timeout: 90_000 }, + async () => { + await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }, + ); }); }); @@ -486,12 +502,16 @@ describeWithDockerCompose('postgresjs auto instrumentation', { workingDirectory: ]), }; - createEsmAndCjsTests(__dirname, 'scenario-unsafe.mjs', 'instrument.mjs', (createTestRunner, test) => { - test('should instrument sql.unsafe() queries', { timeout: 90_000 }, async () => { - // The last query fails on purpose, and its unhandled rejection also sends an error event, which can - // arrive before the transaction. - await createTestRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); - }); + createEsmAndCjsTests(__dirname, 'scenario-unsafe.mjs', 'instrument.mjs', (createTestRunner, test, mode) => { + test.skipIf(RUNTIME === 'bun' && mode === 'cjs')( + 'should instrument sql.unsafe() queries', + { timeout: 90_000 }, + async () => { + // The last query fails on purpose, and its unhandled rejection also sends an error event, which can + // arrive before the transaction. + await createTestRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }, + ); }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts index a6c77cd01225..2e8de3443a55 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts @@ -1,6 +1,7 @@ import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { EXPECTED_SDK_NAME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory: [__dirname] }, () => { @@ -705,7 +706,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'sentry.kind': 'client', 'sentry.origin': redisOrigin, 'sentry.release': '1.0', - 'sentry.sdk.name': 'sentry.javascript.node', + 'sentry.sdk.name': EXPECTED_SDK_NAME, 'sentry.segment.name': segmentName, [SENTRY_TRACE_LIFECYCLE]: 'stream', }), diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts index 77cf91663ed0..06d8cac5a465 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts @@ -1,6 +1,7 @@ import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { EXPECTED_SDK_NAME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose( @@ -183,7 +184,7 @@ describeWithDockerCompose( 'sentry.op': op, 'sentry.origin': ORIGIN, 'sentry.release': '1.0', - 'sentry.sdk.name': 'sentry.javascript.node', + 'sentry.sdk.name': EXPECTED_SDK_NAME, 'sentry.segment.name': SEGMENT_NAME, 'server.address': HOST, 'server.port': PORT, diff --git a/dev-packages/node-integration-tests/suites/tracing/redis/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis/test.ts index 8d5b9339ec33..50cd473957b8 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis/test.ts @@ -1,6 +1,7 @@ import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, type SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { EXPECTED_SDK_NAME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose('redis auto instrumentation', { workingDirectory: [__dirname] }, () => { @@ -85,7 +86,7 @@ describeWithDockerCompose('redis auto instrumentation', { workingDirectory: [__d 'sentry.op': { type: 'string', value: redisSpanOp }, 'sentry.origin': { type: 'string', value: origin }, 'sentry.release': { type: 'string', value: '1.0' }, - 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, 'sentry.segment.id': { type: 'string', value: expect.stringMatching(/^[\da-f]{16}$/) }, 'sentry.segment.name': { type: 'string', value: 'Test Span' }, diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts index e737cfa4be8e..ed94c1e1bd5a 100644 --- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts +++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts @@ -14,7 +14,7 @@ import type { } from '@sentry/core'; import { normalize } from '@sentry/core'; import { createBasicSentryServer } from '@sentry-internal/test-utils'; -import { spawn } from 'child_process'; +import { spawn, spawnSync } from 'child_process'; import { existsSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -501,8 +501,9 @@ export function createRunner(...paths: string[]) { !ensureNoErrorOutput && (expectedEnvelopes.length > 0 || (expectedEnvelopeHeaders?.length ?? 0) > 0); const runtime = getRuntime(); const childFlags = wantsAutoFlush ? [...buildAutoFlushFlags(flags, testPath, runtime), ...flags] : flags; + const entryPath = buildScenario(runtime, testPath); - child = spawn(runtime, buildRuntimeArgs(runtime, childFlags, testPath), { env, cwd: PACKAGE_ROOT }); + child = spawn(runtime, buildRuntimeArgs(runtime, childFlags, entryPath), { env, cwd: PACKAGE_ROOT }); spawnedAt = Date.now(); child.on('error', e => { @@ -794,6 +795,27 @@ function buildAutoFlushFlags(existingFlags: readonly string[], testPath: string, * `DENO_IMPORT_MAP`. Any other Node flag throws, so a suite that needs one fails with a clear * message instead of running with different behavior. */ +/** + * Returns the file the runtime runs for `testPath`. When `RUNTIME_BUILD_SCRIPT` is set, that + * script is run by the same runtime with `testPath`, builds the scenario, and prints + * `BUILD_OK `. The Bun package uses it to bundle scenarios with `@sentry/bun/plugin`. + * Scenarios are built right before they start, because `createEsmAndCjsTests` writes the CJS + * variant of a scenario only while the tests run. + */ +function buildScenario(runtime: Runtime, testPath: string): string { + const buildScript = process.env.RUNTIME_BUILD_SCRIPT; + if (!buildScript) { + return testPath; + } + + const result = spawnSync(runtime, [buildScript, testPath], { cwd: PACKAGE_ROOT, encoding: 'utf8' }); + const outputPath = result.stdout?.match(/^BUILD_OK (.+)$/m)?.[1]; + if (!outputPath) { + throw new Error(`Building ${testPath} with ${buildScript} failed:\n${result.stderr}${result.stdout}`); + } + return outputPath; +} + function buildRuntimeArgs(runtime: Runtime, flags: readonly string[], testPath: string): string[] { if (runtime === 'node') { return [...flags, testPath]; From 5f395ad6f086703b3f8357c185b93e894f86cb56 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:44:09 +0200 Subject: [PATCH 18/65] feat(react-router): Support request-scoped CSP nonces in createSentryHandleRequest (#24826) ## What Adds a `getNonce` option to `createSentryHandleRequest` so apps can pass a per-request CSP nonce to both `` and `renderToPipeableStream`. ## Why React Router needs the same fresh nonce in both places, and the helper had no way to set it. Apps with a strict CSP had to copy the whole handler. Closes: #23445 --------- Co-authored-by: Claude Opus 5.5 --- .../app/entry.server.tsx | 2 ++ .../tests/csp-nonce.server.test.ts | 23 +++++++++++++++++++ .../src/server/createSentryHandleRequest.tsx | 14 +++++++++-- .../server/createSentryHandleRequest.test.ts | 19 +++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/csp-nonce.server.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.tsx index 738cd1515a4d..01682288ab71 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.tsx @@ -1,5 +1,6 @@ import { createReadableStreamFromReadable } from '@react-router/node'; import * as Sentry from '@sentry/react-router'; +import { randomBytes } from 'node:crypto'; import { renderToPipeableStream } from 'react-dom/server'; import { ServerRouter } from 'react-router'; import { type HandleErrorFunction } from 'react-router'; @@ -11,6 +12,7 @@ const handleRequest = Sentry.createSentryHandleRequest({ ServerRouter, renderToPipeableStream, createReadableStreamFromReadable, + getNonce: () => randomBytes(16).toString('base64'), }); export default handleRequest; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/csp-nonce.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/csp-nonce.server.test.ts new file mode 100644 index 000000000000..1787bde746cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/csp-nonce.server.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; + +function getScriptNonces(html: string): string[] { + return Array.from(html.matchAll(/]*\snonce="([^"]+)"/g), match => match[1]!); +} + +test.describe('CSP nonce', () => { + test('adds the same per-request nonce to all inline scripts', async ({ request }) => { + const firstHtml = await (await request.get('/')).text(); + const secondHtml = await (await request.get('/')).text(); + + const firstNonces = getScriptNonces(firstHtml); + const secondNonces = getScriptNonces(secondHtml); + + expect(firstNonces.length).toBeGreaterThan(0); + expect(firstNonces.length).toBe((firstHtml.match(/` : ''; @@ -88,6 +88,19 @@ export function addSentryCodeToPage(options: { }; } +/** + * Prerendered pages would bake one trace id and sampling decision into the HTML for every visitor. + * + * `building` from `$app/environment` isn't available here (the SDK is loaded from node_modules at + * runtime), so we rely on `_SENTRY_SVELTEKIT_BUILDING`, which our Vite plugin sets during `vite build` + * and SvelteKit copies into the worker it prerenders in. + */ +function isSvelteKitBuilding(): boolean { + // `process` doesn't exist in every runtime (e.g. Cloudflare Workers without `nodejs_compat`) + const env = typeof process !== 'undefined' && typeof process.env === 'object' ? process.env : undefined; + return !!env?._SENTRY_SVELTEKIT_BUILDING; +} + /** * We only need to inject the fetch proxy script for SvelteKit versions < 2.16.0. * Exported only for testing. diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index a390e3abf2ba..70a561c1746e 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -78,6 +78,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} sentryOrchestrionPlugin({ buildTimeInstrumentation: mergedOptions.buildTimeInstrumentation, }), + makeBuildFlagPlugin(), ); const sentryVitePluginsOptions = generateVitePluginOptions(mergedOptions); @@ -108,6 +109,65 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} return sentryPlugins; } +/** + * Flags `vite build` so `sentryHandle` can skip trace meta tags on pages SvelteKit renders at build time + * (prerendered and adapter fallback pages). Kit renders them in workers that inherit `process.env`, so the + * flag must stay set until the adapter ran. Afterwards we remove it again, so it doesn't leak into e.g. a + * server started in the same process after a programmatic build. + * + * When that point is reached depends on the Kit version: + * - Kit 2 renders from the SSR build's `writeBundle` and `closeBundle` hooks. In between, it runs a nested + * client build, whose `closeBundle` fires before any page was rendered. + * - Kit 3 builds all environments via Vite's app builder and renders from `buildApp` hooks afterwards. + * Every `closeBundle` fires before that. + */ +function makeBuildFlagPlugin(): Plugin { + let usesAppBuilder = false; + let isServerBuild = false; + let isWatchMode = false; + + const clearFlag = (): void => { + delete process.env._SENTRY_SVELTEKIT_BUILDING; + }; + + // `buildApp` only exists as a plugin hook in Vite 7+ + const plugin: Plugin & { buildApp?: { order: 'post'; handler: () => void } } = { + name: 'sentry-sveltekit-build-flag', + apply: 'build', + // Together with `order: 'post'`, this makes our hooks run after Kit's (including its adapter's) + enforce: 'post', + config() { + process.env._SENTRY_SVELTEKIT_BUILDING = 'true'; + }, + configResolved(config) { + usesAppBuilder = !!config.builder; + isServerBuild = !!config.build.ssr; + // Kit re-renders pages on every rebuild in watch mode + isWatchMode = !!config.build.watch; + }, + closeBundle: { + order: 'post', + sequential: true, + handler() { + if (!usesAppBuilder && isServerBuild && !isWatchMode) { + clearFlag(); + } + }, + }, + buildApp: { + order: 'post', + handler() { + // Without an app builder config, Vite 7+ calls `buildApp` hooks *before* the actual (legacy) build + if (usesAppBuilder && !isWatchMode) { + clearFlag(); + } + }, + }, + }; + + return plugin; +} + // A bare subpath (not a relative import) so this plugin's `resolveId` can intercept it. const BROWSER_TRACING_VARIANT_ID = '@sentry/sveltekit/browser-tracing-variant'; diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 0298d4f5d788..8843e63ed1e6 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -5,7 +5,7 @@ import * as SentryCore from '@sentry/core'; import { NodeClient, setCurrentClient } from '@sentry/node'; import type { Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { addSentryCodeToPage, FETCH_PROXY_SCRIPT, @@ -514,6 +514,23 @@ describe('addSentryCodeToPage', () => { expect(transformed).toContain('${FETCH_PROXY_SCRIPT}`); }); + + describe('while SvelteKit builds the app', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("doesn't add meta tags but still adds the fetch proxy script if _SENTRY_SVELTEKIT_BUILDING is set", () => { + vi.stubEnv('_SENTRY_SVELTEKIT_BUILDING', 'true'); + + const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: true }); + const transformed = transformPageChunk({ html, done: true }) as string; + + expect(transformed).not.toContain('${FETCH_PROXY_SCRIPT}`); + }); + }); }); describe('isFetchProxyRequired', () => { diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index 5221b323d056..c78099729998 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { Plugin } from 'vite'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as autoInstrument from '../../src/vite/autoInstrument'; import { generateVitePluginOptions, sentrySvelteKit } from '../../src/vite/sentryVitePlugins'; import * as sourceMaps from '../../src/vite/sourceMaps'; @@ -69,9 +69,9 @@ describe('sentrySvelteKit()', () => { expect(plugins).toBeInstanceOf(Array); // 1 kit config resolver + 1 browser-tracing variant resolver + 1 OpenTelemetry API resolver // + 1 auto instrument plugin - // + 1 orchestrion plugin + 1 global values injection plugin + 1 modified main plugin + // + 1 orchestrion plugin + 1 build flag plugin + 1 global values injection plugin + 1 modified main plugin // + 3 custom plugins - expect(plugins).toHaveLength(10); + expect(plugins).toHaveLength(11); }); it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => { @@ -88,6 +88,8 @@ describe('sentrySvelteKit()', () => { 'sentry-auto-instrumentation', // orchestrion build-time instrumentation plugin: 'sentry-orchestrion-vite', + // build flag plugin: + 'sentry-sveltekit-build-flag', // global values injection plugin: 'sentry-sveltekit-global-values-injection-plugin', // modified main plugin (writeBundle deferred to closeBundle): @@ -101,7 +103,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the sentry source maps plugins if autoUploadSourcemaps is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); - expect(plugins).toHaveLength(5); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + expect(plugins).toHaveLength(6); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag }); it("doesn't return the sentry source maps plugins if `NODE_ENV` is development", async () => { @@ -111,7 +113,7 @@ describe('sentrySvelteKit()', () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: true, autoInstrument: true }); const instrumentPlugin = plugins[3]; - expect(plugins).toHaveLength(6); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + global values injection + expect(plugins).toHaveLength(7); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag + global values injection expect(instrumentPlugin?.name).toEqual('sentry-auto-instrumentation'); process.env.NODE_ENV = previousEnv; @@ -120,7 +122,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false }); const pluginNames = plugins.map(plugin => plugin.name); - expect(plugins).toHaveLength(9); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + orchestrion + global values injection + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(10); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + orchestrion + build flag + global values injection + 1 modified main plugin + 3 custom plugins expect(pluginNames).not.toContain('sentry-auto-instrumentation'); }); @@ -293,6 +295,94 @@ describe('OpenTelemetry API resolver plugin', () => { }); }); +describe('build flag plugin', () => { + type HookHandler = (...args: unknown[]) => void; + type BuildFlagPlugin = { + enforce: string; + config: HookHandler; + configResolved: HookHandler; + closeBundle: { order: string; sequential: boolean; handler: HookHandler }; + buildApp: { order: string; handler: HookHandler }; + }; + + beforeEach(() => { + vi.stubEnv('_SENTRY_SVELTEKIT_BUILDING', undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + async function startBuild(resolvedConfig: { builder?: object; build: { ssr?: boolean; watch?: object | null } }) { + const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); + const plugin = plugins.find(p => p.name === 'sentry-sveltekit-build-flag') as unknown as BuildFlagPlugin; + + plugin.config({}, { command: 'build', mode: 'production' }); + plugin.configResolved(resolvedConfig); + + return plugin; + } + + it('sets `_SENTRY_SVELTEKIT_BUILDING` so the prerender worker inherits it', async () => { + await startBuild({ build: { ssr: true } }); + + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBe('true'); + }); + + it("runs its hooks after SvelteKit's", async () => { + const plugin = await startBuild({ build: { ssr: true } }); + + expect(plugin.enforce).toBe('post'); + expect(plugin.closeBundle).toMatchObject({ order: 'post', sequential: true }); + expect(plugin.buildApp).toMatchObject({ order: 'post' }); + }); + + describe('Kit 2 (no app builder)', () => { + it('removes the flag after the SSR build', async () => { + const plugin = await startBuild({ build: { ssr: true } }); + + // Vite 7+ calls `buildApp` hooks before the legacy build even starts + plugin.buildApp.handler(); + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBe('true'); + + plugin.closeBundle.handler(); + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBeUndefined(); + }); + + it("keeps the flag after Kit's nested client build", async () => { + const plugin = await startBuild({ build: { ssr: false } }); + + plugin.closeBundle.handler(); + + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBe('true'); + }); + }); + + describe('Kit 3 (app builder)', () => { + it('removes the flag after all `buildApp` hooks ran', async () => { + const plugin = await startBuild({ builder: {}, build: { ssr: true } }); + + plugin.closeBundle.handler(); + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBe('true'); + + plugin.buildApp.handler(); + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBeUndefined(); + }); + }); + + it.each([ + ['Kit 2', {}], + ['Kit 3', { builder: {} }], + ])('keeps the flag in watch mode (%s)', async (_, config) => { + const plugin = await startBuild({ ...config, build: { ssr: true, watch: {} } }); + + plugin.closeBundle.handler(); + plugin.buildApp.handler(); + + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBe('true'); + }); +}); + describe('generateVitePluginOptions', () => { it('returns null if no relevant options are provided', () => { const options: SentrySvelteKitPluginOptions = {}; From 6085cbcb1f9f5d3e62ab1bdbb6e8e01f20b8b0fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 29 Sep 2026 18:17:28 +0300 Subject: [PATCH 30/65] test(test-utils): Extract Cloudflare Worker deploys into `test-utils/cloudflare` (#24815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All files are moved 1:1 to the `@sentry-internal/test-utils/cloudflare` helper, so we can reuse it in the future for other tests as well The global setup and teardown that deploy and delete a real Worker for `cloudflare-workers-send-to-sentry` move to a new `@sentry-internal/test-utils/cloudflare` entrypoint, so the next send-to-sentry app can use the same steps. Each app keeps a global setup and teardown of one statement. The helper now uploads the DSN and any other values with `wrangler deploy --secrets-file` instead of plain-text `--var`. Then wrangler does not print them in the deploy log, and API keys (for example the OpenRouter key of the Flue app) are not kept as plain-text vars on the Worker. The Worker code reads the DSN from `env` as before. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/cleanup-e2e-workers.yml | 2 +- .../deployed-worker.ts | 128 --------- .../global-setup.ts | 62 +---- .../global-teardown.ts | 25 +- .../playwright.config.ts | 14 +- .../tests/send-to-sentry.test.ts | 2 +- .../tsconfig.node.json | 2 +- dev-packages/test-utils/package.json | 10 + dev-packages/test-utils/rollup.npm.config.mjs | 2 +- dev-packages/test-utils/src/cloudflare.ts | 249 ++++++++++++++++++ 10 files changed, 273 insertions(+), 223 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts create mode 100644 dev-packages/test-utils/src/cloudflare.ts diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml index ebb003de3c26..f87a387d281b 100644 --- a/.github/workflows/cleanup-e2e-workers.yml +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -16,7 +16,7 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} strategy: matrix: - # Name prefix of every E2E app that deploys a real worker, see the app's global-setup.mjs + # Name prefix of every E2E app that deploys a real worker, see the app's global-setup.ts worker-prefix: - e2e-send-to-sentry steps: diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts deleted file mode 100644 index 514927322439..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function wrangler(args: string[], env: Record = {}): void { - execFileSync('pnpm', ['exec', 'wrangler', ...args], { - cwd: __dirname, - env: { ...process.env, ...env }, - stdio: ['ignore', 'inherit', 'inherit'], - }); -} - -/** - * Workflow names are unique per Cloudflare account, so every worker gets its own. The Vite build writes the - * config wrangler deploys from, and `.wrangler/deploy/config.json` points to it. - */ -function nameWorkflowsAfterWorker(name: string): void { - const redirect: { configPath: string } = JSON.parse( - readFileSync(join(__dirname, '.wrangler/deploy/config.json'), 'utf8'), - ); - const configPath = join(__dirname, '.wrangler/deploy', redirect.configPath); - const config: { workflows?: { name: string }[] } = JSON.parse(readFileSync(configPath, 'utf8')); - - for (const workflow of config.workflows ?? []) { - workflow.name = name; - } - writeFileSync(configPath, JSON.stringify(config, null, 2)); -} - -/** Deploys the worker under `name` and returns its workers.dev URL. */ -export function deployWorker(name: string, dsn: string): string { - nameWorkflowsAfterWorker(name); - const outputDir = mkdtempSync(join(tmpdir(), 'wrangler-output-')); - const outputFile = join(outputDir, 'output.ndjson'); - - try { - wrangler(['deploy', '--name', name, '--var', `E2E_TEST_DSN:${dsn}`], { WRANGLER_OUTPUT_FILE_PATH: outputFile }); - - const url = readFileSync(outputFile, 'utf8') - .split('\n') - .filter(Boolean) - .map(line => JSON.parse(line) as { type?: string; targets?: string[] }) - .find(entry => entry.type === 'deploy') - ?.targets?.find(target => target.endsWith('.workers.dev')); - - if (!url) { - throw new Error(`Could not find the workers.dev URL in the wrangler deploy output for ${name}.`); - } - - return url; - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } -} - -export function deleteWorker(name: string): void { - wrangler(['delete', '--name', name, '--force']); -} - -/** - * CI keeps its Workers: one per ref, overwritten by the next run of the same ref and deleted by the - * cleanup workflow once a PR closes. Local runs delete theirs unless `E2E_KEEP_WORKER` is set. - */ -export function keepsWorker(): boolean { - return Boolean(process.env.GITHUB_ACTIONS || process.env.E2E_KEEP_WORKER); -} - -/** A freshly created workers.dev route can take a moment to become reachable. */ -export async function waitForWorker(url: string): Promise { - const deadline = Date.now() + 60_000; - - while (Date.now() < deadline) { - try { - // The SDK does not trace HEAD requests, so the probe leaves no spans behind in Sentry. - const response = await fetch(url, { method: 'HEAD' }); - - if (response.ok) { - return; - } - } catch { - // DNS for the new subdomain may not have propagated yet. - } - - await new Promise(resolve => setTimeout(resolve, 2_000)); - } - - throw new Error(`Worker at ${url} did not become reachable within 60s.`); -} - -/** - * Sends a request until the Worker itself answers it, and returns the body of that answer. - * - * On the first deployment of a Worker name, Cloudflare has answered a request with a 500 while - * Workers Logs had no invocation for it. `status` is the status the Worker answers with. A Worker - * that threw answers with status 500 and Cloudflare error code 1101, which sets it apart from a 500 - * that did not come from the Worker. - */ -export async function fetchFromWorker(url: string, status: number, init?: RequestInit): Promise { - const deadline = Date.now() + 60_000; - let lastAnswer = 'no answer'; - - while (Date.now() < deadline) { - try { - const response = await fetch(url, init); - const body = await response.text(); - // Cloudflare sends its error page as HTML to some clients (Node's fetch among them) and as - // `error code: ` plain text to others, so the code is read from either format. - const errorCode = /cf-error-code">(\d+)<|^error code: (\d+)/.exec(body)?.slice(1).find(Boolean); - - if (response.status === status && (status !== 500 || errorCode === '1101')) { - return body; - } - - lastAnswer = `${response.status}, cf-ray ${response.headers.get('cf-ray')}, error code ${errorCode ?? 'none'}, body: ${body.slice(0, 200)}`; - } catch (error) { - lastAnswer = String(error); - } - - console.log(`The Worker did not answer ${url}: ${lastAnswer}`); - await new Promise(resolve => setTimeout(resolve, 2_000)); - } - - throw new Error(`The Worker did not answer ${url} with status ${status} within 60s. Last answer: ${lastAnswer}`); -} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts index b33f187e1d94..552ccce37a94 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts @@ -1,61 +1,3 @@ -import { randomBytes } from 'node:crypto'; -import { existsSync } from 'node:fs'; -import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker'; +import { createWorkerGlobalSetup } from '@sentry-internal/test-utils/cloudflare'; -const WORKER_PREFIX = 'e2e-send-to-sentry'; - -/** - * In CI the name follows the ref, so `develop`, `master` and every PR get a stable Worker that the - * next run of the same ref overwrites. Pull request refs look like `123/merge` and merge queue refs - * like `gh-readonly-queue//pr-123-`; both map to the PR's Worker. - */ -export function getWorkerName(): string { - if (!process.env.GITHUB_ACTIONS) { - return `${WORKER_PREFIX}-local-${randomBytes(3).toString('hex')}`; - } - - const { GITHUB_EVENT_NAME, GITHUB_REF_NAME = '' } = process.env; - const prNumber = - GITHUB_EVENT_NAME === 'pull_request' ? GITHUB_REF_NAME.split('/')[0] : /\/pr-(\d+)-/.exec(GITHUB_REF_NAME)?.[1]; - const ref = prNumber ? `pr-${prNumber}` : GITHUB_REF_NAME; - // Worker names allow lowercase alphanumerics and dashes only, up to 63 characters. - const slug = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-'); - - return `${WORKER_PREFIX}-${slug}`.slice(0, 63).replace(/-+$/, ''); -} - -export default async function globalSetup(): Promise { - if (!existsSync(new URL('.wrangler/deploy/config.json', import.meta.url))) { - throw new Error('Run `pnpm build` first: wrangler would deploy the uninstrumented source.'); - } - const { CLOUDFLARE_ACCOUNT_ID, E2E_TEST_DSN } = process.env; - if (!E2E_TEST_DSN) { - throw new Error('E2E_TEST_DSN must be set to deploy the test worker.'); - } - - // Wrangler authenticates with `CLOUDFLARE_API_TOKEN` (CI) or a `wrangler login` session (local), - // but it cannot pick an account on its own outside of a terminal. - if (!CLOUDFLARE_ACCOUNT_ID) { - throw new Error('CLOUDFLARE_ACCOUNT_ID must be set to deploy the test worker.'); - } - - const workerName = getWorkerName(); - const workerUrl = deployWorker(workerName, E2E_TEST_DSN); - process.env.E2E_TEST_WORKER_NAME = workerName; - - try { - await waitForWorker(workerUrl); - } catch (error) { - if (!keepsWorker()) { - try { - deleteWorker(workerName); - } catch (deleteError) { - // The unreachable worker is the failure to report, not the cleanup. - console.error(`Failed to delete worker ${workerName}:`, deleteError); - } - } - throw error; - } - - process.env.E2E_TEST_WORKER_URL = workerUrl; -} +export default createWorkerGlobalSetup({ workerPrefix: 'e2e-send-to-sentry' }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts index 9f012cd51101..c7bcfe696e66 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts @@ -1,24 +1 @@ -import { deleteWorker, keepsWorker } from './deployed-worker'; - -export default function globalTeardown(): void { - const workerName = process.env.E2E_TEST_WORKER_NAME; - - if (!workerName) { - return; - } - - if (keepsWorker()) { - console.log(`Keeping worker ${workerName} at ${process.env.E2E_TEST_WORKER_URL}`); - return; - } - - try { - deleteWorker(workerName); - } catch (error) { - // A leaked worker is not an SDK failure, so it must not fail a run whose tests passed. - console.error( - `Failed to delete worker ${workerName}, delete it with \`wrangler delete --name ${workerName}\`:`, - error, - ); - } -} +export { workerGlobalTeardown as default } from '@sentry-internal/test-utils/cloudflare'; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts index 91e214f15493..dde66a7e3685 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts @@ -1,16 +1,16 @@ -import { defineConfig } from '@playwright/test'; +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; -export default defineConfig({ - testDir: './tests', +const config = getPlaywrightConfig(undefined, { + // The tests read what arrived in Sentry through the sentry CLI, so there is no event proxy to start. + webServer: undefined, // The worker is deployed once for the whole run and deleted again afterwards. globalSetup: './global-setup.ts', globalTeardown: './global-teardown.ts', /* Spans take ~2min to become queryable via the trace endpoint. */ timeout: 210_000, - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: 0, // Every test spends most of its time polling Sentry, so run them all at once. + fullyParallel: true, workers: '100%', - reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', }); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts index 044883dea3b0..2397db37089f 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts @@ -9,7 +9,7 @@ import { flattenTrace, traceTarget, } from '@sentry-internal/test-utils/cli'; -import { fetchFromWorker } from '../deployed-worker'; +import { fetchFromWorker } from '@sentry-internal/test-utils/cloudflare'; // Set by global-setup.ts once the worker for this run is deployed. const workerUrl = process.env.E2E_TEST_WORKER_URL; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json index 657ecc4ef24a..c49bedb6b3e3 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["node"] }, - "include": ["tests/**/*", "deployed-worker.ts", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"] + "include": ["tests/**/*", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"] } diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index b9291e9a8df1..889ad1a0722b 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -33,6 +33,16 @@ "types": "./build/types/cli.d.ts", "default": "./build/cjs/cli.js" } + }, + "./cloudflare": { + "import": { + "types": "./build/types/cloudflare.d.ts", + "default": "./build/esm/cloudflare.js" + }, + "require": { + "types": "./build/types/cloudflare.d.ts", + "default": "./build/cjs/cloudflare.js" + } } }, "sideEffects": false, diff --git a/dev-packages/test-utils/rollup.npm.config.mjs b/dev-packages/test-utils/rollup.npm.config.mjs index 3c774d19e5a6..600fca5e73e6 100644 --- a/dev-packages/test-utils/rollup.npm.config.mjs +++ b/dev-packages/test-utils/rollup.npm.config.mjs @@ -2,7 +2,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/cli.ts'], + entrypoints: ['src/index.ts', 'src/cli.ts', 'src/cloudflare.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/dev-packages/test-utils/src/cloudflare.ts b/dev-packages/test-utils/src/cloudflare.ts new file mode 100644 index 000000000000..a85f5246b435 --- /dev/null +++ b/dev-packages/test-utils/src/cloudflare.ts @@ -0,0 +1,249 @@ +import { execFileSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { FullConfig } from '@playwright/test'; + +export interface WorkerGlobalSetupOptions { + /** + * The start of the Worker name. The `cleanup-e2e-workers` workflow deletes `-pr-` + * once a PR closes, so every prefix must also be listed there. + */ + workerPrefix: string; + /** + * The secrets of the Worker besides `E2E_TEST_DSN`, as a map from the binding name to the name of the + * environment variable that holds the value. + */ + secrets?: Record; +} + +function wrangler(appDir: string, args: string[], env: Record = {}): void { + execFileSync('pnpm', ['exec', 'wrangler', ...args], { + cwd: appDir, + env: { ...process.env, ...env }, + stdio: ['ignore', 'inherit', 'inherit'], + }); +} + +/** + * In CI the name follows the ref, so `develop`, `master` and every PR get a stable Worker that the + * next run of the same ref overwrites. Pull request refs look like `123/merge` and merge queue refs + * like `gh-readonly-queue//pr-123-`; both map to the PR's Worker. + */ +function getWorkerName(workerPrefix: string): string { + if (!process.env.GITHUB_ACTIONS) { + return `${workerPrefix}-local-${randomBytes(3).toString('hex')}`; + } + + const { GITHUB_EVENT_NAME, GITHUB_REF_NAME = '' } = process.env; + const prNumber = + GITHUB_EVENT_NAME === 'pull_request' ? GITHUB_REF_NAME.split('/')[0] : /\/pr-(\d+)-/.exec(GITHUB_REF_NAME)?.[1]; + const ref = prNumber ? `pr-${prNumber}` : GITHUB_REF_NAME; + // Worker names allow lowercase alphanumerics and dashes only, up to 63 characters. + const slug = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + + return `${workerPrefix}-${slug}`.slice(0, 63).replace(/-+$/, ''); +} + +/** + * Workflow names are unique per Cloudflare account, so every worker gets its own. The Vite build writes the + * config wrangler deploys from, and `.wrangler/deploy/config.json` points to it. + */ +function nameWorkflowsAfterWorker(appDir: string, name: string): void { + const redirect: { configPath: string } = JSON.parse( + readFileSync(join(appDir, '.wrangler/deploy/config.json'), 'utf8'), + ); + const configPath = join(appDir, '.wrangler/deploy', redirect.configPath); + const config: { workflows?: { name: string }[] } = JSON.parse(readFileSync(configPath, 'utf8')); + + for (const workflow of config.workflows ?? []) { + workflow.name = name; + } + writeFileSync(configPath, JSON.stringify(config, null, 2)); +} + +/** + * Deploys the worker under `name` and returns its workers.dev URL. The values go up as secrets, so + * wrangler does not print them the way it prints plain-text vars. + */ +function deployWorker(appDir: string, name: string, secrets: Record): string { + nameWorkflowsAfterWorker(appDir, name); + const tempDir = mkdtempSync(join(tmpdir(), 'wrangler-deploy-')); + const outputFile = join(tempDir, 'output.ndjson'); + const secretsFile = join(tempDir, 'secrets.json'); + + try { + writeFileSync(secretsFile, JSON.stringify(secrets), { mode: 0o600 }); + wrangler(appDir, ['deploy', '--name', name, '--secrets-file', secretsFile], { + WRANGLER_OUTPUT_FILE_PATH: outputFile, + }); + + const url = readFileSync(outputFile, 'utf8') + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line) as { type?: string; targets?: string[] }) + .find(entry => entry.type === 'deploy') + ?.targets?.find(target => target.endsWith('.workers.dev')); + + if (!url) { + throw new Error(`Could not find the workers.dev URL in the wrangler deploy output for ${name}.`); + } + + return url; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function deleteWorker(appDir: string, name: string): void { + wrangler(appDir, ['delete', '--name', name, '--force']); +} + +/** + * CI keeps its Workers: one per ref, overwritten by the next run of the same ref and deleted by the + * cleanup workflow once a PR closes. Local runs delete theirs unless `E2E_KEEP_WORKER` is set. + */ +function keepsWorker(): boolean { + return Boolean(process.env.GITHUB_ACTIONS || process.env.E2E_KEEP_WORKER); +} + +/** A freshly created workers.dev route can take a moment to become reachable. */ +async function waitForWorker(url: string): Promise { + const deadline = Date.now() + 60_000; + + while (Date.now() < deadline) { + try { + // The SDK does not trace HEAD requests, so the probe leaves no spans behind in Sentry. + const response = await fetch(url, { method: 'HEAD' }); + + if (response.ok) { + return; + } + } catch { + // DNS for the new subdomain may not have propagated yet. + } + + await new Promise(resolve => setTimeout(resolve, 2_000)); + } + + throw new Error(`Worker at ${url} did not become reachable within 60s.`); +} + +/** The directory of the test app, where wrangler finds the build output to deploy. */ +function getAppDir(config: FullConfig): string { + return config.configFile ? dirname(config.configFile) : process.cwd(); +} + +/** + * Returns a Playwright global setup that deploys the built test app as a real Worker. The tests read + * its URL from `E2E_TEST_WORKER_URL`, and {@link workerGlobalTeardown} deletes it again. + */ +export function createWorkerGlobalSetup(options: WorkerGlobalSetupOptions): (config: FullConfig) => Promise { + return async config => { + const appDir = getAppDir(config); + + if (!existsSync(join(appDir, '.wrangler/deploy/config.json'))) { + throw new Error('Run `pnpm build` first: wrangler would deploy the uninstrumented source.'); + } + + // Wrangler authenticates with `CLOUDFLARE_API_TOKEN` (CI) or a `wrangler login` session (local), + // but it cannot pick an account on its own outside of a terminal. + if (!process.env.CLOUDFLARE_ACCOUNT_ID) { + throw new Error('CLOUDFLARE_ACCOUNT_ID must be set to deploy the test worker.'); + } + + const secrets: Record = {}; + for (const [binding, envName] of Object.entries({ E2E_TEST_DSN: 'E2E_TEST_DSN', ...options.secrets })) { + const value = process.env[envName]; + if (!value) { + throw new Error(`${envName} must be set to deploy the test worker.`); + } + secrets[binding] = value; + } + + const workerName = getWorkerName(options.workerPrefix); + const workerUrl = deployWorker(appDir, workerName, secrets); + process.env.E2E_TEST_WORKER_NAME = workerName; + + try { + await waitForWorker(workerUrl); + } catch (error) { + if (!keepsWorker()) { + try { + deleteWorker(appDir, workerName); + } catch (deleteError) { + // The unreachable worker is the failure to report, not the cleanup. + // eslint-disable-next-line no-console + console.error(`Failed to delete worker ${workerName}:`, deleteError); + } + } + throw error; + } + + process.env.E2E_TEST_WORKER_URL = workerUrl; + }; +} + +/** Playwright global teardown that deletes the Worker {@link createWorkerGlobalSetup} deployed. */ +export function workerGlobalTeardown(config: FullConfig): void { + const workerName = process.env.E2E_TEST_WORKER_NAME; + + if (!workerName) { + return; + } + + if (keepsWorker()) { + // eslint-disable-next-line no-console + console.log(`Keeping worker ${workerName} at ${process.env.E2E_TEST_WORKER_URL}`); + return; + } + + try { + deleteWorker(getAppDir(config), workerName); + } catch (error) { + // A leaked worker is not an SDK failure, so it must not fail a run whose tests passed. + // eslint-disable-next-line no-console + console.error( + `Failed to delete worker ${workerName}, delete it with \`wrangler delete --name ${workerName}\`:`, + error, + ); + } +} + +/** + * Sends a request until the Worker itself answers it, and returns the body of that answer. + * + * On the first deployment of a Worker name, Cloudflare has answered a request with a 500 while + * Workers Logs had no invocation for it. `status` is the status the Worker answers with. A Worker + * that threw answers with status 500 and Cloudflare error code 1101, which sets it apart from a 500 + * that did not come from the Worker. + */ +export async function fetchFromWorker(url: string, status: number, init?: RequestInit): Promise { + const deadline = Date.now() + 60_000; + let lastAnswer = 'no answer'; + + while (Date.now() < deadline) { + try { + const response = await fetch(url, init); + const body = await response.text(); + // Cloudflare sends its error page as HTML to some clients (Node's fetch among them) and as + // `error code: ` plain text to others, so the code is read from either format. + const errorCode = /cf-error-code">(\d+)<|^error code: (\d+)/.exec(body)?.slice(1).find(Boolean); + + if (response.status === status && (status !== 500 || errorCode === '1101')) { + return body; + } + + lastAnswer = `${response.status}, cf-ray ${response.headers.get('cf-ray')}, error code ${errorCode ?? 'none'}, body: ${body.slice(0, 200)}`; + } catch (error) { + lastAnswer = String(error); + } + + // eslint-disable-next-line no-console + console.log(`The Worker did not answer ${url}: ${lastAnswer}`); + await new Promise(resolve => setTimeout(resolve, 2_000)); + } + + throw new Error(`The Worker did not answer ${url} with status ${status} within 60s. Last answer: ${lastAnswer}`); +} From e340d080998e2e100cd6f2b5a3a560e679e2a39f Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:17:47 +0200 Subject: [PATCH 31/65] chore: Add external contributor to CHANGELOG.md (#24835) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #24777 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 e1896e87fea2..49191067ea6a 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 @nabi-noor and @LuccaRebelloToledo. Thank you for your contributions! +Work in this release was contributed by @nabi-noor, @LuccaRebelloToledo, and @andasan. Thank you for your contributions! ## 11.1.0 From af5d1a74a2b1c053080de3743d996035737de1c9 Mon Sep 17 00:00:00 2001 From: breken Date: Tue, 29 Sep 2026 08:54:17 -0700 Subject: [PATCH 32/65] fix(core): Resolve escape sequences in `fmt` / `parameterize` messages (#24770) Handle and resolve escape sequences when parameterizing a `fmt` log message. With the patch, template and message are aligned and escape characters are rendered correctly. Co-authored-by: breken-ai <312387581+breken-ai@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Lukas Stracke --- packages/core/src/utils/parameterize.ts | 7 +++++-- .../core/test/lib/utils/parameterize.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/parameterize.ts b/packages/core/src/utils/parameterize.ts index d6e7a5e853bf..278bda490c2e 100644 --- a/packages/core/src/utils/parameterize.ts +++ b/packages/core/src/utils/parameterize.ts @@ -12,8 +12,11 @@ import type { ParameterizedString } from '../types/parameterize'; * @returns A `ParameterizedString` object that can be passed into `captureMessage` or Sentry.logger.X methods. */ export function parameterize(strings: TemplateStringsArray, ...values: unknown[]): ParameterizedString { - const formatted = new String(String.raw(strings, ...values)) as ParameterizedString; - formatted.__sentry_template_string__ = strings.join('\x00').replace(/%/g, '%%').replace(/\0/g, '%s'); + // `String.raw` would keep escape sequences such as `\n` as typed, while the template uses the cooked strings. + // A string with an invalid escape sequence has no cooked value, so fall back to its raw form. + const cooked = strings.map((str, i) => str ?? strings.raw[i]); + const formatted = new String(String.raw({ raw: cooked }, ...values)) as ParameterizedString; + formatted.__sentry_template_string__ = cooked.join('\x00').replace(/%/g, '%%').replace(/\0/g, '%s'); formatted.__sentry_template_values__ = values; return formatted; } diff --git a/packages/core/test/lib/utils/parameterize.test.ts b/packages/core/test/lib/utils/parameterize.test.ts index 725fec5d3944..17f72df8e948 100644 --- a/packages/core/test/lib/utils/parameterize.test.ts +++ b/packages/core/test/lib/utils/parameterize.test.ts @@ -24,4 +24,20 @@ describe('parameterize()', () => { expect(formatted.__sentry_template_string__).toEqual(string.__sentry_template_string__); expect(formatted.__sentry_template_values__).toEqual(string.__sentry_template_values__); }); + + test('keeps escape sequences the same in the message and the template', () => { + const x = 'first'; + const formatted = parameterize`Line one\nline two with ${x} → \`done\``; + + expect(String(formatted)).toBe('Line one\nline two with first → `done`'); + expect(formatted.__sentry_template_string__).toBe('Line one\nline two with %s → `done`'); + }); + + test('keeps the raw text of a string with an invalid escape sequence', () => { + const file = 'app.log'; + const formatted = parameterize`Reading C:\users ${file}`; + + expect(String(formatted)).toBe('Reading C:\\users app.log'); + expect(formatted.__sentry_template_string__).toBe('Reading C:\\users %s'); + }); }); From aedd9776c68479759e891cf33f310c1d488de547 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 29 Sep 2026 18:38:12 +0200 Subject: [PATCH 33/65] chore(deps): Update to Vitest 4 and Vite 8 (#24746) This PR updates Vitest to v4, and Vite to v8. In a previous attempt that kept Vite on v7, we encountered several problems with yarn, hence I made the jump to vite 8 directly. Shouldn't affect anything else, since we only use vite for vitest. Changes: - Bump `vitest` and `@vitest/coverage-v8` from 3.2 to 4.1 across the monorepo. - Bump `vite` to 8 in packages that previously pinned Vite 6 or 7. - Replace the removed `poolOptions.threads.singleThread` with `maxWorkers: 1` in the bun and cloudflare integration tests. - Give the bun integration test project its own `sequence.groupOrder`, which Vitest 4 requires for projects with different `maxWorkers`. - Run the shared `vitest-esm-only` plugin with `enforce: 'pre'` so TS transpilation can't move its marker comments. - Use `function` implementations for mocks called with `new`, which Vitest 4 no longer allows as arrow functions. - Clear or reset mocks explicitly, since `vi.restoreAllMocks()` no longer resets `vi.fn()` mocks. - Fix type errors from Vite 8 plugin hook types and the DOM lib types Vitest 3 used to pull in. - Move test options to the second argument in the deno and debug-id-sourcemaps tests. --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../bun-integration-tests/package.json | 2 +- .../bun-integration-tests/tsconfig.json | 2 +- .../bun-integration-tests/vite.config.mts | 8 +- .../package.json | 2 +- dev-packages/bundler-tests/package.json | 4 +- .../cloudflare-integration-tests/package.json | 4 +- .../client-build/client/main.ts | 1 + .../vite.config.mts | 6 +- .../deno-integration-tests/package.json | 2 +- .../debug-id-sourcemaps/package.json | 2 +- .../debug-id-sourcemaps/tests/server.test.ts | 84 +- package.json | 4 +- packages/astro/package.json | 2 +- .../browser/test/profiling/UIProfiler.test.ts | 55 +- .../test/profiling/integration.test.ts | 10 +- packages/bundler-plugins/package.json | 2 +- .../cloudflare/test/durableobject.test.ts | 14 +- .../instrumentWorkerEntrypoint.test.ts | 20 +- packages/cloudflare/test/request.test.ts | 1 + .../test/integrations/spanStreaming.test.ts | 4 +- .../inject-trace-propagation-headers.test.ts | 2 +- .../test/lib/integrations/supabase.test.ts | 1 + packages/core/test/lib/utils/meta.test.ts | 6 +- packages/hono/test/shared/patchAppUse.test.ts | 7 +- .../nextjs/test/config/wrappingLoader.test.ts | 2 +- packages/node/test/transports/http.test.ts | 4 +- packages/node/test/transports/https.test.ts | 4 +- packages/nuxt/package.json | 2 +- .../test/vite/sourceMaps-nuxtHooks.test.ts | 12 +- packages/nuxt/test/vite/utils.test.ts | 25 +- .../profiling-node/test/integration.test.ts | 6 +- packages/react-router/package.json | 2 +- packages/remix/package.json | 2 +- packages/remix/src/vite/orchestrionPlugin.ts | 3 +- .../handleNetworkBreadcrumbs.test.ts | 6 +- packages/server-utils/package.json | 2 +- packages/solid/package.json | 2 +- packages/solidstart/package.json | 2 +- .../src/vite/buildInstrumentationFile.ts | 3 + packages/svelte/package.json | 2 +- packages/sveltekit/package.json | 2 +- .../sveltekit/src/vite/sentryVitePlugins.ts | 2 +- .../test/server/integrations/http.test.ts | 6 +- packages/tanstackstart-react/package.json | 2 +- vite/vite.config.ts | 2 + yarn.lock | 792 +++++++----------- 46 files changed, 497 insertions(+), 633 deletions(-) diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index 28001d684615..4264288b9368 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -24,7 +24,7 @@ "@sentry-internal/node-integration-tests": "11.1.0", "@sentry-internal/test-utils": "11.1.0", "bun-types": "^1.2.9", - "vitest": "^3.2.7" + "vitest": "^4.1.11" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/bun-integration-tests/tsconfig.json b/dev-packages/bun-integration-tests/tsconfig.json index ba0be38e72df..5482ba1aab64 100644 --- a/dev-packages/bun-integration-tests/tsconfig.json +++ b/dev-packages/bun-integration-tests/tsconfig.json @@ -4,7 +4,7 @@ "include": ["suites/**/*.ts", "node-suites/**/*.ts", "*.ts"], "compilerOptions": { - "lib": ["ES2020"], + "lib": ["DOM", "ES2020"], "esModuleInterop": true, "types": ["bun-types"] } diff --git a/dev-packages/bun-integration-tests/vite.config.mts b/dev-packages/bun-integration-tests/vite.config.mts index 51df09e0ce40..f339cd917a02 100644 --- a/dev-packages/bun-integration-tests/vite.config.mts +++ b/dev-packages/bun-integration-tests/vite.config.mts @@ -52,11 +52,9 @@ export default defineConfig({ env: { RUNTIME: 'bun' }, // Above the 30 second port timeout of the runner on Bun, so a slow start can still pass. testTimeout: 45_000, - poolOptions: { - threads: { - singleThread: true, - }, - }, + maxWorkers: 1, + // Vitest requires projects with a different `maxWorkers` to run in their own group. + sequence: { groupOrder: 1 }, }, }, { diff --git a/dev-packages/bundler-plugin-integration-tests/package.json b/dev-packages/bundler-plugin-integration-tests/package.json index c567034183cd..09d3d950fd4f 100644 --- a/dev-packages/bundler-plugin-integration-tests/package.json +++ b/dev-packages/bundler-plugin-integration-tests/package.json @@ -17,7 +17,7 @@ "devDependencies": { "premove": "^4.0.0", "typescript": "~6.0.3", - "vitest": "^3.2.7" + "vitest": "^4.1.11" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/bundler-tests/package.json b/dev-packages/bundler-tests/package.json index 7d90bd591bdb..cee40955305b 100644 --- a/dev-packages/bundler-tests/package.json +++ b/dev-packages/bundler-tests/package.json @@ -15,8 +15,8 @@ "@rollup/plugin-node-resolve": "^16.0.3", "@sentry/browser": "11.1.0", "rollup": "^4.60.3", - "vite": "^6.4.3", - "vitest": "^3.2.7", + "vite": "^8.3.1", + "vitest": "^4.1.11", "webpack": "^5.0.0" }, "volta": { diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index bf09aeca2c22..d211f87fe30b 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -36,8 +36,8 @@ "@sentry/conventions": "0.24.0", "eslint-plugin-regexp": "^3.1.0", "prisma": "6.15.0", - "vite": "7.3.5", - "vitest": "^3.2.7", + "vite": "^8.3.1", + "vitest": "^4.1.11", "wrangler": "4.86.0" }, "volta": { diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/client/main.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/client/main.ts index 7d443658fd7b..f79f6693a500 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/client/main.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/client/main.ts @@ -1,3 +1,4 @@ +/// import { streamText } from 'ai'; // The browser bundle also pulls in an orchestrion-instrumented module (`ai`). diff --git a/dev-packages/cloudflare-integration-tests/vite.config.mts b/dev-packages/cloudflare-integration-tests/vite.config.mts index a93b595b6ecd..600cd6a118ee 100644 --- a/dev-packages/cloudflare-integration-tests/vite.config.mts +++ b/dev-packages/cloudflare-integration-tests/vite.config.mts @@ -26,11 +26,7 @@ export default defineConfig({ // overhead is significantly less. pool: 'threads', // Run tests sequentially to avoid port conflicts with wrangler dev processes - poolOptions: { - threads: { - singleThread: true, - }, - }, + maxWorkers: 1, sequence: { shuffle: true, }, diff --git a/dev-packages/deno-integration-tests/package.json b/dev-packages/deno-integration-tests/package.json index 6283ecc2e050..0dc7d2177228 100644 --- a/dev-packages/deno-integration-tests/package.json +++ b/dev-packages/deno-integration-tests/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "@sentry-internal/node-integration-tests": "11.1.0", - "vitest": "^3.2.7" + "vitest": "^4.1.11" }, "volta": { "extends": "../../package.json" 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 617e904f0bf6..660ab6ae4628 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 @@ -14,7 +14,7 @@ }, "devDependencies": { "rollup": "^4.35.0", - "vitest": "^3.2.7", + "vitest": "^4.1.11", "@sentry/rollup-plugin": "^5.3.0" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts index 3ba3afae4fc8..0d9c81a7753c 100644 --- a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts +++ b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts @@ -28,56 +28,52 @@ function splitFrameContext(frame: SerializedFrame): Record { }; } -test( - 'Find symbolicated event on sentry', - async ({ expect }) => { - const eventId = childProcess.execSync(`node ${path.join(__dirname, '..', 'dist', 'app.js')}`, { - encoding: 'utf-8', - }); - - console.log(`Polling for error eventId: ${eventId}`); +test('Find symbolicated event on sentry', { timeout: EVENT_POLLING_TIMEOUT }, async ({ expect }) => { + const eventId = childProcess.execSync(`node ${path.join(__dirname, '..', 'dist', 'app.js')}`, { + encoding: 'utf-8', + }); - let timedOut = false; - setTimeout(() => { - timedOut = true; - }, EVENT_POLLING_TIMEOUT); + console.log(`Polling for error eventId: ${eventId}`); - while (!timedOut) { - await new Promise(resolve => setTimeout(resolve, 2000)); // poll every two seconds - const response = await fetch(`https://sentry.io/api/0/organizations/${sentryTestOrgSlug}/eventids/${eventId}/`, { - headers: { Authorization: `Bearer ${authToken}` }, - }); + let timedOut = false; + setTimeout(() => { + timedOut = true; + }, EVENT_POLLING_TIMEOUT); - // This is org scoped, so the auth token needs `org:read` on top of the project scopes. - // That never resolves by waiting, so fail loudly rather than timing out. - if (response.status === 401 || response.status === 403) { - throw new Error( - `Event lookup was rejected with ${response.status}: ${await response.text()}. ` + - 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', - ); - } + while (!timedOut) { + await new Promise(resolve => setTimeout(resolve, 2000)); // poll every two seconds + const response = await fetch(`https://sentry.io/api/0/organizations/${sentryTestOrgSlug}/eventids/${eventId}/`, { + headers: { Authorization: `Bearer ${authToken}` }, + }); - // A 404 means the event has not landed yet and a 429 is the shared test org being rate limited. - // Both clear up by polling again. - if (!response.ok) { - expect([404, 429]).toContain(response.status); - continue; - } + // This is org scoped, so the auth token needs `org:read` on top of the project scopes. + // That never resolves by waiting, so fail loudly rather than timing out. + if (response.status === 401 || response.status === 403) { + throw new Error( + `Event lookup was rejected with ${response.status}: ${await response.text()}. ` + + 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', + ); + } - const { event } = await response.json(); - const exception = event.entries.find((entry: { type: string }) => entry.type === 'exception'); - const frames: SerializedFrame[] = exception.data.values[0].stacktrace.frames; - const topFrame = frames[frames.length - 1]; + // A 404 means the event has not landed yet and a 429 is the shared test org being rate limited. + // Both clear up by polling again. + if (!response.ok) { + expect([404, 429]).toContain(response.status); + continue; + } - if (topFrame === undefined) { - throw new Error('Symbolicated event has no stack frames.'); - } + const { event } = await response.json(); + const exception = event.entries.find((entry: { type: string }) => entry.type === 'exception'); + const frames: SerializedFrame[] = exception.data.values[0].stacktrace.frames; + const topFrame = frames[frames.length - 1]; - expect(splitFrameContext(topFrame)).toMatchSnapshot(); - return; + if (topFrame === undefined) { + throw new Error('Symbolicated event has no stack frames.'); } - throw new Error('Test timed out'); - }, - { timeout: EVENT_POLLING_TIMEOUT }, -); + expect(splitFrameContext(topFrame)).toMatchSnapshot(); + return; + } + + throw new Error('Test timed out'); +}); diff --git a/package.json b/package.json index 5fce1bf3ac5a..f755d4692b92 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "@size-limit/webpack": "~12.1.0", "@types/jsdom": "^21.1.6", "@types/node": "^18.19.1", - "@vitest/coverage-v8": "^3.2.7", + "@vitest/coverage-v8": "^4.1.11", "deepmerge": "^4.2.2", "es-check": "^7.2.1", "esbuild": "^0.28.1", @@ -143,7 +143,7 @@ "size-limit": "~12.1.0", "tsx": "^4.23.0", "typescript": "~7.0.2", - "vitest": "^3.2.7", + "vitest": "^4.1.11", "yalc": "^1.0.0-pre.53", "yarn-deduplicate": "6.0.2" }, diff --git a/packages/astro/package.json b/packages/astro/package.json index 9a366cab443e..a66dc2d51a3d 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -61,7 +61,7 @@ }, "devDependencies": { "astro": "^4.16.19", - "vite": "^6.4.3" + "vite": "^8.3.1" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index ede606a6eb6a..38ac888f4d1e 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -46,7 +46,10 @@ describe('Browser Profiling v2 trace lifecycle', () => { addEventListener() {} } - const mockConstructor = vi.fn().mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => { + const mockConstructor = vi.fn().mockImplementation(function (opts: { + sampleInterval: number; + maxBufferSize: number; + }) { return new MockProfilerImpl(opts); }); @@ -348,9 +351,12 @@ describe('Browser Profiling v2 trace lifecycle', () => { addEventListener() {} } - (window as any).Profiler = vi - .fn() - .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + (window as any).Profiler = vi.fn().mockImplementation(function (opts: { + sampleInterval: number; + maxBufferSize: number; + }) { + return new MockProfilerImpl(opts); + }); const send = vi.fn().mockResolvedValue(undefined); @@ -408,9 +414,12 @@ describe('Browser Profiling v2 trace lifecycle', () => { addEventListener() {} } - (window as any).Profiler = vi - .fn() - .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + (window as any).Profiler = vi.fn().mockImplementation(function (opts: { + sampleInterval: number; + maxBufferSize: number; + }) { + return new MockProfilerImpl(opts); + }); const send = vi.fn().mockResolvedValue(undefined); @@ -462,9 +471,12 @@ describe('Browser Profiling v2 trace lifecycle', () => { addEventListener() {} } - (window as any).Profiler = vi - .fn() - .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + (window as any).Profiler = vi.fn().mockImplementation(function (opts: { + sampleInterval: number; + maxBufferSize: number; + }) { + return new MockProfilerImpl(opts); + }); const send = vi.fn().mockResolvedValue(undefined); @@ -521,9 +533,12 @@ describe('Browser Profiling v2 trace lifecycle', () => { addEventListener() {} } - (window as any).Profiler = vi - .fn() - .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + (window as any).Profiler = vi.fn().mockImplementation(function (opts: { + sampleInterval: number; + maxBufferSize: number; + }) { + return new MockProfilerImpl(opts); + }); // Session 1 const send1 = vi.fn().mockResolvedValue(undefined); @@ -747,7 +762,10 @@ describe('Browser Profiling v2 manual lifecycle', () => { addEventListener() {} } - const mockConstructor = vi.fn().mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => { + const mockConstructor = vi.fn().mockImplementation(function (opts: { + sampleInterval: number; + maxBufferSize: number; + }) { return new MockProfilerImpl(opts); }); @@ -869,9 +887,12 @@ describe('Browser Profiling v2 manual lifecycle', () => { addEventListener() {} } - (window as any).Profiler = vi - .fn() - .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + (window as any).Profiler = vi.fn().mockImplementation(function (opts: { + sampleInterval: number; + maxBufferSize: number; + }) { + return new MockProfilerImpl(opts); + }); const send = vi.fn().mockResolvedValue(undefined); diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index 729157ca08cd..0bd7e96d8b1d 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -26,10 +26,12 @@ describe('BrowserProfilingIntegration', () => { resources: [], }); - const mockProfiler = vi.fn().mockImplementation(() => ({ - stop: stopProfile, - addEventListener: vi.fn(), - })); + const mockProfiler = vi.fn().mockImplementation(function () { + return { + stop: stopProfile, + addEventListener: vi.fn(), + }; + }); // @ts-expect-error this is a mock constructor window.Profiler = mockProfiler; diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index bb2712e5c50e..6386c666427c 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -141,7 +141,7 @@ "@types/node": "^20.19.0", "premove": "^4.0.0", "rolldown": "^1.0.0", - "vitest": "^3.2.7", + "vitest": "^4.1.11", "webpack": "5.104.1" }, "volta": { diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index 3450111286c3..e6f4e1bcd551 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -806,12 +806,14 @@ describe('instrumentDurableObjectWithSentry', () => { const before = flush.mock.calls.length; const waitUntil = vi.fn(); - const testClass = vi.fn(context => ({ - fetch: () => { - context.waitUntil(new Promise(res => setTimeout(res))); - return new Response('test'); - }, - })); + const testClass = vi.fn(function (context) { + return { + fetch: () => { + context.waitUntil(new Promise(res => setTimeout(res))); + return new Response('test'); + }, + }; + }); const instrumented = instrumentDurableObjectWithSentry(vi.fn(), testClass as any); const context = { waitUntil, diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index e99afb986319..3c7f114099c0 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -214,15 +214,17 @@ describe('instrumentWorkerEntrypoint', () => { }); const waitUntil = vi.fn(); - const TestClass = vi.fn((context: ExecutionContext) => ({ - fetch: () => { - // The client is created per request, on the scope forked for that request, so it is only - // reachable from inside the handler. - testClient = SentryCore.getClient(); - context.waitUntil(deferred); - return new Response('test'); - }, - })); + const TestClass = vi.fn(function (context: ExecutionContext) { + return { + fetch: () => { + // The client is created per request, on the scope forked for that request, so it is only + // reachable from inside the handler. + testClient = SentryCore.getClient(); + context.waitUntil(deferred); + return new Response('test'); + }, + }; + }); const instrumented = instrumentWorkerEntrypoint(vi.fn(), TestClass as unknown as WorkerEntrypointConstructor); const context = { ...createMockExecutionContext(), waitUntil }; const worker = Reflect.construct(instrumented, [context, {}]); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index baf9282f9e15..249da91d8cd1 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -1172,6 +1172,7 @@ describe('Durable Object (DO) context', () => { describe('cached client (cacheClient)', () => { beforeEach(() => { + vi.clearAllMocks(); _clearGlobalClientCache(); }); diff --git a/packages/core/test/integrations/spanStreaming.test.ts b/packages/core/test/integrations/spanStreaming.test.ts index e1028cd66cbd..fc0715b5faff 100644 --- a/packages/core/test/integrations/spanStreaming.test.ts +++ b/packages/core/test/integrations/spanStreaming.test.ts @@ -11,7 +11,9 @@ const mockSpanBufferInstance = vi.hoisted(() => ({ })); const MockSpanBuffer = vi.hoisted(() => { - return vi.fn(() => mockSpanBufferInstance); + return vi.fn(function () { + return mockSpanBufferInstance; + }); }); vi.mock('../../src/tracing/spans/spanBuffer', async () => { diff --git a/packages/core/test/lib/integrations/http/inject-trace-propagation-headers.test.ts b/packages/core/test/lib/integrations/http/inject-trace-propagation-headers.test.ts index 3338d066328f..ee9e0111c8e6 100644 --- a/packages/core/test/lib/integrations/http/inject-trace-propagation-headers.test.ts +++ b/packages/core/test/lib/integrations/http/inject-trace-propagation-headers.test.ts @@ -55,7 +55,7 @@ describe('injectTracePropagationHeaders', () => { }); afterEach(() => { - vi.restoreAllMocks(); + vi.resetAllMocks(); }); it('injects sentry-trace, traceparent, and baggage headers', () => { diff --git a/packages/core/test/lib/integrations/supabase.test.ts b/packages/core/test/lib/integrations/supabase.test.ts index 961f232e21d3..cae6111f7e4d 100644 --- a/packages/core/test/lib/integrations/supabase.test.ts +++ b/packages/core/test/lib/integrations/supabase.test.ts @@ -162,6 +162,7 @@ function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupa describe('Supabase Integration', () => { beforeEach(() => { + vi.clearAllMocks(); currentScopesMocks.getClient.mockReturnValue(undefined); tracingMocks.startedSpans.length = 0; }); diff --git a/packages/core/test/lib/utils/meta.test.ts b/packages/core/test/lib/utils/meta.test.ts index d6f69f1873b4..13f29fedd20c 100644 --- a/packages/core/test/lib/utils/meta.test.ts +++ b/packages/core/test/lib/utils/meta.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getTraceMetaTags } from '../../../src/utils/meta'; import * as TraceDataModule from '../../../src/utils/traceData'; describe('getTraceMetaTags', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('renders baggage and sentry-trace values to stringified Html meta tags', () => { vi.spyOn(TraceDataModule, 'getTraceData').mockReturnValueOnce({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', diff --git a/packages/hono/test/shared/patchAppUse.test.ts b/packages/hono/test/shared/patchAppUse.test.ts index 8773bb6961c6..ff1739c60ebd 100644 --- a/packages/hono/test/shared/patchAppUse.test.ts +++ b/packages/hono/test/shared/patchAppUse.test.ts @@ -314,18 +314,19 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => return result; }); const fakeApp = Object.assign(new Hono(), { query }); + const middlewareSpy = vi.fn(); async function queryMiddleware(receivedContext: unknown, next: () => Promise) { + middlewareSpy(receivedContext, next); expect(receivedContext).toBe(context); await next(); } - const middleware = vi.fn(queryMiddleware); const handler = vi.fn((receivedContext: unknown) => { expect(receivedContext).toBe(context); return 'handled'; }); patchHttpMethodHandlers(fakeApp as unknown as Parameters[0]); - const registrationResult = fakeApp.query('/test', middleware, handler); + const registrationResult = fakeApp.query('/test', queryMiddleware, handler); expect(registrationResult).toBe(result); if (!registeredMiddleware || !registeredHandler) { @@ -339,7 +340,7 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'queryMiddleware' })); - expect(middleware).toHaveBeenCalledWith(context, next); + expect(middlewareSpy).toHaveBeenCalledWith(context, next); expect(next).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(context); expect(handlerResult).toBe('handled'); diff --git a/packages/nextjs/test/config/wrappingLoader.test.ts b/packages/nextjs/test/config/wrappingLoader.test.ts index cdba71caeb17..1e4ae118972b 100644 --- a/packages/nextjs/test/config/wrappingLoader.test.ts +++ b/packages/nextjs/test/config/wrappingLoader.test.ts @@ -6,7 +6,7 @@ import type { WrappingLoaderOptions } from '../../src/config/loaders/wrappingLoa vi.mock('fs', { spy: true }); -const originalReadfileSync = fs.readFileSync; +const { readFileSync: originalReadfileSync } = await vi.importActual('fs'); vi.spyOn(fs, 'readFileSync').mockImplementation((filePath, options) => { if (filePath.toString().endsWith('/config/templates/apiWrapperTemplate.js')) { diff --git a/packages/node/test/transports/http.test.ts b/packages/node/test/transports/http.test.ts index 4c2db2b796ba..7c74e160acb2 100644 --- a/packages/node/test/transports/http.test.ts +++ b/packages/node/test/transports/http.test.ts @@ -337,7 +337,9 @@ describe('makeNewHttpTransport()', () => { const proxyAgentSpy = vi .spyOn(httpProxyAgent, 'HttpsProxyAgent') // @ts-expect-error using http agent as https proxy agent - .mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); + .mockImplementation(function () { + return new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 }); + }); it('can be configured through option', () => { makeNodeTransport({ diff --git a/packages/node/test/transports/https.test.ts b/packages/node/test/transports/https.test.ts index 79bdeb5a3578..431958c1ce9f 100644 --- a/packages/node/test/transports/https.test.ts +++ b/packages/node/test/transports/https.test.ts @@ -184,7 +184,9 @@ describe('makeNewHttpsTransport()', () => { const proxyAgentSpy = vi .spyOn(httpProxyAgent, 'HttpsProxyAgent') // @ts-expect-error using http agent as https proxy agent - .mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); + .mockImplementation(function () { + return new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 }); + }); it('can be configured through option', () => { makeNodeTransport({ diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 8f965dc46cdd..db7ee90717b2 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -70,7 +70,7 @@ "nitro": "^3.0.260311-beta", "nuxi": "^3.25.1", "nuxt": "3.21.11", - "vite": "^6.4.3" + "vite": "^8.3.1" }, "scripts": { "build": "run-s build:types build:transpile", diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index 4bf5235624b0..2ef55c0959ec 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -1,5 +1,5 @@ import type { Nuxt } from '@nuxt/schema'; -import type { Plugin, UserConfig } from 'vite'; +import type { ConfigEnv, Plugin, UserConfig } from 'vite'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { setupSourceMaps, type SourceMapSetting } from '../../src/vite/sourceMaps'; @@ -379,7 +379,10 @@ describe('setupSourceMaps hooks', () => { const plugin = getCapturedPlugin(); if (plugin && typeof plugin.config === 'function') { - plugin.config({ build: { ssr: false }, plugins: [] } as UserConfig, { mode: 'production', command: 'build' }); + (plugin.config as (config: UserConfig, env: ConfigEnv) => void)( + { build: { ssr: false }, plugins: [] } as UserConfig, + { mode: 'production', command: 'build' }, + ); } const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: {} }, dev: false }; @@ -402,7 +405,10 @@ describe('setupSourceMaps hooks', () => { const plugin = getCapturedPlugin(); if (plugin && typeof plugin.config === 'function') { - plugin.config({ build: {}, plugins: [] } as UserConfig, { mode: 'production', command: 'build' }); + (plugin.config as (config: UserConfig, env: ConfigEnv) => void)({ build: {}, plugins: [] } as UserConfig, { + mode: 'production', + command: 'build', + }); } await mockNuxt.triggerHook('nitro:config', { rollupConfig: { plugins: [] }, dev: false }); diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index 3d135a489b6f..605bf368ca9c 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -23,7 +23,10 @@ vi.mock('@nuxt/kit', () => ({ resolvePath: resolvePathMock, })); -vi.mock('fs'); +vi.mock('fs', async importOriginal => ({ + ...(await importOriginal()), + existsSync: vi.fn(), +})); describe('findDefaultSdkInitFile', () => { afterEach(() => { @@ -33,7 +36,7 @@ describe('findDefaultSdkInitFile', () => { it.each(['ts', 'js', 'mjs', 'cjs', 'mts', 'cts'])( 'should return the server file path with .%s extension if it exists', async ext => { - vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { + vi.mocked(fs.existsSync).mockImplementation(filePath => { return !(filePath instanceof URL) && filePath.toString().includes(`sentry.server.config.${ext}`); }); @@ -45,7 +48,7 @@ describe('findDefaultSdkInitFile', () => { it.each(['ts', 'js', 'mjs', 'cjs', 'mts', 'cts'])( 'should return the client file path with .%s extension if it exists', async ext => { - vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { + vi.mocked(fs.existsSync).mockImplementation(filePath => { return !(filePath instanceof URL) && filePath.toString().includes(`sentry.client.config.${ext}`); }); @@ -57,7 +60,7 @@ describe('findDefaultSdkInitFile', () => { it.each(['ts', 'js', 'mjs', 'cjs', 'mts', 'cts'])( 'should return a client config from a custom config root dir if it exists with .%s extension', async ext => { - vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { + vi.mocked(fs.existsSync).mockImplementation(filePath => { return !(filePath instanceof URL) && filePath.toString().includes(`sentry.client.config.${ext}`); }); @@ -77,7 +80,7 @@ describe('findDefaultSdkInitFile', () => { it.each(['ts', 'js', 'mjs', 'cjs', 'mts', 'cts'])( 'should return a server config from a custom config root dir if it exists with .%s extension', async ext => { - vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { + vi.mocked(fs.existsSync).mockImplementation(filePath => { return !(filePath instanceof URL) && filePath.toString().includes(`sentry.server.config.${ext}`); }); @@ -95,21 +98,21 @@ describe('findDefaultSdkInitFile', () => { ); it('should return undefined if no file with specified extensions exists', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); + vi.mocked(fs.existsSync).mockReturnValue(false); const result = await findDefaultSdkInitFile('server'); expect(result).toBeUndefined(); }); it('should return undefined if no file exists', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); + vi.mocked(fs.existsSync).mockReturnValue(false); const result = await findDefaultSdkInitFile('server'); expect(result).toBeUndefined(); }); it('ignores a public/instrument.server file', async () => { - vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { + vi.mocked(fs.existsSync).mockImplementation(filePath => { return !(filePath instanceof URL) && filePath.toString().includes('instrument.server.js'); }); @@ -118,7 +121,7 @@ describe('findDefaultSdkInitFile', () => { }); it('should return the latest layer config file path if client config exists', async () => { - vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { + vi.mocked(fs.existsSync).mockImplementation(filePath => { return !(filePath instanceof URL) && filePath.toString().includes('sentry.client.config.ts'); }); @@ -140,7 +143,7 @@ describe('findDefaultSdkInitFile', () => { }); it('should return the latest layer config file path if server config exists', async () => { - vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { + vi.mocked(fs.existsSync).mockImplementation(filePath => { return !(filePath instanceof URL) && filePath.toString().includes('sentry.server.config.ts'); }); @@ -162,7 +165,7 @@ describe('findDefaultSdkInitFile', () => { }); it('should return the latest layer config file path if client config exists in former layer', async () => { - vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { + vi.mocked(fs.existsSync).mockImplementation(filePath => { return ( !(filePath instanceof URL) && filePath.toString().includes(path.join('nuxt', 'module', 'sentry.client.config.ts')) diff --git a/packages/profiling-node/test/integration.test.ts b/packages/profiling-node/test/integration.test.ts index 24b00d47e9c3..80a47ec05212 100644 --- a/packages/profiling-node/test/integration.test.ts +++ b/packages/profiling-node/test/integration.test.ts @@ -2,7 +2,7 @@ import type { Transport } from '@sentry/core'; import * as Sentry from '@sentry/node'; import type { NodeClientOptions } from '@sentry/node/build/types/types'; import { CpuProfilerBindings } from '@sentry/node-cpu-profiler'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _nodeProfilingIntegration } from '../src/integration'; import { NODE_VERSION } from '../src/nodeVersion'; @@ -31,6 +31,10 @@ function makeSpanProfilingClient(options: Partial = {}): [Sen const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); describe('ProfilingIntegration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('manual continuous profiling', () => { it('start and stops a profile session', () => { const [client] = makeSpanProfilingClient({ diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 5b3b7af63a38..49051cc83314 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -67,7 +67,7 @@ "@react-router/node": "^7.15.0", "react": "^18.3.1", "react-router": "^7.18.0", - "vite": "^6.4.3" + "vite": "^8.3.1" }, "peerDependencies": { "@react-router/node": ">=7.15.0 || ^8.x", diff --git a/packages/remix/package.json b/packages/remix/package.json index bfccac61e907..24599aee449e 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -93,7 +93,7 @@ "@types/express": "^4.17.14", "react": "^18.3.1", "react-dom": "^18.3.1", - "vite": "^6.4.3" + "vite": "^8.3.1" }, "peerDependencies": { "@remix-run/node": "2.x", diff --git a/packages/remix/src/vite/orchestrionPlugin.ts b/packages/remix/src/vite/orchestrionPlugin.ts index 1bd4c91e7051..aaa1b817c6fc 100644 --- a/packages/remix/src/vite/orchestrionPlugin.ts +++ b/packages/remix/src/vite/orchestrionPlugin.ts @@ -5,6 +5,7 @@ import type { SentryRemixVitePluginOptions } from './types'; type AnyHook = (this: unknown, ...args: never[]) => unknown; type ObjectHook = T | { order?: 'pre' | 'post' | null; handler: T }; type ConfigHook = (this: unknown, config: UserConfig, env: ConfigEnv) => unknown; +type ConfigResolvedHook = (this: unknown, config: ResolvedConfig) => unknown; const WORKER_RESOLVE_CONDITIONS = ['workerd', 'worker']; @@ -55,7 +56,7 @@ export function makeOrchestrionPlugin(options: Pick }; const config = hookHandler(orchestrion.config as ObjectHook | undefined); - const configResolved = hookHandler(orchestrion.configResolved); + const configResolved = hookHandler(orchestrion.configResolved as ObjectHook | undefined); let isWorkerConfig = false; let isWorkerBuild = false; diff --git a/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts index b785435bd312..16de1b59bbdc 100644 --- a/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -926,7 +926,7 @@ other-header: test`; method: 'POST', statusCode: 200, request: { - headers: {}, + headers: { 'content-type': 'text/plain;charset=UTF-8' }, size: 33, body: 'Some example request body content', // When body is stored via Symbol, the body text should be captured }, @@ -1067,7 +1067,9 @@ other-header: test`; data: { method: 'POST', statusCode: 200, - request: undefined, + request: { + headers: { 'content-type': 'text/plain;charset=UTF-8' }, + }, response: { size: 13, headers: {}, diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index a590c6aa668d..f0dd27de2517 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -96,7 +96,7 @@ "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4", "@types/node": "^18.19.1", "meriyah": "^6.1.4", - "vite": "^6.4.3" + "vite": "^8.3.1" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/solid/package.json b/packages/solid/package.json index 8cbf476df219..fdec83c00a9f 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -79,7 +79,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.5.2", "solid-js": "^1.9.11", - "vite": "^6.4.3", + "vite": "^8.3.1", "vite-plugin-solid": "^2.11.6" }, "scripts": { diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 923f8bb374cc..12805dc81346 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -97,7 +97,7 @@ "@testing-library/user-event": "^14.5.2", "solid-js": "^1.9.11", "vinxi": "^0.5.11", - "vite": "^6.4.3", + "vite": "^8.3.1", "vite-plugin-solid": "^2.11.6" }, "scripts": { diff --git a/packages/solidstart/src/vite/buildInstrumentationFile.ts b/packages/solidstart/src/vite/buildInstrumentationFile.ts index a5cd6488ca83..f9b8c93fb0b0 100644 --- a/packages/solidstart/src/vite/buildInstrumentationFile.ts +++ b/packages/solidstart/src/vite/buildInstrumentationFile.ts @@ -17,6 +17,8 @@ export function makeBuildInstrumentationFilePlugin(options: SentrySolidStartPlug const instrumentationFilePath = options.instrumentation || './src/instrument.server.ts'; const router = (config as UserConfig & { router: { target: string; name: string; root: string } }).router; const build = config.build || {}; + // SolidStart builds with vinxi's Vite, which predates `rolldownOptions` + // oxlint-disable-next-line typescript/no-deprecated const rollupOptions = build.rollupOptions || {}; const input = [...((rollupOptions.input || []) as string[])]; @@ -44,6 +46,7 @@ export function makeBuildInstrumentationFilePlugin(options: SentrySolidStartPlug ...config, build: { ...build, + // oxlint-disable-next-line typescript/no-deprecated rollupOptions: { ...rollupOptions, input, diff --git a/packages/svelte/package.json b/packages/svelte/package.json index a627ea13254a..fcad44b61512 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -44,7 +44,7 @@ "@sveltejs/vite-plugin-svelte": "1.4.0", "@testing-library/svelte": "^3.2.1", "svelte": "3.59.2", - "vite": "^6.4.3" + "vite": "^8.3.1" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 2c8a64d05e7e..733432afd5e1 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -88,7 +88,7 @@ "@sveltejs/kit": "^2.70.2", "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.2.8", - "vite": "^6.4.3" + "vite": "^8.3.1" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index 70a561c1746e..9052560af04b 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -156,7 +156,7 @@ function makeBuildFlagPlugin(): Plugin { }, buildApp: { order: 'post', - handler() { + handler: async () => { // Without an app builder config, Vite 7+ calls `buildApp` hooks *before* the actual (legacy) build if (usesAppBuilder && !isWatchMode) { clearFlag(); diff --git a/packages/sveltekit/test/server/integrations/http.test.ts b/packages/sveltekit/test/server/integrations/http.test.ts index 08a6fffcd06f..53e854d45441 100644 --- a/packages/sveltekit/test/server/integrations/http.test.ts +++ b/packages/sveltekit/test/server/integrations/http.test.ts @@ -1,8 +1,12 @@ import * as SentryNode from '@sentry/node'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { httpIntegration as svelteKitHttpIntegration } from '../../../src/server/integrations/http'; describe('httpIntegration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('calls the original httpIntegration with incoming request span recording disabled', () => { const sentryNodeHttpIntegration = vi.spyOn(SentryNode, 'httpIntegration'); svelteKitHttpIntegration({ breadcrumbs: false }); diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index b34dbae115dc..52ed9335a644 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -62,7 +62,7 @@ "@sentry/bundler-plugins": "11.1.0" }, "devDependencies": { - "vite": "^6.4.3" + "vite": "^8.3.1" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/vite/vite.config.ts b/vite/vite.config.ts index a22ca23e42f1..5c6e36c55c0d 100644 --- a/vite/vite.config.ts +++ b/vite/vite.config.ts @@ -5,8 +5,10 @@ const STRIP_ESM_MARKERS = /[ \t]*\/\*! rollup-include-esm-only(?:-end)? \*\/[ \t // Mirrors the ESM path of makeEsmCjsReplacePlugin from rollup-utils. Vitest // transforms TypeScript source to ESM so tests see only the ESM branch. +// Runs before TS transpilation, which can move the marker comments. const esmOnlyPlugin = { name: 'vitest-esm-only', + enforce: 'pre' as const, transform(code: string) { if (!code.includes('rollup-include-')) return null; return { code: code.replace(REMOVE_CJS_BLOCK, '').replace(STRIP_ESM_MARKERS, '') }; diff --git a/yarn.lock b/yarn.lock index ee1fb8ab4e39..0c0021e81a56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -190,7 +190,7 @@ "@jridgewell/gen-mapping" "^0.1.0" "@jridgewell/trace-mapping" "^0.3.9" -"@ampproject/remapping@^2.1.0", "@ampproject/remapping@^2.2.1", "@ampproject/remapping@^2.3.0": +"@ampproject/remapping@^2.1.0", "@ampproject/remapping@^2.2.1": version "2.3.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== @@ -3549,14 +3549,6 @@ source-map-url "^0.4.1" terser "^5.7.0" -"@emnapi/core@1.11.1", "@emnapi/core@^1.1.0": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6" - integrity sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ== - dependencies: - "@emnapi/wasi-threads" "1.2.2" - tslib "^2.4.0" - "@emnapi/core@1.4.5": version "1.4.5" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.5.tgz#bfbb0cbbbb9f96ec4e2c4fd917b7bbe5495ceccb" @@ -3565,11 +3557,12 @@ "@emnapi/wasi-threads" "1.0.4" tslib "^2.4.0" -"@emnapi/runtime@1.11.1": +"@emnapi/core@^1.1.0": version "1.11.1" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.1.tgz#58f1f3d5d81a9b12f793ab688c96371901027c24" - integrity sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw== + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6" + integrity sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ== dependencies: + "@emnapi/wasi-threads" "1.2.2" tslib "^2.4.0" "@emnapi/runtime@1.4.5": @@ -3631,11 +3624,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== -"@esbuild/aix-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" - integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== - "@esbuild/aix-ppc64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" @@ -3666,11 +3654,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== -"@esbuild/android-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" - integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== - "@esbuild/android-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" @@ -3706,11 +3689,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== -"@esbuild/android-arm@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" - integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== - "@esbuild/android-arm@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" @@ -3741,11 +3719,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== -"@esbuild/android-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" - integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== - "@esbuild/android-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" @@ -3776,11 +3749,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== -"@esbuild/darwin-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" - integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== - "@esbuild/darwin-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" @@ -3811,11 +3779,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== -"@esbuild/darwin-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" - integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== - "@esbuild/darwin-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" @@ -3846,11 +3809,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== -"@esbuild/freebsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" - integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== - "@esbuild/freebsd-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" @@ -3881,11 +3839,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== -"@esbuild/freebsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" - integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== - "@esbuild/freebsd-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" @@ -3916,11 +3869,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== -"@esbuild/linux-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" - integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== - "@esbuild/linux-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" @@ -3951,11 +3899,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== -"@esbuild/linux-arm@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" - integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== - "@esbuild/linux-arm@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" @@ -3986,11 +3929,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== -"@esbuild/linux-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" - integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== - "@esbuild/linux-ia32@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" @@ -4031,11 +3969,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== -"@esbuild/linux-loong64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" - integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== - "@esbuild/linux-loong64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" @@ -4066,11 +3999,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== -"@esbuild/linux-mips64el@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" - integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== - "@esbuild/linux-mips64el@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" @@ -4101,11 +4029,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== -"@esbuild/linux-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" - integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== - "@esbuild/linux-ppc64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" @@ -4136,11 +4059,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== -"@esbuild/linux-riscv64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" - integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== - "@esbuild/linux-riscv64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" @@ -4171,11 +4089,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== -"@esbuild/linux-s390x@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" - integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== - "@esbuild/linux-s390x@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" @@ -4206,11 +4119,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== -"@esbuild/linux-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" - integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== - "@esbuild/linux-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" @@ -4236,11 +4144,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== -"@esbuild/netbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" - integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== - "@esbuild/netbsd-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" @@ -4271,11 +4174,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== -"@esbuild/netbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" - integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== - "@esbuild/netbsd-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" @@ -4301,11 +4199,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== -"@esbuild/openbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" - integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== - "@esbuild/openbsd-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" @@ -4336,11 +4229,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== -"@esbuild/openbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" - integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== - "@esbuild/openbsd-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" @@ -4366,11 +4254,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== -"@esbuild/openharmony-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" - integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== - "@esbuild/openharmony-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" @@ -4401,11 +4284,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== -"@esbuild/sunos-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" - integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== - "@esbuild/sunos-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" @@ -4436,11 +4314,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== -"@esbuild/win32-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" - integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== - "@esbuild/win32-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" @@ -4471,11 +4344,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== -"@esbuild/win32-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" - integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== - "@esbuild/win32-ia32@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" @@ -4506,11 +4374,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== -"@esbuild/win32-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" - integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== - "@esbuild/win32-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" @@ -5584,7 +5447,7 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28", "@jridgewell/trace-mapping@^0.3.31", "@jridgewell/trace-mapping@^0.3.9": +"@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28", "@jridgewell/trace-mapping@^0.3.31", "@jridgewell/trace-mapping@^0.3.9": version "0.3.31" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== @@ -5831,13 +5694,6 @@ "@emnapi/runtime" "^1.1.0" "@tybys/wasm-util" "^0.9.0" -"@napi-rs/wasm-runtime@^1.1.5": - version "1.1.5" - resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz#cccd6ebc40b991dea6936f9126b1b8155b6c4c95" - integrity sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q== - dependencies: - "@tybys/wasm-util" "^0.10.2" - "@nestjs/common@^10.0.0": version "10.4.15" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70" @@ -6891,10 +6747,10 @@ resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.152.0.tgz#c80fc3a2e18f88df2d375ebd9c666fa7d41b9cdb" integrity sha512-sw0M80/dn9rcpu+Rqqph/C+MjvDDce6MlXmFjsNQtPEymha0eJy3ml6+foSUTqFfpDIDyHZZ4eoJ43IoMe8bJw== -"@oxc-project/types@=0.137.0": - version "0.137.0" - resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.137.0.tgz#56e77f8bb221fa05f18b1cd34d73f94f0954a773" - integrity sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA== +"@oxc-project/types@=0.151.0": + version "0.151.0" + resolved "https://sfw.security.sentry.io/npm/@oxc-project/types/-/types-0.151.0.tgz#37206ed3fdebb8c4c5fc49ae20d53d9b4eed2bfe" + integrity sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA== "@oxc-project/types@^0.143.0": version "0.143.0" @@ -7788,84 +7644,80 @@ dependencies: web-streams-polyfill "^3.1.1" -"@rolldown/binding-android-arm64@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.2.tgz#88fd6b295a411e62b7926433a45eb5e17e68bba4" - integrity sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA== +"@rolldown/binding-android-arm-eabi@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz#898b1c0c0c4de1ffb5997050a705f04cc102b4eb" + integrity sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw== -"@rolldown/binding-darwin-arm64@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.2.tgz#2f840f7e6501cb52370411c2fb008119f1fbf400" - integrity sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw== +"@rolldown/binding-android-arm64@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz#bf8ee88baaecd1747326d6c1d05ebdad483edc34" + integrity sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA== -"@rolldown/binding-darwin-x64@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.2.tgz#2ed3b66dded5140d22ca2ac58d4e1c1e3143f490" - integrity sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw== +"@rolldown/binding-darwin-arm64@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz#4ebc103da1e0d6b24d815c1fb1d6af73245c1b1f" + integrity sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ== -"@rolldown/binding-freebsd-x64@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.2.tgz#d3d8603ae480a505eb8c12643c901b2a3771b875" - integrity sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA== +"@rolldown/binding-darwin-x64@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz#ab669b448eb2cd2366a7ff01a84579a0f854f5f0" + integrity sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w== -"@rolldown/binding-linux-arm-gnueabihf@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.2.tgz#bc36e0e33566bc80877fe4bca56fd32b241dbacb" - integrity sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw== +"@rolldown/binding-freebsd-x64@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz#74c26ec877c293c47c8958ab58e07e57f9382070" + integrity sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow== -"@rolldown/binding-linux-arm64-gnu@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.2.tgz#6267a447e6bfc5a99eb030a6a99194ecc917a652" - integrity sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw== +"@rolldown/binding-linux-arm-gnueabihf@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz#cbfe844867d7dbce1655c9b593be05bcaffb501d" + integrity sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ== -"@rolldown/binding-linux-arm64-musl@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.2.tgz#68fc068f5ebc1d137eecdb651d90830f330aad48" - integrity sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ== +"@rolldown/binding-linux-arm64-gnu@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz#2028ebac4a3f56b26ecd02f8850c74447087cbb2" + integrity sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw== -"@rolldown/binding-linux-ppc64-gnu@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.2.tgz#fe072f0bc3b713ae25b357651ced38d39e3d81e0" - integrity sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ== +"@rolldown/binding-linux-arm64-musl@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz#a143325f96d5d5040dff50e292374f7467e7c82c" + integrity sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg== -"@rolldown/binding-linux-s390x-gnu@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.2.tgz#9492609384775c6edec9bfbe5b1cbba9660dddcc" - integrity sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg== +"@rolldown/binding-linux-ppc64-gnu@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz#b38937ad8b4821682e3671ebfb609cb0e3ecb935" + integrity sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw== -"@rolldown/binding-linux-x64-gnu@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.2.tgz#74029d9f86d60fa9bcf2670b1480e22438203c98" - integrity sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw== +"@rolldown/binding-linux-s390x-gnu@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz#6be2645a1ad89185e8d03f69116798c94eae19ec" + integrity sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ== -"@rolldown/binding-linux-x64-musl@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.2.tgz#eb3004015027a7af12f9b0ac85ffa1634015c873" - integrity sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw== +"@rolldown/binding-linux-x64-gnu@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz#fbc9a6a6c3b3db00e35573c67894bfbaa3128040" + integrity sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A== -"@rolldown/binding-openharmony-arm64@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.2.tgz#6a02c49dc0f698614f97c68c6f7c21aadc7600b1" - integrity sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw== +"@rolldown/binding-linux-x64-musl@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz#f3f07a30c03d1d3a673b21fd4eaba84979aa4fd4" + integrity sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg== -"@rolldown/binding-wasm32-wasi@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.2.tgz#3f67c083e0762b8cd6c95e8edfe1a743d7ffdf78" - integrity sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg== - dependencies: - "@emnapi/core" "1.11.1" - "@emnapi/runtime" "1.11.1" - "@napi-rs/wasm-runtime" "^1.1.5" +"@rolldown/binding-openharmony-arm64@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz#c3848c61dd3b15edde9c98dfa8d50edb356ec71f" + integrity sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA== -"@rolldown/binding-win32-arm64-msvc@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.2.tgz#a69c5a03b3ba36cb0b1154709293e0cefc9dd69c" - integrity sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg== +"@rolldown/binding-win32-arm64-msvc@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz#99d9f7eb53b26f09212d646e9f50708a2f14414f" + integrity sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw== -"@rolldown/binding-win32-x64-msvc@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.2.tgz#e1d9a6ccd29de00378f8dd6e275adbde5731d30a" - integrity sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA== +"@rolldown/binding-win32-x64-msvc@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz#c7bf663043c01021f63122a8499e9aca36408467" + integrity sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA== "@rolldown/pluginutils@^1.0.0", "@rolldown/pluginutils@^1.0.1": version "1.0.1" @@ -9254,13 +9106,6 @@ dependencies: tslib "^2.4.0" -"@tybys/wasm-util@^0.10.2": - version "0.10.2" - resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e" - integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== - dependencies: - tslib "^2.4.0" - "@types/amqplib@^0.10.5": version "0.10.5" resolved "https://registry.yarnpkg.com/@types/amqplib/-/amqplib-0.10.5.tgz#fd883eddfbd669702a727fa10007b27c4c1e6ec7" @@ -10258,85 +10103,81 @@ dependencies: "@rolldown/pluginutils" "^1.0.1" -"@vitest/coverage-v8@^3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz#2e9ce1103445c237aaa420a7f0058125fe4a7854" - integrity sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg== +"@vitest/coverage-v8@^4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz#6f0636abe7e23e86dd35127244554be2c60bc2a5" + integrity sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw== dependencies: - "@ampproject/remapping" "^2.3.0" "@bcoe/v8-coverage" "^1.0.2" - ast-v8-to-istanbul "^0.3.3" - debug "^4.4.1" + "@vitest/utils" "4.1.11" + ast-v8-to-istanbul "^1.0.0" istanbul-lib-coverage "^3.2.2" istanbul-lib-report "^3.0.1" - istanbul-lib-source-maps "^5.0.6" - istanbul-reports "^3.1.7" - magic-string "^0.30.17" - magicast "^0.3.5" - std-env "^3.9.0" - test-exclude "^7.0.1" - tinyrainbow "^2.0.0" + istanbul-reports "^3.2.0" + magicast "^0.5.2" + obug "^2.1.1" + std-env "^4.0.0-rc.1" + tinyrainbow "^3.1.0" -"@vitest/expect@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.7.tgz#70a34158383d008c3bf5d802e2643317f09df6d8" - integrity sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w== +"@vitest/expect@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/expect/-/expect-4.1.11.tgz#5f580d1f9cdbba314dbf23b2d911f8eb23878f5f" + integrity sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw== dependencies: + "@standard-schema/spec" "^1.1.0" "@types/chai" "^5.2.2" - "@vitest/spy" "3.2.7" - "@vitest/utils" "3.2.7" - chai "^5.2.0" - tinyrainbow "^2.0.0" + "@vitest/spy" "4.1.11" + "@vitest/utils" "4.1.11" + chai "^6.2.2" + tinyrainbow "^3.1.0" -"@vitest/mocker@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.7.tgz#331be944cb783c642dd42bd743411aca24ea0466" - integrity sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA== +"@vitest/mocker@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/mocker/-/mocker-4.1.11.tgz#8e2906361bc5dfa271757a858ae80643118fcbb4" + integrity sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ== dependencies: - "@vitest/spy" "3.2.7" + "@vitest/spy" "4.1.11" estree-walker "^3.0.3" - magic-string "^0.30.17" + magic-string "^0.30.21" -"@vitest/pretty-format@3.2.7", "@vitest/pretty-format@^3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.7.tgz#2a7b593f8e007e9d8ef7e7343aa30ec73fdeaf29" - integrity sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA== +"@vitest/pretty-format@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/pretty-format/-/pretty-format-4.1.11.tgz#8b28eb8240771d6ea970e33beaeb41384b51868e" + integrity sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw== dependencies: - tinyrainbow "^2.0.0" + tinyrainbow "^3.1.0" -"@vitest/runner@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.7.tgz#c0c080228189f1fa6cda40f59be09d746b0aca51" - integrity sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA== +"@vitest/runner@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/runner/-/runner-4.1.11.tgz#bfbad98c8d6c3f1fb4df12056ad569821ff77f21" + integrity sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw== dependencies: - "@vitest/utils" "3.2.7" + "@vitest/utils" "4.1.11" pathe "^2.0.3" - strip-literal "^3.0.0" -"@vitest/snapshot@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.7.tgz#a3a7e1950ce99ec4cf02395e20ddca403b6c818e" - integrity sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g== +"@vitest/snapshot@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/snapshot/-/snapshot-4.1.11.tgz#df461eb165924a3155986dde68e13360f53f3d4c" + integrity sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog== dependencies: - "@vitest/pretty-format" "3.2.7" - magic-string "^0.30.17" + "@vitest/pretty-format" "4.1.11" + "@vitest/utils" "4.1.11" + magic-string "^0.30.21" pathe "^2.0.3" -"@vitest/spy@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.7.tgz#ca7fbee44019523ca450395d9a2284ce9ece1f31" - integrity sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ== - dependencies: - tinyspy "^4.0.3" +"@vitest/spy@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/spy/-/spy-4.1.11.tgz#0add45cae953afed9c88f98e2f6fc9164558c32a" + integrity sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA== -"@vitest/utils@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.7.tgz#302c8126211ac4dfea87b3b5085c098d6d22e89e" - integrity sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw== +"@vitest/utils@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/utils/-/utils-4.1.11.tgz#9b27a4293b827942b223539bfab1bd9f7eada31b" + integrity sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ== dependencies: - "@vitest/pretty-format" "3.2.7" - loupe "^3.1.4" - tinyrainbow "^2.0.0" + "@vitest/pretty-format" "4.1.11" + convert-source-map "^2.0.0" + tinyrainbow "^3.1.0" "@volar/kit@~2.4.28": version "2.4.28" @@ -11498,11 +11339,6 @@ assert-never@^1.1.0, assert-never@^1.2.1: resolved "https://registry.yarnpkg.com/assert-never/-/assert-never-1.4.0.tgz#b0d4988628c87f35eb94716cc54422a63927e175" integrity sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA== -assertion-error@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" - integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== - ast-kit@^2.1.2, ast-kit@^2.1.3: version "2.2.0" resolved "https://registry.yarnpkg.com/ast-kit/-/ast-kit-2.2.0.tgz#6d9a298acefef5bdfc5a0fa51d94d1334ef2e671" @@ -11535,14 +11371,14 @@ ast-types@^0.16.1: dependencies: tslib "^2.0.1" -ast-v8-to-istanbul@^0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.3.tgz#697101c116cff6b51c0e668ba6352e7e41fe8dd5" - integrity sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw== +ast-v8-to-istanbul@^1.0.0: + version "1.0.7" + resolved "https://sfw.security.sentry.io/npm/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz#ead45f98ca59413f048f1b1466a78b6c8a1070bb" + integrity sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA== dependencies: - "@jridgewell/trace-mapping" "^0.3.25" + "@jridgewell/trace-mapping" "^0.3.31" estree-walker "^3.0.3" - js-tokens "^9.0.1" + js-tokens "^10.0.0" ast-walker-scope@^0.8.3: version "0.8.3" @@ -12732,16 +12568,10 @@ ccount@^2.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== -chai@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/chai/-/chai-5.2.0.tgz#1358ee106763624114addf84ab02697e411c9c05" - integrity sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw== - dependencies: - assertion-error "^2.0.1" - check-error "^2.1.1" - deep-eql "^5.0.1" - loupe "^3.1.0" - pathval "^2.0.0" +chai@^6.2.2: + version "6.2.2" + resolved "https://sfw.security.sentry.io/npm/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== chainsaw@~0.1.0: version "0.1.0" @@ -12792,11 +12622,6 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== -check-error@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" - integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== - "chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.2, chokidar@^3.5.3: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -13825,11 +13650,6 @@ dedent@^1.5.3: resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.3.tgz#99aee19eb9bae55a67327717b6e848d0bf777e5a" integrity sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ== -deep-eql@^5.0.1: - version "5.0.2" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" - integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== - deep-equal@^2.0.5: version "2.2.3" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.3.tgz#af89dafb23a396c7da3e862abc0be27cf51d56e1" @@ -15253,38 +15073,6 @@ esbuild@^0.25.0, esbuild@^0.25.3: "@esbuild/win32-ia32" "0.25.12" "@esbuild/win32-x64" "0.25.12" -esbuild@^0.27.0: - version "0.27.7" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" - integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== - optionalDependencies: - "@esbuild/aix-ppc64" "0.27.7" - "@esbuild/android-arm" "0.27.7" - "@esbuild/android-arm64" "0.27.7" - "@esbuild/android-x64" "0.27.7" - "@esbuild/darwin-arm64" "0.27.7" - "@esbuild/darwin-x64" "0.27.7" - "@esbuild/freebsd-arm64" "0.27.7" - "@esbuild/freebsd-x64" "0.27.7" - "@esbuild/linux-arm" "0.27.7" - "@esbuild/linux-arm64" "0.27.7" - "@esbuild/linux-ia32" "0.27.7" - "@esbuild/linux-loong64" "0.27.7" - "@esbuild/linux-mips64el" "0.27.7" - "@esbuild/linux-ppc64" "0.27.7" - "@esbuild/linux-riscv64" "0.27.7" - "@esbuild/linux-s390x" "0.27.7" - "@esbuild/linux-x64" "0.27.7" - "@esbuild/netbsd-arm64" "0.27.7" - "@esbuild/netbsd-x64" "0.27.7" - "@esbuild/openbsd-arm64" "0.27.7" - "@esbuild/openbsd-x64" "0.27.7" - "@esbuild/openharmony-arm64" "0.27.7" - "@esbuild/sunos-x64" "0.27.7" - "@esbuild/win32-arm64" "0.27.7" - "@esbuild/win32-ia32" "0.27.7" - "@esbuild/win32-x64" "0.27.7" - "esbuild@^0.27.0 || ^0.28.0", esbuild@^0.28.0, esbuild@^0.28.1, esbuild@~0.28.0: version "0.28.2" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" @@ -15705,10 +15493,10 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -expect-type@^1.2.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68" - integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== +expect-type@^1.3.0: + version "1.4.0" + resolved "https://sfw.security.sentry.io/npm/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== express@5.2.1: version "5.2.1" @@ -16717,7 +16505,7 @@ glob@8.0.3: minimatch "^5.0.1" once "^1.3.0" -glob@^10.0.0, glob@^10.3.7, glob@^10.4.1: +glob@^10.0.0, glob@^10.3.7: version "10.5.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== @@ -18387,19 +18175,10 @@ istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: make-dir "^4.0.0" supports-color "^7.1.0" -istanbul-lib-source-maps@^5.0.6: - version "5.0.6" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" - integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== - dependencies: - "@jridgewell/trace-mapping" "^0.3.23" - debug "^4.1.1" - istanbul-lib-coverage "^3.0.0" - -istanbul-reports@^3.1.7: - version "3.1.7" - resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz#daed12b9e1dca518e15c056e1e537e741280fa0b" - integrity sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g== +istanbul-reports@^3.2.0: + version "3.2.0" + resolved "https://sfw.security.sentry.io/npm/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== dependencies: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" @@ -18503,11 +18282,6 @@ js-tokens@^10.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-tokens@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" - integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== - js-yaml@^3.13.0, js-yaml@^3.13.1: version "3.15.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.15.1.tgz#24bc95028f361cdaaa84745b06a109c4486773c0" @@ -19024,6 +18798,80 @@ light-my-request@^6.0.0: process-warning "^4.0.0" set-cookie-parser "^2.6.0" +lightningcss-android-arm64@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz#9a6841f88ae50fc83502903892b41af41bc2b907" + integrity sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== + +lightningcss-darwin-arm64@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz#c0f2c31c0bfd19fa4dd3f18e957a1f1a152097d6" + integrity sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== + +lightningcss-darwin-x64@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz#cb0705965acb538c6683949ce6925fb3cdf7c361" + integrity sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== + +lightningcss-freebsd-x64@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz#763538828b26bab2680dadafcc84ee78b0eb502b" + integrity sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== + +lightningcss-linux-arm-gnueabihf@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz#6862e3176a331aedbdec1ed352b4d7d0dd0784de" + integrity sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== + +lightningcss-linux-arm64-gnu@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz#c6a3a2ed15141daf6bdc2628930f8e39bdf473aa" + integrity sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== + +lightningcss-linux-arm64-musl@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz#7fa1334971fc82845f9827df6ef8a0b20914bac6" + integrity sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== + +lightningcss-linux-x64-gnu@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz#8b927862ea8c2bbc6831a46509244b50d9936e55" + integrity sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== + +lightningcss-linux-x64-musl@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz#0c525bb077dfd94404c059cfe42dad797e96aeaf" + integrity sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== + +lightningcss-win32-arm64-msvc@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz#850ee1103dac989cfab50e3ac22d1a69e394e63d" + integrity sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== + +lightningcss-win32-x64-msvc@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz#e343ae152eed3609dc6e11949d1a3bf39a1c946f" + integrity sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== + +lightningcss@^1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss/-/lightningcss-1.33.0.tgz#c08867d71a79385c6e190214fd72fef3e5f95f0b" + integrity sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.33.0" + lightningcss-darwin-arm64 "1.33.0" + lightningcss-darwin-x64 "1.33.0" + lightningcss-freebsd-x64 "1.33.0" + lightningcss-linux-arm-gnueabihf "1.33.0" + lightningcss-linux-arm64-gnu "1.33.0" + lightningcss-linux-arm64-musl "1.33.0" + lightningcss-linux-x64-gnu "1.33.0" + lightningcss-linux-x64-musl "1.33.0" + lightningcss-win32-arm64-msvc "1.33.0" + lightningcss-win32-x64-msvc "1.33.0" + lilconfig@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4" @@ -19288,11 +19136,6 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3 dependencies: js-tokens "^3.0.0 || ^4.0.0" -loupe@^3.1.0, loupe@^3.1.4: - version "3.1.4" - resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.4.tgz#784a0060545cb38778ffb19ccde44d7870d5fdd9" - integrity sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg== - lower-case@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" @@ -19398,7 +19241,7 @@ magic-string@^0.26.0, magic-string@^0.26.7: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.14, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.21, magic-string@~0.30.8: +magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.14, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.21, magic-string@~0.30.8: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== @@ -20605,10 +20448,10 @@ named-placeholders@^1.1.6: dependencies: lru.min "^1.1.0" -nanoid@^3.3.17, nanoid@^3.3.6, nanoid@^3.3.8: - version "3.3.18" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" - integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== +nanoid@^3.3.18, nanoid@^3.3.6, nanoid@^3.3.8: + version "3.3.19" + resolved "https://sfw.security.sentry.io/npm/nanoid/-/nanoid-3.3.19.tgz#336d4aa4bcd4fb24d2cddede7ffeae40bec03f0a" + integrity sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug== nanoid@^5.1.7: version "5.1.9" @@ -22315,11 +22158,6 @@ pathe@^2.0.1, pathe@^2.0.3: resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== -pathval@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.0.tgz#7e2550b422601d4f6b8e26f1301bc8f15a741a25" - integrity sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA== - pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" @@ -22441,10 +22279,10 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== -picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4, picomatch@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" - integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== +picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4, picomatch@^4.0.5, picomatch@^4.0.7: + version "4.0.7" + resolved "https://sfw.security.sentry.io/npm/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== pidtree@^0.6.0: version "0.6.0" @@ -23195,12 +23033,12 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.1.10, postcss@^8.2.14, postcss@^8.3.7, postcss@^8.4.43, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.19, postcss@^8.5.25, postcss@^8.5.3, postcss@^8.5.6: - version "8.5.26" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.26.tgz#6e75135780c7e10df3433bf2266c552d35c8c620" - integrity sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ== +postcss@^8.1.10, postcss@^8.2.14, postcss@^8.3.7, postcss@^8.4.43, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.19, postcss@^8.5.25, postcss@^8.5.28, postcss@^8.5.3, postcss@^8.5.6: + version "8.5.28" + resolved "https://sfw.security.sentry.io/npm/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9" + integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== dependencies: - nanoid "^3.3.17" + nanoid "^3.3.18" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -24500,29 +24338,29 @@ roarr@^7.0.4: safe-stable-stringify "^2.4.1" semver-compare "^1.0.0" -rolldown@^1.0.0, rolldown@^1.0.0-rc.15, rolldown@^1.0.0-rc.8: - version "1.1.2" - resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.1.2.tgz#accb41e26c872ad2c5198a39c1281c7b6b844097" - integrity sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ== +rolldown@^1.0.0, rolldown@^1.0.0-rc.15, rolldown@^1.0.0-rc.8, rolldown@~1.2.9: + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/rolldown/-/rolldown-1.2.11.tgz#89c4e07d9098b88fe0b023de59d63a6216e9285c" + integrity sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw== dependencies: - "@oxc-project/types" "=0.137.0" + "@oxc-project/types" "=0.151.0" "@rolldown/pluginutils" "^1.0.0" optionalDependencies: - "@rolldown/binding-android-arm64" "1.1.2" - "@rolldown/binding-darwin-arm64" "1.1.2" - "@rolldown/binding-darwin-x64" "1.1.2" - "@rolldown/binding-freebsd-x64" "1.1.2" - "@rolldown/binding-linux-arm-gnueabihf" "1.1.2" - "@rolldown/binding-linux-arm64-gnu" "1.1.2" - "@rolldown/binding-linux-arm64-musl" "1.1.2" - "@rolldown/binding-linux-ppc64-gnu" "1.1.2" - "@rolldown/binding-linux-s390x-gnu" "1.1.2" - "@rolldown/binding-linux-x64-gnu" "1.1.2" - "@rolldown/binding-linux-x64-musl" "1.1.2" - "@rolldown/binding-openharmony-arm64" "1.1.2" - "@rolldown/binding-wasm32-wasi" "1.1.2" - "@rolldown/binding-win32-arm64-msvc" "1.1.2" - "@rolldown/binding-win32-x64-msvc" "1.1.2" + "@rolldown/binding-android-arm-eabi" "1.2.11" + "@rolldown/binding-android-arm64" "1.2.11" + "@rolldown/binding-darwin-arm64" "1.2.11" + "@rolldown/binding-darwin-x64" "1.2.11" + "@rolldown/binding-freebsd-x64" "1.2.11" + "@rolldown/binding-linux-arm-gnueabihf" "1.2.11" + "@rolldown/binding-linux-arm64-gnu" "1.2.11" + "@rolldown/binding-linux-arm64-musl" "1.2.11" + "@rolldown/binding-linux-ppc64-gnu" "1.2.11" + "@rolldown/binding-linux-s390x-gnu" "1.2.11" + "@rolldown/binding-linux-x64-gnu" "1.2.11" + "@rolldown/binding-linux-x64-musl" "1.2.11" + "@rolldown/binding-openharmony-arm64" "1.2.11" + "@rolldown/binding-win32-arm64-msvc" "1.2.11" + "@rolldown/binding-win32-x64-msvc" "1.2.11" rollup-plugin-copy-assets@^2.0.3: version "2.0.3" @@ -25744,14 +25582,14 @@ statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.1, statuses@~2.0.2: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== -std-env@^3.7.0, std-env@^3.9.0: +std-env@^3.7.0: version "3.10.0" resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== -std-env@^4.0.0, std-env@^4.1.0, std-env@^4.2.0: +std-env@^4.0.0, std-env@^4.0.0-rc.1, std-env@^4.1.0, std-env@^4.2.0: version "4.2.0" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" + resolved "https://sfw.security.sentry.io/npm/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" integrity sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw== stdin-discarder@^0.2.2: @@ -26010,13 +25848,6 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= -strip-literal@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" - integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== - dependencies: - js-tokens "^9.0.1" - strip-literal@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-4.0.0.tgz#01bb0daf7af141ce8d2328bdeecc6b1597aebc0c" @@ -26346,15 +26177,6 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -test-exclude@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" - integrity sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg== - dependencies: - "@istanbuljs/schema" "^0.1.2" - glob "^10.4.1" - minimatch "^9.0.4" - text-decoder@^1.1.0: version "1.2.3" resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.3.tgz#b19da364d981b2326d5f43099c310cc80d770c65" @@ -26447,15 +26269,15 @@ tinyclip@^0.1.15: resolved "https://registry.yarnpkg.com/tinyclip/-/tinyclip-0.1.15.tgz#db35eaa2bd5fe627b3ef2ea38d8b0407f2bc2def" integrity sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A== -tinyexec@^0.3.1, tinyexec@^0.3.2: +tinyexec@^0.3.1: version "0.3.2" resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== -tinyexec@^1.2.4: - version "1.3.0" - resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.3.0.tgz#aacc1dbb1d4e93e6ad8dd64944e09f9ad147a474" - integrity sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ== +tinyexec@^1.0.2, tinyexec@^1.2.4: + version "1.3.1" + resolved "https://sfw.security.sentry.io/npm/tinyexec/-/tinyexec-1.3.1.tgz#16a2e3c6e23fafce72640e678e651db36145b9c6" + integrity sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA== tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15, tinyglobby@^0.2.16, tinyglobby@^0.2.17, tinyglobby@^0.2.2: version "0.2.17" @@ -26470,20 +26292,10 @@ tinypool@2.1.2: resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-2.1.2.tgz#fcb3df44405c2530259e4bef5928a90bf46134dd" integrity sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww== -tinypool@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" - integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== - -tinyrainbow@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" - integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== - -tinyspy@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.3.tgz#d1d0f0602f4c15f1aae083a34d6d0df3363b1b52" - integrity sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A== +tinyrainbow@^3.1.0: + version "3.1.1" + resolved "https://sfw.security.sentry.io/npm/tinyrainbow/-/tinyrainbow-3.1.1.tgz#c0168387d3d8d70b6b3c2c0936de5fee738cea20" + integrity sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw== tldts-core@^6.1.86: version "6.1.86" @@ -27623,7 +27435,7 @@ vite-hot-client@^2.2.0: resolved "https://registry.yarnpkg.com/vite-hot-client/-/vite-hot-client-2.2.0.tgz#284fa1afa63cc8781d079515884eb49d2890ac2f" integrity sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w== -vite-node@3.2.4, vite-node@^3.2.2: +vite-node@^3.2.2: version "3.2.4" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== @@ -27696,20 +27508,6 @@ vite-plugin-vue-tracer@^1.4.0: pathe "^2.0.3" source-map-js "^1.2.1" -vite@7.3.5: - version "7.3.5" - resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.5.tgz#90c2d0b7b94a224e7e7dcf22d2912ff0b5291165" - integrity sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww== - dependencies: - esbuild "^0.27.0" - fdir "^6.5.0" - picomatch "^4.0.3" - postcss "^8.5.6" - rollup "^4.43.0" - tinyglobby "^0.2.15" - optionalDependencies: - fsevents "~2.3.3" - "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^7.3.1, vite@^7.3.6: version "7.3.6" resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.6.tgz#0547a395e68d3746e9a505f1fd4469fe09b49cc4" @@ -27735,7 +27533,20 @@ vite@^5.4.11: optionalDependencies: fsevents "~2.3.3" -vite@^6.4.1, vite@^6.4.3: +"vite@^6.0.0 || ^7.0.0 || ^8.0.0", vite@^8.3.1: + version "8.3.1" + resolved "https://sfw.security.sentry.io/npm/vite/-/vite-8.3.1.tgz#aa1099a4ccaf104a8c32492951497fa1781d9fce" + integrity sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA== + dependencies: + lightningcss "^1.33.0" + picomatch "^4.0.7" + postcss "^8.5.28" + rolldown "~1.2.9" + tinyglobby "^0.2.17" + optionalDependencies: + fsevents "~2.3.3" + +vite@^6.4.1: version "6.4.3" resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.3.tgz#85a164db7ce706f2a776812efa2b340f1721858e" integrity sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A== @@ -27759,33 +27570,30 @@ vitefu@^1.0.4: resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-1.0.7.tgz#430a6b178450ee90c1ea448cf3739ce100e9c54c" integrity sha512-eRWXLBbJjW3X5z5P5IHcSm2yYbYRPb2kQuc+oqsbAl99WB5kVsPbiiox+cymo8twTzifA6itvhr2CmjnaZZp0Q== -vitest@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.7.tgz#1944b6ed013a25fd26a73d18e1af92c10a57af6c" - integrity sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg== - dependencies: - "@types/chai" "^5.2.2" - "@vitest/expect" "3.2.7" - "@vitest/mocker" "3.2.7" - "@vitest/pretty-format" "^3.2.7" - "@vitest/runner" "3.2.7" - "@vitest/snapshot" "3.2.7" - "@vitest/spy" "3.2.7" - "@vitest/utils" "3.2.7" - chai "^5.2.0" - debug "^4.4.1" - expect-type "^1.2.1" - magic-string "^0.30.17" +vitest@^4.1.11: + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/vitest/-/vitest-4.1.11.tgz#1653c1521ae917f960d9b21877797c47dfd8bf21" + integrity sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw== + dependencies: + "@vitest/expect" "4.1.11" + "@vitest/mocker" "4.1.11" + "@vitest/pretty-format" "4.1.11" + "@vitest/runner" "4.1.11" + "@vitest/snapshot" "4.1.11" + "@vitest/spy" "4.1.11" + "@vitest/utils" "4.1.11" + es-module-lexer "^2.0.0" + expect-type "^1.3.0" + magic-string "^0.30.21" + obug "^2.1.1" pathe "^2.0.3" - picomatch "^4.0.2" - std-env "^3.9.0" + picomatch "^4.0.3" + std-env "^4.0.0-rc.1" tinybench "^2.9.0" - tinyexec "^0.3.2" - tinyglobby "^0.2.14" - tinypool "^1.1.1" - tinyrainbow "^2.0.0" - vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" - vite-node "3.2.4" + tinyexec "^1.0.2" + tinyglobby "^0.2.15" + tinyrainbow "^3.1.0" + vite "^6.0.0 || ^7.0.0 || ^8.0.0" why-is-node-running "^2.3.0" volar-service-html@~0.0.71: From 68b6f79dce0471f7bb5d659593ba2f0f6c4acdf8 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 29 Sep 2026 18:38:13 +0200 Subject: [PATCH 34/65] chore(solidstart): Remove unused Vitest setup from e2e apps (#24789) The SolidStart e2e apps only run Playwright, so `vitest`, `@vitest/ui` and `vitest.config.ts` are unused leftovers from the Solid CLI template. Noticed while working on #24746. Didn't cause a direct dependency duplication but thought it would be good to get this cleaned up. Co-authored-by: Claude Opus 5.5 (1M context) --- .../test-applications/solidstart-2/README.md | 11 ----------- .../test-applications/solidstart-2/package.json | 4 +--- .../test-applications/solidstart-2/tsconfig.json | 2 +- .../test-applications/solidstart-2/vitest.config.ts | 10 ---------- .../solidstart-dynamic-import/README.md | 11 ----------- .../solidstart-dynamic-import/package.json | 3 +-- .../solidstart-dynamic-import/tsconfig.json | 2 +- .../solidstart-dynamic-import/vitest.config.ts | 10 ---------- .../test-applications/solidstart-spa/README.md | 11 ----------- .../test-applications/solidstart-spa/package.json | 3 +-- .../test-applications/solidstart-spa/tsconfig.json | 2 +- .../test-applications/solidstart-spa/vitest.config.ts | 10 ---------- .../test-applications/solidstart-static/README.md | 11 ----------- .../test-applications/solidstart-static/package.json | 3 +-- .../test-applications/solidstart-static/tsconfig.json | 2 +- .../solidstart-static/vitest.config.ts | 10 ---------- .../solidstart-top-level-import/README.md | 11 ----------- .../solidstart-top-level-import/package.json | 3 +-- .../solidstart-top-level-import/tsconfig.json | 2 +- .../solidstart-top-level-import/vitest.config.ts | 10 ---------- .../e2e-tests/test-applications/solidstart/README.md | 11 ----------- .../test-applications/solidstart/package.json | 3 +-- .../test-applications/solidstart/tsconfig.json | 2 +- .../test-applications/solidstart/vitest.config.ts | 10 ---------- 24 files changed, 12 insertions(+), 145 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/solidstart-2/vitest.config.ts delete mode 100644 dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/vitest.config.ts delete mode 100644 dev-packages/e2e-tests/test-applications/solidstart-spa/vitest.config.ts delete mode 100644 dev-packages/e2e-tests/test-applications/solidstart-static/vitest.config.ts delete mode 100644 dev-packages/e2e-tests/test-applications/solidstart-top-level-import/vitest.config.ts delete mode 100644 dev-packages/e2e-tests/test-applications/solidstart/vitest.config.ts diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/README.md b/dev-packages/e2e-tests/test-applications/solidstart-2/README.md index 9a141e9c2f0d..2b2846ee4ac8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/README.md +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/README.md @@ -31,15 +31,4 @@ Solid apps are built with _presets_, which optimise your project for deployment By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`. -## Testing - -Tests are written with `vitest`, `@solidjs/testing-library` and `@testing-library/jest-dom` to extend expect with some -helpful custom matchers. - -To run them, simply start: - -```sh -npm test -``` - ## This project was created with the [Solid CLI](https://solid-cli.netlify.app) diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json index 58c2b2b37c00..9fd694db92d4 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json @@ -25,13 +25,11 @@ "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^6.4.2", "@testing-library/user-event": "^14.5.2", - "@vitest/ui": "^1.5.0", "jsdom": "^24.0.0", "solid-js": "1.9.14", "typescript": "^5.4.5", "vite": "^8.1.5", - "vite-plugin-solid": "^2.11.6", - "vitest": "^1.5.0" + "vite-plugin-solid": "^2.11.6" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/tsconfig.json b/dev-packages/e2e-tests/test-applications/solidstart-2/tsconfig.json index 3dcf7bfb7398..773b6011155d 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/tsconfig.json @@ -10,7 +10,7 @@ "allowJs": true, "strict": true, "noEmit": true, - "types": ["@solidjs/start/env", "vitest/globals", "@testing-library/jest-dom"], + "types": ["@solidjs/start/env", "@testing-library/jest-dom"], "isolatedModules": true, "paths": { "~/*": ["./src/*"] diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/vitest.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/vitest.config.ts deleted file mode 100644 index 6c2b639dc300..000000000000 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import solid from 'vite-plugin-solid'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [solid()], - resolve: { - conditions: ['development', 'browser'], - }, - envPrefix: 'PUBLIC_', -}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/README.md b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/README.md index 9a141e9c2f0d..2b2846ee4ac8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/README.md +++ b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/README.md @@ -31,15 +31,4 @@ Solid apps are built with _presets_, which optimise your project for deployment By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`. -## Testing - -Tests are written with `vitest`, `@solidjs/testing-library` and `@testing-library/jest-dom` to extend expect with some -helpful custom matchers. - -To run them, simply start: - -```sh -npm test -``` - ## This project was created with the [Solid CLI](https://solid-cli.netlify.app) diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json index 9072e925225b..9d4519417b19 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json @@ -28,8 +28,7 @@ "typescript": "^5.4.5", "vinxi": "^0.4.0", "vite": "^5.4.10", - "vite-plugin-solid": "^2.11.6", - "vitest": "^3.2.7" + "vite-plugin-solid": "^2.11.6" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tsconfig.json b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tsconfig.json index 6f11292cc5d8..8619427a3226 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tsconfig.json @@ -10,7 +10,7 @@ "allowJs": true, "strict": true, "noEmit": true, - "types": ["vinxi/types/client", "vitest/globals", "@testing-library/jest-dom"], + "types": ["vinxi/types/client", "@testing-library/jest-dom"], "isolatedModules": true, "paths": { "~/*": ["./src/*"] diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/vitest.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/vitest.config.ts deleted file mode 100644 index 6c2b639dc300..000000000000 --- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import solid from 'vite-plugin-solid'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [solid()], - resolve: { - conditions: ['development', 'browser'], - }, - envPrefix: 'PUBLIC_', -}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/README.md b/dev-packages/e2e-tests/test-applications/solidstart-spa/README.md index 9a141e9c2f0d..2b2846ee4ac8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/README.md +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/README.md @@ -31,15 +31,4 @@ Solid apps are built with _presets_, which optimise your project for deployment By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`. -## Testing - -Tests are written with `vitest`, `@solidjs/testing-library` and `@testing-library/jest-dom` to extend expect with some -helpful custom matchers. - -To run them, simply start: - -```sh -npm test -``` - ## This project was created with the [Solid CLI](https://solid-cli.netlify.app) diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json index 88f352108701..9636c7bb4600 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json @@ -28,8 +28,7 @@ "typescript": "^5.4.5", "vinxi": "^0.4.0", "vite": "^5.4.11", - "vite-plugin-solid": "^2.11.6", - "vitest": "^3.2.7" + "vite-plugin-solid": "^2.11.6" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/tsconfig.json b/dev-packages/e2e-tests/test-applications/solidstart-spa/tsconfig.json index 6f11292cc5d8..8619427a3226 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/tsconfig.json @@ -10,7 +10,7 @@ "allowJs": true, "strict": true, "noEmit": true, - "types": ["vinxi/types/client", "vitest/globals", "@testing-library/jest-dom"], + "types": ["vinxi/types/client", "@testing-library/jest-dom"], "isolatedModules": true, "paths": { "~/*": ["./src/*"] diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/vitest.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-spa/vitest.config.ts deleted file mode 100644 index 6c2b639dc300..000000000000 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import solid from 'vite-plugin-solid'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [solid()], - resolve: { - conditions: ['development', 'browser'], - }, - envPrefix: 'PUBLIC_', -}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-static/README.md b/dev-packages/e2e-tests/test-applications/solidstart-static/README.md index 9a141e9c2f0d..2b2846ee4ac8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-static/README.md +++ b/dev-packages/e2e-tests/test-applications/solidstart-static/README.md @@ -31,15 +31,4 @@ Solid apps are built with _presets_, which optimise your project for deployment By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`. -## Testing - -Tests are written with `vitest`, `@solidjs/testing-library` and `@testing-library/jest-dom` to extend expect with some -helpful custom matchers. - -To run them, simply start: - -```sh -npm test -``` - ## This project was created with the [Solid CLI](https://solid-cli.netlify.app) diff --git a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json index 1077b195acb6..e1d92d3bd733 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json @@ -30,8 +30,7 @@ "typescript": "^5.4.5", "vinxi": "^0.4.0", "vite": "^5.4.11", - "vite-plugin-solid": "^2.11.6", - "vitest": "^3.2.7" + "vite-plugin-solid": "^2.11.6" }, "volta": { "node": "20.19.5", diff --git a/dev-packages/e2e-tests/test-applications/solidstart-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/solidstart-static/tsconfig.json index 6f11292cc5d8..8619427a3226 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-static/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-static/tsconfig.json @@ -10,7 +10,7 @@ "allowJs": true, "strict": true, "noEmit": true, - "types": ["vinxi/types/client", "vitest/globals", "@testing-library/jest-dom"], + "types": ["vinxi/types/client", "@testing-library/jest-dom"], "isolatedModules": true, "paths": { "~/*": ["./src/*"] diff --git a/dev-packages/e2e-tests/test-applications/solidstart-static/vitest.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-static/vitest.config.ts deleted file mode 100644 index 6c2b639dc300..000000000000 --- a/dev-packages/e2e-tests/test-applications/solidstart-static/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import solid from 'vite-plugin-solid'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [solid()], - resolve: { - conditions: ['development', 'browser'], - }, - envPrefix: 'PUBLIC_', -}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/README.md b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/README.md index 9a141e9c2f0d..2b2846ee4ac8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/README.md +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/README.md @@ -31,15 +31,4 @@ Solid apps are built with _presets_, which optimise your project for deployment By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`. -## Testing - -Tests are written with `vitest`, `@solidjs/testing-library` and `@testing-library/jest-dom` to extend expect with some -helpful custom matchers. - -To run them, simply start: - -```sh -npm test -``` - ## This project was created with the [Solid CLI](https://solid-cli.netlify.app) diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json index ad24ad3da154..fd1d1c82cbdc 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json @@ -28,8 +28,7 @@ "typescript": "^5.4.5", "vinxi": "^0.4.0", "vite": "^5.4.11", - "vite-plugin-solid": "^2.11.6", - "vitest": "^3.2.7" + "vite-plugin-solid": "^2.11.6" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tsconfig.json b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tsconfig.json index 6f11292cc5d8..8619427a3226 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tsconfig.json @@ -10,7 +10,7 @@ "allowJs": true, "strict": true, "noEmit": true, - "types": ["vinxi/types/client", "vitest/globals", "@testing-library/jest-dom"], + "types": ["vinxi/types/client", "@testing-library/jest-dom"], "isolatedModules": true, "paths": { "~/*": ["./src/*"] diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/vitest.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/vitest.config.ts deleted file mode 100644 index 6c2b639dc300..000000000000 --- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import solid from 'vite-plugin-solid'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [solid()], - resolve: { - conditions: ['development', 'browser'], - }, - envPrefix: 'PUBLIC_', -}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/README.md b/dev-packages/e2e-tests/test-applications/solidstart/README.md index 9a141e9c2f0d..2b2846ee4ac8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/README.md +++ b/dev-packages/e2e-tests/test-applications/solidstart/README.md @@ -31,15 +31,4 @@ Solid apps are built with _presets_, which optimise your project for deployment By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`. -## Testing - -Tests are written with `vitest`, `@solidjs/testing-library` and `@testing-library/jest-dom` to extend expect with some -helpful custom matchers. - -To run them, simply start: - -```sh -npm test -``` - ## This project was created with the [Solid CLI](https://solid-cli.netlify.app) diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index ed907f5c5a47..a9c96734e0a2 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -32,8 +32,7 @@ "typescript": "^5.4.5", "vinxi": "^0.4.0", "vite": "^5.4.11", - "vite-plugin-solid": "^2.11.6", - "vitest": "^3.2.7" + "vite-plugin-solid": "^2.11.6" }, "volta": { "node": "20.19.5", diff --git a/dev-packages/e2e-tests/test-applications/solidstart/tsconfig.json b/dev-packages/e2e-tests/test-applications/solidstart/tsconfig.json index 6f11292cc5d8..8619427a3226 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/tsconfig.json @@ -10,7 +10,7 @@ "allowJs": true, "strict": true, "noEmit": true, - "types": ["vinxi/types/client", "vitest/globals", "@testing-library/jest-dom"], + "types": ["vinxi/types/client", "@testing-library/jest-dom"], "isolatedModules": true, "paths": { "~/*": ["./src/*"] diff --git a/dev-packages/e2e-tests/test-applications/solidstart/vitest.config.ts b/dev-packages/e2e-tests/test-applications/solidstart/vitest.config.ts deleted file mode 100644 index 6c2b639dc300..000000000000 --- a/dev-packages/e2e-tests/test-applications/solidstart/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import solid from 'vite-plugin-solid'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [solid()], - resolve: { - conditions: ['development', 'browser'], - }, - envPrefix: 'PUBLIC_', -}); From 21bc772a401426a2275d0e9b3e9e03f0bc6d1081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20Brunelle?= <4140279+EmileBrunelle@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:16:46 -0400 Subject: [PATCH 35/65] chore(bundler-plugins): Allow magic-string 1.x (#24768) Follow-up to #19510 / #19520: `@sentry/bundler-plugins` pins `magic-string` to `~0.30.8`, so apps whose other tooling already moved to 1.x (e.g. vitest) install two copies. This widens the range to `~0.30.8 || ^1.0.0` instead of forcing 1.x. Apps get whichever copy they already have. That matters because `@sentry/nextjs` itself still brings 0.30 through `@rollup/plugin-commonjs`, so a hard `^1` would create the duplicate this is meant to remove. `~` stays on the 0.30 side, as #19520 intended. 1.0.0's only breaking change is ESM-only packaging (no top-level await, no API changes up to 1.4.2). The package's engines field is already the `require(esm)` floor, so the CJS build works with both. I checked the 1.x side by `require()`-ing `build/cjs` from a plain `.cjs` script and running the component-annotation transform, on Node 20.19.0, 22.12.0, 22.13.0 and 24. The only difference: Node 22.12.x prints Node's `require(esm)` ExperimentalWarning once at build time. 22.13.0+ and 20.19.0 print nothing. `@sentry/svelte`, `@sentry/sveltekit` and `@sentry/cloudflare` have the same engines floor and still use `~0.30`. I'm happy to send the same change for them separately. --------- Co-authored-by: Claude Opus 5.5 --- packages/bundler-plugins/package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index 6386c666427c..3036f114e927 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -113,7 +113,7 @@ "@sentry/core": "11.1.0", "dotenv": "^17.4.2", "glob": "^13.0.6", - "magic-string": "~0.30.8", + "magic-string": "~0.30.8 || ^1.0.0", "oxc-parser": "^0.152.0", "sentry": "^0.45.0", "supports-color": "^8.1.1" diff --git a/yarn.lock b/yarn.lock index 0c0021e81a56..ffb9bd242b2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19241,14 +19241,14 @@ magic-string@^0.26.0, magic-string@^0.26.7: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.14, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.21, magic-string@~0.30.8: +magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.14, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.21: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" -magic-string@^1.1.0: +magic-string@^1.1.0, "magic-string@~0.30.8 || ^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-1.1.0.tgz#54a8470aabf2a04b970177f26c0308cabea74a3f" integrity sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g== From a7be467d4f1577101f74241d838ee0ef9adc8923 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:09:53 +0200 Subject: [PATCH 36/65] chore: Add external contributor to CHANGELOG.md (#24851) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #24768 Co-authored-by: timfish <1150298+timfish@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49191067ea6a..ee4522872cb0 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 @nabi-noor, @LuccaRebelloToledo, and @andasan. Thank you for your contributions! +Work in this release was contributed by @nabi-noor, @LuccaRebelloToledo, @andasan, and @EmileBrunelle. Thank you for your contributions! ## 11.1.0 From 6baedf11cfbbe465867d4f93628e7100056b425c Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:51:27 +0200 Subject: [PATCH 37/65] chore: Add external contributor to CHANGELOG.md (#24850) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #24770 Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> Co-authored-by: Nicolas Hrubec --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4522872cb0..0fbf2d36a92d 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 @nabi-noor, @LuccaRebelloToledo, @andasan, and @EmileBrunelle. Thank you for your contributions! +Work in this release was contributed by @nabi-noor, @LuccaRebelloToledo, @andasan, @breken-ai, and @EmileBrunelle. Thank you for your contributions! ## 11.1.0 From 9011a0099a7b31e496237fa09bd0fe5f940bd7d3 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 30 Sep 2026 08:36:40 +0200 Subject: [PATCH 38/65] perf(server-utils): Avoid quadratic SQL sanitizer output handling (#24834) Large SQL statements with many quoted literals can block the event loop, because the prefix checks in `getLiteralPrefix` cause repeated flattening of the result string buffer (resulting in a full copy of the result string up to that point). This is very costly and can be easily avoided by instead pushing the sanitized bits to an array and joining once at the end. Local benchmark comparing the approaches: | Literals | SQL size | String | Array | | --- | --- | --- | --- | | 10,000 | 0.4 MB | 5.01 ms | 1.51 ms | | 50,000 | 2.0 MB | 197.72 ms | 6.73 ms | | 100,000 | 4.0 MB | 1,354.99 ms | 13.80 ms | Closes #24812 --------- Co-authored-by: GPT-6 --- packages/server-utils/src/utils/sql.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/server-utils/src/utils/sql.ts b/packages/server-utils/src/utils/sql.ts index fb8ecd5ca8a8..25caaa4df01f 100644 --- a/packages/server-utils/src/utils/sql.ts +++ b/packages/server-utils/src/utils/sql.ts @@ -237,7 +237,7 @@ function findQuotedRunEnd(sql: string, start: number, delimiter: string, backsla */ function stripLiteralsAndComments(sql: string, dialect: SqlDialect): string { const isMysql = dialect === 'mysql'; - let out = ''; + const out: string[] = []; let i = 0; while (i < sql.length) { @@ -265,7 +265,7 @@ function stripLiteralsAndComments(sql: string, dialect: SqlDialect): string { if (tag) { const bodyEnd = sql.indexOf(tag, i + tag.length); i = bodyEnd === -1 ? sql.length : bodyEnd + tag.length; - out += '?'; + out.push('?'); continue; } } @@ -275,7 +275,7 @@ function stripLiteralsAndComments(sql: string, dialect: SqlDialect): string { const runEnd = findQuotedRunEnd(sql, i, identifierCloser, false); // A run that never closes is not an identifier, so it collapses instead of being copied out. // Copying would carry every literal in the rest of the statement through unlexed. - out += runEnd === -1 ? '?' : sql.slice(i, runEnd); + out.push(runEnd === -1 ? '?' : sql.slice(i, runEnd)); i = runEnd === -1 ? sql.length : runEnd; continue; } @@ -284,18 +284,20 @@ function stripLiteralsAndComments(sql: string, dialect: SqlDialect): string { // A prefix like `X'1A'`, `B'01'`, `N'…'` or PostgreSQL's `E'a\nb'` is part of the literal, so it has // to collapse into the same `?` instead of being left behind as a bare identifier. const prefix = char === "'" ? getLiteralPrefix(out, dialect) : undefined; - out = prefix ? out.slice(0, -1) : out; + if (prefix) { + out.pop(); + } const runEnd = findQuotedRunEnd(sql, i, char, isMysql || prefix === 'E'); i = runEnd === -1 ? sql.length : runEnd; - out += '?'; + out.push('?'); continue; } - out += char; + out.push(char); i++; } - return out; + return out.join(''); } /** @@ -329,13 +331,14 @@ function matchDollarQuoteTag(sql: string, start: number): string | undefined { * hex/binary literals, `N` for a national-character literal (SQL Server, MySQL), or `E` for a * PostgreSQL escape string (which honors backslash escapes). */ -function getLiteralPrefix(out: string, dialect: SqlDialect): 'X' | 'B' | 'N' | 'E' | undefined { +function getLiteralPrefix(out: string[], dialect: SqlDialect): 'X' | 'B' | 'N' | 'E' | undefined { + const last = out[out.length - 1]; // A prefix only counts when it stands alone — the `X` in `MAX'...'` belongs to the identifier - if (isIdentifierChar(out.slice(-2, -1))) { + if (last?.length !== 1 || isIdentifierChar(out[out.length - 2]?.slice(-1))) { return undefined; } - const prefix = out.slice(-1).toUpperCase(); + const prefix = last.toUpperCase(); if (prefix === 'X' || prefix === 'B' || prefix === 'N') { return prefix; } From 4aab1b8eadeb7640aa08a2cdf585656dd608caef Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 30 Sep 2026 10:17:51 +0200 Subject: [PATCH 39/65] fix(solidstart): Support `rolldownOptions` in instrumentation file plugin (#24863) In Vite 8, `rollupOptions` is a deprecated alias of `rolldownOptions`, and Vite ignores `rollupOptions` when a plugin returns both. The plugin now reads either key and writes the input back only under the one the config already uses, so older Vite keeps working. Follow-up to https://github.com/getsentry/sentry-javascript/pull/24746#discussion_r4131424058 (i.e. our Vite 8 bump surfaced this. It's not a breaking change. Only future proofing the solid start injection for Vite 8) --- .../src/vite/buildInstrumentationFile.ts | 15 +++++++------ .../test/vite/buildInstrumentation.test.ts | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/solidstart/src/vite/buildInstrumentationFile.ts b/packages/solidstart/src/vite/buildInstrumentationFile.ts index f9b8c93fb0b0..6fbbb60e1615 100644 --- a/packages/solidstart/src/vite/buildInstrumentationFile.ts +++ b/packages/solidstart/src/vite/buildInstrumentationFile.ts @@ -16,11 +16,13 @@ export function makeBuildInstrumentationFilePlugin(options: SentrySolidStartPlug async config(config: UserConfig, { command }) { const instrumentationFilePath = options.instrumentation || './src/instrument.server.ts'; const router = (config as UserConfig & { router: { target: string; name: string; root: string } }).router; - const build = config.build || {}; - // SolidStart builds with vinxi's Vite, which predates `rolldownOptions` + // `rollupOptions` is a deprecated alias of `rolldownOptions` in Vite 8+, but the only option in older Vite. + // Returning both makes Vite 8 ignore `rollupOptions`, so only write back the one that is in use. // oxlint-disable-next-line typescript/no-deprecated - const rollupOptions = build.rollupOptions || {}; - const input = [...((rollupOptions.input || []) as string[])]; + const { rollupOptions, rolldownOptions, ...build } = config.build || {}; + const bundlerOptionsKey = rolldownOptions ? 'rolldownOptions' : 'rollupOptions'; + const bundlerOptions = rolldownOptions || rollupOptions || {}; + const input = [...((bundlerOptions.input || []) as string[])]; // plugin runs for client, server and sever-fns, we only want to run it for the server once. if (command !== 'build' || router.target !== 'server' || router.name === 'server-fns') { @@ -46,9 +48,8 @@ export function makeBuildInstrumentationFilePlugin(options: SentrySolidStartPlug ...config, build: { ...build, - // oxlint-disable-next-line typescript/no-deprecated - rollupOptions: { - ...rollupOptions, + [bundlerOptionsKey]: { + ...bundlerOptions, input, }, }, diff --git a/packages/solidstart/test/vite/buildInstrumentation.test.ts b/packages/solidstart/test/vite/buildInstrumentation.test.ts index e0a3cbd41a02..465f2801da55 100644 --- a/packages/solidstart/test/vite/buildInstrumentation.test.ts +++ b/packages/solidstart/test/vite/buildInstrumentation.test.ts @@ -63,6 +63,28 @@ describe('makeBuildInstrumentationFilePlugin()', () => { expect(config.build.rollupOptions.input).toContain('/some/project/path/src/myapp/instrument.server.ts'); }); + it('adds the instrumentation file to `rolldownOptions` if the config uses them', async () => { + const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - this is always defined and always a function + const config = await buildInstrumentationFilePlugin.config( + { + ...viteConfig, + build: { + rolldownOptions: { + input: ['/path/to/entry1.js'], + }, + }, + }, + { command: 'build' }, + ); + expect(config.build.rolldownOptions.input).toEqual([ + '/path/to/entry1.js', + '/some/project/path/src/instrument.server.ts', + ]); + expect(config.build).not.toHaveProperty('rollupOptions'); + }); + it("doesn't add the instrumentation file for server function builds", async () => { const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment From bff1fba5cc65188c1f3647c652b65b66cd923201 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:36:39 +0200 Subject: [PATCH 40/65] test(e2e): Add trace test for background use cache revalidation (#24740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a test for background stale-while-revalidate refills of `use cache` entries. Target behavior: the refill runs in its own trace, not as part of the trace that served the stale value. The refill trace links back to the request that triggered it and becomes the `cache_origin` for future hits. The behavior is not implemented yet. The test is `test.fixme()`, not `test.fail()`: it waits for a `cache.revalidate` trace that never arrives, so it would hit the test timeout — and Playwright reports a timeout as a real failure even under `test.fail()`. Production only — the SWR timing does not hold on the dev server. ``` GET /api/use-cache-swr?id= — cacheLife({ stale: 5, revalidate: 2, expire: 300 }); sleep 3s after fill Trace1 (fill) Trace2 (stale hit) Trace3 (hit) |- put key:A <----link-----o get hit=true key:A |- get hit=true key:A ^ | | link (type TBD in the spec) | TraceR: cache.revalidate --o (own trace) | | | |- put key:A <----------------------link-------------------o cache_origin ``` Closes https://github.com/getsentry/sentry-javascript/issues/24732 Linear https://linear.app/getsentry/issue/JS-3805/trace-test-for-background-use-cache-revalidation --- .../app/api/use-cache-swr/route.ts | 15 +++ .../cacheOriginLinks-revalidation.spec.ts | 113 +++++++++++++++++ .../app/api/use-cache-swr/route.ts | 15 +++ .../cacheOriginLinks-revalidation.spec.ts | 119 ++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-swr/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-swr/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-swr/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-swr/route.ts new file mode 100644 index 000000000000..0332efe522b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-swr/route.ts @@ -0,0 +1,15 @@ +import { cacheLife } from 'next/cache'; +import type { NextRequest } from 'next/server'; + +async function getSwrValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + // After `revalidate` (2s), a read serves the stale value and triggers a background refill. + // `expire` is long so the entry stays valid for the whole test. + cacheLife({ stale: 5, revalidate: 2, expire: 300 }); + return { id, createdAt: Date.now() }; +} + +export async function GET(request: NextRequest) { + const id = request.nextUrl.searchParams.get('id') ?? 'default-id'; + return Response.json(await getSwrValue(id)); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts new file mode 100644 index 000000000000..50f88a41bdbb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts @@ -0,0 +1,113 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// Background stale-while-revalidate refills. Target behavior: the revalidation gets its own trace, +// separate from the request trace that served the stale value. The revalidation trace links back to +// that request, and its `cache.put` becomes the `cache_origin` for future hits. + +/* + +Trace1 (fill) Trace2 (stale hit) Trace3 (hit) +|- put key:A <----link-----o get hit=true key:A |- get hit=true key:A + ^ | + | link (type TBD in the spec) | +TraceR: cache.revalidate --o (own trace) | +| | +|- put key:A <----------------------link-------------------o cache_origin + +*/ + +// Not implemented yet. Unlike cacheOriginLinks-nesting.spec.ts, this test cannot document the +// target behavior with `test.fail()`: it would wait for a `cache.revalidate` trace that never +// arrives and time out, and Playwright reports a timeout as a real failure even under +// `test.fail()`. So the test is `test.fixme()` until the SDK emits the revalidation trace. + +test('runs background revalidation in its own trace linked to the triggering request', async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'SWR revalidation timing only holds in production'); + test.fixme(); + + const id = crypto.randomUUID(); + + const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-swr' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const fillTx = await fillTxPromise; + + // Sleep past `revalidate` (2s) but not `expire`, so the next read serves the stale value and triggers a background refill. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const staleHitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-swr' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + const revalidationTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'cache.revalidate'; + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const staleHitTx = await staleHitTxPromise; + + const fillPutSpan = fillTx.spans?.find(span => span.op === 'cache.put'); + expect(fillPutSpan).toBeDefined(); + + // The stale response still came from the original fill. + const staleHitGetSpan = staleHitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); + expect(staleHitGetSpan).toBeDefined(); + expect(staleHitGetSpan?.links).toEqual([ + { + trace_id: fillTx.contexts?.trace?.trace_id, + span_id: fillPutSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); + + // The visitor never waited for the refill, so the refill is not part of the serving trace: the + // background revalidation is its own trace, linked back to the request that triggered it. + const revalidationTx = await revalidationTxPromise; + expect(revalidationTx.contexts?.trace?.trace_id).not.toBe(staleHitTx.contexts?.trace?.trace_id); + expect(revalidationTx.contexts?.trace?.links).toEqual([ + { + trace_id: staleHitTx.contexts?.trace?.trace_id, + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + sampled: true, + // No link type for cache spans specced yet, so assert only the link target. + attributes: { 'sentry.link.type': expect.any(String) }, + }, + ]); + + const revalidationPutSpan = revalidationTx.spans?.find(span => span.op === 'cache.put'); + expect(revalidationPutSpan).toBeDefined(); + + // The revalidation's `cache.put` becomes the `cache_origin` for future hits. + const hitAfterRefillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-swr' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const hitAfterRefillTx = await hitAfterRefillTxPromise; + + const hitAfterRefillGetSpan = hitAfterRefillTx.spans?.find( + span => span.op === 'cache.get' && span.data?.['cache.hit'] === true, + ); + expect(hitAfterRefillGetSpan).toBeDefined(); + expect(hitAfterRefillGetSpan?.links).toEqual([ + { + trace_id: revalidationTx.contexts?.trace?.trace_id, + span_id: revalidationPutSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-swr/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-swr/route.ts new file mode 100644 index 000000000000..0332efe522b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-swr/route.ts @@ -0,0 +1,15 @@ +import { cacheLife } from 'next/cache'; +import type { NextRequest } from 'next/server'; + +async function getSwrValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + // After `revalidate` (2s), a read serves the stale value and triggers a background refill. + // `expire` is long so the entry stays valid for the whole test. + cacheLife({ stale: 5, revalidate: 2, expire: 300 }); + return { id, createdAt: Date.now() }; +} + +export async function GET(request: NextRequest) { + const id = request.nextUrl.searchParams.get('id') ?? 'default-id'; + return Response.json(await getSwrValue(id)); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts new file mode 100644 index 000000000000..b6aa21aa89c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks-utils'; + +// Background stale-while-revalidate refills. Target behavior: the revalidation gets its own trace, +// separate from the request trace that served the stale value. The revalidation trace links back to +// that request, and its `cache.put` becomes the `cache_origin` for future hits. + +/* + +Trace1 (fill) Trace2 (stale hit) Trace3 (hit) +|- put key:A <----link-----o get hit=true key:A |- get hit=true key:A + ^ | + | link (type TBD in the spec) | +TraceR: cache.revalidate --o (own trace) | +| | +|- put key:A <----------------------link-------------------o cache_origin + +*/ + +// Not implemented yet. Unlike cacheOriginLinks-nesting.spec.ts, this test cannot document the +// target behavior with `test.fail()`: it would wait for a `cache.revalidate` trace that never +// arrives and time out, and Playwright reports a timeout as a real failure even under +// `test.fail()`. So the test is `test.fixme()` until the SDK emits the revalidation trace. + +test('runs background revalidation in its own trace linked to the triggering request', async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'SWR revalidation timing only holds in production'); + test.fixme(); + + const id = crypto.randomUUID(); + + const fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-swr' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const fillSpans = await fillSpansPromise; + + // Sleep past `revalidate` (2s) but not `expire`, so the next read serves the stale value and + // triggers a background refill. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const staleHitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-swr' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + // The visitor never waited for the refill, so the refill is not part of the serving trace: the + // background revalidation is its own trace with a `cache.revalidate` segment. + const revalidationSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => getSpanOp(span) === 'cache.revalidate' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const staleHitSpans = await staleHitSpansPromise; + + const fillPutSpan = findCacheSpan(fillSpans, 'cache.put'); + expect(fillPutSpan).toBeDefined(); + + // The stale response still came from the original fill. + const staleHitGetSpan = findCacheSpan(staleHitSpans, 'cache.get', true); + expect(staleHitGetSpan).toBeDefined(); + expect(staleHitGetSpan?.links).toEqual([ + { + trace_id: fillPutSpan!.trace_id, + span_id: fillPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); + + const revalidationSpans = await revalidationSpansPromise; + const revalidationSegment = revalidationSpans.find(span => span.is_segment && getSpanOp(span) === 'cache.revalidate'); + expect(revalidationSegment).toBeDefined(); + expect(revalidationSegment!.trace_id).not.toBe(staleHitGetSpan!.trace_id); + expect(revalidationSegment!.links).toEqual([ + { + trace_id: staleHitGetSpan!.trace_id, + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + sampled: true, + // No link type for cache spans specced yet, so assert only the link target. + attributes: { 'sentry.link.type': { value: expect.any(String), type: 'string' } }, + }, + ]); + + const revalidationPutSpan = findCacheSpan(revalidationSpans, 'cache.put'); + expect(revalidationPutSpan).toBeDefined(); + + // The revalidation's `cache.put` becomes the `cache_origin` for future hits. + const hitAfterRefillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-swr' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) && + spansOfTrace.every(span => span.trace_id !== staleHitGetSpan!.trace_id) + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const hitAfterRefillSpans = await hitAfterRefillSpansPromise; + + const hitAfterRefillGetSpan = findCacheSpan(hitAfterRefillSpans, 'cache.get', true); + expect(hitAfterRefillGetSpan).toBeDefined(); + expect(hitAfterRefillGetSpan?.links).toEqual([ + { + trace_id: revalidationPutSpan!.trace_id, + span_id: revalidationPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); From 9f99ac6c7704c638c88226e8e91e71f721308d2b Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:09:24 +0200 Subject: [PATCH 41/65] fix(vue): Share one root render span debounce timer across components (#24868) The tracing mixin stored its debounce timer on the calling component while ending the span on `$root`, so every mounted component started its own 2-second timer. All debounce state now lives on `$root`: activity is a property write with one shared timer. The span ends at the last render activity, so late-mounting children now extend it when the mixins API is used (the intent of #16488). There's a behavior difference between mixins and the `app.mount()` wrap (for components that mount later): Without the Options API there is no way to hook into child component lifecycles, so the `app.mount()` wrap can only observe the root and late children (async components, `Suspense`children) extend the span only when mixins are used. Fixes #24858 --- .../vue-3/src/views/DelayedView.vue | 6 +- .../vue-3/tests/performance.test.ts | 14 +++-- packages/vue/src/rootInstrumentation.ts | 3 +- packages/vue/src/tracing.ts | 40 ++++++++++--- .../integration/mixinRegistration.test.ts | 46 +++++++++++++-- .../vue/test/tracing/tracingMixin.test.ts | 57 +++++++++++++++++++ 6 files changed, 146 insertions(+), 20 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue index c5dad2ae5e76..f426ab742fca 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue @@ -5,8 +5,10 @@ import { defineAsyncComponent, h } from 'vue'; // Must stay in sync with `ASYNC_CHILD_DELAY_S` in `tests/performance.test.ts`. const ASYNC_CHILD_DELAY_MS = 300; -// A child that mounts a fixed delay after the rest of the page, so tests can prove the -// `Application Render` span does not wait for late children on either instrumentation path. +// A child that mounts a fixed delay after the rest of the page, so tests can prove how each +// instrumentation path treats late children: +// - mixin: extends the `Application Render` span until the child mounts +// - `app.mount()` wrap: does not observe late children const DelayedChild = defineAsyncComponent( () => new Promise(resolve => { diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts index 107ac0fa9f82..a7ae9b1599ca 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts @@ -172,10 +172,13 @@ test('sends a pageload span with a route name as span name if available', async }); }); -// True on both variants: the mixin arms one debounce timer per component (`tracing.ts`), so a -// late child never clears the root's earlier timer and the span ends at the root's mount. The -// `app.mount()` wrap only observes the root, so it matches. -test('ends the application render span before a delayed async component mounts', async ({ page }) => { +// Mixin: records render activity from every component, so the late child pushes the root render span's end out to its own mount. +// `app.mount()` wrap: only observes the root, so without the Options API the span still ends before the child mounts. +const [minRenderSpanDuration, maxRenderSpanDuration] = OPTIONS_API_DISABLED + ? [0, ASYNC_CHILD_DELAY_S] + : [ASYNC_CHILD_DELAY_S, 10]; + +test('ends the application render span at the last observed render activity', async ({ page }) => { const spansPromise = collectStreamedSpans('vue-3', spans => spans.some( span => span.is_segment && getSpanOp(span) === 'pageload' && span.attributes['url.path']?.value === '/delayed', @@ -197,7 +200,8 @@ test('ends the application render span before a delayed async component mounts', expect(applicationRenderSpan?.end_timestamp).toEqual(expect.any(Number)); const duration = (applicationRenderSpan?.end_timestamp ?? 0) - (applicationRenderSpan?.start_timestamp ?? 0); - expect(duration).toBeLessThan(ASYNC_CHILD_DELAY_S); + expect(duration).toBeGreaterThanOrEqual(minRenderSpanDuration); + expect(duration).toBeLessThan(maxRenderSpanDuration); }); test('sends a lifecycle span for the root and for each tracked component only', async ({ page }) => { diff --git a/packages/vue/src/rootInstrumentation.ts b/packages/vue/src/rootInstrumentation.ts index 4f0e30dd2b2c..5b16a7346b4b 100644 --- a/packages/vue/src/rootInstrumentation.ts +++ b/packages/vue/src/rootInstrumentation.ts @@ -18,7 +18,8 @@ function createRootViewModel(): VueSentry { * builds where the Options API is compiled out and `app.mixin()` is a silent no-op (Nuxt 5 default). * * Vue runs all `mounted` hooks before `mount()` returns, so the wrap covers the same window as the - * mixin's root hooks. Late mounts extend neither path; the mixin's debounce timers are per component. + * mixin's root hooks. Late mounts extend the span only when mixins are used: they record render activity + * from every component, while the `app.mount()` wrap only observes the root component. */ export function instrumentAppMountWithoutMixin(app: Vue, mixins: Mixins): void { // A second wrap would duplicate the root spans (e.g. user and Nuxt SDK both add the integration). diff --git a/packages/vue/src/tracing.ts b/packages/vue/src/tracing.ts index bbcd1002a376..a6084cd58ff0 100644 --- a/packages/vue/src/tracing.ts +++ b/packages/vue/src/tracing.ts @@ -45,6 +45,7 @@ export interface VueSentry extends ViewModel { }; $_sentryRootComponentSpan?: Span; $_sentryRootComponentSpanTimer?: ReturnType; + $_sentryRootComponentSpanActivity?: number; } // Mappings from operation to corresponding lifecycle hook. @@ -59,18 +60,41 @@ const HOOKS: { [key in Operation]: Hook[] } = { update: ['beforeUpdate', 'updated'], }; -/** End the top-level component span and activity with a debounce configured using `timeout` option */ +/** + * End the root component span once no render activity happened for `timeout` (a debounce). + * + * All debounce state lives on `$root`, so hooks from every component share one timer. Each component + * only writes a timestamp (no new timer is created). So mounting any number of components uses a single timer. + */ function maybeEndRootComponentSpan(vm: VueSentry, timestamp: number, timeout: number): void { - if (vm.$_sentryRootComponentSpanTimer) { - clearTimeout(vm.$_sentryRootComponentSpanTimer); + const root = vm.$root; + root.$_sentryRootComponentSpanActivity = timestamp; + + if (!root.$_sentryRootComponentSpanTimer) { + scheduleRootComponentSpanEnd(root, timestamp, timeout); } +} + +/** + * Fires `delayMs` after the activity that scheduled it. Activity recorded in the meantime pushes + * the deadline out by the recorded gap, so the span always ends at the last activity timestamp. + */ +function scheduleRootComponentSpanEnd(root: VueSentry, activityWhenScheduled: number, delayMs: number): void { + root.$_sentryRootComponentSpanTimer = setTimeout(() => { + root.$_sentryRootComponentSpanTimer = undefined; + + const lastActivity = root.$_sentryRootComponentSpanActivity ?? activityWhenScheduled; + if (lastActivity > activityWhenScheduled) { + // activity happened after this timer was scheduled: push the deadline out + scheduleRootComponentSpanEnd(root, lastActivity, (lastActivity - activityWhenScheduled) * 1000); + return; + } - vm.$_sentryRootComponentSpanTimer = setTimeout(() => { - if (vm.$root?.$_sentryRootComponentSpan) { - vm.$root.$_sentryRootComponentSpan.end(timestamp); - vm.$root.$_sentryRootComponentSpan = undefined; + if (root.$_sentryRootComponentSpan) { + root.$_sentryRootComponentSpan.end(lastActivity); + root.$_sentryRootComponentSpan = undefined; } - }, timeout); + }, delayMs); } /** Find if the current component exists in the provided `TracingOptions.trackComponents` array option. */ diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts index 9f9a03aa71d4..8b5eb9de4048 100644 --- a/packages/vue/test/integration/mixinRegistration.test.ts +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ +import type * as SentryCore from '@sentry/core'; import { spanToJSON } from '@sentry/core'; import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it as baseIt, vi } from 'vitest'; @@ -10,6 +11,13 @@ import { createApp, defineAsyncComponent, h, nextTick, ref } from 'vue'; import * as Sentry from '../../src'; import type { Options, TracingOptions } from '../../src/types'; +vi.mock('@sentry/core', async importOriginal => { + return { + ...(await importOriginal()), + timestampInSeconds: () => Date.now() / 1000, + }; +}); + const PUBLIC_DSN = 'https://username@domain/123'; const ROOT_SPAN_TIMEOUT_MS = 100; @@ -259,9 +267,8 @@ describe('tracing mixin span creation', () => { ]); }); - // `maybeEndRootComponentSpan` arms one debounce timer per component, so a late child never - // clears the root's earlier timer, and the root's timer ends the span first. The twin test in - // the disabled describe below proves the `app.mount()` wrap matches. + // The debounce only waits for recorded render activity, so a child that never mounts records + // none and cannot keep the root render span open. it('ends the root render span before a deferred child mounts', ({ uiSpans, initSentry }) => { const { app } = createAppWithDeferredChild(); initSentry({ sdk: { app } }); @@ -274,6 +281,36 @@ describe('tracing mixin span creation', () => { ]); }); + it('extends the root render span while children keep mounting within the timeout', async ({ + uiSpans, + initSentry, + }) => { + const showChild = ref(false); + const child = { name: 'ChildComponent', render: () => h('p', 'child') }; + const app = createApp({ name: 'RootComponent', render: () => h('div', [showChild.value ? h(child) : null]) }); + initSentry({ sdk: { app } }); + const container = document.createElement('div'); + + await Sentry.startSpan({ name: 'pageload' }, async () => { + app.mount(container); + + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS - 1); + showChild.value = true; + await nextTick(); + + // The original deadline passes, but the child's mount pushed it out. + vi.advanceTimersByTime(2); + expect(uiSpans.map(span => span.op)).not.toContain(UI_RENDER_SPAN_OP); + + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS + 1); + }); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + it('names UI spans after the component and preserves the original description when span streaming is enabled', ({ app, uiSpans, @@ -350,7 +387,8 @@ describe('tracing mixin span creation', () => { expect(rootInstance.$el).toBe(container.firstElementChild); }); - // Matches the mixin-path twin above: the mixin never waited for late children either. + // Unlike the mixin path, the `app.mount()` wrap only observes the root, so a late child can + // never extend the root render span here. it('ends the root render span before a deferred child mounts', ({ uiSpans, initSentry }) => { const { app } = createAppWithDeferredChild(); disableOptionsApi(app); diff --git a/packages/vue/test/tracing/tracingMixin.test.ts b/packages/vue/test/tracing/tracingMixin.test.ts index 72767c1005b6..5598c98f2f5c 100644 --- a/packages/vue/test/tracing/tracingMixin.test.ts +++ b/packages/vue/test/tracing/tracingMixin.test.ts @@ -1,9 +1,18 @@ import { getActiveSpan, startInactiveSpan } from '@sentry/browser'; +import type * as SentryCore from '@sentry/core'; import type { Mock } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_HOOKS } from '../../src/constants'; import { createTracingMixins } from '../../src/tracing'; +const clock = vi.hoisted(() => ({ now: 0 })); +vi.mock('@sentry/core', async importOriginal => { + return { + ...(await importOriginal()), + timestampInSeconds: () => clock.now, + }; +}); + vi.mock('@sentry/browser', () => { return { getActiveSpan: vi.fn(), @@ -42,6 +51,7 @@ describe('Vue Tracing Mixins', () => { beforeEach(() => { vi.clearAllMocks(); + clock.now = 0; mockRootInstance = { $root: null, @@ -148,6 +158,53 @@ describe('Vue Tracing Mixins', () => { ); }); + describe('Root Span Debounce', () => { + it('arms a single shared timer on the root for any number of untracked components', () => { + const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 }); + + mixins.beforeMount.call(mockRootInstance); + for (let i = 0; i < 100; i++) { + const child = { $root: mockRootInstance, componentName: `ChildComponent${i}` }; + mixins.beforeMount.call(child); + mixins.mounted.call(child); + } + + expect(vi.getTimerCount()).toBe(1); + }); + + it('ends the root span at the last recorded activity, pushing the deadline out for late activity', () => { + const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 }); + clock.now = 10; + mixins.beforeMount.call(mockRootInstance); + const rootSpan = mockRootInstance.$_sentryRootComponentSpan; + + clock.now = 10.8; + mixins.mounted.call(mockVueInstance); + + vi.advanceTimersByTime(1000); + expect(rootSpan.end).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(800); + expect(rootSpan.end).toHaveBeenCalledWith(10.8); + }); + + it('records no span when a component mounts after the root span already ended', () => { + const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 }); + mixins.beforeMount.call(mockRootInstance); + const rootSpan = mockRootInstance.$_sentryRootComponentSpan; + vi.advanceTimersByTime(1001); + expect(rootSpan.end).toHaveBeenCalledTimes(1); + + clock.now = 5; + mixins.beforeMount.call(mockVueInstance); + mixins.mounted.call(mockVueInstance); + vi.advanceTimersByTime(1001); + + expect(rootSpan.end).toHaveBeenCalledTimes(1); + expect(mockRootInstance.$_sentryRootComponentSpan).toBeUndefined(); + }); + }); + describe('Component Span Lifecycle', () => { it('should create and end spans correctly through lifecycle hooks', () => { const mixins = createTracingMixins({ trackComponents: true }); From a06c37fdc5e33cce3cbc2fa90b14dee8889ac169 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 30 Sep 2026 14:18:38 +0200 Subject: [PATCH 42/65] ref(hono): move shared Hono instrumentation into @sentry/server-utils (#24496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of two stacked PRs splitting the Hono instrumentation rework (originally #24371). Relocates the runtime-agnostic Hono instrumentation out of `@sentry/hono` into `@sentry/server-utils` (`src/integrations/hono/`), with `@sentry/hono` re-exporting it. Since `@sentry/server-utils` must not depend on `hono`, the relocated code no longer imports it: the `Hono` class is passed in by the SDK and a vendored `honoTypes` module provides the shapes. The `sentry()` middleware now builds its request handler via the shared `createHonoRequestMiddleware`. Pure relocation — no behavior change. Also renames the `hono-4` e2e test app to `hono-4-legacy` and moves the corresponding unit tests alongside the relocated code. The orchestrion-based auto-instrumentation that builds on this lives in the stacked PR #24497. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 6 +- .../{hono-4 => hono-4-legacy}/.gitignore | 0 .../{hono-4 => hono-4-legacy}/deno.json | 0 .../{hono-4 => hono-4-legacy}/package.json | 8 +- .../playwright.config.ts | 0 .../src/entry.bun.ts | 0 .../src/entry.cloudflare.ts | 0 .../src/entry.deno.ts | 0 .../src/entry.node.ts | 0 .../src/instrument.node.ts | 0 .../hono-4-legacy/src/middleware.ts | 34 ++++ .../src/route-groups/test-errors.ts | 0 .../src/route-groups/test-middleware.ts | 4 +- .../src/route-groups/test-multi-fetch.ts | 19 +++ .../src/route-groups/test-route-patterns.ts | 0 .../{hono-4 => hono-4-legacy}/src/routes.ts | 3 +- .../start-event-proxy.mjs | 2 +- .../tests/basepath-and-late-routes.test.ts | 0 .../tests/constants.ts | 2 +- .../tests/errors.test.ts | 0 .../tests/middleware.test.ts | 44 ++++- .../tests/multi-fetch.test.ts | 13 ++ .../tests/route-patterns.test.ts | 0 .../tests/tracing.test.ts | 0 .../{hono-4 => hono-4-legacy}/tsconfig.json | 0 .../{hono-4 => hono-4-legacy}/wrangler.jsonc | 2 +- .../hono-4/src/middleware.ts | 17 -- packages/hono/package.json | 3 +- packages/hono/src/bun/middleware.ts | 14 +- packages/hono/src/cloudflare/middleware.ts | 27 ++-- packages/hono/src/debug-build.ts | 8 - packages/hono/src/deno/middleware.ts | 20 +-- packages/hono/src/index.bun.ts | 5 +- packages/hono/src/index.cloudflare.ts | 5 +- packages/hono/src/index.deno.ts | 5 +- packages/hono/src/index.node.ts | 5 +- packages/hono/src/node/middleware.ts | 14 +- packages/hono/src/shared/patchAppRequest.ts | 75 --------- .../hono/test/shared/applyPatches.test.ts | 2 +- .../hono/test/shared/earlyPatchRoute.test.ts | 144 +---------------- .../test/shared/middlewareHandlers.test.ts | 152 ++++++++---------- .../hono/test/shared/patchAppRequest.test.ts | 40 ++--- packages/hono/test/shared/patchAppUse.test.ts | 149 +++-------------- .../hono/test/shared/resolveRouteName.test.ts | 113 ------------- .../test/shared/wrapMiddlewareSpan.test.ts | 127 --------------- packages/server-utils/src/exports.ts | 9 ++ .../src/integrations/hono}/applyPatches.ts | 27 +++- .../integrations/hono/createHonoMiddleware.ts | 97 +++++++++++ .../hono}/defaultShouldHandleError.ts | 0 .../src/integrations/hono}/hono-context.ts | 2 +- .../src/integrations/hono/honoTypes.ts | 71 ++++++++ .../src/integrations/hono/index.ts | 7 + .../src/integrations/hono}/isMiddleware.ts | 0 .../integrations/hono}/middlewareHandlers.ts | 7 +- .../src/integrations/hono/patchAppRequest.ts | 132 +++++++++++++++ .../src/integrations/hono}/patchAppUse.ts | 4 +- .../src/integrations/hono}/patchRoute.ts | 23 ++- .../integrations/hono}/resolveRouteName.ts | 18 ++- .../src/integrations/hono}/types.ts | 0 .../integrations/hono}/wrapMiddlewareSpan.ts | 9 +- .../hono/createHonoMiddleware.test.ts | 83 ++++++++++ .../hono}/defaultShouldHandleError.test.ts | 13 +- .../integrations/hono}/isMiddleware.test.ts | 2 +- 63 files changed, 758 insertions(+), 808 deletions(-) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/.gitignore (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/deno.json (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/package.json (90%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/playwright.config.ts (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/src/entry.bun.ts (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/src/entry.cloudflare.ts (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/src/entry.deno.ts (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/src/entry.node.ts (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/src/instrument.node.ts (100%) create mode 100644 dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/src/route-groups/test-errors.ts (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/src/route-groups/test-middleware.ts (92%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/src/route-groups/test-multi-fetch.ts (78%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/src/route-groups/test-route-patterns.ts (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/src/routes.ts (96%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/start-event-proxy.mjs (75%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/tests/basepath-and-late-routes.test.ts (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/tests/constants.ts (76%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/tests/errors.test.ts (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/tests/middleware.test.ts (89%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/tests/multi-fetch.test.ts (94%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/tests/route-patterns.test.ts (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/tests/tracing.test.ts (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/tsconfig.json (100%) rename dev-packages/e2e-tests/test-applications/{hono-4 => hono-4-legacy}/wrangler.jsonc (86%) delete mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts delete mode 100644 packages/hono/src/debug-build.ts delete mode 100644 packages/hono/src/shared/patchAppRequest.ts delete mode 100644 packages/hono/test/shared/resolveRouteName.test.ts delete mode 100644 packages/hono/test/shared/wrapMiddlewareSpan.test.ts rename packages/{hono/src/shared => server-utils/src/integrations/hono}/applyPatches.ts (60%) create mode 100644 packages/server-utils/src/integrations/hono/createHonoMiddleware.ts rename packages/{hono/src/shared => server-utils/src/integrations/hono}/defaultShouldHandleError.ts (100%) rename packages/{hono/src/utils => server-utils/src/integrations/hono}/hono-context.ts (87%) create mode 100644 packages/server-utils/src/integrations/hono/honoTypes.ts create mode 100644 packages/server-utils/src/integrations/hono/index.ts rename packages/{hono/src/utils => server-utils/src/integrations/hono}/isMiddleware.ts (100%) rename packages/{hono/src/shared => server-utils/src/integrations/hono}/middlewareHandlers.ts (94%) create mode 100644 packages/server-utils/src/integrations/hono/patchAppRequest.ts rename packages/{hono/src/shared => server-utils/src/integrations/hono}/patchAppUse.ts (96%) rename packages/{hono/src/shared => server-utils/src/integrations/hono}/patchRoute.ts (87%) rename packages/{hono/src/shared => server-utils/src/integrations/hono}/resolveRouteName.ts (65%) rename packages/{hono/src/shared => server-utils/src/integrations/hono}/types.ts (100%) rename packages/{hono/src/shared => server-utils/src/integrations/hono}/wrapMiddlewareSpan.ts (83%) create mode 100644 packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts rename packages/{hono/test/shared => server-utils/test/integrations/hono}/defaultShouldHandleError.test.ts (88%) rename packages/{hono/test/utils => server-utils/test/integrations/hono}/isMiddleware.test.ts (95%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fdaafc4b6e89..13bfa91520fc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1055,8 +1055,8 @@ jobs: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Bun if: - contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "elysia-bun-static", "hono-4", - "bun-bytecode", "bun-express", "bun-mysql"]'), matrix.test-application) + matrix.test-application == 'node-exports-test-app' || contains(matrix.test-application, 'bun') || + contains(matrix.label, 'bun') uses: oven-sh/setup-bun@v2 with: bun-version: '1.3.14' @@ -1067,7 +1067,7 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: matrix.test-application == 'deno' || matrix.test-application == 'hono-4' + if: contains(matrix.test-application, 'deno') || contains(matrix.label, 'deno') uses: denoland/setup-deno@v2.0.5 with: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} diff --git a/dev-packages/e2e-tests/test-applications/hono-4/.gitignore b/dev-packages/e2e-tests/test-applications/hono-4-legacy/.gitignore similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/.gitignore rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/.gitignore diff --git a/dev-packages/e2e-tests/test-applications/hono-4/deno.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/deno.json similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/deno.json rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/deno.json diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json similarity index 90% rename from dev-packages/e2e-tests/test-applications/hono-4/package.json rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json index db3b18166812..ab3b68ce1f5d 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json @@ -1,5 +1,5 @@ { - "name": "hono-4", + "name": "hono-4-legacy", "type": "module", "version": "0.0.0", "private": true, @@ -37,15 +37,15 @@ "variants": [ { "assert-command": "RUNTIME=node pnpm test:assert", - "label": "hono-4 (node)" + "label": "hono-4-legacy (node)" }, { "assert-command": "RUNTIME=bun pnpm test:assert", - "label": "hono-4 (bun)" + "label": "hono-4-legacy (bun)" }, { "assert-command": "RUNTIME=deno pnpm test:assert", - "label": "hono-4 (deno)" + "label": "hono-4-legacy (deno)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/playwright.config.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/playwright.config.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.bun.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.bun.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.cloudflare.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.cloudflare.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.deno.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.deno.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.node.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.node.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/instrument.node.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/instrument.node.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts new file mode 100644 index 000000000000..9304a980dff3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts @@ -0,0 +1,34 @@ +import type { MiddlewareHandler } from 'hono'; + +// Defined as anonymous function expressions so the function name is inferred from the `const` +// binding. A named expression (`const middlewareA = async function middlewareA() {}`) collides with +// the binding when bundled, and Bun renames the inner function (→ `middlewareA2`), which would then +// surface as the middleware span name. The inferred name stays stable across all runtimes. +export const middlewareA: MiddlewareHandler = async function (c, next) { + // Add some delay + await new Promise(resolve => setTimeout(resolve, 50)); + await next(); +}; + +export const middlewareB: MiddlewareHandler = async function (_c, next) { + // Add some delay + await new Promise(resolve => setTimeout(resolve, 60)); + await next(); +}; + +let failingMiddlewareCount = 0; +export const failingMiddleware: MiddlewareHandler = async function (_c, _next) { + // Each throw gets a unique suffix so the Dedupe integration doesn't collapse the identical errors + // that several tests (and their retries) trigger through this shared middleware — otherwise only the + // first would be reported and the other tests' `waitForError` would time out. Tests match on the + // stable `Middleware error` prefix. + throw new Error(`Middleware error #${(failingMiddlewareCount += 1)}`); +}; + +// Intentionally a NAMED function expression (unlike the anonymous ones above) so the named-function +// path stays covered: the span name is taken from the function's own name. Under bundled Bun the inner +// name collides with the `const` binding and is suffixed (→ `namedMiddleware2`), so the test matches on +// the `namedMiddleware` prefix rather than an exact name. +export const namedMiddleware: MiddlewareHandler = async function namedMiddleware(_c, next) { + await next(); +}; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-errors.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-errors.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-middleware.ts similarity index 92% rename from dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-middleware.ts index d82201b7cdb3..6f21c1cac33b 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-middleware.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { failingMiddleware, middlewareA, middlewareB } from '../middleware'; +import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } from '../middleware'; const middlewareRoutes = new Hono(); @@ -8,11 +8,13 @@ middlewareRoutes.get('/anonymous', c => c.json({ middleware: 'anonymous' })); middlewareRoutes.get('/multi', c => c.json({ middleware: 'multi' })); middlewareRoutes.get('/error', c => c.text('should not reach')); middlewareRoutes.get('/param/:id', c => c.json({ paramId: c.req.param('id') })); +middlewareRoutes.get('/declared', c => c.json({ middleware: 'declared' })); // Self-contained sub-app registering its own middleware via .use() const subAppWithMiddleware = new Hono(); subAppWithMiddleware.use('/named/*', middlewareA); +subAppWithMiddleware.use('/declared/*', namedMiddleware); subAppWithMiddleware.use('/anonymous/*', async (c, next) => { c.header('X-Custom', 'anonymous'); await next(); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-multi-fetch.ts similarity index 78% rename from dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-multi-fetch.ts index 58eb5b504640..ed9f0a3e131b 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-multi-fetch.ts @@ -28,6 +28,14 @@ inventoryApp.get('/item/:productId/stock', c => { return c.json({ productId, inStock: item.stock > 0, quantity: item.stock }); }); +// Simulates an inner-route failure (a plain 5xx Error, not an HTTPException) reached only via an +// internal .request() — used by the storefront's `/degraded` route below. Each throw gets a unique +// suffix so the Dedupe integration doesn't collapse repeats across test retries. +let dbErrorCount = 0; +inventoryApp.get('/item/:productId/db-error', () => { + throw new Error(`inventory db is down #${(dbErrorCount += 1)}`); +}); + // Storefront service — orchestrates internal .request() calls to inventoryApp. const storefrontApp = new Hono(); @@ -83,6 +91,17 @@ storefrontApp.get('/product/:productId/availability', async c => { }); }); +// Degraded response: the inner route throws, but the outer handler swallows the failed internal +// response and returns a 200 instead of propagating. The inner error should still reach Sentry (auto +// instrumentation); the outer request stays healthy. +storefrontApp.get('/product/:productId/degraded', async c => { + const res = await inventoryApp.request(`/item/${c.req.param('productId')}/db-error`); + if (!res.ok) { + return c.json({ product: null, degraded: true }, 200); + } + return c.json({ product: await res.json() }); +}); + // Error propagation: internal 404 causes the handler to throw a plain Error storefrontApp.get('/product-or-throw/:productId', async c => { const res = await inventoryApp.request(`/item/${c.req.param('productId')}`); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/routes.ts similarity index 96% rename from dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/routes.ts index b095618fb52b..008a1ca6fd53 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/routes.ts @@ -1,6 +1,6 @@ import { type Hono as HonoType, Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; -import { failingMiddleware, middlewareA, middlewareB } from './middleware'; +import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } from './middleware'; import { errorRoutes } from './route-groups/test-errors'; import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware'; import { multiFetchRoutes } from './route-groups/test-multi-fetch'; @@ -42,6 +42,7 @@ export function addRoutes(app: HonoType<{ Bindings?: { E2E_TEST_DSN: string } }> app.use('/test-middleware/multi/*', middlewareA, middlewareB); app.use('/test-middleware/error/*', failingMiddleware); app.use('/test-middleware/param/*', middlewareA); + app.use('/test-middleware/declared/*', namedMiddleware); app.route('/test-middleware', middlewareRoutes); // Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching diff --git a/dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs similarity index 75% rename from dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs index cd6f91b3455d..8f6b794b1827 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'hono-4', + proxyServerName: 'hono-4-legacy', }); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/basepath-and-late-routes.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/basepath-and-late-routes.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts similarity index 76% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts index 0bd38ea85aa3..2aee60fc6be8 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts @@ -2,4 +2,4 @@ export type Runtime = 'cloudflare' | 'node' | 'bun' | 'deno'; export const RUNTIME = (process.env.RUNTIME || 'node') as Runtime; -export const APP_NAME = 'hono-4'; +export const APP_NAME = 'hono-4-legacy'; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/errors.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/errors.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/middleware.test.ts similarity index 89% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/middleware.test.ts index 111ff3bfe34a..d760f12e6451 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/middleware.test.ts @@ -78,6 +78,38 @@ for (const { name, prefix } of SCENARIOS) { expect(anonymousSpan?.status).not.toBe('error'); }); + test('creates a span for a named-function middleware (name from the function binding)', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/declared`), + ); + + const response = await fetch(`${baseURL}${prefix}/declared`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/declared`), + )!; + expect(segment.name).toBe(`GET ${prefix}/declared`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + // Named function expression: the span name comes from the function's own name. A bundler (Bun) + // may append a numeric suffix when the inner name collides with the `const` binding, so match on + // the `namedMiddleware` prefix rather than an exact name. + const middlewareSpan = spans.find( + span => getSpanOp(span) === 'middleware' && !!span.name?.match(/^namedMiddleware\d*$/), + ); + + expect(middlewareSpan).toBeDefined(); + expect(middlewareSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(middlewareSpan?.status).not.toBe('error'); + }); + test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { const segmentPromise = collectStreamedSpansUntilSegment( APP_NAME, @@ -115,14 +147,17 @@ for (const { name, prefix } of SCENARIOS) { test('captures error thrown in middleware', async ({ baseURL }) => { const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Middleware error'; + return ( + !!event.exception?.values?.[0]?.value?.startsWith('Middleware error') && + !!event.request?.url?.includes(prefix) + ); }); const response = await fetch(`${baseURL}${prefix}/error`); expect(response.status).toBe(500); const errorEvent = await errorPromise; - expect(errorEvent.exception?.values?.[0]?.value).toBe('Middleware error'); + expect(errorEvent.exception?.values?.[0]?.value).toMatch(/^Middleware error/); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( expect.objectContaining({ handled: false, @@ -184,7 +219,10 @@ for (const { name, prefix } of SCENARIOS) { test('includes request data on error events from middleware', async ({ baseURL }) => { const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Middleware error' && !!event.request?.url?.includes(prefix); + return ( + !!event.exception?.values?.[0]?.value?.startsWith('Middleware error') && + !!event.request?.url?.includes(prefix) + ); }); await fetch(`${baseURL}${prefix}/error`); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/multi-fetch.test.ts similarity index 94% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/multi-fetch.test.ts index 29298c7b8f89..eecd5b99bca4 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/multi-fetch.test.ts @@ -183,6 +183,19 @@ test.describe('multi-fetch: internal .request() calls between sub-apps', () => { }); }); + test.describe('error inside internal fetch (degraded response)', () => { + // The manual `sentry()` middleware only instruments the main app's request lifecycle. An error + // thrown solely inside an internal sub-app `.request()` — whose failed response the outer handler + // swallows — is therefore NOT captured here, unlike the orchestrion auto-instrumentation in the + // `hono-4` app, which instruments every dispatched context. We assert only that the outer request + // stays healthy. + test('degrades to a 200 when the inner route fails', async ({ baseURL }) => { + const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/degraded`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ product: null, degraded: true }); + }); + }); + test.describe('inventory sub-app direct access', () => { test('creates its own span when accessed directly via HTTP', async ({ baseURL }) => { const segmentPromise = waitForStreamedSpan( diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/tracing.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/tracing.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.json similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tsconfig.json rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc similarity index 86% rename from dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc index d4344dfa198a..a3c646c03aa9 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc @@ -1,6 +1,6 @@ { "$schema": "node_modules/wrangler/config-schema.json", - "name": "hono-4", + "name": "hono-4-legacy", "main": "src/entry.cloudflare.ts", "compatibility_date": "2026-04-20", "compatibility_flags": ["nodejs_compat"], diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts deleted file mode 100644 index cc7bfae9896d..000000000000 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { MiddlewareHandler } from 'hono'; - -export const middlewareA: MiddlewareHandler = async function middlewareA(c, next) { - // Add some delay - await new Promise(resolve => setTimeout(resolve, 50)); - await next(); -}; - -export const middlewareB: MiddlewareHandler = async function middlewareB(_c, next) { - // Add some delay - await new Promise(resolve => setTimeout(resolve, 60)); - await next(); -}; - -export const failingMiddleware: MiddlewareHandler = async function failingMiddleware(_c, _next) { - throw new Error('Middleware error'); -}; diff --git a/packages/hono/package.json b/packages/hono/package.json index 3eef877d601d..9b2d6c69bb5f 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -74,7 +74,8 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/core": "11.1.0", - "@sentry/conventions": "^0.24.0" + "@sentry/conventions": "^0.24.0", + "@sentry/server-utils": "11.1.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x || ^5.x", diff --git a/packages/hono/src/bun/middleware.ts b/packages/hono/src/bun/middleware.ts index 6a1fbaf5a53e..20c922c32c0c 100644 --- a/packages/hono/src/bun/middleware.ts +++ b/packages/hono/src/bun/middleware.ts @@ -1,10 +1,8 @@ import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { init } from './sdk'; import { getConnInfo } from 'hono/bun'; +import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; export interface HonoBunOptions extends Options, SentryHonoMiddlewareOptions {} @@ -16,13 +14,7 @@ export const sentry = (app: Hono, options: HonoBunOptions): Mi init(options); - applyPatches(app); + applyHonoPatches(app); - return async (context, next) => { - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ - - responseHandler(context, options.shouldHandleError); - }; + return createHonoRequestMiddleware({ getConnInfo, shouldHandleError: options.shouldHandleError }); }; diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index 205b7c28129a..df009ccfb945 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -1,12 +1,14 @@ import { withSentry } from '@sentry/cloudflare'; import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { getConnInfo } from 'hono/cloudflare-workers'; +import { + applyHonoPatches, + createHonoRequestMiddleware, + type SentryHonoMiddlewareOptions, +} from '@sentry/server-utils/no-diagnostic-channels'; import type { Env, Hono, MiddlewareHandler } from 'hono'; import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../shared/lowQualityTransactionPatterns'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; export interface HonoCloudflareOptions extends Options, SentryHonoMiddlewareOptions {} @@ -41,18 +43,15 @@ export function sentry( app as unknown as ExportedHandler, ); - applyPatches(app); + applyHonoPatches(app); - return async (context, next) => { - const shouldHandleError = + return createHonoRequestMiddleware({ + getConnInfo, + // Cloudflare accepts middleware options as a function of `env`, so `shouldHandleError` is only + // known per request. + resolveShouldHandleError: context => typeof options === 'function' ? options(context.env as E['Bindings']).shouldHandleError - : options.shouldHandleError; - - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ - - responseHandler(context, shouldHandleError); - }; + : options.shouldHandleError, + }); } diff --git a/packages/hono/src/debug-build.ts b/packages/hono/src/debug-build.ts deleted file mode 100644 index 60aa50940582..000000000000 --- a/packages/hono/src/debug-build.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const __DEBUG_BUILD__: boolean; - -/** - * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. - * - * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. - */ -export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/hono/src/deno/middleware.ts b/packages/hono/src/deno/middleware.ts index 081589925ee1..1e63c24d9b65 100644 --- a/packages/hono/src/deno/middleware.ts +++ b/packages/hono/src/deno/middleware.ts @@ -1,10 +1,12 @@ import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { init } from './sdk'; -import type { Env, Hono, MiddlewareHandler } from 'hono'; import { getConnInfo } from 'hono/deno'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; +import { + applyHonoPatches, + createHonoRequestMiddleware, + type SentryHonoMiddlewareOptions, +} from '@sentry/server-utils/no-diagnostic-channels'; +import type { Env, Hono, MiddlewareHandler } from 'hono'; export interface HonoDenoOptions extends Options, SentryHonoMiddlewareOptions {} @@ -16,13 +18,7 @@ export const sentry = (app: Hono, options: HonoDenoOptions): M init(options); - applyPatches(app); - - return async (context, next) => { - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ + applyHonoPatches(app); - responseHandler(context, options.shouldHandleError); - }; + return createHonoRequestMiddleware({ getConnInfo, shouldHandleError: options.shouldHandleError }); }; diff --git a/packages/hono/src/index.bun.ts b/packages/hono/src/index.bun.ts index 7c456a21c52e..917a160284f6 100644 --- a/packages/hono/src/index.bun.ts +++ b/packages/hono/src/index.bun.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './bun/middleware'; diff --git a/packages/hono/src/index.cloudflare.ts b/packages/hono/src/index.cloudflare.ts index e46e119c206c..2691c9efde81 100644 --- a/packages/hono/src/index.cloudflare.ts +++ b/packages/hono/src/index.cloudflare.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils/no-diagnostic-channels'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './cloudflare/middleware'; diff --git a/packages/hono/src/index.deno.ts b/packages/hono/src/index.deno.ts index aa407b9192ca..e3148afc1169 100644 --- a/packages/hono/src/index.deno.ts +++ b/packages/hono/src/index.deno.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils/no-diagnostic-channels'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './deno/middleware'; diff --git a/packages/hono/src/index.node.ts b/packages/hono/src/index.node.ts index 761cd27a44a6..99c03230df7c 100644 --- a/packages/hono/src/index.node.ts +++ b/packages/hono/src/index.node.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './node/middleware'; diff --git a/packages/hono/src/node/middleware.ts b/packages/hono/src/node/middleware.ts index b46dfbbac489..0b4688ef72f4 100644 --- a/packages/hono/src/node/middleware.ts +++ b/packages/hono/src/node/middleware.ts @@ -1,9 +1,7 @@ import { type BaseTransportOptions, consoleSandbox, debug, getClient, type Options } from '@sentry/core'; import { getConnInfo } from '@hono/node-server/conninfo'; +import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; export interface HonoNodeOptions extends Options {} @@ -40,13 +38,7 @@ export const sentry = (app: Hono, options?: SentryHonoMiddlewa } } - applyPatches(app); + applyHonoPatches(app); - return async (context, next) => { - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ - - responseHandler(context, options?.shouldHandleError); - }; + return createHonoRequestMiddleware({ getConnInfo, shouldHandleError: options?.shouldHandleError }); }; diff --git a/packages/hono/src/shared/patchAppRequest.ts b/packages/hono/src/shared/patchAppRequest.ts deleted file mode 100644 index a066d00a3c01..000000000000 --- a/packages/hono/src/shared/patchAppRequest.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { SENTRY_OP } from '@sentry/conventions/attributes'; -import { HTTP_SERVER } from '@sentry/conventions/op'; -import { - debug, - getActiveSpan, - getOriginalFunction, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startSpan, - type WrappedFunction, -} from '@sentry/core'; -import type { Env, Hono } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; - -const INTERNAL_REQUEST_OP = HTTP_SERVER; -const INTERNAL_REQUEST_ORIGIN = 'auto.http.hono.internal_request'; - -function extractPathname(input: string | Request | URL): string { - if (typeof input === 'string') { - return /^https?:\/\//.test(input) ? new URL(input).pathname : input; - } - - return input instanceof Request ? new URL(input.url).pathname : input.pathname; -} - -/** - * Patches `app.request()` on a Hono instance so that each internal dispatch - * is traced as an `http.server` span — child of whatever span is active at - * the call site. - * - * `.request()` is a class field (arrow function), so this must run per-instance. - * Idempotent: safe to call multiple times on the same instance. - */ -export function patchAppRequest(app: Hono): void { - if (getOriginalFunction(app.request as unknown as WrappedFunction)) { - DEBUG_BUILD && debug.log('[hono] app.request already patched — skipping.'); - return; - } - - const originalRequest = app.request; - - app.request = new Proxy(originalRequest, { - apply(_target, thisArg, args: [string | Request | URL, RequestInit?, ...unknown[]]) { - const [input, requestInit, ...rest] = args; - - if (!getActiveSpan()) { - return Reflect.apply(_target, thisArg, args); - } - - let method = requestInit?.method ?? (input instanceof Request ? input.method : 'GET'); - method = method.toUpperCase(); - - const path = extractPathname(input); - - return startSpan( - { - name: `${method} ${path}`, - onlyIfParent: true, - attributes: { - [SENTRY_OP]: INTERNAL_REQUEST_OP, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: INTERNAL_REQUEST_ORIGIN, - }, - }, - () => Reflect.apply(_target, thisArg, [input, requestInit, ...rest]), - ); - }, - get(target, prop, receiver) { - if (prop === '__sentry_original__') { - return originalRequest; - } - return Reflect.get(target, prop, receiver); - }, - }); - - DEBUG_BUILD && debug.log('[hono] Patched app.request for internal dispatch tracing.'); -} diff --git a/packages/hono/test/shared/applyPatches.test.ts b/packages/hono/test/shared/applyPatches.test.ts index 129e83d341d2..1347a72f1b0c 100644 --- a/packages/hono/test/shared/applyPatches.test.ts +++ b/packages/hono/test/shared/applyPatches.test.ts @@ -1,7 +1,7 @@ import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { applyPatches } from '../../src/shared/applyPatches'; +import { applyHonoPatches as applyPatches } from '@sentry/server-utils'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); diff --git a/packages/hono/test/shared/earlyPatchRoute.test.ts b/packages/hono/test/shared/earlyPatchRoute.test.ts index 3e2eee6208a7..6cc210a7d6b7 100644 --- a/packages/hono/test/shared/earlyPatchRoute.test.ts +++ b/packages/hono/test/shared/earlyPatchRoute.test.ts @@ -1,8 +1,7 @@ import * as SentryCore from '@sentry/core'; +import { applyHonoPatches as applyPatches, earlyPatchHono } from '@sentry/server-utils'; import { Hono } from 'hono'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { applyPatches, earlyPatchHono } from '../../src/shared/applyPatches'; -import { installRouteHookOnPrototype } from '../../src/shared/patchRoute'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -19,10 +18,12 @@ vi.mock('@sentry/core', async () => { const startSpanMock = SentryCore.startSpan as ReturnType; -const honoBaseProto = Object.getPrototypeOf(Hono.prototype) as { route: Function }; +const honoBaseProto = Object.getPrototypeOf(Hono.prototype) as { route: (path: string, app: unknown) => unknown }; const originalRoute = honoBaseProto.route; -earlyPatchHono(); +// `earlyPatchHono` installs the `HonoBase.prototype.route` hook at import time, before any +// `sentry()`/`applyHonoPatches` runs, so sub-apps mounted early are still collected. +earlyPatchHono(Hono); describe('earlyPatchHono (two-phase prototype hook)', () => { beforeEach(() => { @@ -44,7 +45,7 @@ describe('earlyPatchHono (two-phase prototype hook)', () => { expect(startSpanMock).not.toHaveBeenCalled(); }); - it('patches collected sub-apps when applyPatches activates', async () => { + it('patches collected sub-apps when applyHonoPatches activates', async () => { const subApp = new Hono(); subApp.get('/hello', c => c.text('world')); @@ -59,29 +60,6 @@ describe('earlyPatchHono (two-phase prototype hook)', () => { expect(startSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET /hello' }), expect.any(Function)); }); - it('emits a debug log and applies patchAppRequest when sub-app was mounted before applyPatches', async () => { - const debugLogSpy = vi.spyOn(SentryCore.debug, 'log'); - - honoBaseProto.route = originalRoute; - installRouteHookOnPrototype(); - - const subApp = new Hono(); - subApp.get('/hello', c => c.text('world')); - - const parent = new Hono(); - parent.route('/api', subApp); - - applyPatches(parent); // retroactive instrumentation - - // The log warns the developer about the out-of-order setup. - expect(debugLogSpy).toHaveBeenCalledWith(expect.stringContaining('sub-app(s) were mounted before sentry()')); - - // patchAppRequest is applied retroactively - await subApp.request('/hello'); - - expect(startSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET /hello' }), expect.any(Function)); - }); - it('preserves correct route behavior', async () => { const subApp = new Hono(); subApp.get('/hello', c => c.text('world')); @@ -94,113 +72,3 @@ describe('earlyPatchHono (two-phase prototype hook)', () => { expect(await res.text()).toBe('world'); }); }); - -describe('installRouteHookOnPrototype idempotency', () => { - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - - it('returns the same handle on repeated calls', () => { - const handle1 = installRouteHookOnPrototype(); - const handle2 = installRouteHookOnPrototype(); - - expect(handle1).toBe(handle2); - }); - - it('does not replace the patched route function on repeated calls', () => { - installRouteHookOnPrototype(); - const patchedRoute = honoBaseProto.route; - - installRouteHookOnPrototype(); - expect(honoBaseProto.route).toBe(patchedRoute); - }); - - it('calling activate() multiple times has no adverse effect', async () => { - const handle = installRouteHookOnPrototype(); - - handle.activate(); - handle.activate(); - handle.activate(); - - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/hello', c => c.text('world')); - app.route('/api', subApp); - - await subApp.request('/hello'); - - expect(startSpanMock).toHaveBeenCalledTimes(1); - }); -}); - -describe('installRouteHookOnPrototype non-invasive patching', () => { - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - - it('preserves function.name of the original route method', () => { - honoBaseProto.route = originalRoute; - const originalName = originalRoute.name; - - installRouteHookOnPrototype(); - - expect(honoBaseProto.route!.name).toBe(originalName); - }); - - it('preserves function.length of the original route method', () => { - honoBaseProto.route = originalRoute; - const originalLength = originalRoute.length; - - installRouteHookOnPrototype(); - - expect(honoBaseProto.route!.length).toBe(originalLength); - }); - - it('preserves symbol-keyed properties on the route method', () => { - honoBaseProto.route = originalRoute; - const ROUTER_META = Symbol('router-meta'); - (originalRoute as any)[ROUTER_META] = { version: 3 }; - - installRouteHookOnPrototype(); - - const symbols = Object.getOwnPropertySymbols(honoBaseProto.route!); - expect(symbols).toContain(ROUTER_META); - expect((honoBaseProto.route as any)[ROUTER_META]).toEqual({ version: 3 }); - }); - - it('preserves string-keyed custom properties on the route method', () => { - honoBaseProto.route = originalRoute; - (originalRoute as any).pluginId = 'openapi-router'; - (originalRoute as any).__patched_by_other_lib__ = true; - - installRouteHookOnPrototype(); - - expect((honoBaseProto.route as any).pluginId).toBe('openapi-router'); - expect((honoBaseProto.route as any).__patched_by_other_lib__).toBe(true); - }); - - it('preserves prototype chain of the original function', () => { - honoBaseProto.route = originalRoute; - const originalProto = Object.getPrototypeOf(originalRoute); - - installRouteHookOnPrototype(); - - expect(Object.getPrototypeOf(honoBaseProto.route!)).toBe(originalProto); - }); - - it('correctly calls the original route and preserves return value', () => { - honoBaseProto.route = originalRoute; - installRouteHookOnPrototype(); - - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/test', c => c.text('ok')); - - const result = app.route('/api', subApp); - expect(result).toBe(app); - }); -}); diff --git a/packages/hono/test/shared/middlewareHandlers.test.ts b/packages/hono/test/shared/middlewareHandlers.test.ts index c52e4f62eb69..9253cf56cbd6 100644 --- a/packages/hono/test/shared/middlewareHandlers.test.ts +++ b/packages/hono/test/shared/middlewareHandlers.test.ts @@ -1,16 +1,10 @@ +import { HTTP_ROUTE, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; import * as SentryCore from '@sentry/core'; -import { SENTRY_SEGMENT_NAME_SOURCE, HTTP_ROUTE } from '@sentry/conventions/attributes'; +import { createHonoRequestMiddleware } from '@sentry/server-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { requestHandler, responseHandler } from '../../src/shared/middlewareHandlers'; -vi.mock('hono/route', () => ({ - routePath: () => '/test', - matchedRoutes: () => [{ basePath: '/', path: '/test', method: 'GET', handler: (_c: unknown) => undefined }], -})); - -vi.mock('../../src/utils/hono-context', () => ({ - hasFetchEvent: () => false, -})); +// These exercise the request/response handlers via the public `createHonoRequestMiddleware`, which +// runs `requestHandler` on the way in and `responseHandler` on the way out of `next()`. const mockSetTransactionName = vi.fn(); const mockSetSDKProcessingMetadata = vi.fn(); @@ -51,79 +45,92 @@ const getClientMock = SentryCore.getClient as ReturnType; const captureExceptionMock = SentryCore.captureException as ReturnType; const getActiveSpanMock = SentryCore.getActiveSpan as ReturnType; +// `event` throws on access so `hasFetchEvent` reports `false` (the Node/Bun/Deno path), which makes +// the request handler read request data from `req.raw` rather than a Cloudflare fetch event. function createMockContext(status: number, error?: Error): unknown { return { - req: { method: 'GET', routeIndex: 0, raw: new Request('http://localhost/test') }, + req: { + method: 'GET', + routeIndex: 0, + // `resolveRouteName` reads the matched routes straight off the request. + matchedRoutes: [{ basePath: '/', path: '/test', method: 'GET', handler: (_c: unknown) => undefined }], + raw: new Request('http://localhost/test'), + }, + get event(): never { + throw new Error('no fetch event'); + }, res: { status }, error, }; } -describe('responseHandler', () => { +const noop = async (): Promise => {}; + +// oxlint-disable-next-line typescript/no-explicit-any +async function runMiddleware(context: unknown, options: Record = {}): Promise { + const middleware = createHonoRequestMiddleware(options); + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(context as any, noop); +} + +describe('responseHandler (via createHonoRequestMiddleware)', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('error capture — default behavior (no shouldHandleError)', () => { - it('captures error when context.error is set', () => { + it('captures error when context.error is set', async () => { const error = new Error('server error'); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any); + await runMiddleware(createMockContext(500, error)); expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); - it('captures plain Error with no status (not an HTTPException) regardless of response status', () => { + it('captures plain Error with no status (not an HTTPException) regardless of response status', async () => { const error = new Error('plain error, no status property'); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(404, error) as any); + await runMiddleware(createMockContext(404, error)); expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); - it('does not call captureException when there is no error', () => { - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(200) as any); + it('does not call captureException when there is no error', async () => { + await runMiddleware(createMockContext(200)); expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it('delegates deduplication to the public capture API', () => { + it('delegates deduplication to the public capture API', async () => { const error = new Error('already captured'); Object.defineProperty(error, '__sentry_captured__', { value: true, writable: false }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any); + await runMiddleware(createMockContext(500, error)); expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); - it('does not capture 4xx HTTPException (status on error object)', () => { + it('does not capture 4xx HTTPException (status on error object)', async () => { const error = Object.assign(new Error('Not Found'), { status: 404 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(404, error) as any); + await runMiddleware(createMockContext(404, error)); expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it('does not capture 3xx HTTPException (status on error object)', () => { + it('does not capture 3xx HTTPException (status on error object)', async () => { const error = Object.assign(new Error('Redirect'), { status: 301 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(301, error) as any); + await runMiddleware(createMockContext(301, error)); expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it('captures 5xx HTTPException (status on error object)', () => { + it('captures 5xx HTTPException (status on error object)', async () => { const error = Object.assign(new Error('Service Unavailable'), { status: 503 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(503, error) as any); + await runMiddleware(createMockContext(503, error)); expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, @@ -132,12 +139,11 @@ describe('responseHandler', () => { }); describe('error capture — custom shouldHandleError', () => { - it('calls shouldHandleError with the error and captures when it returns true', () => { + it('calls shouldHandleError with the error and captures when it returns true', async () => { const shouldHandleError = vi.fn().mockReturnValue(true); const error = Object.assign(new Error('Not Found'), { status: 404 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(404, error) as any, shouldHandleError); + await runMiddleware(createMockContext(404, error), { shouldHandleError }); expect(shouldHandleError).toHaveBeenCalledWith(error); expect(captureExceptionMock).toHaveBeenCalledWith(error, { @@ -145,39 +151,35 @@ describe('responseHandler', () => { }); }); - it('does not capture when shouldHandleError returns false', () => { + it('does not capture when shouldHandleError returns false', async () => { const shouldHandleError = vi.fn().mockReturnValue(false); const error = new Error('suppressed 500 error'); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any, shouldHandleError); + await runMiddleware(createMockContext(500, error), { shouldHandleError }); expect(shouldHandleError).toHaveBeenCalledWith(error); expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it('captures 4xx error that would normally be skipped when shouldHandleError returns true', () => { + it('captures 4xx error that would normally be skipped when shouldHandleError returns true', async () => { const error = Object.assign(new Error('Unauthorized'), { status: 401 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(401, error) as any, () => true); + await runMiddleware(createMockContext(401, error), { shouldHandleError: () => true }); expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); - it('suppresses 5xx error when shouldHandleError returns false', () => { + it('suppresses 5xx error when shouldHandleError returns false', async () => { const error = Object.assign(new Error('Internal Server Error'), { status: 500 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any, () => false); + await runMiddleware(createMockContext(500, error), { shouldHandleError: () => false }); expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it('does not invoke shouldHandleError when context.error is absent', () => { + it('does not invoke shouldHandleError when context.error is absent', async () => { const shouldHandleError = vi.fn().mockReturnValue(true); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(200) as any, shouldHandleError); + await runMiddleware(createMockContext(200), { shouldHandleError }); expect(shouldHandleError).not.toHaveBeenCalled(); expect(captureExceptionMock).not.toHaveBeenCalled(); @@ -185,18 +187,16 @@ describe('responseHandler', () => { }); describe('transaction name', () => { - it('sets transaction name on isolation scope', () => { - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); + it('sets transaction name on isolation scope', async () => { + await runMiddleware(createMockContext(200)); expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test'); }); - it('sets http.route and segment name source on the root span', () => { + it('sets http.route and segment name source on the root span', async () => { getActiveSpanMock.mockReturnValue(mockRootSpan); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); + await runMiddleware(createMockContext(200)); expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(HTTP_ROUTE, '/test'); expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SENTRY_SEGMENT_NAME_SOURCE, 'route'); @@ -204,7 +204,7 @@ describe('responseHandler', () => { }); }); -describe('requestHandler — connection info', () => { +describe('requestHandler — connection info (via createHonoRequestMiddleware)', () => { const activeSpan = { updateName: vi.fn(), setAttribute: vi.fn() }; beforeEach(() => { @@ -224,12 +224,11 @@ describe('requestHandler — connection info', () => { }); } - it('sets non-PII attributes (port, transport, type) regardless of userInfo', () => { + it('sets non-PII attributes (port, transport, type) regardless of userInfo', async () => { mockUserInfo(false); const getConnInfo = getConnInfoStub({ port: 54321, transport: 'tcp', addressType: 'IPv4' }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(rootSpanAttributes['client.port']).toBe(54321); expect(rootSpanAttributes['network.peer.port']).toBe(54321); @@ -238,12 +237,11 @@ describe('requestHandler — connection info', () => { expect(rootSpanAttributes['client.address']).toBeUndefined(); }); - it('sets IP-bearing attributes and user.ip_address when userInfo is true', () => { + it('sets IP-bearing attributes and user.ip_address when userInfo is true', async () => { mockUserInfo(true); const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443, addressType: 'IPv6' }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(rootSpanAttributes['client.address']).toBe('203.0.113.5'); expect(rootSpanAttributes['network.peer.address']).toBe('203.0.113.5'); @@ -251,13 +249,12 @@ describe('requestHandler — connection info', () => { expect(mockSetUser).toHaveBeenCalledWith({ ip_address: '203.0.113.5' }); }); - it('merges ip_address into the existing user without overwriting other fields', () => { + it('merges ip_address into the existing user without overwriting other fields', async () => { mockUserInfo(true); mockGetUser.mockReturnValue({ id: 'user-123', email: 'jane@example.com' }); const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443 }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(mockSetUser).toHaveBeenCalledWith({ id: 'user-123', @@ -266,12 +263,11 @@ describe('requestHandler — connection info', () => { }); }); - it('omits IP-bearing attributes when userInfo is false', () => { + it('omits IP-bearing attributes when userInfo is false', async () => { mockUserInfo(false); const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 8080 }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(rootSpanAttributes['client.address']).toBeUndefined(); expect(rootSpanAttributes['network.peer.address']).toBeUndefined(); @@ -280,12 +276,11 @@ describe('requestHandler — connection info', () => { expect(rootSpanAttributes['client.port']).toBe(8080); }); - it('sets no connection attributes when remote info is empty', () => { + it('sets no connection attributes when remote info is empty', async () => { mockUserInfo(true); const getConnInfo = getConnInfoStub({}); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(rootSpanAttributes['client.port']).toBeUndefined(); expect(rootSpanAttributes['network.peer.port']).toBeUndefined(); @@ -295,38 +290,33 @@ describe('requestHandler — connection info', () => { expect(mockSetUser).not.toHaveBeenCalled(); }); - it('does not throw or set attributes when getConnInfo throws', () => { + it('does not throw or set attributes when getConnInfo throws', async () => { mockUserInfo(true); const getConnInfo = vi.fn(() => { throw new Error('socket unavailable'); }); - expect(() => - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any), - ).not.toThrow(); + await expect(runMiddleware(createMockContext(200), { getConnInfo })).resolves.toBeUndefined(); expect(rootSpanAttributes['client.port']).toBeUndefined(); expect(rootSpanAttributes['client.address']).toBeUndefined(); expect(mockSetUser).not.toHaveBeenCalled(); }); - it('does not set connection attributes when there is no active span', () => { + it('does not set connection attributes when there is no active span', async () => { mockUserInfo(true); getActiveSpanMock.mockReturnValue(null); const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443 }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(getConnInfo).not.toHaveBeenCalled(); expect(rootSpanAttributes).toEqual({}); }); - it('is a no-op when getConnInfo is not provided', () => { + it('is a no-op when getConnInfo is not provided', async () => { mockUserInfo(true); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); + await runMiddleware(createMockContext(200)); expect(rootSpanAttributes['client.port']).toBeUndefined(); expect(mockSetUser).not.toHaveBeenCalled(); diff --git a/packages/hono/test/shared/patchAppRequest.test.ts b/packages/hono/test/shared/patchAppRequest.test.ts index bcd5ccf3a863..245821091143 100644 --- a/packages/hono/test/shared/patchAppRequest.test.ts +++ b/packages/hono/test/shared/patchAppRequest.test.ts @@ -1,7 +1,7 @@ import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { patchAppRequest } from '../../src/shared/patchAppRequest'; +import { applyHonoPatches } from '@sentry/server-utils'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -24,7 +24,7 @@ describe('patchAppRequest', () => { it('creates a hono.request span when .request() is called with an active parent span', async () => { const app = new Hono(); app.get('/hello', c => c.text('world')); - patchAppRequest(app); + applyHonoPatches(app); await app.request('/hello'); @@ -47,7 +47,7 @@ describe('patchAppRequest', () => { const app = new Hono(); app.get('/hello', c => c.text('world')); - patchAppRequest(app); + applyHonoPatches(app); const res = await app.request('/hello'); @@ -58,7 +58,7 @@ describe('patchAppRequest', () => { it('uses the method from requestInit when provided', async () => { const app = new Hono(); app.post('/submit', c => c.text('ok')); - patchAppRequest(app); + applyHonoPatches(app); await app.request('/submit', { method: 'POST' }); @@ -68,7 +68,7 @@ describe('patchAppRequest', () => { it('uses the method from a Request object when no requestInit is provided', async () => { const app = new Hono(); app.post('/submit', c => c.text('ok')); - patchAppRequest(app); + applyHonoPatches(app); await app.request(new Request('http://localhost/submit', { method: 'POST' })); @@ -78,7 +78,7 @@ describe('patchAppRequest', () => { it('defaults to GET when no method info is available', async () => { const app = new Hono(); app.get('/hello', c => c.text('world')); - patchAppRequest(app); + applyHonoPatches(app); await app.request('/hello'); @@ -89,17 +89,17 @@ describe('patchAppRequest', () => { const app = new Hono(); app.get('/hello', c => c.text('world')); - patchAppRequest(app); + applyHonoPatches(app); const firstPatched = app.request; - patchAppRequest(app); + applyHonoPatches(app); expect(app.request).toBe(firstPatched); }); it('preserves the original .request() return value', async () => { const app = new Hono(); app.get('/hello', c => c.json({ message: 'world' })); - patchAppRequest(app); + applyHonoPatches(app); const res = await app.request('/hello'); expect(res.status).toBe(200); @@ -111,7 +111,7 @@ describe('patchAppRequest', () => { it('stores the original request via __sentry_original__', () => { const app = new Hono(); const originalRequest = app.request; - patchAppRequest(app); + applyHonoPatches(app); // oxlint-disable-next-line typescript/no-explicit-any const sentryOriginal = (app.request as any).__sentry_original__; @@ -121,7 +121,7 @@ describe('patchAppRequest', () => { it('extracts pathname from a full URL string instead of using the raw string', async () => { const app = new Hono(); app.get('/api/hello', c => c.text('world')); - patchAppRequest(app); + applyHonoPatches(app); await app.request('http://localhost/api/hello'); @@ -134,7 +134,7 @@ describe('patchAppRequest', () => { it('extracts pathname from an https URL string', async () => { const app = new Hono(); app.get('/secure', c => c.text('ok')); - patchAppRequest(app); + applyHonoPatches(app); await app.request('https://example.com/secure'); @@ -144,7 +144,7 @@ describe('patchAppRequest', () => { it('extracts pathname from a Request object input', async () => { const app = new Hono(); app.get('/items/abc', c => c.text('found')); - patchAppRequest(app); + applyHonoPatches(app); await app.request(new Request('http://localhost/items/abc')); @@ -160,7 +160,7 @@ describe('patchAppRequest', () => { const CUSTOM_SYMBOL = Symbol('custom-meta'); (app.request as any)[CUSTOM_SYMBOL] = { version: 2 }; - patchAppRequest(app); + applyHonoPatches(app); const symbols = Object.getOwnPropertySymbols(app.request); expect(symbols).toContain(CUSTOM_SYMBOL); @@ -172,7 +172,7 @@ describe('patchAppRequest', () => { (app.request as any).customFlag = true; (app.request as any).metadata = { wrapped: false }; - patchAppRequest(app); + applyHonoPatches(app); expect((app.request as any).customFlag).toBe(true); expect((app.request as any).metadata).toEqual({ wrapped: false }); @@ -181,7 +181,7 @@ describe('patchAppRequest', () => { it('preserves function.name of the original request method', () => { const app = new Hono(); const originalName = app.request.name; - patchAppRequest(app); + applyHonoPatches(app); expect(app.request.name).toBe(originalName); }); @@ -189,14 +189,14 @@ describe('patchAppRequest', () => { it('preserves function.length of the original request method', () => { const app = new Hono(); const originalLength = app.request.length; - patchAppRequest(app); + applyHonoPatches(app); expect(app.request.length).toBe(originalLength); }); it('does not interfere with instanceof or typeof checks', () => { const app = new Hono(); - patchAppRequest(app); + applyHonoPatches(app); expect(typeof app.request).toBe('function'); }); @@ -204,7 +204,7 @@ describe('patchAppRequest', () => { it('preserves prototype chain of the original function', () => { const app = new Hono(); const originalProto = Object.getPrototypeOf(app.request); - patchAppRequest(app); + applyHonoPatches(app); expect(Object.getPrototypeOf(app.request)).toBe(originalProto); }); @@ -215,7 +215,7 @@ describe('patchAppRequest', () => { (app.request as any)[OPENAPI] = { paths: { '/hello': { get: {} } } }; (app.request as any).__middleware_chain__ = ['auth', 'cors']; - patchAppRequest(app); + applyHonoPatches(app); expect((app.request as any)[OPENAPI]).toEqual({ paths: { '/hello': { get: {} } } }); expect((app.request as any).__middleware_chain__).toEqual(['auth', 'cors']); diff --git a/packages/hono/test/shared/patchAppUse.test.ts b/packages/hono/test/shared/patchAppUse.test.ts index ff1739c60ebd..7fcb03774a29 100644 --- a/packages/hono/test/shared/patchAppUse.test.ts +++ b/packages/hono/test/shared/patchAppUse.test.ts @@ -1,7 +1,7 @@ import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { patchAppUse, patchHttpMethodHandlers } from '../../src/shared/patchAppUse'; +import { applyHonoPatches } from '@sentry/server-utils'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -23,7 +23,7 @@ describe('patchAppUse (middleware spans)', () => { it('wraps handlers in app.use(handler) so startInactiveSpan is called when middleware runs', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); const userHandler = vi.fn(async (_c: unknown, next: () => Promise) => { await next(); @@ -52,7 +52,7 @@ describe('patchAppUse (middleware spans)', () => { describe('span naming', () => { it('uses handler.name for span when handler has a name', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); async function myNamedMiddleware(_c: unknown, next: () => Promise) { await next(); @@ -66,7 +66,7 @@ describe('patchAppUse (middleware spans)', () => { it('uses for span when handler is anonymous', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); app.use(async (_c: unknown, next: () => Promise) => next()); @@ -80,7 +80,7 @@ describe('patchAppUse (middleware spans)', () => { it('wraps each handler in app.use(path, ...handlers) and passes path through', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); const handler = async (_c: unknown, next: () => Promise) => next(); app.use('/api', handler); @@ -93,7 +93,7 @@ describe('patchAppUse (middleware spans)', () => { it('sets span error status when middleware throws a 5xx-like error', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); const err = new Error('middleware error'); app.use(async () => { @@ -109,7 +109,7 @@ describe('patchAppUse (middleware spans)', () => { it('creates sibling spans for multiple middlewares (onion order, not parent-child)', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); app.use( async (_c: unknown, next: () => Promise) => next(), @@ -133,10 +133,10 @@ describe('patchAppUse (middleware spans)', () => { it('does not stack proxies when called twice on the same instance', () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); const firstUse = app.use; - patchAppUse(app); + applyHonoPatches(app); expect(app.use).toBe(firstUse); }); @@ -144,15 +144,15 @@ describe('patchAppUse (middleware spans)', () => { const app1 = new Hono(); const app2 = new Hono(); - patchAppUse(app1); - patchAppUse(app2); + applyHonoPatches(app1); + applyHonoPatches(app2); expect(app1.use).not.toBe(app2.use); }); it('preserves symbol-keyed and string-keyed properties on wrapped handlers', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); const META = Symbol('test-meta'); const OPENAPI = Symbol('openapi'); @@ -176,27 +176,6 @@ describe('patchAppUse (middleware spans)', () => { expect((route!.handler as any)[OPENAPI]).toEqual({ responses: { 200: {} } }); expect((route!.handler as any).customProp).toBe('hello'); }); - - it('preserves this context when calling the original use (Proxy forwards thisArg)', () => { - type FakeApp = { - _capturedThis: unknown; - use: (...args: unknown[]) => FakeApp; - }; - const fakeApp: FakeApp = { - _capturedThis: null, - use(this: FakeApp, ..._args: unknown[]) { - this._capturedThis = this; - return this; - }, - }; - - patchAppUse(fakeApp as unknown as Parameters[0]); - - const noop = async (_c: unknown, next: () => Promise) => next(); - fakeApp.use(noop); - - expect(fakeApp._capturedThis).toBe(fakeApp); - }); }); describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => { @@ -208,7 +187,7 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => 'wraps inline middleware in app.%s(path, mw, handler)', async method => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); app[method]( '/test', @@ -236,7 +215,7 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('does not wrap the sole handler when only one handler is passed', async () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); app.get('/test', async function onlyHandler() { return new Response('ok'); @@ -249,7 +228,7 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('wraps all handlers except the last when multiple handlers are passed', async () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); app.get( '/test', @@ -275,7 +254,7 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('wraps inline middleware in app.on(method, path, mw, handler)', async () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); app.on( 'QUERY', @@ -296,65 +275,15 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => expect(spanNames).not.toContain('onHandler'); }); - it('wraps app.query middleware when query is available (from 4.13.0)', async () => { - const context = { value: 'context' }; - const result = { value: 'result' }; - let registeredMiddleware: ((context: unknown, next: () => Promise) => Promise) | undefined; - let registeredHandler: ((context: unknown) => unknown) | undefined; - const query = vi.fn(function ( - this: unknown, - path: string, - middleware: (context: unknown, next: () => Promise) => Promise, - handler: (context: unknown) => unknown, - ) { - expect(this).toBe(fakeApp); - expect(path).toBe('/test'); - registeredMiddleware = middleware; - registeredHandler = handler; - return result; - }); - const fakeApp = Object.assign(new Hono(), { query }); - const middlewareSpy = vi.fn(); - async function queryMiddleware(receivedContext: unknown, next: () => Promise) { - middlewareSpy(receivedContext, next); - expect(receivedContext).toBe(context); - await next(); - } - const handler = vi.fn((receivedContext: unknown) => { - expect(receivedContext).toBe(context); - return 'handled'; - }); - - patchHttpMethodHandlers(fakeApp as unknown as Parameters[0]); - const registrationResult = fakeApp.query('/test', queryMiddleware, handler); - - expect(registrationResult).toBe(result); - if (!registeredMiddleware || !registeredHandler) { - throw new Error('query handlers were not registered'); - } - expect(registeredHandler).toBe(handler); - - const next = vi.fn(async () => undefined); - await registeredMiddleware(context, next); - const handlerResult = registeredHandler(context); - - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'queryMiddleware' })); - expect(middlewareSpy).toHaveBeenCalledWith(context, next); - expect(next).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith(context); - expect(handlerResult).toBe('handled'); - }); - it('patches apps without app.query', () => { const app = new Hono(); - expect(() => patchHttpMethodHandlers(app)).not.toThrow(); + expect(() => applyHonoPatches(app)).not.toThrow(); }); it('does not wrap sole handler in app.on(method, path, handler)', async () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); app.on('GET', '/test', async function soleHandler() { return new Response('ok'); @@ -367,8 +296,8 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('does not double-wrap handlers already wrapped by patchAppUse', async () => { const app = new Hono(); - patchAppUse(app); - patchHttpMethodHandlers(app); + applyHonoPatches(app); + applyHonoPatches(app); app.use(async function useMw(_c: unknown, next: () => Promise) { await next(); @@ -383,15 +312,15 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('produces exactly one span per middleware and does not stack Proxy layers when called multiple times on the same instance', async () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); const firstGet = app.get; const firstOn = app.on; - patchHttpMethodHandlers(app); + applyHonoPatches(app); expect(app.get).toBe(firstGet); expect(app.on).toBe(firstOn); - patchHttpMethodHandlers(app); + applyHonoPatches(app); expect(app.get).toBe(firstGet); expect(app.on).toBe(firstOn); @@ -414,8 +343,8 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('creates spans for both app.use middleware and inline middleware in app.get', async () => { const app = new Hono(); - patchAppUse(app); - patchHttpMethodHandlers(app); + applyHonoPatches(app); + applyHonoPatches(app); app.use('/test', async function globalMw(_c: unknown, next: () => Promise) { await next(); @@ -438,36 +367,10 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('preserves return value and chaining', () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); const result = app.get('/test', () => new Response('ok')); expect(result).toBe(app); }); - - it('forwards thisArg to the original method', () => { - let capturedThis: unknown = null; - const fakeMethod = function (this: unknown) { - // oxlint-disable-next-line @typescript-eslint/no-this-alias - capturedThis = this; - return this; - }; - const fakeApp = { - get: fakeMethod, - post: fakeMethod, - put: fakeMethod, - delete: fakeMethod, - options: fakeMethod, - patch: fakeMethod, - all: fakeMethod, - on: fakeMethod, - }; - - patchHttpMethodHandlers(fakeApp as unknown as Parameters[0]); - - // @ts-expect-error - we're only testing that thisArg is forwarded, so the args don't need to be correct - fakeApp.get('/test', () => new Response('ok')); - - expect(capturedThis).toBe(fakeApp); - }); }); diff --git a/packages/hono/test/shared/resolveRouteName.test.ts b/packages/hono/test/shared/resolveRouteName.test.ts deleted file mode 100644 index 4ca9f103db22..000000000000 --- a/packages/hono/test/shared/resolveRouteName.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Context } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockMatchedRoutes = vi.fn(); -const mockRoutePath = vi.fn(); - -vi.mock('hono/route', () => ({ - matchedRoutes: (c: unknown) => mockMatchedRoutes(c), - routePath: (c: unknown, index?: number) => mockRoutePath(c, index), -})); - -import { resolveRouteName } from '../../src/shared/resolveRouteName'; - -type Route = { - basePath: string; - path: string; - method: string; - handler: (...args: unknown[]) => unknown; -}; - -// Middleware has the signature `(context, next)` → arity 2 -// Route handlers are `(context)` → arity (no. of args) 1 -// `resolveRouteName` relies on this arity difference to tell them apart. -function mw(path: string, method = 'ALL'): Route { - return { basePath: '/', path, method, handler: (_c: unknown, _next: unknown) => undefined }; -} - -function handler(path: string, method = 'GET'): Route { - return { basePath: '/', path, method, handler: (_c: unknown) => undefined }; -} - -function ctx(routeIndex: number): Context { - return { req: { method: 'GET', routeIndex } } as unknown as Context; -} - -describe('resolveRouteName', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRoutePath.mockReturnValue('/fallback'); - }); - - it('returns the handler path when routeIndex points at a handler (normal flow)', () => { - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/users/:id')]); - - expect(resolveRouteName(ctx(1))).toBe('/users/:id'); - }); - - it('ignores a trailing catch-all middleware and uses the handler path', () => { - // app.use(fn) registered after the handlers → trailing `/*` is the last matched entry. - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/test-routes'), mw('/*')]); - - expect(resolveRouteName(ctx(1))).toBe('/test-routes'); - }); - - it('resolves the handler before dispatch when routeIndex still points at the sentry middleware', () => { - // Provisional pass: routeIndex is 0 (the sentry middleware) and `matchedRoutes` is already populated. - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/test-routes'), mw('/*')]); - - expect(resolveRouteName(ctx(0))).toBe('/test-routes'); - }); - - it('falls back to the matched handler when a middleware short-circuits (routeIndex on middleware)', () => { - // A scoped middleware throws before reaching the handler, so routeIndex stays on the middleware. - mockMatchedRoutes.mockReturnValue([mw('/*'), mw('/test/middleware/*'), handler('/test/middleware'), mw('/*')]); - - expect(resolveRouteName(ctx(1))).toBe('/test/middleware'); - }); - - it('prefers the responding handler over other matched handlers (overlap)', () => { - // Both `/users/:id` and a `/*` catch-all handler match; routeIndex disambiguates. - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/users/:id'), handler('/*')]); - - expect(resolveRouteName(ctx(1))).toBe('/users/:id'); - }); - - it('detects a sub-app handler wrapped by a custom onError (COMPOSED_HANDLER)', () => { - // Hono wraps the handler in an arity-2 closure but exposes the original via `__COMPOSED_HANDLER`. - const wrapped = ((_c: unknown, _next: unknown) => undefined) as Route['handler']; - (wrapped as unknown as Record).__COMPOSED_HANDLER = (_c: unknown) => undefined; - - mockMatchedRoutes.mockReturnValue([ - mw('/*'), - { basePath: '/', path: '/test/custom-on-error/fail', method: 'GET', handler: wrapped }, - mw('/*'), - ]); - - expect(resolveRouteName(ctx(1))).toBe('/test/custom-on-error/fail'); - }); - - it('falls back to routePath(c, -1) when only middleware matched', () => { - const context = ctx(1); - mockMatchedRoutes.mockReturnValue([mw('/*'), mw('/test-basepath/v1/*')]); - mockRoutePath.mockReturnValue('/test-basepath/v1/*'); - - expect(resolveRouteName(context)).toBe('/test-basepath/v1/*'); - expect(mockRoutePath).toHaveBeenCalledWith(context, -1); - }); - - it('falls back to routePath(c, -1) when no routes matched', () => { - const context = ctx(0); - mockMatchedRoutes.mockReturnValue([]); - mockRoutePath.mockReturnValue(''); - - expect(resolveRouteName(context)).toBe(''); - expect(mockRoutePath).toHaveBeenCalledWith(context, -1); - }); - - it('walks back to the last handler when routeIndex is out of range', () => { - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/test-late-get')]); - - expect(resolveRouteName(ctx(5))).toBe('/test-late-get'); - }); -}); diff --git a/packages/hono/test/shared/wrapMiddlewareSpan.test.ts b/packages/hono/test/shared/wrapMiddlewareSpan.test.ts deleted file mode 100644 index edf778188552..000000000000 --- a/packages/hono/test/shared/wrapMiddlewareSpan.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { SPAN_STATUS_ERROR } from '@sentry/core'; -import { type MiddlewareHandler } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { wrapMiddlewareWithSpan } from '../../src/shared/wrapMiddlewareSpan'; - -const mockSpan = { - setStatus: vi.fn(), - end: vi.fn(), -}; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - startInactiveSpan: vi.fn(() => mockSpan), - getActiveSpan: vi.fn(() => ({ spanId: 'root-span' })), - getRootSpan: vi.fn(span => span), - getOriginalFunction: vi.fn(() => undefined), - }; -}); - -const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; - -function makeContext(): unknown { - return { req: { method: 'GET' }, res: { status: 200 } }; -} - -const noop: () => Promise = async () => {}; - -describe('wrapMiddlewareWithSpan', () => { - beforeEach(() => { - vi.clearAllMocks(); - startInactiveSpanMock.mockReturnValue(mockSpan); - }); - - describe('span status', () => { - it('does not set span error status for a 4xx error', async () => { - const error = Object.assign(new Error('Not Found'), { status: 404 }); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).not.toHaveBeenCalled(); - }); - - it('sets span status to error for a 5xx error', async () => { - const error = Object.assign(new Error('Server Error'), { status: 500 }); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - }); - - it('sets span status to error for a plain Error with no status', async () => { - const error = new Error('unexpected failure'); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - }); - - it('does not set span error status for a 3xx error', async () => { - const error = Object.assign(new Error('Redirect'), { status: 301 }); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).not.toHaveBeenCalled(); - }); - }); - - describe('span lifecycle', () => { - it('always rethrows the error', async () => { - const error = new Error('must propagate'); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow('must propagate'); - }); - - it('ends the span even when the handler throws', async () => { - const handler: MiddlewareHandler = async () => { - throw new Error('boom'); - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(); - - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('ends the span when the handler succeeds', async () => { - const handler: MiddlewareHandler = async (_c, next) => { - await next(); - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await wrapped(makeContext() as any, noop); - - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/server-utils/src/exports.ts b/packages/server-utils/src/exports.ts index 3f14e69d93ff..15381f95dc9b 100644 --- a/packages/server-utils/src/exports.ts +++ b/packages/server-utils/src/exports.ts @@ -7,3 +7,12 @@ export { getSqlQuerySummary, sanitizeSqlQuery, sanitizeSqlQueryWithSummary } fro export type { SqlDialect } from './utils/sql'; export { instrumentPostgresJsSql } from './integrations/postgresjs'; export type { PostgresConnectionContext } from './integrations/postgresjs'; + +// Shared, runtime-agnostic Hono instrumentation re-used by the `@sentry/hono` SDK across all runtimes. +// Sourced directly from their modules (not the `./integrations/hono` barrel, which also exports the +// diagnostics-channel-based `honoIntegration`) so the non-Node adapters can import them from the +// Node-free `@sentry/server-utils/no-diagnostic-channels` entry. +export { applyHonoPatches, earlyPatchHono } from './integrations/hono/applyPatches'; +export { createHonoRequestMiddleware } from './integrations/hono/createHonoMiddleware'; +export type { CreateHonoRequestMiddlewareOptions } from './integrations/hono/createHonoMiddleware'; +export type { SentryHonoMiddlewareOptions } from './integrations/hono/types'; diff --git a/packages/hono/src/shared/applyPatches.ts b/packages/server-utils/src/integrations/hono/applyPatches.ts similarity index 60% rename from packages/hono/src/shared/applyPatches.ts rename to packages/server-utils/src/integrations/hono/applyPatches.ts index e3189f2f63f2..70e1d16af392 100644 --- a/packages/hono/src/shared/applyPatches.ts +++ b/packages/server-utils/src/integrations/hono/applyPatches.ts @@ -1,6 +1,6 @@ import { debug } from '@sentry/core'; -import type { Env, Hono } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { Env, Hono } from './honoTypes'; import { patchAppRequest } from './patchAppRequest'; import { patchAppUse, patchHttpMethodHandlers } from './patchAppUse'; import { type RouteHookHandle, installRouteHookOnPrototype, wrapSubAppMiddleware } from './patchRoute'; @@ -11,10 +11,13 @@ let _routeHook: RouteHookHandle | undefined; /** * Hooks `HonoBase.prototype.route` at import time, before `sentry()` runs. * - * Collecting sub-app references early ensures nothing is missed if sub-apps are mounted synchronously before the `sentry()` middleware is registered. + * Collecting sub-app references early ensures nothing is missed if sub-apps are mounted synchronously + * before the `sentry()` middleware is registered. The `Hono` class is passed in by the caller (the + * `@sentry/hono` SDK, where `hono` is a peer dependency) so this module never imports `hono` itself; + * `HonoBase.prototype` is one level above the class prototype. */ -export function earlyPatchHono(): void { - _routeHook ??= installRouteHookOnPrototype(); +export function earlyPatchHono(honoClass: { prototype: object }): void { + _routeHook ??= installRouteHookOnPrototype(Object.getPrototypeOf(honoClass.prototype)); } /** @@ -25,8 +28,10 @@ export function earlyPatchHono(): void { * - Retroactively instruments sub-apps mounted before `sentry()` was called. */ export function applyPatches(app: Hono): void { + // `HonoBase.prototype` (where `route` lives) is two levels up from the app instance: + // app → Hono.prototype → HonoBase.prototype. Deriving it from the live app avoids importing `hono`. // Always call — installRouteHookOnPrototype is idempotent and returns existing handle when prototype already patched - _routeHook = installRouteHookOnPrototype(); + _routeHook = installRouteHookOnPrototype(Object.getPrototypeOf(Object.getPrototypeOf(app))); // `app.use` (instance own property) — wraps middleware at registration time on this instance. patchAppUse(app); @@ -54,3 +59,13 @@ export function applyPatches(app: Hono): void { pendingSubApps.clear(); } + +/** + * Applies Sentry's Hono span patches to an app instance. + * + * Typed loosely (`object`) so the real `hono` `Hono` type used by the `@sentry/hono` SDK is + * accepted without a cast; internally it is treated as the vendored {@link Hono} shape. + */ +export function applyHonoPatches(app: object): void { + applyPatches(app as Hono); +} diff --git a/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts new file mode 100644 index 000000000000..952b45743590 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts @@ -0,0 +1,97 @@ +import { addNonEnumerableProperty, getDefaultIsolationScope, getIsolationScope } from '@sentry/core'; +import type { Context, GetConnInfo, MiddlewareHandler } from './honoTypes'; +import { requestHandler, responseHandler } from './middlewareHandlers'; +import type { SentryHonoMiddlewareOptions } from './types'; + +// Marks the Sentry request/response middleware so the span-wrapping patches never turn it into a +// middleware span — most importantly when an auto-instrumented sub-app carrying this middleware is +// mounted into a parent and `wrapSubAppMiddleware` wraps its handlers. Read by `wrapMiddlewareWithSpan`. +export const SENTRY_HONO_MIDDLEWARE = '__SENTRY_HONO_MIDDLEWARE__'; + +// `Symbol.for` (global registry) so the markers are shared even if this module is evaluated from more +// than one copy of `@sentry/server-utils`. +const HONO_REQUEST_HANDLED = Symbol.for('sentry.hono.requestHandled'); +// The effective `shouldHandleError` for the request, recorded even by a deduplicated middleware so a +// user-provided callback wins over the default (see below). +const HONO_SHOULD_HANDLE_ERROR = Symbol.for('sentry.hono.shouldHandleError'); + +export interface CreateHonoRequestMiddlewareOptions { + /** + * Runtime-specific `getConnInfo` helper (e.g. `@hono/node-server/conninfo`, `hono/bun`). + * Optional — connection-info attributes are simply skipped when it is not provided. + */ + getConnInfo?: GetConnInfo; + + /** Static `shouldHandleError` callback (Node/Bun/Deno). */ + shouldHandleError?: SentryHonoMiddlewareOptions['shouldHandleError']; + + /** + * Resolves `shouldHandleError` per request from the context. Cloudflare accepts + * middleware options as a function of `env`, so the callback is only known once a + * request comes in. When provided, this wins over the static `shouldHandleError`. + */ + resolveShouldHandleError?: (context: Context) => SentryHonoMiddlewareOptions['shouldHandleError']; +} + +/** + * The object that carries the per-request dedup/config markers. + * + * Prefer the per-request isolation scope over the Hono `Context`, so the request is treated as one + * even when several Sentry middlewares run for it: + * - a mounted sub-app that carries its own auto-registered middleware (same context, same scope), + * - an internal `app.request()` dispatch, which runs in a *new* Hono context but the *same* + * isolation scope (so it must not re-record the transaction name or overwrite the request data), + * - a manual `sentry()` middleware registered alongside the auto-instrumentation. + * + * When there is no per-request isolation scope (the default scope), fall back to the context so that + * at least same-context duplicates are still deduplicated. + */ +function getRequestScope(context: Context): Record { + const isolationScope = getIsolationScope(); + const target: object = isolationScope === getDefaultIsolationScope() ? context : isolationScope; + return target as Record; +} + +/** + * Builds the core Sentry request/response Hono middleware: it names the transaction, records the + * request, and captures unhandled context errors around `next()`. + * + * Idempotent per request (see {@link getRequestScope}), so duplicate registrations — a manual + * `sentry()` alongside the auto-instrumentation, mounted sub-apps, internal `.request()` dispatches — + * are all safe and run the handling exactly once. + * + * A user-provided `shouldHandleError` still takes effect even when the middleware carrying it is + * deduplicated behind the auto-instrumentation (which is prepended first, per request, from the + * `Context` constructor hook): the deduplicated middleware records its callback on the request scope, and the + * middleware that actually runs `responseHandler` uses it in preference to its own default. + */ +export function createHonoRequestMiddleware(options: CreateHonoRequestMiddlewareOptions = {}): MiddlewareHandler { + const middleware: MiddlewareHandler = async (context, next) => { + const scope = getRequestScope(context); + + const shouldHandleError = options.resolveShouldHandleError + ? options.resolveShouldHandleError(context) + : options.shouldHandleError; + // Record a user-provided callback so it wins even if this middleware is deduplicated. Runs before + // the dedup check so a later manual `sentry({ shouldHandleError })` overrides the auto default. + if (shouldHandleError) { + addNonEnumerableProperty(scope, HONO_SHOULD_HANDLE_ERROR, shouldHandleError); + } + + if (scope[HONO_REQUEST_HANDLED]) { + return next(); + } + addNonEnumerableProperty(scope, HONO_REQUEST_HANDLED, true); + + requestHandler(context, options.getConnInfo); + + await next(); // Handler runs in between Request above ⤴ and Response below ⤵ + + const effectiveShouldHandleError = + (scope[HONO_SHOULD_HANDLE_ERROR] as SentryHonoMiddlewareOptions['shouldHandleError']) ?? shouldHandleError; + responseHandler(context, effectiveShouldHandleError); + }; + + addNonEnumerableProperty(middleware, SENTRY_HONO_MIDDLEWARE, true); + return middleware; +} diff --git a/packages/hono/src/shared/defaultShouldHandleError.ts b/packages/server-utils/src/integrations/hono/defaultShouldHandleError.ts similarity index 100% rename from packages/hono/src/shared/defaultShouldHandleError.ts rename to packages/server-utils/src/integrations/hono/defaultShouldHandleError.ts diff --git a/packages/hono/src/utils/hono-context.ts b/packages/server-utils/src/integrations/hono/hono-context.ts similarity index 87% rename from packages/hono/src/utils/hono-context.ts rename to packages/server-utils/src/integrations/hono/hono-context.ts index 96df44ee655a..f1529c6eef10 100644 --- a/packages/hono/src/utils/hono-context.ts +++ b/packages/server-utils/src/integrations/hono/hono-context.ts @@ -1,4 +1,4 @@ -import type { Context } from 'hono'; +import type { Context } from './honoTypes'; /** * Checks whether the given Hono context has a fetch event. diff --git a/packages/server-utils/src/integrations/hono/honoTypes.ts b/packages/server-utils/src/integrations/hono/honoTypes.ts new file mode 100644 index 000000000000..a4dc01e14152 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/honoTypes.ts @@ -0,0 +1,71 @@ +/** + * Vendored subset of `hono`'s public types used by the Sentry Hono instrumentation. + * + * The instrumentation lives in `@sentry/server-utils`, a dependency of every server SDK — including + * apps that do not use Hono. We therefore declare no dependency on `hono` at all: not at runtime + * (the instrumentation never statically imports it; the `Hono` prototype is derived from a live app + * instance and matched routes are read from the request's own getters), and not at build/type time + * (these minimal structural types stand in for `hono`'s). + * + * ATTENTION: keep these permissive. Values cross the boundary to the `@sentry/hono` SDK, which uses + * the real `hono` types, so these must stay assignable from them at those call sites. + */ + +/* oxlint-disable typescript/no-explicit-any -- vendored, deliberately permissive types */ + +export interface Env { + Bindings?: any; + Variables?: any; +} + +export type Next = () => Promise; + +export interface HonoRoute { + method: string; + path: string; + // Loose on purpose: Hono's own route handler union (`Handler | MiddlewareHandler`) is wider than a + // middleware handler, and this must stay assignable from it so the real `Context` flows into the + // vendored one at the `@sentry/hono` boundary. + handler: (...args: any[]) => any; +} + +export interface HonoRequest { + raw: Request; + method: string; + path: string; + routeIndex: number; + // These are public (though deprecated) getters on Hono's request. Reading them avoids a runtime + // import of the `hono/route` helpers, which are their non-deprecated replacements. + matchedRoutes: HonoRoute[]; + routePath: string; + [key: string]: any; +} + +export interface Context { + req: HonoRequest; + env: unknown; + error?: Error; + event: { request: Request }; + [key: string]: any; +} + +// Return type is `Promise` (not the wider sync union) to stay assignable to the real +// `hono` `MiddlewareHandler` at the `@sentry/hono` boundary; our handlers are always async. +export type MiddlewareHandler = (context: Context, next: Next) => Promise; + +export interface Hono { + // `use` is chainable (returns the app), which is also where the `E` type parameter is threaded. + use: (...args: any[]) => Hono; + request: (...args: any[]) => Response | Promise; + routes: HonoRoute[]; + [key: string]: any; +} + +export interface ConnInfoRemote { + address?: string; + port?: number; + transport?: string; + addressType?: string; +} + +export type GetConnInfo = (context: any) => { remote?: ConnInfoRemote }; diff --git a/packages/server-utils/src/integrations/hono/index.ts b/packages/server-utils/src/integrations/hono/index.ts new file mode 100644 index 000000000000..7f6d3b13e19d --- /dev/null +++ b/packages/server-utils/src/integrations/hono/index.ts @@ -0,0 +1,7 @@ +// Shared, runtime-agnostic Hono instrumentation, re-used by the `@sentry/hono` SDK across all of its +// runtimes (Node, Bun, Cloudflare, Deno). None of these modules import `hono` or `node:diagnostics_channel`, +// so they stay safe to load in every server SDK — including the Node-free (`no-diagnostic-channels`) entry. +export { applyHonoPatches, earlyPatchHono } from './applyPatches'; +export { createHonoRequestMiddleware } from './createHonoMiddleware'; +export type { CreateHonoRequestMiddlewareOptions } from './createHonoMiddleware'; +export type { SentryHonoMiddlewareOptions } from './types'; diff --git a/packages/hono/src/utils/isMiddleware.ts b/packages/server-utils/src/integrations/hono/isMiddleware.ts similarity index 100% rename from packages/hono/src/utils/isMiddleware.ts rename to packages/server-utils/src/integrations/hono/isMiddleware.ts diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts similarity index 94% rename from packages/hono/src/shared/middlewareHandlers.ts rename to packages/server-utils/src/integrations/hono/middlewareHandlers.ts index ffba879603ea..ab1f3f000199 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts @@ -10,12 +10,11 @@ import { type Scope, winterCGRequestToRequestData, } from '@sentry/core'; -import type { Context } from 'hono'; -import { hasFetchEvent } from '../utils/hono-context'; +import type { Context, GetConnInfo } from './honoTypes'; +import { hasFetchEvent } from './hono-context'; import { defaultShouldHandleError } from './defaultShouldHandleError'; import { resolveRouteName } from './resolveRouteName'; -import { type SentryHonoMiddlewareOptions } from '../shared/types'; -import { type GetConnInfo } from 'hono/conninfo'; +import { type SentryHonoMiddlewareOptions } from './types'; import { HTTP_ROUTE } from '@sentry/conventions/attributes'; /** diff --git a/packages/server-utils/src/integrations/hono/patchAppRequest.ts b/packages/server-utils/src/integrations/hono/patchAppRequest.ts new file mode 100644 index 000000000000..69f073eb2185 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/patchAppRequest.ts @@ -0,0 +1,132 @@ +import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { HTTP_SERVER } from '@sentry/conventions/op'; +import { + debug, + getActiveSpan, + getOriginalFunction, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, + type WrappedFunction, +} from '@sentry/core'; +import type { Env, Hono } from './honoTypes'; +import { DEBUG_BUILD } from '../../debug-build'; + +const INTERNAL_REQUEST_OP = HTTP_SERVER; +const INTERNAL_REQUEST_ORIGIN = 'auto.http.hono.internal_request'; + +// Re-entrancy guard shared with the orchestrion channel subscriber +// (`instrumentInternalRequests` in `honoIntegration`). Both wrap the same `app.request`: this Proxy +// calls through to the original, which — when the default integration is active — is orchestrion- +// instrumented and publishes to the request channel. Without this guard the two stack a second, +// identical internal-request span inside the first. The Proxy owns the span; the channel subscriber +// reads this flag and opts out. `Symbol.for` on `globalThis` keeps it shared across copies of +// `@sentry/server-utils`, matching the request-handling dedup markers. +const INTERNAL_REQUEST_SPAN_ACTIVE = Symbol.for('sentry.hono.internalRequestSpanActive'); +type GuardCarrier = { [INTERNAL_REQUEST_SPAN_ACTIVE]?: boolean }; + +/** Whether the instance `app.request` Proxy is currently opening an internal-request span. */ +export function isInternalRequestSpanActive(): boolean { + return !!(globalThis as GuardCarrier)[INTERNAL_REQUEST_SPAN_ACTIVE]; +} + +function setInternalRequestSpanActive(active: boolean): void { + (globalThis as GuardCarrier)[INTERNAL_REQUEST_SPAN_ACTIVE] = active; +} + +function stripQueryAndHash(path: string): string { + const end = path.search(/[?#]/); + return end === -1 ? path : path.slice(0, end); +} + +/** + * Derive the span-name path from an `app.request()` argument, mirroring Hono's own handling so the + * name matches the path actually dispatched, with the query/hash stripped so they can't leak into + * span names or inflate cardinality. + * + * Hono treats an absolute `http(s)://` input as a full URL and everything else as a path under + * `http://localhost` (see `hono-base`'s `request`). We prepend that same fixed host rather than + * resolving the string as a URL reference: resolution rewrites protocol-relative inputs + * (`//example.com/foo` → host `example.com`, dropping the segment Hono keeps in the path) and throws + * on inputs Hono accepts (`//`, `http:`). This runs before the underlying dispatch, so it must never + * throw — the `catch` is a final guard against any remaining malformed input. + */ +export function extractPathname(input: unknown): string { + if (typeof input === 'string') { + try { + const url = /^https?:\/\//.test(input) + ? new URL(input) + : new URL(`http://localhost${input.startsWith('/') ? '' : '/'}${input}`); + return url.pathname; + } catch { + return stripQueryAndHash(input); + } + } + + if (input instanceof Request) { + return new URL(input.url).pathname; + } + + return input instanceof URL ? input.pathname : '/'; +} + +/** + * Patches `app.request()` on a Hono instance so that each internal dispatch + * is traced as an `http.server` span — child of whatever span is active at + * the call site. + * + * `.request()` is a class field (arrow function), so this must run per-instance. + * Idempotent: safe to call multiple times on the same instance. + */ +export function patchAppRequest(app: Hono): void { + if (getOriginalFunction(app.request as unknown as WrappedFunction)) { + DEBUG_BUILD && debug.log('[hono] app.request already patched — skipping.'); + return; + } + + const originalRequest = app.request; + + app.request = new Proxy(originalRequest, { + apply(_target, thisArg, args: [string | Request | URL, RequestInit?, ...unknown[]]) { + const [input, requestInit, ...rest] = args; + + if (!getActiveSpan()) { + return Reflect.apply(_target, thisArg, args); + } + + let method = requestInit?.method ?? (input instanceof Request ? input.method : 'GET'); + method = method.toUpperCase(); + + const path = extractPathname(input); + + return startSpan( + { + name: `${method} ${path}`, + onlyIfParent: true, + attributes: { + [SENTRY_OP]: INTERNAL_REQUEST_OP, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: INTERNAL_REQUEST_ORIGIN, + }, + }, + () => { + // The flag only needs to cover the synchronous dispatch start, where the orchestrion + // channel would fire and open its duplicate span. `Reflect.apply` returns the pending + // promise synchronously, so `finally` clears it before any nested `.request()` runs. + setInternalRequestSpanActive(true); + try { + return Reflect.apply(_target, thisArg, [input, requestInit, ...rest]); + } finally { + setInternalRequestSpanActive(false); + } + }, + ); + }, + get(target, prop, receiver) { + if (prop === '__sentry_original__') { + return originalRequest; + } + return Reflect.get(target, prop, receiver); + }, + }); + + DEBUG_BUILD && debug.log('[hono] Patched app.request for internal dispatch tracing.'); +} diff --git a/packages/hono/src/shared/patchAppUse.ts b/packages/server-utils/src/integrations/hono/patchAppUse.ts similarity index 96% rename from packages/hono/src/shared/patchAppUse.ts rename to packages/server-utils/src/integrations/hono/patchAppUse.ts index 05bb058bf499..7268151072b5 100644 --- a/packages/hono/src/shared/patchAppUse.ts +++ b/packages/server-utils/src/integrations/hono/patchAppUse.ts @@ -1,6 +1,6 @@ import { debug } from '@sentry/core'; -import type { Env, Hono, MiddlewareHandler } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; +import type { Env, Hono, MiddlewareHandler } from './honoTypes'; +import { DEBUG_BUILD } from '../../debug-build'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; // oxlint-disable-next-line typescript/no-explicit-any diff --git a/packages/hono/src/shared/patchRoute.ts b/packages/server-utils/src/integrations/hono/patchRoute.ts similarity index 87% rename from packages/hono/src/shared/patchRoute.ts rename to packages/server-utils/src/integrations/hono/patchRoute.ts index 67e6d83d52f3..4f13b1b760ad 100644 --- a/packages/hono/src/shared/patchRoute.ts +++ b/packages/server-utils/src/integrations/hono/patchRoute.ts @@ -1,17 +1,12 @@ import { debug, getOriginalFunction } from '@sentry/core'; import type { WrappedFunction } from '@sentry/core'; -import type { Hono, MiddlewareHandler } from 'hono'; -import { Hono as HonoClass } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; -import { isMiddleware } from '../utils/isMiddleware'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { Hono, HonoRoute } from './honoTypes'; +import { isMiddleware } from './isMiddleware'; import { patchAppRequest } from './patchAppRequest'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; -export type HonoRoute = { - method: string; - path: string; - handler: MiddlewareHandler; -}; +export type { HonoRoute }; // oxlint-disable-next-line typescript/no-explicit-any type HonoAny = Hono; @@ -60,15 +55,17 @@ function createRouteHook(): { handle: RouteHookHandle; onSubAppMounted: (subApp: /** * Installs a hook on `HonoBase.prototype.route` to intercept sub-app mounting. * + * `honoBaseProto` is `HonoBase.prototype`, where `route` is defined — one level above the concrete + * subclass. Callers derive it from a live app instance (`Object.getPrototypeOf(Object.getPrototypeOf(app))`) + * or from the `Hono` class (`Object.getPrototypeOf(Hono.prototype)`), so the instrumentation never + * imports `hono` itself. + * * Returns a handle with `activate()` and `getPendingSubApps()`. * Idempotent: subsequent calls return the same handle */ -export function installRouteHookOnPrototype(): RouteHookHandle { +export function installRouteHookOnPrototype(honoBaseProto: HonoBaseProto): RouteHookHandle { const noopHandle: RouteHookHandle = { activate: () => {}, getPendingSubApps: () => new Set() }; - // `route` is defined on HonoBase.prototype, one level above the concrete subclass - const honoBaseProto = Object.getPrototypeOf(HonoClass.prototype) as HonoBaseProto; - if (!honoBaseProto || typeof honoBaseProto.route !== 'function') { DEBUG_BUILD && debug.warn('[hono] Could not find HonoBase.prototype.route — sub-app instrumentation disabled.'); return noopHandle; diff --git a/packages/hono/src/shared/resolveRouteName.ts b/packages/server-utils/src/integrations/hono/resolveRouteName.ts similarity index 65% rename from packages/hono/src/shared/resolveRouteName.ts rename to packages/server-utils/src/integrations/hono/resolveRouteName.ts index 1fb720c22e5b..97b1283722ed 100644 --- a/packages/hono/src/shared/resolveRouteName.ts +++ b/packages/server-utils/src/integrations/hono/resolveRouteName.ts @@ -1,6 +1,5 @@ -import type { Context } from 'hono'; -import { matchedRoutes, routePath } from 'hono/route'; -import { isMiddleware } from '../utils/isMiddleware'; +import type { Context, HonoRoute } from './honoTypes'; +import { isMiddleware } from './isMiddleware'; // Arity alone is enough here (unlike `wrapSubAppMiddleware` in patchRoute.ts, which also needs position) // We only want the path, and inline middleware shares its handler's path. @@ -8,6 +7,19 @@ function isRouteHandler(handler: unknown): boolean { return typeof handler === 'function' && !isMiddleware(handler); } +// Read the request's own matched routes rather than importing the `hono/route` helpers, so the +// instrumentation needs no runtime import from `hono`. `c.req.matchedRoutes` and `c.req.routePath` +// are the (deprecated but public) getters those helpers wrap; `routePath(c, index)` is reimplemented +// here as `matchedRoutes(c).at(index)?.path` so an arbitrary index (e.g. -1) can be resolved. +function matchedRoutes(context: Context): HonoRoute[] { + return context.req.matchedRoutes ?? []; +} + +function routePath(context: Context, index?: number): string { + const routes = matchedRoutes(context); + return routes.at(index ?? context.req.routeIndex)?.path ?? ''; +} + /** * Resolves the route path of the matched handler for the transaction name. * diff --git a/packages/hono/src/shared/types.ts b/packages/server-utils/src/integrations/hono/types.ts similarity index 100% rename from packages/hono/src/shared/types.ts rename to packages/server-utils/src/integrations/hono/types.ts diff --git a/packages/hono/src/shared/wrapMiddlewareSpan.ts b/packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts similarity index 83% rename from packages/hono/src/shared/wrapMiddlewareSpan.ts rename to packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts index d1141000e137..e3e451247614 100644 --- a/packages/hono/src/shared/wrapMiddlewareSpan.ts +++ b/packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts @@ -9,7 +9,8 @@ import { startInactiveSpan, type WrappedFunction, } from '@sentry/core'; -import { type MiddlewareHandler } from 'hono'; +import { type MiddlewareHandler } from './honoTypes'; +import { SENTRY_HONO_MIDDLEWARE } from './createHonoMiddleware'; import { defaultShouldHandleError } from './defaultShouldHandleError'; const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; @@ -21,6 +22,12 @@ const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; * (onion order: A → B → handler → B → A would otherwise nest B under A). */ export function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHandler { + // Never turn Sentry's own request/response middleware into a middleware span — e.g. when an + // auto-instrumented sub-app carrying it is mounted into a parent and its handlers get wrapped. + if ((handler as unknown as Record)[SENTRY_HONO_MIDDLEWARE]) { + return handler; + } + if (getOriginalFunction(handler as unknown as WrappedFunction)) { return handler; } diff --git a/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts b/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts new file mode 100644 index 000000000000..cf2062390fe9 --- /dev/null +++ b/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Spy on the request/response handlers so we can assert exactly how many times they run. +const requestHandler = vi.fn(); +const responseHandler = vi.fn(); +vi.mock('../../../src/integrations/hono/middlewareHandlers', () => ({ + requestHandler: (...args: unknown[]) => requestHandler(...args), + responseHandler: (...args: unknown[]) => responseHandler(...args), +})); + +// eslint-disable-next-line import/first +import { createHonoRequestMiddleware } from '../../../src/integrations/hono/createHonoMiddleware'; + +// Minimal fake Hono context — dedup only needs a stable object to mark, not a real Hono app. +// oxlint-disable-next-line typescript/no-explicit-any +const fakeContext = (): any => ({ req: {} }); + +describe('createHonoRequestMiddleware — duplicate registration handling', () => { + beforeEach(() => { + requestHandler.mockClear(); + responseHandler.mockClear(); + }); + + it('runs request/response handling exactly once when two Sentry middlewares wrap the same request', async () => { + const outer = createHonoRequestMiddleware(); + const inner = createHonoRequestMiddleware(); + const context = fakeContext(); + + // Onion order: outer → inner → handler → inner → outer. + await outer(context, async () => { + await inner(context, async () => {}); + }); + + expect(requestHandler).toHaveBeenCalledTimes(1); + expect(responseHandler).toHaveBeenCalledTimes(1); + }); + + it('passes an already-handled context straight through to next()', async () => { + const context = fakeContext(); + + // First middleware handles the request and marks the context. + await createHonoRequestMiddleware()(context, async () => {}); + requestHandler.mockClear(); + responseHandler.mockClear(); + + // A second (duplicate) middleware on the same context must not re-run the handlers. + let nextCalled = false; + await createHonoRequestMiddleware()(context, async () => { + nextCalled = true; + }); + + expect(nextCalled).toBe(true); + expect(requestHandler).not.toHaveBeenCalled(); + expect(responseHandler).not.toHaveBeenCalled(); + }); + + it('handles independent requests independently (marker is per-context)', async () => { + const middleware = createHonoRequestMiddleware(); + + await middleware(fakeContext(), async () => {}); + await middleware(fakeContext(), async () => {}); + + expect(requestHandler).toHaveBeenCalledTimes(2); + expect(responseHandler).toHaveBeenCalledTimes(2); + }); + + it("uses a deduplicated middleware's shouldHandleError over the outer (auto) default", async () => { + const userShouldHandleError = (): boolean => true; + // Outer middleware mirrors the auto-instrumentation: registered first, no shouldHandleError. + const auto = createHonoRequestMiddleware(); + // Inner middleware mirrors a manual `sentry({ shouldHandleError })`: deduplicated behind `auto`. + const manual = createHonoRequestMiddleware({ shouldHandleError: userShouldHandleError }); + const context = fakeContext(); + + await auto(context, async () => { + await manual(context, async () => {}); + }); + + // The request is still handled exactly once, but with the user's callback, not the default. + expect(responseHandler).toHaveBeenCalledTimes(1); + expect(responseHandler).toHaveBeenCalledWith(context, userShouldHandleError); + }); +}); diff --git a/packages/hono/test/shared/defaultShouldHandleError.test.ts b/packages/server-utils/test/integrations/hono/defaultShouldHandleError.test.ts similarity index 88% rename from packages/hono/test/shared/defaultShouldHandleError.test.ts rename to packages/server-utils/test/integrations/hono/defaultShouldHandleError.test.ts index 85a29493c752..2b7705554f1e 100644 --- a/packages/hono/test/shared/defaultShouldHandleError.test.ts +++ b/packages/server-utils/test/integrations/hono/defaultShouldHandleError.test.ts @@ -1,6 +1,15 @@ -import { HTTPException } from 'hono/http-exception'; import { describe, expect, it } from 'vitest'; -import { defaultShouldHandleError } from '../../src/shared/defaultShouldHandleError'; +import { defaultShouldHandleError } from '../../../src/integrations/hono/defaultShouldHandleError'; + +// Minimal stand-in for hono's `HTTPException` (which carries a numeric `status`), so this unit test +// stays free of a `hono` dependency in `@sentry/server-utils`. +class HTTPException extends Error { + public status: number; + public constructor(status: number, options?: { message?: string }) { + super(options?.message); + this.status = status; + } +} describe('defaultShouldHandleError', () => { describe('HTTPException', () => { diff --git a/packages/hono/test/utils/isMiddleware.test.ts b/packages/server-utils/test/integrations/hono/isMiddleware.test.ts similarity index 95% rename from packages/hono/test/utils/isMiddleware.test.ts rename to packages/server-utils/test/integrations/hono/isMiddleware.test.ts index 6266d14e86db..ee193ac5ac8d 100644 --- a/packages/hono/test/utils/isMiddleware.test.ts +++ b/packages/server-utils/test/integrations/hono/isMiddleware.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isMiddleware } from '../../src/utils/isMiddleware'; +import { isMiddleware } from '../../../src/integrations/hono/isMiddleware'; describe('isMiddleware', () => { it.each([ From f9487f2fdda04a2bbaddf513fe0ea2e077497f88 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 30 Sep 2026 14:18:39 +0200 Subject: [PATCH 43/65] feat(hono): add orchestrion-based auto-instrumentation (#24497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second of two stacked PRs splitting the Hono instrumentation rework (originally #24371). Stacked on #24496 — review/merge that first; the diff here is against the base PR's branch. Adds `honoIntegration`, the auto-instrumentation that hooks Hono through the orchestrion module transform (`node:diagnostics_channel`) so requests are route-enriched without a manual `sentry()` middleware, plus its manual counterpart `honoMiddleware`. Registers it in `getErrorIntegrations()` and re-exports both from the server runtimes (node, cloudflare, bun, deno, and the serverless / meta-framework packages). Also adds the orchestrion transform config for `hono`, node-integration-tests for the auto-instrumentation, and a new orchestrion-based `hono-4` e2e app (the middleware-based app now lives as `hono-4-legacy`, added in the base PR). `node-mastra` now asserts route-enriched Hono spans in prod, where Hono is external and orchestrion-instrumented. _Root cause / notable fix:_ pulling the Hono module into the Cloudflare barrel reshuffled the worker bundle's module-init order and surfaced a latent TDZ crash for provided-module integrations (Flue): `Cannot access 'flueIntegration' before initialization` at worker startup. The orchestrion snippet injected into each instrumented module passed the integration factory **by reference**, reading the binding the instant that module evaluated — and since `@sentry/*/vite` imports a provided-module integration back into its own instrumented package, that closes an import cycle. The snippet now wraps the factory in an arrow (`() => flueIntegration()`) so the binding is only read when the stored thunk runs at `init()`, breaking the cycle. Also folds in a few follow-ups: `honoMiddleware` is now exported from the remaining runtimes that only had `honoIntegration` (elysia, remix, solidstart, sveltekit), the shared `INTERNAL_REQUEST_ORIGIN` constant moved into `hono/constants.ts`, and the previously-skipped `.basePath()` middleware e2e test is unskipped — the per-request orchestrion hook discovers middleware from the matched-handler list at dispatch, so it now works on the clone `.basePath()` returns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: isaacs Co-authored-by: Jan Peer Stöcklmair --- .size-limit.js | 4 +- .../node-suites/excludes.ts | 1 + dev-packages/e2e-tests/lib/getTestMatrix.mjs | 10 +- dev-packages/e2e-tests/run.ts | 10 +- .../test-applications/hono-4/.gitignore | 36 ++ .../test-applications/hono-4/build-bun.ts | 28 ++ .../test-applications/hono-4/deno.json | 9 + .../test-applications/hono-4/package.json | 57 +++ .../hono-4/playwright.config.ts | 30 ++ .../test-applications/hono-4/src/entry.bun.ts | 16 + .../hono-4/src/entry.cloudflare.ts | 8 + .../hono-4/src/entry.deno.ts | 13 + .../hono-4/src/entry.node.ts | 14 + .../hono-4/src/instrument.bun.ts | 8 + .../hono-4/src/instrument.deno.ts | 9 + .../hono-4/src/instrument.node.ts | 8 + .../hono-4/src/instrument.server.ts | 8 + .../hono-4/src/middleware.ts | 34 ++ .../hono-4/src/route-groups/test-errors.ts | 45 ++ .../src/route-groups/test-middleware.ts | 85 ++++ .../src/route-groups/test-multi-fetch.ts | 118 +++++ .../src/route-groups/test-route-patterns.ts | 33 ++ .../test-applications/hono-4/src/routes.ts | 134 ++++++ .../hono-4/start-event-proxy.mjs | 6 + .../tests/basepath-and-late-routes.test.ts | 90 ++++ .../hono-4/tests/constants.ts | 5 + .../hono-4/tests/errors.test.ts | 262 +++++++++++ .../hono-4/tests/middleware.test.ts | 442 ++++++++++++++++++ .../hono-4/tests/multi-fetch.test.ts | 391 ++++++++++++++++ .../hono-4/tests/route-patterns.test.ts | 178 +++++++ .../hono-4/tests/tracing.test.ts | 168 +++++++ .../test-applications/hono-4/tsconfig.json | 13 + .../test-applications/hono-4/vite.config.ts | 7 + .../test-applications/hono-4/wrangler.jsonc | 7 + .../node-mastra/src/mastra/index.ts | 5 + .../node-mastra/tests/manual-route.test.ts | 30 +- .../node-mastra/tests/mastra.test.ts | 31 +- .../suites/hono/instrument.mjs | 8 + .../suites/hono/scenario.mjs | 102 ++++ .../suites/hono/test.ts | 323 +++++++++++++ packages/astro/src/index.server.ts | 2 + packages/aws-serverless/src/index.ts | 2 + packages/bun/src/index.ts | 2 + packages/cloudflare/src/index.ts | 2 + packages/deno/src/index.ts | 2 + .../deno/test/__snapshots__/mod.test.ts.snap | 3 + packages/elysia/src/index.ts | 2 + packages/google-cloud-serverless/src/index.ts | 2 + .../hono/test/shared/earlyPatchRoute.test.ts | 54 +++ packages/node/src/index.ts | 2 + packages/remix/src/server/index.ts | 2 + packages/server-utils/src/index.ts | 2 + .../src/integrations/hono/constants.ts | 4 + .../integrations/hono/createHonoMiddleware.ts | 29 +- .../src/integrations/hono/honoIntegration.ts | 234 ++++++++++ .../src/integrations/hono/index.ts | 9 + .../integrations/hono/middlewareHandlers.ts | 15 + .../src/integrations/hono/patchAppRequest.ts | 2 +- .../server-utils/src/integrations/index.ts | 3 +- .../src/orchestrion/bundler/bun.ts | 1 + .../bundler/moduleInjectedTransform.ts | 9 +- .../server-utils/src/orchestrion/channels.ts | 2 + .../config/channel-integration-definitions.ts | 1 + .../src/orchestrion/config/hono.ts | 32 ++ .../src/orchestrion/config/index.ts | 2 + .../hono/createHonoMiddleware.test.ts | 48 +- .../moduleInjectedTransform.test.ts | 8 +- .../test/orchestrion/webpack-loader.test.ts | 6 +- packages/solidstart/src/server/index.ts | 2 + packages/sveltekit/src/server/index.ts | 2 + 70 files changed, 3224 insertions(+), 48 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/build-bun.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/deno.json create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/package.json create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/instrument.bun.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/instrument.deno.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/instrument.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc create mode 100644 dev-packages/node-integration-tests/suites/hono/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/hono/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/hono/test.ts create mode 100644 packages/server-utils/src/integrations/hono/constants.ts create mode 100644 packages/server-utils/src/integrations/hono/honoIntegration.ts create mode 100644 packages/server-utils/src/orchestrion/config/hono.ts diff --git a/.size-limit.js b/.size-limit.js index 0b03cc41e003..80ad8c9f5f4f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '143 KB', + limit: '145 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -452,7 +452,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '121 KB', + limit: '123 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 7bf45d467d15..c9a89b180612 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -78,6 +78,7 @@ const NO_OUTGOING_HTTP_INSTRUMENTATION = [ export const NO_AUTO_INSTRUMENTATION = [ 'suites/express/**', 'suites/fs-instrumentation/test.ts', + 'suites/hono/test.ts', 'suites/hono-sdk/test.ts', 'suites/pino/test.ts', 'suites/tracing/amqplib/test.ts', diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.mjs b/dev-packages/e2e-tests/lib/getTestMatrix.mjs index b8be7af5f528..7433dc989ead 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.mjs +++ b/dev-packages/e2e-tests/lib/getTestMatrix.mjs @@ -85,9 +85,17 @@ function addIncludesForTestApp(testApp, includes, { optionalMode }) { } variants.forEach(variant => { + // Allow skipping an individual variant (e.g. one blocked by an upstream bug) while keeping the + // others. `sentryTest.skip` above skips the whole app; this is the per-variant equivalent. + if (variant.skip) { + return; + } + + // Don't leak the `skip` flag into the matrix include. + const { skip: _skip, ...variantConfig } = variant; includes.push({ 'test-application': testApp, - ...variant, + ...variantConfig, }); }); } diff --git a/dev-packages/e2e-tests/run.ts b/dev-packages/e2e-tests/run.ts index e2c56813a3bf..317aa42a3a64 100644 --- a/dev-packages/e2e-tests/run.ts +++ b/dev-packages/e2e-tests/run.ts @@ -13,6 +13,7 @@ interface SentryTestVariant { 'build-command': string; 'assert-command'?: string; label?: string; + skip?: boolean; } interface PackageJson { @@ -82,7 +83,13 @@ async function getVariantBuildCommand( packageJsonPath: string, variantLabel: string, testAppPath: string, -): Promise<{ buildCommand: string; assertCommand: string; testLabel: string; matchedVariantLabel?: string }> { +): Promise<{ + buildCommand: string; + assertCommand: string; + testLabel: string; + matchedVariantLabel?: string; + skip?: boolean; +}> { try { const packageJsonContent = await readFile(packageJsonPath, 'utf-8'); const packageJson: PackageJson = JSON.parse(packageJsonContent); @@ -100,6 +107,7 @@ async function getVariantBuildCommand( assertCommand: matchingVariant['assert-command'] || 'pnpm test:assert', testLabel: matchingVariant.label || testAppPath, matchedVariantLabel: matchingVariant.label, + skip: matchingVariant.skip, }; } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/.gitignore b/dev-packages/e2e-tests/test-applications/hono-4/.gitignore new file mode 100644 index 000000000000..534f51704346 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/.gitignore @@ -0,0 +1,36 @@ +# prod +dist/ + +# dev +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ +.wrangler + +# env +.env +.env.production +.dev.vars + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# test +test-results + +# misc +.DS_Store diff --git a/dev-packages/e2e-tests/test-applications/hono-4/build-bun.ts b/dev-packages/e2e-tests/test-applications/hono-4/build-bun.ts new file mode 100644 index 000000000000..fd337d063992 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/build-bun.ts @@ -0,0 +1,28 @@ +// Builds `src/entry.bun.ts` with the orchestrion `bun build` plugin, emitting `dist/entry.bun.js` +// for the server to run. The plugin injects the `orchestrion:hono:context` diagnostics +// channel into the bundled `hono`, which the `honoIntegration` default subscribes to. + +// @ts-ignore -- subpath export resolved by Bun at runtime; the package tsconfig's node module +// resolution can't see `exports` subpaths. +import { sentryBunPlugin } from '@sentry/bun/plugin'; +import { join } from 'path'; + +void (async () => { + const result = await Bun.build({ + entrypoints: [join(__dirname, 'src/entry.bun.ts')], + target: 'bun', + outdir: join(__dirname, 'dist'), + // `@sentry/bun` (and its deps) stay external, so we don't bundle the whole SDK stack. + external: ['@sentry/bun'], + plugins: [sentryBunPlugin()], + }); + + if (!result.success) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED', result.logs); + process.exit(1); + } + + // eslint-disable-next-line no-console + console.log('BUILD_OK'); +})(); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/deno.json b/dev-packages/e2e-tests/test-applications/hono-4/deno.json new file mode 100644 index 000000000000..7a7e681c4a82 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/deno.json @@ -0,0 +1,9 @@ +{ + "imports": { + "@sentry/deno": "npm:@sentry/deno", + "@sentry/core": "npm:@sentry/core", + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", + "hono": "npm:hono@^4.13.1" + }, + "nodeModulesDir": "manual" +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json new file mode 100644 index 000000000000..a3fd78b9ec67 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -0,0 +1,57 @@ +{ + "name": "hono-4", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "dev:node": "node --import tsx/esm --import ./src/instrument.node.ts src/entry.node.ts", + "dev:bun": "bun run dist/entry.bun.js", + "dev:deno": "deno run --allow-net --allow-env --allow-read --preload=npm:@sentry/deno/import src/entry.deno.ts", + "dev:cloudflare": "wrangler dev --config ./dist/hono_4/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "test:build": "pnpm install", + "test": "TEST_ENV=production playwright test", + "test:assert": "pnpm test:assert:node", + "test:assert:node": "RUNTIME=node pnpm test", + "test:assert:bun": "bun run build-bun.ts && RUNTIME=bun pnpm test", + "test:assert:deno": "RUNTIME=deno pnpm test", + "test:assert:cloudflare": "vite build && RUNTIME=cloudflare pnpm test" + }, + "dependencies": { + "@sentry/bun": "latest || *", + "@sentry/cloudflare": "latest || *", + "@sentry/deno": "latest || *", + "@sentry/node": "latest || *", + "@hono/node-server": "^2.0.5", + "hono": "^4.13.1" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@cloudflare/vite-plugin": "^1.47.0", + "@cloudflare/workers-types": "^4.20240725.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "tsx": "4.21.0", + "typescript": "^5.5.2", + "vite": "^8.1.5", + "wrangler": "^4.114.0" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "assert-command": "pnpm test:assert:bun", + "label": "hono-4 (bun)" + }, + { + "assert-command": "pnpm test:assert:deno", + "label": "hono-4 (deno)" + }, + { + "assert-command": "pnpm test:assert:cloudflare", + "label": "hono-4 (cloudflare)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts new file mode 100644 index 000000000000..b8c04df05aee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts @@ -0,0 +1,30 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { RUNTIME, type Runtime } from './tests/constants'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38787; + +const startCommands: Record = { + cloudflare: `pnpm dev:cloudflare --port ${APP_PORT}`, + node: `pnpm dev:node`, + bun: `pnpm dev:bun`, + deno: `pnpm dev:deno`, +}; + +const config = getPlaywrightConfig( + { + startCommand: startCommands[RUNTIME], + port: APP_PORT, + }, + { + workers: '100%', + retries: 0, + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts new file mode 100644 index 000000000000..b47f15997480 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts @@ -0,0 +1,16 @@ +// Import the Sentry init first so `honoIntegration` is set up (and subscribed to Hono's per-request +// Context channel) before any request is handled. +import './instrument.bun'; +import { Hono } from 'hono'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +addRoutes(app); + +const port = Number(process.env.PORT || 38787); + +export default { + port, + fetch: app.fetch, +}; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts new file mode 100644 index 000000000000..7fb84f59947a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts @@ -0,0 +1,8 @@ +import { Hono } from 'hono'; +import { addRoutes } from './routes'; + +const app = new Hono<{ Bindings: { E2E_TEST_DSN: string } }>(); + +addRoutes(app); + +export default app; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts new file mode 100644 index 000000000000..cc2411fd6cf9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts @@ -0,0 +1,13 @@ +// Import the Sentry init first so `honoIntegration` is set up (and subscribed to Hono's per-request +// Context channel) before any request is handled. +import './instrument.deno'; +import { Hono } from 'hono'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +addRoutes(app); + +const port = Number(Deno.env.get('PORT') || 38787); + +Deno.serve({ port }, app.fetch); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts new file mode 100644 index 000000000000..c67b3a206ef2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts @@ -0,0 +1,14 @@ +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +addRoutes(app); + +const port = Number(process.env.PORT || 38787); + +serve({ fetch: app.fetch, port }, () => { + // eslint-disable-next-line no-console + console.log(`Hono (Node) listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.bun.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.bun.ts new file mode 100644 index 000000000000..9cb58d8c2aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.bun.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/bun'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.deno.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.deno.ts new file mode 100644 index 000000000000..49c9d1d452f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.deno.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/deno'; + +Sentry.init({ + dsn: Deno.env.get('E2E_TEST_DSN'), + environment: 'qa', + dataCollection: {}, + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts new file mode 100644 index 000000000000..5c3e586fd48b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.server.ts new file mode 100644 index 000000000000..d1990a0ea930 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.server.ts @@ -0,0 +1,8 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions<{ E2E_TEST_DSN: string }>(env => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +})); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts new file mode 100644 index 000000000000..9304a980dff3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts @@ -0,0 +1,34 @@ +import type { MiddlewareHandler } from 'hono'; + +// Defined as anonymous function expressions so the function name is inferred from the `const` +// binding. A named expression (`const middlewareA = async function middlewareA() {}`) collides with +// the binding when bundled, and Bun renames the inner function (→ `middlewareA2`), which would then +// surface as the middleware span name. The inferred name stays stable across all runtimes. +export const middlewareA: MiddlewareHandler = async function (c, next) { + // Add some delay + await new Promise(resolve => setTimeout(resolve, 50)); + await next(); +}; + +export const middlewareB: MiddlewareHandler = async function (_c, next) { + // Add some delay + await new Promise(resolve => setTimeout(resolve, 60)); + await next(); +}; + +let failingMiddlewareCount = 0; +export const failingMiddleware: MiddlewareHandler = async function (_c, _next) { + // Each throw gets a unique suffix so the Dedupe integration doesn't collapse the identical errors + // that several tests (and their retries) trigger through this shared middleware — otherwise only the + // first would be reported and the other tests' `waitForError` would time out. Tests match on the + // stable `Middleware error` prefix. + throw new Error(`Middleware error #${(failingMiddlewareCount += 1)}`); +}; + +// Intentionally a NAMED function expression (unlike the anonymous ones above) so the named-function +// path stays covered: the span name is taken from the function's own name. Under bundled Bun the inner +// name collides with the `const` binding and is suffixed (→ `namedMiddleware2`), so the test matches on +// the `namedMiddleware` prefix rather than an exact name. +export const namedMiddleware: MiddlewareHandler = async function namedMiddleware(_c, next) { + await next(); +}; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts new file mode 100644 index 000000000000..b8f2fd96fe93 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts @@ -0,0 +1,45 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +const errorRoutes = new Hono(); + +// Middleware that throws a 5xx HTTPException (should be captured) +errorRoutes.use('/middleware-http-exception/*', async (_c, _next) => { + throw new HTTPException(503, { message: 'Service Unavailable from middleware' }); +}); + +errorRoutes.get('/middleware-http-exception', c => c.text('should not reach')); + +// Middleware that throws a 4xx HTTPException (should NOT be captured) +errorRoutes.use('/middleware-http-exception-4xx/*', async (_c, _next) => { + throw new HTTPException(401, { message: 'Unauthorized from middleware' }); +}); + +errorRoutes.get('/middleware-http-exception-4xx', c => c.text('should not reach')); + +// Sub-app with a custom onError handler that swallows errors +const subAppWithOnError = new Hono(); + +subAppWithOnError.onError((err, c) => { + return c.text(`Handled by onError: ${err.message}`, 500); +}); + +subAppWithOnError.get('/fail', () => { + throw new Error('Error caught by custom onError'); +}); + +errorRoutes.route('/custom-on-error', subAppWithOnError); + +// Nested sub-apps: parent mounts child, child route throws +const childApp = new Hono(); + +childApp.get('/error', () => { + throw new Error('Nested child app error'); +}); + +const parentApp = new Hono(); +parentApp.route('/child', childApp); + +errorRoutes.route('/nested', parentApp); + +export { errorRoutes }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts new file mode 100644 index 000000000000..6f21c1cac33b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts @@ -0,0 +1,85 @@ +import { Hono } from 'hono'; +import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } from '../middleware'; + +const middlewareRoutes = new Hono(); + +middlewareRoutes.get('/named', c => c.json({ middleware: 'named' })); +middlewareRoutes.get('/anonymous', c => c.json({ middleware: 'anonymous' })); +middlewareRoutes.get('/multi', c => c.json({ middleware: 'multi' })); +middlewareRoutes.get('/error', c => c.text('should not reach')); +middlewareRoutes.get('/param/:id', c => c.json({ paramId: c.req.param('id') })); +middlewareRoutes.get('/declared', c => c.json({ middleware: 'declared' })); + +// Self-contained sub-app registering its own middleware via .use() +const subAppWithMiddleware = new Hono(); + +subAppWithMiddleware.use('/named/*', middlewareA); +subAppWithMiddleware.use('/declared/*', namedMiddleware); +subAppWithMiddleware.use('/anonymous/*', async (c, next) => { + c.header('X-Custom', 'anonymous'); + await next(); +}); +subAppWithMiddleware.use('/multi/*', middlewareA, middlewareB); +subAppWithMiddleware.use('/error/*', failingMiddleware); +subAppWithMiddleware.use('/param/*', middlewareA); + +// .all() handler (1 parameter) — should NOT be wrapped as middleware by patchRoute. +subAppWithMiddleware.all('/all-handler', async function allCatchAll(c) { + return c.json({ handler: 'all' }); +}); + +subAppWithMiddleware.route('/', middlewareRoutes); + +// Sub-app with inline middleware for different registration styles. +// patchRoute wraps non-last handlers per method+path group as middleware. +const subAppWithInlineMiddleware = new Hono(); + +const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const; + +// Direct method registration for each HTTP method +METHODS.forEach(method => { + subAppWithInlineMiddleware[method]( + '/direct', + async function inlineMiddleware(_c, next) { + await next(); + }, + c => c.text(`${method} direct response`), + ); + + subAppWithInlineMiddleware[method]('/direct/separately', async function inlineSeparateMiddleware(_c, next) { + await next(); + }); + subAppWithInlineMiddleware[method]('/direct/separately', c => c.text(`${method} direct separate response`)); +}); + +// .all(): .all('/path', mw, handler) +subAppWithInlineMiddleware.all( + '/all', + async function inlineMiddlewareAll(_c, next) { + await next(); + }, + c => c.text('all response'), +); +subAppWithInlineMiddleware.all('/all/separately', async function inlineSeparateMiddlewareAll(_c, next) { + await next(); +}); +subAppWithInlineMiddleware.all('/all/separately', c => c.text('all separate response')); + +// .on() registration for each HTTP method +METHODS.forEach(method => { + subAppWithInlineMiddleware.on( + method, + '/on', + async function inlineMiddlewareOn(_c, next) { + await next(); + }, + c => c.text(`${method} on response`), + ); + + subAppWithInlineMiddleware.on(method, '/on/separately', async function inlineSeparateMiddlewareOn(_c, next) { + await next(); + }); + subAppWithInlineMiddleware.on(method, '/on/separately', c => c.text(`${method} on separate response`)); +}); + +export { middlewareRoutes, subAppWithMiddleware, subAppWithInlineMiddleware }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts new file mode 100644 index 000000000000..ed9f0a3e131b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts @@ -0,0 +1,118 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +const ITEMS: Record = { + 'self-watering-plant': { name: 'Self-Watering Plant', stock: 5, price: 2500 }, + 'solar-powered-cyberdeck': { name: 'Solar-Powered Cyberdeck', stock: 0, price: 5000 }, +}; + +// Inventory service — standalone sub-app used as a data source. +// Mounted on the main app AND called internally via .request() from the storefront. +const inventoryApp = new Hono(); + +inventoryApp.get('/item/:productId', c => { + const productId = c.req.param('productId'); + const item = ITEMS[productId]; + if (!item) { + throw new HTTPException(404, { message: `Item ${productId} not found` }); + } + return c.json({ productId, ...item }); +}); + +inventoryApp.get('/item/:productId/stock', c => { + const productId = c.req.param('productId'); + const item = ITEMS[productId]; + if (!item) { + throw new HTTPException(404, { message: `Stock check failed: ${productId}` }); + } + return c.json({ productId, inStock: item.stock > 0, quantity: item.stock }); +}); + +// Simulates an inner-route failure (a plain 5xx Error, not an HTTPException) reached only via an +// internal .request() — used by the storefront's `/degraded` route below. Each throw gets a unique +// suffix so the Dedupe integration doesn't collapse repeats across test retries. +let dbErrorCount = 0; +inventoryApp.get('/item/:productId/db-error', () => { + throw new Error(`inventory db is down #${(dbErrorCount += 1)}`); +}); + +// Storefront service — orchestrates internal .request() calls to inventoryApp. +const storefrontApp = new Hono(); + +storefrontApp.use('/*', async function storefrontAuth(_c, next) { + await new Promise(resolve => setTimeout(resolve, 10)); + await next(); +}); + +// Single internal fetch: look up one product +storefrontApp.get('/product/:productId', async c => { + const res = await inventoryApp.request(`/item/${c.req.param('productId')}`); + if (!res.ok) { + throw new HTTPException(404, { message: 'Product not found' }); + } + const item = await res.json(); + return c.json({ product: item, source: 'storefront' }); +}); + +// Parallel internal fetches: compare two products via Promise.all +storefrontApp.get('/compare/:productId1/:productId2', async c => { + const [res1, res2] = await Promise.all([ + inventoryApp.request(`/item/${c.req.param('productId1')}`), + inventoryApp.request(`/item/${c.req.param('productId2')}`), + ]); + if (!res1.ok || !res2.ok) { + throw new HTTPException(404, { message: 'One or more products not found' }); + } + const [item1, item2] = (await Promise.all([res1.json(), res2.json()])) as [ + Record, + Record, + ]; + return c.json({ + items: [item1, item2], + priceDifference: Math.abs(item1.price - item2.price), + }); +}); + +// Sequential chained fetches: look up item, then check its stock +storefrontApp.get('/product/:productId/availability', async c => { + const itemRes = await inventoryApp.request(`/item/${c.req.param('productId')}`); + if (!itemRes.ok) { + throw new HTTPException(404, { message: 'Product not found' }); + } + const item: Record = await itemRes.json(); + + const stockRes = await inventoryApp.request(`/item/${item.productId}/stock`); + const stock: Record = await stockRes.json(); + + return c.json({ + product: item.name, + available: stock.inStock, + quantity: stock.quantity, + }); +}); + +// Degraded response: the inner route throws, but the outer handler swallows the failed internal +// response and returns a 200 instead of propagating. The inner error should still reach Sentry (auto +// instrumentation); the outer request stays healthy. +storefrontApp.get('/product/:productId/degraded', async c => { + const res = await inventoryApp.request(`/item/${c.req.param('productId')}/db-error`); + if (!res.ok) { + return c.json({ product: null, degraded: true }, 200); + } + return c.json({ product: await res.json() }); +}); + +// Error propagation: internal 404 causes the handler to throw a plain Error +storefrontApp.get('/product-or-throw/:productId', async c => { + const res = await inventoryApp.request(`/item/${c.req.param('productId')}`); + if (!res.ok) { + throw new Error(`Failed to fetch product: ${c.req.param('productId')}`); + } + return c.json({ product: await res.json() }); +}); + +const multiFetchRoutes = new Hono(); +multiFetchRoutes.route('/inventory', inventoryApp); +multiFetchRoutes.route('/storefront', storefrontApp); + +export { multiFetchRoutes }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts new file mode 100644 index 000000000000..ac6fea1320cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts @@ -0,0 +1,33 @@ +import { Hono } from 'hono'; + +const routePatterns = new Hono(); + +const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const; + +// Direct method registration for each HTTP method (sync handlers) +METHODS.forEach(method => { + routePatterns[method]('/', c => c.text(`${method} response`)); +}); + +// Async handler +routePatterns.get('/async', async c => { + await new Promise(resolve => setTimeout(resolve, 10)); + return c.text('async response'); +}); + +// Dedicated route for query_string test to avoid transaction name collisions +routePatterns.get('/query-test', c => c.text('query test response')); + +// Dedicated route for request data extraction tests to avoid transaction name collisions +routePatterns.get('/request-data', c => c.text('request data response')); +routePatterns.post('/request-data', c => c.text('request data response')); + +// .all() registration +routePatterns.all('/all', c => c.text('all handler response')); + +// .on() registration +METHODS.forEach(method => { + routePatterns.on(method, '/on', c => c.text(`${method} on response`)); +}); + +export { routePatterns }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts new file mode 100644 index 000000000000..008a1ca6fd53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts @@ -0,0 +1,134 @@ +import { type Hono as HonoType, Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } from './middleware'; +import { errorRoutes } from './route-groups/test-errors'; +import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware'; +import { multiFetchRoutes } from './route-groups/test-multi-fetch'; +import { routePatterns } from './route-groups/test-route-patterns'; + +export function addRoutes(app: HonoType<{ Bindings?: { E2E_TEST_DSN: string } }>): void { + app.get('/', c => { + return c.text('Hello Hono!'); + }); + + app.get('/test-param/:paramId', c => { + return c.json({ paramId: c.req.param('paramId') }); + }); + + app.get('/error/:cause', c => { + throw new Error('This is a test error for Sentry!', { + cause: c.req.param('cause'), + }); + }); + + app.get('/linked-error', () => { + const cause = new Error('Failure 1'); + const errorCause = new Error('Failure 2', { cause }); + throw new Error('Failure 3', { cause: errorCause }); + }); + + app.get('/http-exception/:code', c => { + // oxlint-disable-next-line typescript/no-explicit-any + const code = Number(c.req.param('code')) as any; + throw new HTTPException(code, { message: `HTTPException ${code}` }); + }); + + // Root-app middleware: registered on the patched main app instance + app.use('/test-middleware/named/*', middlewareA); + app.use('/test-middleware/anonymous/*', async (c, next) => { + c.header('X-Custom', 'anonymous'); + await next(); + }); + app.use('/test-middleware/multi/*', middlewareA, middlewareB); + app.use('/test-middleware/error/*', failingMiddleware); + app.use('/test-middleware/param/*', middlewareA); + app.use('/test-middleware/declared/*', namedMiddleware); + app.route('/test-middleware', middlewareRoutes); + + // Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching + app.route('/test-subapp-middleware', subAppWithMiddleware); + + // Inline middleware patterns: direct method, .all(), .on() with inline/separate middleware + app.route('/test-inline-middleware', subAppWithInlineMiddleware); + + // Inline middleware on the main app via HTTP method registration (not .use()). + app.get( + '/test-main-inline/get', + async function mainInlineGet(_c, next) { + await next(); + }, + c => c.text('main inline get'), + ); + app.post( + '/test-main-inline/post', + async function mainInlinePost(_c, next) { + await next(); + }, + c => c.text('main inline post'), + ); + app.all( + '/test-main-inline/all', + async function mainInlineAll(_c, next) { + await next(); + }, + c => c.text('main inline all'), + ); + app.query( + '/test-main-inline/query', + async function mainInlineQuery(_c, next) { + await next(); + }, + async c => { + const body = await c.req.json<{ value: string }>(); + return c.json({ method: c.req.method, value: body.value }); + }, + ); + + // Combined: .use() middleware + inline middleware via .get() on the same path. + app.use('/test-main-inline/combined/*', middlewareA); + app.get( + '/test-main-inline/combined/resource', + async function combinedInlineMw(_c, next) { + await next(); + }, + c => c.text('combined response'), + ); + + // Route patterns: HTTP methods, .all(), .on(), sync/async, errors + app.route('/test-routes', routePatterns); + + // Error-specific routes: onError handler, nested sub-apps, middleware HTTPException + app.route('/test-errors', errorRoutes); + + // Multi-fetch routes: storefront sub-app calls inventoryApp via .request() + app.route('/test-multi-fetch', multiFetchRoutes); + + // .basePath() with sub-app mounting via .route() + const apiSubApp = new Hono(); + apiSubApp.use(async function apiAuth(_c, next) { + await next(); + }); + apiSubApp.get('/users', c => c.json({ users: [{ id: 1, name: 'Alice' }] })); + apiSubApp.get('/users/:userId', c => c.json({ userId: c.req.param('userId') })); + + app.basePath('/test-basepath').route('/v1', apiSubApp); + + app.use(async function trailingMiddleware(_c, next) { + // Trailing middleware to make sure the route names are resolved correctly (not `/*`). + await new Promise(resolve => setTimeout(resolve, 50)); + await next(); + }); + + // .use() on the cloned instance returned by .basePath() — the clone has its own + // .use class field, so this tests whether middleware instrumentation propagates. + app + .basePath('/test-basepath-mw') + .use(async function basepathMiddleware(_c, next) { + await new Promise(resolve => setTimeout(resolve, 50)); + await next(); + }) + .get('/hello', c => c.json({ greeting: 'world' })); + + // .get() registered on the root app after .basePath()/.route() chains + app.get('/test-late-get', c => c.json({ registered: 'after-chains' })); +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs new file mode 100644 index 000000000000..cd6f91b3455d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'hono-4', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts new file mode 100644 index 000000000000..6295dc4c9e32 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +test.describe('basePath with sub-app routes', () => { + test('traces GET on a sub-app mounted via .basePath().route()', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-basepath/v1/users', + ); + + const response = await fetch(`${baseURL}/test-basepath/v1/users`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ users: [{ id: 1, name: 'Alice' }] }); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-basepath/v1/users'); + expect(getSpanOp(segment)).toBe('http.server'); + }); + + test('traces parameterized route under .basePath().route()', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === 'GET /test-basepath/v1/users/:userId', + ); + + const response = await fetch(`${baseURL}/test-basepath/v1/users/42`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ userId: '42' }); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-basepath/v1/users/:userId'); + expect(getSpanOp(segment)).toBe('http.server'); + }); +}); + +test.describe('.basePath() middleware instrumentation', () => { + test('creates middleware span for .use() on .basePath() clone', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-basepath-mw/hello', + ); + + const response = await fetch(`${baseURL}/test-basepath-mw/hello`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ greeting: 'world' }); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-basepath-mw/hello', + )!; + expect(segment.name).toBe('GET /test-basepath-mw/hello'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'basepathMiddleware'); + + expect(middlewareSpan).toBeDefined(); + expect(middlewareSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + }); +}); + +test('traces .get() route registered after .basePath()/.route() chains', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-late-get', + ); + + const response = await fetch(`${baseURL}/test-late-get`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ registered: 'after-chains' }); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-late-get'); + expect(getSpanOp(segment)).toBe('http.server'); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts new file mode 100644 index 000000000000..0bd38ea85aa3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts @@ -0,0 +1,5 @@ +export type Runtime = 'cloudflare' | 'node' | 'bun' | 'deno'; + +export const RUNTIME = (process.env.RUNTIME || 'node') as Runtime; + +export const APP_NAME = 'hono-4'; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts new file mode 100644 index 000000000000..b315233ec15b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts @@ -0,0 +1,262 @@ +import { expect, test } from '@playwright/test'; +import { + waitForError, + waitForStreamedSpan, + getSpanOp, + collectStreamedSpansUntilSegment, +} from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +test.describe('route handler errors', () => { + test('captures error with mechanism and trace correlation', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'This is a test error for Sentry!'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/error/'), + ); + + const response = await fetch(`${baseURL}/error/test-cause`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + const segmentEvent = await segmentPromise; + + expect(segmentEvent.name).toBe('GET /error/:cause'); + + expect(errorEvent.exception?.values).toHaveLength(1); + + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is a test error for Sentry!'); + expect(exception?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + + expect(errorEvent.transaction).toBe('GET /error/:cause'); + expect(errorEvent.request?.method).toBe('GET'); + expect(errorEvent.request?.url).toContain('/error/test-cause'); + expect(errorEvent.request?.headers).toBeDefined(); + + expect(errorEvent.contexts?.trace?.trace_id).toBe(segmentEvent?.trace_id); + }); + + test('captures three linked errors', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.some(exception => exception.value === 'Failure 3'); + }); + + const response = await fetch(`${baseURL}/linked-error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values).toHaveLength(3); + + const firstCause = errorEvent.exception?.values?.[0]; + expect(firstCause?.value).toBe('Failure 1'); + expect(firstCause?.mechanism).toEqual({ + exception_id: 2, + handled: true, + parent_id: 1, + source: 'cause', + type: 'chained', + }); + + const secondCause = errorEvent.exception?.values?.[1]; + expect(secondCause?.value).toBe('Failure 2'); + expect(secondCause?.mechanism).toEqual({ + exception_id: 1, + handled: true, + parent_id: 0, + source: 'cause', + type: 'chained', + }); + + const capturedError = errorEvent.exception?.values?.[2]; + expect(capturedError?.value).toBe('Failure 3'); + expect(capturedError?.mechanism).toEqual({ + exception_id: 0, + handled: false, + type: 'auto.http.hono.context_error', + }); + + expect(errorEvent.transaction).toBe('GET /linked-error'); + }); +}); + +test.describe('HTTPException errors', () => { + test('captures 5xx HTTPException', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'HTTPException 500'; + }); + + const response = await fetch(`${baseURL}/http-exception/500`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('HTTPException 500'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + }); + + // 3xx/4xx responses must not be captured as errors. Some runtimes drop the transaction for those + // status codes (httpServerSpansIntegration's ignoreStatusCodes), so instead of waiting on the + // HTTPException route's own (possibly dropped) transaction, we wait on a defined 2xx route's + // transaction as a flush guard — that one is produced on every runtime — then assert no error was + // captured. (Parametrized route naming is covered by tracing.test.ts on 2xx routes.) + const expectHttpExceptionNotCaptured = async (baseURL: string, code: number): Promise => { + let errorEventOccurred = false; + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { + errorEventOccurred = true; + } + return false; + }); + + const guardPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/http-exception/${code}`, { redirect: 'manual' }); + expect(response.status).toBe(code); + + await fetch(`${baseURL}/`); + await guardPromise; + + expect(errorEventOccurred).toBe(false); + }; + + [301, 302, 401, 403, 404].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + await expectHttpExceptionNotCaptured(baseURL!, code); + }); + }); +}); + +test.describe('middleware errors', () => { + test('captures 5xx HTTPException thrown in middleware with error span status', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Service Unavailable from middleware'; + }); + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-errors/middleware-http-exception'), + ); + + const response = await fetch(`${baseURL}/test-errors/middleware-http-exception`); + expect(response.status).toBe(503); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('Service Unavailable from middleware'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.http.hono.context_error'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false); + expect(errorEvent.transaction).toBe('GET /test-errors/middleware-http-exception'); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + !!segment.name?.includes('/test-errors/middleware-http-exception'), + )!; + const middlewareSpan = segmentSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) + .find(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpan?.status).toBe('error'); + }); + + test('does not capture 4xx HTTPException thrown in middleware', async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === 'Unauthorized from middleware') { + errorEventOccurred = true; + } + return false; + }); + + // Guard on a defined 2xx route's transaction (produced on every runtime) rather than the 4xx + // route's own transaction, which some runtimes drop — then assert no error was captured. + const guardPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/test-errors/middleware-http-exception-4xx`); + expect(response.status).toBe(401); + + await fetch(`${baseURL}/`); + await guardPromise; + + expect(errorEventOccurred).toBe(false); + }); +}); + +test.describe('nested sub-app errors', () => { + test('captures error from nested child sub-app', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Nested child app error'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/nested/child/error'), + ); + + const response = await fetch(`${baseURL}/test-errors/nested/child/error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + const segment = await segmentPromise; + + expect(segment.name).toBe('GET /test-errors/nested/child/error'); + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Nested child app error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + expect(errorEvent.request?.method).toBe('GET'); + expect(errorEvent.request?.url).toContain('/test-errors/nested/child/error'); + expect(errorEvent.request?.headers).toBeDefined(); + }); +}); + +test.describe('custom onError handler', () => { + test('captures error even when onError handles the response', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Error caught by custom onError'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/custom-on-error/fail'), + ); + + const response = await fetch(`${baseURL}/test-errors/custom-on-error/fail`); + expect(response.status).toBe(500); + + const body = await response.text(); + expect(body).toContain('Handled by onError'); + + const errorEvent = await errorPromise; + const segment = await segmentPromise; + + expect(segment.name).toBe('GET /test-errors/custom-on-error/fail'); + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Error caught by custom onError'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts new file mode 100644 index 000000000000..d760f12e6451 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts @@ -0,0 +1,442 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +const SCENARIOS = [ + { + name: 'root app middleware', + prefix: '/test-middleware', + }, + { + name: 'sub-app middleware (route group)', + prefix: '/test-subapp-middleware', + }, +] as const; + +for (const { name, prefix } of SCENARIOS) { + test.describe(name, () => { + test('creates a span for named middleware', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/named`), + ); + + const response = await fetch(`${baseURL}${prefix}/named`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/named`), + )!; + expect(segment.name).toBe(`GET ${prefix}/named`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'middlewareA'); + + expect(middlewareSpan).toEqual( + expect.objectContaining({ + name: 'middlewareA', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'middleware', type: 'string' }, + 'sentry.origin': { value: 'auto.middleware.hono', type: 'string' }, + }), + }), + ); + expect(middlewareSpan?.status).not.toBe('error'); + + const durationMs = (middlewareSpan!.end_timestamp - middlewareSpan!.start_timestamp) * 1000; + expect(durationMs).toBeGreaterThanOrEqual(49); + }); + + test('creates a span for anonymous middleware', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/anonymous`), + ); + + const response = await fetch(`${baseURL}${prefix}/anonymous`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/anonymous`), + )!; + expect(segment.name).toBe(`GET ${prefix}/anonymous`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const anonymousSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === ''); + expect(anonymousSpan).toBeDefined(); + expect(anonymousSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(anonymousSpan?.status).not.toBe('error'); + }); + + test('creates a span for a named-function middleware (name from the function binding)', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/declared`), + ); + + const response = await fetch(`${baseURL}${prefix}/declared`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/declared`), + )!; + expect(segment.name).toBe(`GET ${prefix}/declared`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + // Named function expression: the span name comes from the function's own name. A bundler (Bun) + // may append a numeric suffix when the inner name collides with the `const` binding, so match on + // the `namedMiddleware` prefix rather than an exact name. + const middlewareSpan = spans.find( + span => getSpanOp(span) === 'middleware' && !!span.name?.match(/^namedMiddleware\d*$/), + ); + + expect(middlewareSpan).toBeDefined(); + expect(middlewareSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(middlewareSpan?.status).not.toBe('error'); + }); + + test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/multi`), + ); + + const response = await fetch(`${baseURL}${prefix}/multi`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/multi`), + )!; + expect(segment.name).toBe(`GET ${prefix}/multi`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const middlewareSpans = spans.sort((a, b) => (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0)); + + expect(middlewareSpans).toHaveLength(2); + expect(middlewareSpans[0].name).toBe('middlewareA'); + expect(middlewareSpans[1].name).toBe('middlewareB'); + + expect(middlewareSpans[0]?.parent_span_id).toBe(middlewareSpans[1]?.parent_span_id); + + // middlewareA has a 50ms delay, middlewareB has a 60ms delay + const aDurationMs = (middlewareSpans[0].end_timestamp - middlewareSpans[0]?.start_timestamp) * 1000; + const bDurationMs = (middlewareSpans[1].end_timestamp - middlewareSpans[1]?.start_timestamp) * 1000; + expect(aDurationMs).toBeGreaterThanOrEqual(49); + expect(bDurationMs).toBeGreaterThanOrEqual(59); + }); + + test('captures error thrown in middleware', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return ( + !!event.exception?.values?.[0]?.value?.startsWith('Middleware error') && + !!event.request?.url?.includes(prefix) + ); + }); + + const response = await fetch(`${baseURL}${prefix}/error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toMatch(/^Middleware error/); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.http.hono.context_error', + }), + ); + + // The transaction name on the error event determines the culprit shown in Sentry. + expect(errorEvent.transaction).toBe(`GET ${prefix}/error`); + }); + + test('sets error status on middleware span when middleware throws', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/error`), + ); + + await fetch(`${baseURL}${prefix}/error`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/error`), + )!; + expect(segment.name).toBe(`GET ${prefix}/error`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const failingSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.status === 'error'); + + expect(failingSpan).toBeDefined(); + expect(failingSpan?.status).toBe('error'); + }); + + test('uses parameterized route in span name', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/param/`), + ); + + const response = await fetch(`${baseURL}${prefix}/param/42`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/param/`), + )!; + expect(segment.name).toBe(`GET ${prefix}/param/:id`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'middlewareA'); + expect(middlewareSpan).toBeDefined(); + }); + + test('includes request data on error events from middleware', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return ( + !!event.exception?.values?.[0]?.value?.startsWith('Middleware error') && + !!event.request?.url?.includes(prefix) + ); + }); + + await fetch(`${baseURL}${prefix}/error`); + + const errorEvent = await errorPromise; + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining(`${prefix}/error`), + }), + ); + }); + }); +} + +test.describe('.all() handler in sub-app', () => { + test('does not create middleware span for .all() route handler', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-subapp-middleware/all-handler'), + ); + + const response = await fetch(`${baseURL}/test-subapp-middleware/all-handler`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ handler: 'all' }); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + !!segment.name?.includes('/test-subapp-middleware/all-handler'), + )!; + expect(segment.name).toBe('GET /test-subapp-middleware/all-handler'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + // No middleware is called for this route, so there should be no spans. + expect(spans).toEqual([]); + }); +}); + +const INLINE_PREFIX = '/test-inline-middleware'; + +const REGISTRATION_STYLES = [ + { name: 'direct method (.get())', path: '/direct' }, + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, +] as const; + +const MIDDLEWARE_STYLES = [ + { name: 'inline', path: '' }, + { name: 'separately registered', path: '/separately' }, +] as const; + +test.describe('inline middleware spans (sub-app)', () => { + for (const { name: regName, path: regPath } of REGISTRATION_STYLES) { + for (const { name: mwName, path: mwPath } of MIDDLEWARE_STYLES) { + test(`creates middleware span for ${mwName} middleware via ${regName}`, async ({ baseURL }) => { + const fullPath = `${INLINE_PREFIX}${regPath}${mwPath}`; + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(fullPath), + ); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(fullPath), + )!; + expect(segment.name).toBe(`GET ${fullPath}`); + + const EXPECTED_DESCRIPTIONS: Record> = { + '/direct': { '': 'inlineMiddleware', '/separately': 'inlineSeparateMiddleware' }, + '/all': { '': 'inlineMiddlewareAll', '/separately': 'inlineSeparateMiddlewareAll' }, + '/on': { '': 'inlineMiddlewareOn', '/separately': 'inlineSeparateMiddlewareOn' }, + }; + const expectedDescription = EXPECTED_DESCRIPTIONS[regPath]![mwPath]!; + + const inlineSpan = segmentSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) + .find(s => s.name === expectedDescription); + expect(inlineSpan).toBeDefined(); + expect(getSpanOp(inlineSpan!)).toBe('middleware'); + expect(inlineSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(inlineSpan?.status).not.toBe('error'); + }); + } + } +}); + +const MAIN_INLINE_PREFIX = '/test-main-inline'; + +const MAIN_INLINE_CASES = [ + { name: '.get()', path: '/get', method: 'GET', expectedMiddlewareName: 'mainInlineGet' }, + { name: '.post()', path: '/post', method: 'POST', expectedMiddlewareName: 'mainInlinePost' }, + { name: '.all()', path: '/all', method: 'GET', expectedMiddlewareName: 'mainInlineAll' }, +] as const; + +test.describe('inline middleware spans (main app)', () => { + test('creates middleware span for inline middleware via .query()', async ({ baseURL }) => { + const fullPath = `${MAIN_INLINE_PREFIX}/query`; + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `QUERY ${fullPath}`, + ); + + const response = await fetch(`${baseURL}${fullPath}`, { + method: 'QUERY', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: 'query-body' }), + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ method: 'QUERY', value: 'query-body' }); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `QUERY ${fullPath}`, + )!; + expect(segment.name).toBe(`QUERY ${fullPath}`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(segment.attributes['http.request.method']?.value).toBe('QUERY'); + + const middlewareSpans = segmentSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) + .filter(span => getSpanOp(span) === 'middleware'); + expect(middlewareSpans).toHaveLength(1); + expect(middlewareSpans[0]).toEqual( + expect.objectContaining({ + name: 'mainInlineQuery', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'middleware', type: 'string' }, + 'sentry.origin': { value: 'auto.middleware.hono', type: 'string' }, + }), + }), + ); + }); + + MAIN_INLINE_CASES.forEach(({ name, path, method, expectedMiddlewareName }) => { + test(`creates middleware span for inline middleware via ${name}`, async ({ baseURL }) => { + const fullPath = `${MAIN_INLINE_PREFIX}${path}`; + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `${method} ${fullPath}`, + ); + + const response = await fetch(`${baseURL}${fullPath}`, { method }); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `${method} ${fullPath}`, + )!; + expect(segment.name).toBe(`${method} ${fullPath}`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const inlineSpan = spans.find(s => s.name === expectedMiddlewareName); + + expect(inlineSpan).toBeDefined(); + expect(getSpanOp(inlineSpan!)).toBe('middleware'); + expect(inlineSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(inlineSpan?.status).not.toBe('error'); + + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toHaveLength(1); + }); + }); + + test('creates spans for both .use() middleware and inline middleware via .get()', async ({ baseURL }) => { + const fullPath = `${MAIN_INLINE_PREFIX}/combined/resource`; + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${fullPath}`, + ); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${fullPath}`, + )!; + expect(segment.name).toBe(`GET ${fullPath}`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + + expect(middlewareSpans).toHaveLength(2); + + const [spanA, spanB] = middlewareSpans.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + expect(spanA.name).toBe('combinedInlineMw'); + expect(getSpanOp(spanA!)).toBe('middleware'); + expect(spanA.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(spanA?.status).not.toBe('error'); + + expect(spanB.name).toBe('middlewareA'); + expect(getSpanOp(spanB!)).toBe('middleware'); + expect(spanB.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(spanB?.status).not.toBe('error'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts new file mode 100644 index 000000000000..b98a7e6df7ee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts @@ -0,0 +1,391 @@ +import { expect, test } from '@playwright/test'; +import { + waitForError, + waitForStreamedSpan, + getSpanOp, + collectStreamedSpansUntilSegment, +} from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +const STOREFRONT = '/test-multi-fetch/storefront'; +const INVENTORY = '/test-multi-fetch/inventory'; + +test.describe('multi-fetch: internal .request() calls between sub-apps', () => { + test.describe('single internal fetch', () => { + test('returns enriched product data and creates span with parameterized route', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.product).toEqual( + expect.objectContaining({ + productId: 'self-watering-plant', + name: 'Self-Watering Plant', + stock: 5, + price: 2500, + }), + ); + expect(body.source).toBe('storefront'); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/product/:productId`); + expect(getSpanOp(segment)).toBe('http.server'); + }); + + test('creates storefrontAuth middleware span', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product/:productId`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/solar-powered-cyberdeck`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId`, + )!; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'storefrontAuth'); + + expect(middlewareSpan).toEqual( + expect.objectContaining({ + name: 'storefrontAuth', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'middleware', type: 'string' }, + 'sentry.origin': { value: 'auto.middleware.hono', type: 'string' }, + }), + }), + ); + expect(middlewareSpan?.status).not.toBe('error'); + }); + }); + + test.describe('parallel internal fetches', () => { + test('aggregates data from two concurrent .request() calls', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/compare/:productId1/:productId2`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/compare/self-watering-plant/solar-powered-cyberdeck`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.items).toHaveLength(2); + expect(body.items[0].productId).toBe('self-watering-plant'); + expect(body.items[1].productId).toBe('solar-powered-cyberdeck'); + expect(body.priceDifference).toBe(2500); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/compare/:productId1/:productId2`); + }); + }); + + test.describe('sequential chained fetches', () => { + test('composes data from item lookup followed by stock check', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/availability`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ product: 'Self-Watering Plant', available: true, quantity: 5 }); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/product/:productId/availability`); + expect(getSpanOp(segment)).toBe('http.server'); + }); + + test('reports out-of-stock item as unavailable', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/solar-powered-cyberdeck/availability`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ product: 'Solar-Powered Cyberdeck', available: false, quantity: 0 }); + + await segmentPromise; + }); + }); + + test.describe('error propagation from internal fetch', () => { + test('captures error when handler throws after failed internal .request()', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Failed to fetch product: nonexistent'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product-or-throw/nonexistent`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('Failed to fetch product: nonexistent'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(expect.objectContaining({ handled: false })); + expect(errorEvent.transaction).toBe(`GET ${STOREFRONT}/product-or-throw/:productId`); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/product-or-throw/:productId`); + expect(segment?.status).toBe('error'); + }); + + test('error event includes request data', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Failed to fetch product: missing'; + }); + + await fetch(`${baseURL}${STOREFRONT}/product-or-throw/missing`); + + const errorEvent = await errorPromise; + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining(`${STOREFRONT}/product-or-throw/missing`), + }), + ); + }); + }); + + test.describe('error inside internal fetch (degraded response)', () => { + test('reports the inner-route error to Sentry even though the outer handler returns 200', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return !!event.exception?.values?.[0]?.value?.startsWith('inventory db is down'); + }); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/degraded`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ product: null, degraded: true }); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toMatch(/^inventory db is down/); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ handled: false, type: 'auto.http.hono.context_error' }), + ); + }); + }); + + test.describe('inventory sub-app direct access', () => { + test('creates its own span when accessed directly via HTTP', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${INVENTORY}/item/:productId`, + ); + + const response = await fetch(`${baseURL}${INVENTORY}/item/self-watering-plant`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual(expect.objectContaining({ productId: 'self-watering-plant', name: 'Self-Watering Plant' })); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${INVENTORY}/item/:productId`); + expect(getSpanOp(segment)).toBe('http.server'); + }); + }); + + test.describe('trace propagation through internal .request() calls', () => { + test('single internal fetch produces an internal-request child span', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product/:productId`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId`, + )!; + const traceId = segment?.trace_id; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans.filter( + s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request', + ); + + expect(internalRequestSpans).toHaveLength(1); + expect(internalRequestSpans[0]).toEqual( + expect.objectContaining({ + trace_id: traceId, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.hono.internal_request', type: 'string' }, + }), + }), + ); + expect(internalRequestSpans[0].name).toContain('GET /item/self-watering-plant'); + }); + + test('parallel internal fetches produce two sibling internal-request spans', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/compare/:productId1/:productId2`, + ); + + await fetch(`${baseURL}${STOREFRONT}/compare/self-watering-plant/solar-powered-cyberdeck`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/compare/:productId1/:productId2`, + )!; + const traceId = segment?.trace_id; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans.filter( + s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request', + ); + + expect(internalRequestSpans).toHaveLength(2); + + expect(internalRequestSpans[0]?.parent_span_id).toBe(internalRequestSpans[1]?.parent_span_id); + + expect(internalRequestSpans[0]?.trace_id).toBe(traceId); + expect(internalRequestSpans[1]?.trace_id).toBe(traceId); + + expect(internalRequestSpans[0].attributes['sentry.origin']?.value).toBe('auto.http.hono.internal_request'); + expect(internalRequestSpans[1].attributes['sentry.origin']?.value).toBe('auto.http.hono.internal_request'); + }); + + test('sequential chained fetches produce two ordered internal-request spans', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/availability`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + )!; + const traceId = segment?.trace_id; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans + .filter(s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request') + .sort( + (a: { start_timestamp?: number }, b: { start_timestamp?: number }) => + (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0), + ); + + expect(internalRequestSpans).toHaveLength(2); + + // Sequential: second span starts at or after first span ends (with tolerance for clock precision) + expect(internalRequestSpans[1].start_timestamp).toBeGreaterThanOrEqual( + internalRequestSpans[0].end_timestamp! - 0.001, + ); + + expect(internalRequestSpans[0]?.trace_id).toBe(traceId); + expect(internalRequestSpans[1]?.trace_id).toBe(traceId); + }); + + test('internal-request span has no error status for internal 4xx HTTPException', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product-or-throw/ghost`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + )!; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans.filter( + s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request', + ); + + expect(internalRequestSpans).toHaveLength(1); + expect(internalRequestSpans[0]?.status).not.toBe('error'); + }); + + test('error from failed internal fetch is correlated with the storefront trace', async ({ baseURL }) => { + // Use a param unique to this test: the `no error status` test above fetches `/ghost` too, and its + // identical `Failed to fetch product: ghost` error would otherwise be dropped by the Dedupe + // integration, so this test's `waitForError` would never fire. + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Failed to fetch product: phantom'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product-or-throw/phantom`); + + const [errorEvent, segment] = await Promise.all([errorPromise, segmentPromise]); + + expect(errorEvent.contexts?.trace?.trace_id).toBe(segment?.trace_id); + expect(errorEvent.contexts?.trace?.span_id).toBeDefined(); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts new file mode 100644 index 000000000000..bfebb9f5b6ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts @@ -0,0 +1,178 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +const PREFIX = '/test-routes'; + +const REGISTRATION_STYLES = [ + { name: 'direct method', path: '' }, + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, +] as const; + +test.describe('HTTP methods', () => { + ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].forEach(method => { + test(`sends transaction for ${method}`, async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `${method} ${PREFIX}`, + ); + + const response = await fetch(`${baseURL}${PREFIX}`, { method }); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `${method} ${PREFIX}`, + )!; + expect(segment.name).toBe(`${method} ${PREFIX}`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); + }); + }); +}); + +test.describe('route registration styles', () => { + REGISTRATION_STYLES.forEach(({ name, path }) => { + test(`${name} sends transaction with route source`, async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}${path}`, + ); + + const response = await fetch(`${baseURL}${PREFIX}${path}`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}${path}`, + )!; + expect(segment.name).toBe(`GET ${PREFIX}${path}`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); + }); + }); + + [ + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, + ].forEach(({ name, path }) => { + test(`${name} responds to POST`, async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `POST ${PREFIX}${path}`, + ); + + const response = await fetch(`${baseURL}${PREFIX}${path}`, { method: 'POST' }); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `POST ${PREFIX}${path}`, + )!; + expect(segment.name).toBe(`POST ${PREFIX}${path}`); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); + }); + }); +}); + +test.describe('request data extraction', () => { + test('includes method, url, and headers on span', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}/request-data`, + ); + + const response = await fetch(`${baseURL}${PREFIX}/request-data`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.attributes['http.request.method']?.value).toBe('GET'); + expect(segment.attributes['url.full']?.value).toContain(PREFIX); + expect(segment.attributes['http.request.header.host']).toBeDefined(); + }); + + test('includes query_string when present', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${PREFIX}/query-test` && + segment.attributes['url.query']?.value === 'foo=bar&baz=42', + ); + + const response = await fetch(`${baseURL}${PREFIX}/query-test?foo=bar&baz=42`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + + expect(segment.attributes['http.request.method']?.value).toBe('GET'); + expect(segment.attributes['url.full']?.value).toContain(`${PREFIX}/query-test`); + expect(segment.attributes['url.query']?.value).toBe('foo=bar&baz=42'); + }); + + test('includes request data for POST with headers', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `POST ${PREFIX}/request-data`, + ); + + const response = await fetch(`${baseURL}${PREFIX}/request-data`, { + method: 'POST', + headers: { 'X-Custom-Header': 'test-value' }, + }); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.attributes['http.request.method']?.value).toBe('POST'); + expect(segment.attributes['url.full']?.value).toContain(PREFIX); + expect(segment.attributes['http.request.header.x-custom-header']?.value).toEqual(['test-value']); + }); +}); + +test('async handler sends span', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}/async`, + ); + + const response = await fetch(`${baseURL}${PREFIX}/async`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}/async`, + )!; + expect(segment.name).toBe(`GET ${PREFIX}/async`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts new file mode 100644 index 000000000000..9350259d7e5c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts @@ -0,0 +1,168 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; +import { APP_NAME, RUNTIME, type Runtime } from './constants'; + +const anyString = expect.any(String) as unknown; +const anyNumber = expect.any(Number) as unknown; +const ipvType = expect.stringMatching(/^ipv[46]$/) as unknown; + +// Per-runtime `http.server` connection-info expectations. Each runtime's `getConnInfo` helper exposes +// a different set of fields, so the expected attribute values are declared here once (keyed by +// runtime) instead of branching inside the tests. `undefined` means the attribute must be absent. +// Relational checks (`network.peer.*` mirroring `client.*`) are asserted in the tests, since they +// hold on every runtime (including when both sides are absent). +// +// - `added`: attributes Hono's conninfo middleware contributes. +// - `baseline`: attributes the SDK sends independent of conninfo (regression guard that conninfo only +// adds, never clobbers). +const CONN_INFO: Record; baseline: Record }> = { + node: { + added: { + 'client.address': anyString, + 'client.port': anyNumber, + 'network.type': ipvType, + 'network.transport': undefined, + }, + baseline: { + 'server.address': 'localhost', + 'server.port': anyNumber, + 'client.port': anyNumber, + 'network.type': ipvType, + 'network.protocol.name': 'http', + 'network.protocol.version': '1.1', + 'network.transport': undefined, + 'network.local.address': anyString, + 'network.local.port': anyNumber, + }, + }, + bun: { + added: { + 'client.address': anyString, + 'client.port': anyNumber, + 'network.type': ipvType, + 'network.transport': undefined, + }, + baseline: { 'client.port': anyNumber, 'network.type': ipvType }, + }, + deno: { + // Only `hono/deno` exposes `network.transport`. + added: { 'client.address': anyString, 'client.port': anyNumber, 'network.transport': expect.stringMatching(/tcp/) }, + baseline: { + 'server.address': 'localhost', + 'client.port': anyNumber, + 'network.transport': 'tcp', + 'network.protocol.name': 'http', + }, + }, + cloudflare: { + // Cloudflare Workers expose no client address, port, address family, or transport: there is no + // `getConnInfo` on Workers and nothing else populates `client.address`, so all of these are + // absent. Asserting `undefined` here lets us notice if that ever changes. + added: { + 'client.address': undefined, + 'client.port': undefined, + 'network.type': undefined, + 'network.transport': undefined, + }, + baseline: { + 'server.address': 'localhost', + 'network.protocol.name': 'http', + 'network.protocol.version': '1.1', + }, + }, +}; + +const connInfo = CONN_INFO[RUNTIME]; + +test('sends a span for the index route', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /'); + expect(getSpanOp(segment)).toBe('http.server'); +}); + +test('sends a span for a parameterized route', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-param/'), + ); + + const response = await fetch(`${baseURL}/test-param/123`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-param/:paramId'); + expect(getSpanOp(segment)).toBe('http.server'); +}); + +test('attaches HTTP connection info to the server span', async ({ baseURL, page }) => { + page.on('console', msg => { + console.log(`PAGE LOG: ${msg.text()}`); + }); + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + const data = segment.attributes ?? {}; + + for (const [key, expected] of Object.entries(connInfo.added)) { + expect(data[key]?.value).toEqual(expected); + } + + // conninfo must only *add* attributes, never replace: peer mirrors client on every runtime + // (including when both are absent). + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); +}); + +// Regression guard against connection info attributes. +// The conninfo middleware must only *add* attributes, never replace or clear existing ones. +// These are the baseline attributes the server transaction carries *without* the conninfo feature +test("preserves the baseline server.*, client.* and network.* server span attributes that the SDK sends without Hono's conninfo", async ({ + baseURL, +}) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + const data = segment.attributes ?? {}; + + for (const [key, expected] of Object.entries(connInfo.baseline)) { + expect(data[key]?.value).toEqual(expected); + } + + // Relational checks that hold on every runtime (both sides absent → still equal). + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); +}); + +test('sends a span for a route that throws', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/error/'), + ); + + await fetch(`${baseURL}/error/test-cause`); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /error/:cause'); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment?.status).toBe('error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.json b/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.json new file mode 100644 index 000000000000..3c4abeff44d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ESNext"], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "types": ["@cloudflare/workers-types"] + } +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4/vite.config.ts b/dev-packages/e2e-tests/test-applications/hono-4/vite.config.ts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/vite.config.ts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc new file mode 100644 index 000000000000..d4344dfa198a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "hono-4", + "main": "src/entry.cloudflare.ts", + "compatibility_date": "2026-04-20", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts index 71ad1709ad55..b6f2dd975b39 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -26,6 +26,11 @@ export const mastra = new Mastra({ port: 4111, apiRoutes: [manualRoute], }, + // No `bundler.externals` override: `mastra build` defaults to externalizing all non-workspace deps, + // so Hono (and the other instrumented modules) stay unbundled and the `--import` orchestrion hook + // can transform them in prod. In `mastra dev` the bundler inlines its own Hono-based server + // framework into the entry regardless of any `externals` config, so Hono is not + // orchestrion-instrumented in dev — the tests assert the un-routed span name there. }); /** diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts index e3aa759000cc..f287cc794217 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts @@ -3,6 +3,12 @@ import { collectStreamedSpans, getSpanOp, SerializedStreamedSpan } from '@sentry const APP = 'node-mastra'; +// In `mastra dev`, Mastra inlines its Hono-based server into the dev bundle, so the `--import` +// orchestrion hook cannot transform Hono and the request span is not route-enriched: its name is the +// bare method and it carries no `http.route`/route name source. In prod (`mastra build`/`start`) Hono +// is external and fully instrumented, so the span is named after the matched route. +const IS_DEV = process.env.TEST_ENV === 'development'; + const attrValue = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value; // A plain custom route registered on the Mastra (Hono) server — see the @@ -21,19 +27,17 @@ test('wraps a custom Mastra route in an http.server span with correct attributes await res.json(); const spans = await spansPromise; - const serverSpan = spans.find(isManualRouteServerSpan); + const serverSpan = spans.find(isManualRouteServerSpan)!; expect(serverSpan).toBeDefined(); - expect(getSpanOp(serverSpan!)).toBe('http.server'); - expect(serverSpan!.name).toBe('GET'); - expect(attrValue(serverSpan!, 'http.request.method')).toBe('GET'); - expect(attrValue(serverSpan!, 'http.response.status_code')).toBe(200); - expect(String(attrValue(serverSpan!, 'url.full') ?? '')).toContain('/manual-route'); - - // Codifies current behavior: the transaction name is derived from the URL path, - // not a route pattern. Mastra serves custom routes through Hono, which Sentry - // does not route-instrument the way it does Express — so there is no `http.route` - // attribute and the name source is `url` (an Express route would give `route`). - expect(attrValue(serverSpan!, 'sentry.segment.name.source')).toBe('url'); - expect(attrValue(serverSpan!, 'http.route')).toBeUndefined(); + expect(getSpanOp(serverSpan)).toBe('http.server'); + expect(attrValue(serverSpan, 'http.request.method')).toBe('GET'); + expect(attrValue(serverSpan, 'http.response.status_code')).toBe(200); + expect(String(attrValue(serverSpan, 'url.full') ?? '')).toContain('/manual-route'); + expect(serverSpan.name).toBe(IS_DEV ? 'GET' : 'GET /manual-route'); + + if (!IS_DEV) { + expect(attrValue(serverSpan, 'sentry.segment.name.source')).toBe('route'); + expect(attrValue(serverSpan, 'http.route')).toBe('/manual-route'); + } }); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts index ef68fdf82b9a..7927a8f9531a 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts @@ -4,6 +4,12 @@ import { runAgentTurn } from './utils'; const APP = 'node-mastra'; +// In `mastra dev`, Mastra inlines its Hono-based server into the dev bundle, so the `--import` +// orchestrion hook cannot transform Hono and the root request span is not route-enriched: its name is +// the bare method and it carries no `http.route`/route name source. In prod (`mastra build`/`start`) +// Hono is external and fully instrumented, so the span is named after the matched route. +const IS_DEV = process.env.TEST_ENV === 'development'; + const attrValue = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value; const MASTRA_ORIGIN = 'auto.ai.mastra'; @@ -34,11 +40,16 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs // across envelopes until the agent/model spans have also arrived. Tool spans are // matched by op alone (not origin), so a double-instrumented span is collected // too and caught by the assertions below. + const isGenerateServerSpan = (span: SerializedStreamedSpan): boolean => + isOp('http.server')(span) && String(attrValue(span, 'url.full') ?? '').includes('/api/agents/'); + const traceSpansPromise = collectStreamedSpans( APP, spansOfTrace => spansOfTrace.some(callsTool('get_weather')) && - ['gen_ai.invoke_agent', 'gen_ai.chat'].every(op => spansOfTrace.some(isOp(op))), + ['gen_ai.invoke_agent', 'gen_ai.chat'].every(op => spansOfTrace.some(isOp(op))) && + // The root `http.server` span ends (and streams) after its children, so wait for it too. + spansOfTrace.some(isGenerateServerSpan), ); await runAgentTurn(baseURL!, 'What is the weather in Paris?', { thread, resource: 'e2e-user' }); @@ -96,6 +107,24 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs expect(attrValue(invokeAgent!, 'gen_ai.conversation.id')).toBe(thread); expect(attrValue(chat!, 'gen_ai.conversation.id')).toBe(thread); expect(attrValue(executeTool!, 'gen_ai.conversation.id')).toBe(thread); + + // http.server span: Mastra serves the agent through its internal Hono server, so the incoming + // `POST /api/agents/weatherAgent/generate` request is the root `http.server` span of this trace, + // and the AI spans above are its children. + const serverSpan = traceSpans.find(isGenerateServerSpan); + expect(serverSpan).toBeDefined(); + expect(getSpanOp(serverSpan!)).toBe('http.server'); + expect(attrValue(serverSpan!, 'http.request.method')).toBe('POST'); + expect(attrValue(serverSpan!, 'http.response.status_code')).toBe(200); + expect(String(attrValue(serverSpan!, 'url.full') ?? '')).toContain('/api/agents/weatherAgent/generate'); + + if (IS_DEV) { + expect(serverSpan!.name).toBe('POST'); + } else { + expect(attrValue(serverSpan!, 'sentry.segment.name.source')).toBe('route'); + expect(attrValue(serverSpan!, 'http.route')).toMatch(/^\/api\/agents\/:[^/]+\/generate$/); + expect(serverSpan!.name).toMatch(/^POST \/api\/agents\/:[^/]+\/generate$/); + } }); test('captures a Mastra tool error as an issue and marks the tool span', async ({ baseURL }) => { diff --git a/dev-packages/node-integration-tests/suites/hono/instrument.mjs b/dev-packages/node-integration-tests/suites/hono/instrument.mjs new file mode 100644 index 000000000000..62e64bf8dc10 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hono/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/hono/scenario.mjs b/dev-packages/node-integration-tests/suites/hono/scenario.mjs new file mode 100644 index 000000000000..ed0f816c03cf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hono/scenario.mjs @@ -0,0 +1,102 @@ +import { serve } from '@hono/node-server'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { Hono } from 'hono'; + +// No `@sentry/hono` and no `sentry()` middleware: the app is instrumented automatically by the +// `honoIntegration` default in `@sentry/node` (via orchestrion hooking the `Context` constructor). +const app = new Hono(); + +app.get('/', c => { + return c.text('Hello from Hono on Node!'); +}); + +app.get('/hello/:name', c => { + const name = c.req.param('name'); + return c.text(`Hello, ${name}!`); +}); + +app.get('/error/:param', () => { + throw new Error('Test error from Hono app'); +}); + +// A route handler declared with an unused `next` param (arity 2). It is the last handler in its +// method+path group, so it must be classified as the route handler — not wrapped as a middleware +// span — even though arity alone would misclassify it. +app.get('/arity-two-handler', (c, _next) => c.text('handler with two params')); + +// A sub-app with a named middleware, mounted via `app.route()`. The sub-app is also +// auto-instrumented (every `new Hono()` is), so its own Sentry middleware must NOT show up as an +// `` middleware span when it is copied into the parent at mount time. +const subApp = new Hono(); +subApp.use(async function subMiddleware(_c, next) { + await next(); +}); +subApp.get('/hello', c => c.text('sub hello')); +app.route('/sub', subApp); + +// An inner app dispatched to via internal `.request()`. It runs in a fresh Hono context but the same +// isolation scope, so its auto-registered Sentry middleware must be deduplicated — it must not +// re-name the internal-request span, overwrite the request data, or add a middleware span. +const innerApp = new Hono(); +innerApp.get('/item/:itemId', c => c.json({ itemId: c.req.param('itemId') })); +innerApp.get('/flaky/:itemId', () => { + throw new Error('inventory db is down'); +}); + +app.get('/outer/:itemId', async c => { + const res = await innerApp.request(`/item/${c.req.param('itemId')}`); + const data = await res.json(); + return c.json({ outer: c.req.param('itemId'), inner: data }); +}); + +// The inner route throws, but the outer handler catches the failed internal response and degrades to +// a 200 instead of propagating. The inner route's error must still reach Sentry. +app.get('/degraded/:itemId', async c => { + const res = await innerApp.request(`/flaky/${c.req.param('itemId')}`); + if (!res.ok) { + return c.json({ item: null, degraded: true }, 200); + } + return c.json({ item: await res.json() }); +}); + +app.get('/outer-error/:itemId', async c => { + // Do a successful internal dispatch first — this is what used to overwrite the request data on the + // isolation scope — then throw from the outer handler, so the captured error must still carry the + // outer request's data. + await innerApp.request(`/item/${c.req.param('itemId')}`); + throw new Error('Test error from outer Hono app after internal request'); +}); + +// Overlapping handlers: a parameterized handler and a catch-all handler both match `/overlap/:id`. +// The parameterized handler is registered first and responds, so `routeIndex` lands on it and the +// transaction must resolve to `/overlap/:id` — not the catch-all `/*`. +app.get('/overlap/:id', c => c.json({ id: c.req.param('id') })); +app.get('/overlap/*', c => c.text('catch-all handler')); + +// Middleware-only match: a scoped middleware short-circuits with its own response and no route +// handler matches. The transaction name must fall back to the matched middleware path `/mw-only/*`. +app.use('/mw-only/*', async (_c, _next) => new Response('handled by middleware')); + +// Middleware span status by thrown error (see `wrapMiddlewareWithSpan` + `defaultShouldHandleError`): +// only 5xx and status-less errors set the middleware span to `error` — 3xx and 4xx do not, even +// though they still fail the request. +app.use('/mw-throw/:code', async function throwingMiddleware(c, _next) { + const status = Number(c.req.param('code')); + throw Object.assign(new Error(`middleware failed (${status})`), { status }); +}); +app.get('/mw-throw/:code', c => c.text('never reached')); + +// Middleware registered via `.use()` on the clone returned by `.basePath()`. The clone shares the +// root router, so the middleware still ends up in the matched-handler list at dispatch — the +// per-request orchestrion hook must wrap it as a middleware span regardless of which instance +// registered it. +app + .basePath('/test-basepath-mw') + .use(async function basepathMiddleware(_c, next) { + await next(); + }) + .get('/hello', c => c.json({ greeting: 'world' })); + +serve({ fetch: app.fetch, port: 0 }, info => { + sendPortToRunner(info.port); +}); diff --git a/dev-packages/node-integration-tests/suites/hono/test.ts b/dev-packages/node-integration-tests/suites/hono/test.ts new file mode 100644 index 000000000000..20a16f7e1eba --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hono/test.ts @@ -0,0 +1,323 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../utils/runner'; + +// Verifies that Hono is auto-instrumented out of the box by `@sentry/node` (the `honoIntegration` +// default), without importing `@sentry/hono` or registering the `sentry()` middleware manually. +// +// The SDK runs with the default `traceLifecycle` (span streaming), so the transaction is asserted via +// the streamed span container (`container.items`, root = `is_segment`) rather than a `transaction` +// envelope, and `.unordered()` lets the segment/child spans and error events arrive in any order +// while ignoring unrelated envelopes (client reports, etc.). + +// oxlint-disable-next-line typescript/no-explicit-any +type StreamedSpan = { name?: string; status?: string; is_segment?: boolean; attributes?: Record }; + +const attr = (span: StreamedSpan, key: string): unknown => span.attributes?.[key]?.value; +const op = (span: StreamedSpan): unknown => attr(span, 'sentry.op'); + +describe('hono auto-instrumentation', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates a transaction for a basic GET request', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /'); + if (!segment) { + throw new Error('segment for `GET /` not in this container'); + } + expect(op(segment)).toBe('http.server'); + expect(segment.status).toBe('ok'); + }, + }) + .start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); + + test('creates a transaction with a parametrized route name', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /hello/:name'); + if (!segment) { + throw new Error('segment for `GET /hello/:name` not in this container'); + } + expect(op(segment)).toBe('http.server'); + expect(attr(segment, 'sentry.segment.name.source')).toBe('route'); + }, + }) + .start(); + runner.makeRequest('get', '/hello/world'); + await runner.completed(); + }); + + test('captures an error with the correct mechanism', async () => { + const runner = createRunner() + .unordered() + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'Test error from Hono app', + mechanism: { + type: 'auto.http.hono.context_error', + handled: false, + }, + }, + ], + }, + transaction: 'GET /error/:param', + }, + }) + .start(); + runner.makeRequest('get', '/error/param-123', { expectError: true }); + await runner.completed(); + }); + + test('creates a transaction with internal_error status when an error occurs', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /error/:param'); + if (!segment) { + throw new Error('segment for `GET /error/:param` not in this container'); + } + expect(op(segment)).toBe('http.server'); + expect(segment.status).toBe('error'); + expect(attr(segment, 'http.response.status_code')).toBe(500); + }, + }) + .start(); + runner.makeRequest('get', '/error/param-456', { expectError: true }); + await runner.completed(); + }); + + test('does not wrap a route handler declared with an unused next param as middleware', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /arity-two-handler'); + if (!segment) { + throw new Error('segment for `GET /arity-two-handler` not in this container'); + } + // The arity-2 handler is the route handler (last in its method+path group), so it must not + // produce a middleware span. + const middlewareSpans = container.items.filter(item => op(item) === 'middleware'); + expect(middlewareSpans).toHaveLength(0); + }, + }) + .start(); + runner.makeRequest('get', '/arity-two-handler'); + await runner.completed(); + }); + + test('creates a middleware span for .use() on a .basePath() clone', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find( + item => item.is_segment && item.name === 'GET /test-basepath-mw/hello', + ); + if (!segment) { + throw new Error('segment for `GET /test-basepath-mw/hello` not in this container'); + } + const middlewareSpan = container.items.find( + item => op(item) === 'middleware' && item.name === 'basepathMiddleware', + ); + expect(middlewareSpan).toBeDefined(); + expect(attr(middlewareSpan!, 'sentry.origin')).toBe('auto.middleware.hono'); + }, + }) + .start(); + runner.makeRequest('get', '/test-basepath-mw/hello'); + await runner.completed(); + }); + + test('does not create a middleware span for the Sentry middleware in a mounted sub-app', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /sub/hello'); + if (!segment) { + throw new Error('segment for `GET /sub/hello` not in this container'); + } + const middlewareNames = container.items.filter(item => op(item) === 'middleware').map(item => item.name); + // The sub-app's user middleware is traced … + expect(middlewareNames).toContain('subMiddleware'); + // … but the sub-app's own auto-registered Sentry middleware must not appear as a span + // (it is copied into the parent at mount time and must stay unwrapped). + expect(middlewareNames).not.toContain(''); + }, + }) + .start(); + runner.makeRequest('get', '/sub/hello'); + await runner.completed(); + }); + + test('traces an internal .request() call without the inner app re-instrumenting the request', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + // Transaction is named after the outer route, not the internal one. + const segment = container.items.find(item => item.is_segment && item.name === 'GET /outer/:itemId'); + if (!segment) { + throw new Error('segment for `GET /outer/:itemId` not in this container'); + } + + // The internal dispatch is traced with the raw inner path — the inner app's Sentry + // middleware must not re-name it to the parametrized route. + const internalRequestSpans = container.items.filter( + item => attr(item, 'sentry.origin') === 'auto.http.hono.internal_request', + ); + expect(internalRequestSpans).toHaveLength(1); + expect(internalRequestSpans[0]?.name).toBe('GET /item/self-watering-plant'); + + // The inner app's Sentry middleware must not add a middleware span. + const middlewareNames = container.items.filter(item => op(item) === 'middleware').map(item => item.name); + expect(middlewareNames).not.toContain(''); + }, + }) + .start(); + runner.makeRequest('get', '/outer/self-watering-plant'); + await runner.completed(); + }); + + test('captures an error thrown inside an internal .request() even when the outer handler degrades to 200', async () => { + const runner = createRunner() + .unordered() + .expect({ + event: event => { + expect(event.exception?.values?.[0]?.value).toBe('inventory db is down'); + expect(event.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ type: 'auto.http.hono.context_error', handled: false }), + ); + }, + }) + .start(); + runner.makeRequest('get', '/degraded/self-watering-plant'); + await runner.completed(); + }); + + test('captures an error thrown after an internal .request() with the outer request data', async () => { + const runner = createRunner() + .unordered() + .expect({ + event: event => { + expect(event.exception?.values?.[0]?.value).toBe('Test error from outer Hono app after internal request'); + // The internal `.request()` dispatch must not overwrite the request data with its own URL. + expect(event.request?.url).toContain('/outer-error/self-watering-plant'); + expect(event.request?.url).not.toContain('/item/'); + }, + }) + .start(); + runner.makeRequest('get', '/outer-error/self-watering-plant', { expectError: true }); + await runner.completed(); + }); + + test('resolves overlapping handlers to the responding route, not the catch-all', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /overlap/:id'); + if (!segment) { + throw new Error('segment for `GET /overlap/:id` not in this container'); + } + // The catch-all `GET /overlap/*` handler also matched, but the parameterized handler + // responded — the route name must not collapse to `/overlap/*`. + expect(attr(segment, 'sentry.segment.name.source')).toBe('route'); + }, + }) + .start(); + runner.makeRequest('get', '/overlap/123'); + await runner.completed(); + }); + + test('falls back to the matched middleware path when only middleware matched', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /mw-only/*'); + if (!segment) { + throw new Error('segment for `GET /mw-only/*` not in this container'); + } + expect(op(segment)).toBe('http.server'); + }, + }) + .start(); + runner.makeRequest('get', '/mw-only/anything'); + await runner.completed(); + }); + + test('does not set the middleware span to error for a 3xx middleware error', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const middlewareSpan = container.items.find( + item => op(item) === 'middleware' && item.name === 'throwingMiddleware', + ); + if (!middlewareSpan) { + throw new Error('middleware span `throwingMiddleware` not in this container'); + } + expect(middlewareSpan.status).not.toBe('error'); + }, + }) + .start(); + runner.makeRequest('get', '/mw-throw/301', { expectError: true }); + await runner.completed(); + }); + + test('does not set the middleware span to error for a 4xx middleware error', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const middlewareSpan = container.items.find( + item => op(item) === 'middleware' && item.name === 'throwingMiddleware', + ); + if (!middlewareSpan) { + throw new Error('middleware span `throwingMiddleware` not in this container'); + } + expect(middlewareSpan.status).not.toBe('error'); + }, + }) + .start(); + runner.makeRequest('get', '/mw-throw/404', { expectError: true }); + await runner.completed(); + }); + + test('sets the middleware span to error for a 5xx middleware error', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const middlewareSpan = container.items.find( + item => op(item) === 'middleware' && item.name === 'throwingMiddleware', + ); + if (!middlewareSpan) { + throw new Error('middleware span `throwingMiddleware` not in this container'); + } + expect(middlewareSpan.status).toBe('error'); + }, + }) + .start(); + runner.makeRequest('get', '/mw-throw/503', { expectError: true }); + await runner.completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index a791eac011a3..b67a20a788f8 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -72,6 +72,8 @@ export { isInitialized, isEnabled, kafkaIntegration, + honoIntegration, + honoMiddleware, koaIntegration, knexIntegration, lastEventId, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 1687e2c16a10..5d0c37614a3e 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -104,6 +104,8 @@ export { expressErrorHandler, // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, + honoIntegration, + honoMiddleware, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index e4b5cd64f60b..d5b1d11ee4e5 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -129,6 +129,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupFastifyErrorHandler, firebaseIntegration, + honoIntegration, + honoMiddleware, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 2a6413cb2fdc..59f8713b181e 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -137,6 +137,8 @@ export { instrumentStateGraph, instrumentCreateReactAgent, vercelAIIntegration, + honoIntegration, + honoMiddleware, eveConversationHook, eveIntegration, getInstrumentedModuleNames, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 847e979bf706..8db992d91e6a 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -130,6 +130,8 @@ export { googleGenAIIntegration, graphqlIntegration, hapiIntegration, + honoIntegration, + honoMiddleware, kafkaIntegration, knexIntegration, koaIntegration, diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index d74fe9c036e2..3500fc9330c7 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -65,6 +65,7 @@ snapshot[`captureMessage 1`] = ` "Express", "Fastify", "Hapi", + "Hono", "Koa", ], name: "sentry.javascript.deno", @@ -153,6 +154,7 @@ snapshot[`captureMessage twice 1`] = ` "Express", "Fastify", "Hapi", + "Hono", "Koa", ], name: "sentry.javascript.deno", @@ -248,6 +250,7 @@ snapshot[`captureMessage twice 2`] = ` "Express", "Fastify", "Hapi", + "Hono", "Koa", ], name: "sentry.javascript.deno", diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index b9d21796fcbb..05179daba6e6 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -108,6 +108,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupFastifyErrorHandler, firebaseIntegration, + honoIntegration, + honoMiddleware, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index ec0838487d36..d789d83c91a8 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -105,6 +105,8 @@ export { expressErrorHandler, // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, + honoIntegration, + honoMiddleware, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/hono/test/shared/earlyPatchRoute.test.ts b/packages/hono/test/shared/earlyPatchRoute.test.ts index 6cc210a7d6b7..e5c07c844bc8 100644 --- a/packages/hono/test/shared/earlyPatchRoute.test.ts +++ b/packages/hono/test/shared/earlyPatchRoute.test.ts @@ -72,3 +72,57 @@ describe('earlyPatchHono (two-phase prototype hook)', () => { expect(await res.text()).toBe('world'); }); }); + +// The prototype `route` hook must patch `HonoBase.prototype.route` without stripping metadata another +// library (e.g. an OpenAPI router) may have attached to it. This behaviour is not observable through +// spans/events, so it lives here rather than in the node-integration tests. +describe('route hook non-invasive patching', () => { + const ROUTER_META = Symbol('router-meta'); + + afterAll(() => { + honoBaseProto.route = originalRoute; + Reflect.deleteProperty(originalRoute, ROUTER_META); + Reflect.deleteProperty(originalRoute, 'pluginId'); + }); + + // Re-install the hook over a pristine `route` (carrying any pre-set metadata) via the public entry. + function reinstallOverPristineRoute(): void { + honoBaseProto.route = originalRoute; + applyPatches(new Hono()); + } + + it('preserves function name and length of the original route method', () => { + reinstallOverPristineRoute(); + + const patched = honoBaseProto.route as (...args: unknown[]) => unknown; + expect(patched.name).toBe(originalRoute.name); + expect(patched.length).toBe(originalRoute.length); + }); + + it('preserves symbol- and string-keyed properties on the route method', () => { + (originalRoute as unknown as Record)[ROUTER_META] = { version: 3 }; + (originalRoute as unknown as Record).pluginId = 'openapi-router'; + + reinstallOverPristineRoute(); + + expect(Object.getOwnPropertySymbols(honoBaseProto.route)).toContain(ROUTER_META); + expect((honoBaseProto.route as unknown as Record)[ROUTER_META]).toEqual({ version: 3 }); + expect((honoBaseProto.route as unknown as Record).pluginId).toBe('openapi-router'); + }); + + it('preserves the prototype chain of the original route method', () => { + reinstallOverPristineRoute(); + + expect(Object.getPrototypeOf(honoBaseProto.route)).toBe(Object.getPrototypeOf(originalRoute)); + }); + + it('still mounts sub-apps and returns the parent app', () => { + reinstallOverPristineRoute(); + + const parent = new Hono(); + const subApp = new Hono(); + subApp.get('/test', c => c.text('ok')); + + expect(parent.route('/api', subApp)).toBe(parent); + }); +}); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index ae598683d38b..deed6c7c0b65 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -15,6 +15,8 @@ export { graphqlIntegration, groqIntegration, hapiIntegration, + honoIntegration, + honoMiddleware, kafkaIntegration, knexIntegration, koaIntegration, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 3d713e88b0f3..f18b713dac6b 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -59,6 +59,8 @@ export { isEnabled, knexIntegration, kafkaIntegration, + honoIntegration, + honoMiddleware, koaIntegration, lastEventId, linkedErrorsIntegration, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 23515673a427..c162dadb1629 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -40,6 +40,8 @@ export { genericPoolIntegration } from './integrations/generic-pool'; export { googleGenAIIntegration } from './integrations/google-genai'; export { graphqlIntegration } from './integrations/graphql'; export { hapiIntegration } from './integrations/hapi'; +export { honoIntegration, honoMiddleware } from './integrations/hono'; +export type { HonoIntegrationOptions } from './integrations/hono'; export { koaIntegration } from './integrations/koa'; export { redisIntegration } from './integrations/redis'; export { kafkaIntegration } from './integrations/kafkajs'; diff --git a/packages/server-utils/src/integrations/hono/constants.ts b/packages/server-utils/src/integrations/hono/constants.ts new file mode 100644 index 000000000000..297f3a469271 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/constants.ts @@ -0,0 +1,4 @@ +// Origin for the `http.server` child span that traces Hono's internal `app.request(...)` dispatches. +// Shared by the `app.request` Proxy (`patchAppRequest`) and the orchestrion channel subscriber +// (`instrumentInternalRequests` in `honoIntegration`), which both open this span. +export const INTERNAL_REQUEST_ORIGIN = 'auto.http.hono.internal_request'; diff --git a/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts index 952b45743590..74f0a96ca809 100644 --- a/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts +++ b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts @@ -1,6 +1,6 @@ import { addNonEnumerableProperty, getDefaultIsolationScope, getIsolationScope } from '@sentry/core'; import type { Context, GetConnInfo, MiddlewareHandler } from './honoTypes'; -import { requestHandler, responseHandler } from './middlewareHandlers'; +import { captureContextError, requestHandler, responseHandler } from './middlewareHandlers'; import type { SentryHonoMiddlewareOptions } from './types'; // Marks the Sentry request/response middleware so the span-wrapping patches never turn it into a @@ -79,17 +79,32 @@ export function createHonoRequestMiddleware(options: CreateHonoRequestMiddleware } if (scope[HONO_REQUEST_HANDLED]) { - return next(); + await next(); + // A deduplicated middleware still reports errors from its own context — e.g. an inner + // `.request()` whose route threw but whose failed response the outer handler swallowed, so the + // outer context never sees the error. Route naming and request data stay owned by the request + // that ran first, so only the error is captured here. + const dedupShouldHandleError = + (scope[HONO_SHOULD_HANDLE_ERROR] as SentryHonoMiddlewareOptions['shouldHandleError']) ?? shouldHandleError; + captureContextError(context, dedupShouldHandleError); + return; } addNonEnumerableProperty(scope, HONO_REQUEST_HANDLED, true); - requestHandler(context, options.getConnInfo); + try { + requestHandler(context, options.getConnInfo); - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ + await next(); // Handler runs in between Request above ⤴ and Response below ⤵ - const effectiveShouldHandleError = - (scope[HONO_SHOULD_HANDLE_ERROR] as SentryHonoMiddlewareOptions['shouldHandleError']) ?? shouldHandleError; - responseHandler(context, effectiveShouldHandleError); + const effectiveShouldHandleError = + (scope[HONO_SHOULD_HANDLE_ERROR] as SentryHonoMiddlewareOptions['shouldHandleError']) ?? shouldHandleError; + responseHandler(context, effectiveShouldHandleError); + } finally { + // An isolation scope that outlives the request (e.g. forked once around the whole server on a + // runtime without per-request isolation) must not dedupe the next request against this one. + addNonEnumerableProperty(scope, HONO_REQUEST_HANDLED, undefined); + addNonEnumerableProperty(scope, HONO_SHOULD_HANDLE_ERROR, undefined); + } }; addNonEnumerableProperty(middleware, SENTRY_HONO_MIDDLEWARE, true); diff --git a/packages/server-utils/src/integrations/hono/honoIntegration.ts b/packages/server-utils/src/integrations/hono/honoIntegration.ts new file mode 100644 index 000000000000..6c30f31ccafb --- /dev/null +++ b/packages/server-utils/src/integrations/hono/honoIntegration.ts @@ -0,0 +1,234 @@ +import * as diagnosticsChannel from '../../utils/diagnosticsChannel'; +import { createRequire } from 'node:module'; +import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { HTTP_SERVER } from '@sentry/conventions/op'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { Env, GetConnInfo, Hono, MiddlewareHandler } from './honoTypes'; +import { CHANNELS } from '../../orchestrion/channels'; +import { honoModuleNames } from '../../orchestrion/config/hono'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { bindTracingChannelToSpan, safeChannelCallback } from '../../tracing-channel'; +import { applyPatches } from './applyPatches'; +import { INTERNAL_REQUEST_ORIGIN } from './constants'; +import { createHonoRequestMiddleware } from './createHonoMiddleware'; +import { isMiddleware } from './isMiddleware'; +import { extractPathname, isInternalRequestSpanActive } from './patchAppRequest'; +import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; +import type { SentryHonoMiddlewareOptions } from './types'; + +// Matches the `@sentry/hono` SDK's integration name, so the SDK filters its own out of the +// forwarded defaults and the two never stack. +const INTEGRATION_NAME = 'Hono' as const; + +export interface HonoIntegrationOptions extends SentryHonoMiddlewareOptions {} + +const globalAny = globalThis as { Bun?: unknown; Deno?: unknown; navigator?: { userAgent?: string } }; +const isBun = typeof globalAny.Bun !== 'undefined'; +const isDeno = typeof globalAny.Deno !== 'undefined'; +const isCloudflare = globalAny.navigator?.userAgent === 'Cloudflare-Workers'; + +let connInfoResolved = false; +let cachedGetConnInfo: GetConnInfo | undefined; + +/** + * Resolves the runtime's `getConnInfo` helper once, best-effort. + * + * Cloudflare Workers can't `require()` out of a bundle at runtime, so there conn-info is left to the + * platform's `requestDataIntegration`. On Node/Bun/Deno a missing optional peer dependency degrades + * to `undefined`, which just skips the connection-info attributes. + */ +function resolveGetConnInfo(): GetConnInfo | undefined { + if (connInfoResolved) { + return cachedGetConnInfo; + } + connInfoResolved = true; + + const specifier = isBun ? 'hono/bun' : isDeno ? 'hono/deno' : isCloudflare ? undefined : '@hono/node-server/conninfo'; + + if (!specifier) { + return undefined; + } + + try { + // `createRequire` resolves relative to its argument's directory, so a never-loaded dummy path + // roots resolution at the app directory, where the runtime helper is installed. + const appRequire = createRequire(`${process.cwd()}/noop.js`); + cachedGetConnInfo = (appRequire(specifier) as { getConnInfo?: GetConnInfo }).getConnInfo; + } catch { + DEBUG_BUILD && debug.log(`[instrumentation:hono] could not resolve \`getConnInfo\` from "${specifier}"`); + cachedGetConnInfo = undefined; + } + + return cachedGetConnInfo; +} + +/** + * Manually instruments a Hono app and returns the Sentry request/response middleware to register + * first: `app.use(honoMiddleware(app))`. + * + * Only needed when neither the Sentry runtime hook nor the bundler plugin is active — otherwise + * {@link honoIntegration} does this automatically. Safe to combine with the automatic instrumentation + * (request handling is deduplicated per request, `applyPatches` is idempotent). `Sentry.init(...)` + * must still be called separately. + */ +export function honoMiddleware(app: Hono, options: HonoIntegrationOptions = {}): MiddlewareHandler { + applyPatches(app); + + return createHonoRequestMiddleware({ + getConnInfo: resolveGetConnInfo(), + shouldHandleError: options.shouldHandleError, + }); +} + +type ChannelArgs = { arguments: unknown[] }; + +// A Hono `matchResult[0]` entry: `[[handler, routeMeta], paramIndexMap]` — `compose` runs the +// handler (`entry[0][0]`) and the `matchedRoutes` getter reads `routeMeta` (`entry[0][1]`). +// oxlint-disable-next-line typescript/no-explicit-any +type MatchedHandlerEntry = [[any, any], any]; + +/** + * Per-request Context hook. `#dispatch` builds `new Context(req, { matchResult })` before its + * single-handler fast-path check, so from the live `matchResult` we: + * 1. wrap the matched middleware handlers for spans (route handlers are covered by the request span); + * 2. prepend the Sentry request/response middleware — it drives route naming, request data and error + * capture from inside the chain, and forces the ≥2-handler `compose` path so the fast-path is + * never taken. + * + * Running per request (no module-scope state) is what lets this work on Cloudflare. + */ +function injectHonoInstrumentation( + message: ChannelArgs, + requestMiddleware: MiddlewareHandler, + injectedHandlerLists: WeakSet, +): void { + const ctorOptions = message.arguments?.[1] as { matchResult?: [MatchedHandlerEntry[], unknown] } | undefined; + const handlers = ctorOptions?.matchResult?.[0]; + if (!Array.isArray(handlers) || injectedHandlerLists.has(handlers)) { + return; + } + injectedHandlerLists.add(handlers); + + // Classify matched handlers with the same positional heuristic as `wrapSubAppMiddleware`: within a + // method+path group the last handler is the route handler and earlier ones are middleware; `.use()` + // registers as method 'ALL' with a lone genuine-middleware entry, so fall back to arity there. + // Position is needed because a route handler declared with an unused `next` param has middleware arity. + const lastIndexByKey = new Map(); + for (const [i, entry] of handlers.entries()) { + const routeMeta = entry?.[0]?.[1] as { method?: string; path?: string } | undefined; + if (routeMeta?.method != null && routeMeta.path != null) { + // \0 is a collision-free delimiter: it cannot appear in a valid HTTP method or URL path. + lastIndexByKey.set(`${routeMeta.method}\0${routeMeta.path}`, i); + } + } + + for (const [i, entry] of handlers.entries()) { + const pair = entry?.[0]; + const handler = pair?.[0]; + if (typeof handler !== 'function') { + continue; + } + + const routeMeta = pair?.[1] as { method?: string; path?: string } | undefined; + const isMW = + routeMeta?.method != null && routeMeta.path != null + ? lastIndexByKey.get(`${routeMeta.method}\0${routeMeta.path}`) !== i || + (routeMeta.method === 'ALL' && isMiddleware(handler)) + : isMiddleware(handler); + + if (isMW) { + pair[0] = wrapMiddlewareWithSpan(handler as MiddlewareHandler); + } + } + + // Prepend the Sentry request/response middleware. `routeMeta` is what the `matchedRoutes` getter + // exposes; its middleware arity keeps route-name resolution from picking it. + const routeMeta = { basePath: '/', path: '/*', method: 'ALL', handler: requestMiddleware }; + handlers.unshift([[requestMiddleware, routeMeta], {}]); +} + +/** + * Traces Hono's internal `app.request(...)` dispatches (sub-app-to-sub-app fetches) as `http.server` + * child spans, but only when there is a parent span (so a top-level `.request()` is not traced). + */ +function instrumentInternalRequests(): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.HONO_REQUEST), + data => { + // Alongside the manual middleware, the instance `app.request` Proxy has already opened this + // span and is calling through — returning `undefined` (no span) avoids nesting a duplicate. + if (isInternalRequestSpanActive()) { + return undefined; + } + + const [input, requestInit] = data.arguments; + const method = ( + (requestInit as RequestInit | undefined)?.method ?? (input instanceof Request ? input.method : 'GET') + ).toUpperCase(); + return startInactiveSpan({ + name: `${method} ${extractPathname(input)}`, + attributes: { + [SENTRY_OP]: HTTP_SERVER, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: INTERNAL_REQUEST_ORIGIN, + }, + }); + }, + { requiresParentSpan: true }, + ); +} + +/** + * Wires the per-request Hono hooks (Context constructor + internal `app.request`) to the diagnostics + * channel. + * + * Note: + * Because the Sentry request/response middleware is prepended into the matched-handler list (`matchResult[0]`), + * it also shows up in `c.req.matchedRoutes` — apps that inspect `matchedRoutes` + * will see an extra `ALL /*` entry (the Sentry middleware) that they did not register themselves. + */ +function instrumentHono(options: HonoIntegrationOptions): void { + // `router.match` may hand back a cached handler array for a route, so track the lists we've already + // prepended into and never inject the Sentry middleware twice. + const injectedHandlerLists = new WeakSet(); + + // The request/response middleware is stateless (per-request state lives on the request scope) and + // `options` is fixed, so build it once and reuse it across every dispatched Context. + const requestMiddleware = createHonoRequestMiddleware({ + getConnInfo: resolveGetConnInfo(), + shouldHandleError: options.shouldHandleError, + }); + + // The Context constructor's `end` fires synchronously inside `new Context()`, before `#dispatch` + // reads `matchResult[0].length`, so the injected middleware is in place for the same request. + diagnosticsChannel.tracingChannel(CHANNELS.HONO_CONTEXT).end.subscribe(message => { + safeChannelCallback(() => + injectHonoInstrumentation(message as ChannelArgs, requestMiddleware, injectedHandlerLists), + ); + }); + + instrumentInternalRequests(); +} + +const _honoIntegration = ((options: HonoIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, honoModuleNames, instrumentHono, [options]); + }, + }; +}) satisfies IntegrationFn; + +/** + * Automatically instruments Hono applications for Sentry tracing. + * + * Hooks Hono's per-request internals (the `Context` constructor and `app.request`) via the + * orchestrion diagnostics channel — so instrumentation happens per request, never at module scope, + * which is what lets it work on Cloudflare Workers with no manual middleware. Enabled by default in + * the Node, Bun, Deno and Cloudflare SDKs; requires the Sentry runtime hook or bundler plugin. + * + * Registering `@sentry/hono`'s `sentry()` middleware manually alongside it is safe — request handling + * is deduplicated per request. + */ +export const honoIntegration = defineIntegration(_honoIntegration); diff --git a/packages/server-utils/src/integrations/hono/index.ts b/packages/server-utils/src/integrations/hono/index.ts index 7f6d3b13e19d..9b09e7de57ec 100644 --- a/packages/server-utils/src/integrations/hono/index.ts +++ b/packages/server-utils/src/integrations/hono/index.ts @@ -1,3 +1,12 @@ +// The auto-instrumentation integration (uses `node:diagnostics_channel`). +export { honoIntegration } from './honoIntegration'; +export type { HonoIntegrationOptions } from './honoIntegration'; + +// Manual counterpart of the auto-instrumentation, for setups where neither the Sentry runtime hook +// nor the bundler plugin is active (so the per-request Context hook never fires): +// `app.use(honoMiddleware(app))`. +export { honoMiddleware } from './honoIntegration'; + // Shared, runtime-agnostic Hono instrumentation, re-used by the `@sentry/hono` SDK across all of its // runtimes (Node, Bun, Cloudflare, Deno). None of these modules import `hono` or `node:diagnostics_channel`, // so they stay safe to load in every server SDK — including the Node-free (`no-diagnostic-channels`) entry. diff --git a/packages/server-utils/src/integrations/hono/middlewareHandlers.ts b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts index ab1f3f000199..d72d357b4c86 100644 --- a/packages/server-utils/src/integrations/hono/middlewareHandlers.ts +++ b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts @@ -90,6 +90,21 @@ export function responseHandler( // Overwrite the route name now that the middleware chain has run: `routeIndex` is accurate here updateSpanRouteName(getCurrentIsolationScope(), context); + captureContextError(context, shouldHandleError); +} + +/** + * Captures an unhandled context error, gated by the user's `shouldHandleError` (or the default). + * + * Split out from {@link responseHandler} so a deduplicated middleware can still report its own + * context's error without re-resolving the route name or overwriting request data — e.g. the inner + * context of an internal `.request()` whose route threw but whose failed response the outer handler + * swallowed. + */ +export function captureContextError( + context: Context, + shouldHandleError?: SentryHonoMiddlewareOptions['shouldHandleError'], +): void { if (context.error) { if ((shouldHandleError ?? defaultShouldHandleError)(context.error)) { captureException(context.error, { diff --git a/packages/server-utils/src/integrations/hono/patchAppRequest.ts b/packages/server-utils/src/integrations/hono/patchAppRequest.ts index 69f073eb2185..bc54e3716b32 100644 --- a/packages/server-utils/src/integrations/hono/patchAppRequest.ts +++ b/packages/server-utils/src/integrations/hono/patchAppRequest.ts @@ -8,11 +8,11 @@ import { startSpan, type WrappedFunction, } from '@sentry/core'; +import { INTERNAL_REQUEST_ORIGIN } from './constants'; import type { Env, Hono } from './honoTypes'; import { DEBUG_BUILD } from '../../debug-build'; const INTERNAL_REQUEST_OP = HTTP_SERVER; -const INTERNAL_REQUEST_ORIGIN = 'auto.http.hono.internal_request'; // Re-entrancy guard shared with the orchestrion channel subscriber // (`instrumentInternalRequests` in `honoIntegration`). Both wrap the same `app.request`: this Proxy diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index 3546d98d0310..df8d488b9d28 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -30,6 +30,7 @@ import { firebaseIntegration } from './firebase'; import { expressIntegration } from './express'; import { fastifyIntegration } from './fastify'; import { hapiIntegration } from './hapi'; +import { honoIntegration } from './hono'; import { koaIntegration } from './koa'; import type { Integration } from '@sentry/core'; import { awsIntegration } from './aws-sdk'; @@ -74,5 +75,5 @@ export function getTracingIntegrations(): Integration[] { /** These are integrations that cover error capture, in addition to tracing. */ export function getErrorIntegrations(): Integration[] { - return [expressIntegration(), fastifyIntegration(), hapiIntegration(), koaIntegration()]; + return [expressIntegration(), fastifyIntegration(), hapiIntegration(), honoIntegration(), koaIntegration()]; } diff --git a/packages/server-utils/src/orchestrion/bundler/bun.ts b/packages/server-utils/src/orchestrion/bundler/bun.ts index e61814beed39..f8f154028e13 100644 --- a/packages/server-utils/src/orchestrion/bundler/bun.ts +++ b/packages/server-utils/src/orchestrion/bundler/bun.ts @@ -39,6 +39,7 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): UnknownPlu // Route through the shared assembly point so any future option reaches Bun too, but opt out of // the transformer's own `injectDiagnostics` — Bun injects the marker banner via its native // `banner` config below (which, unlike the upstream path, needs no `outdir`). + // // Typed upstream as an esbuild `Plugin`, but Bun passes its own `PluginBuilder` (which has the // `onLoad` the transform uses) to `setup`. Cast to the Bun-compatible shape so we can forward // Bun's builder to its `setup`. diff --git a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts index 87a9fa6ddad6..737d9be98f40 100644 --- a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts +++ b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts @@ -76,7 +76,14 @@ function moduleInjectedSnippet( ? `import { ${bindings} } from ${JSON.stringify(importSpecifier)};` : `const { ${bindings} } = require(${JSON.stringify(importSpecifier)});`; - const args = exportName ? `${JSON.stringify(moduleName)}, ${exportName}` : JSON.stringify(moduleName); + // `exportName` is itself an integration factory, invoked inside the arrow (`() => exportName()`), so + // `orchestrionModuleInjected`'s consumer still gets an Integration back from calling the stored + // thunk. The point of the arrow is to defer the read of the `exportName` binding to when that thunk + // runs (at `init()`), instead of reading it by reference the moment this snippet evaluates. A + // provided-module integration (e.g. `flueIntegration`) is imported back into its own instrumented + // package by `@sentry/*/vite`, so a direct reference here would close an import cycle and touch the + // binding in its TDZ ("Cannot access '…' before initialization"); deferring the read breaks that. + const args = exportName ? `${JSON.stringify(moduleName)}, () => ${exportName}()` : JSON.stringify(moduleName); return `${importStmt}\n${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${args});`; } diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 13eef100a767..a6ab33d4d4c6 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -9,6 +9,7 @@ import { googleGenAiChannels } from './config/google-genai'; import { graphqlChannels } from './config/graphql'; import { groqChannels } from './config/groq'; import { hapiChannels } from './config/hapi'; +import { honoChannels } from './config/hono'; import { ioredisChannels } from './config/ioredis'; import { kafkajsChannels } from './config/kafkajs'; import { knexChannels } from './config/knex'; @@ -63,6 +64,7 @@ export const CHANNELS = { ...graphqlChannels, ...groqChannels, ...hapiChannels, + ...honoChannels, ...ioredisChannels, ...kafkajsChannels, ...knexChannels, diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index 2f24d7865c18..51d25ab28328 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -53,6 +53,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'firebaseIntegration', modules: ['@firebase/firestore', 'firebase-functions'] }, { exportName: 'amqplibIntegration', modules: ['amqplib'] }, { exportName: 'hapiIntegration', modules: ['@hapi/hapi'] }, + { exportName: 'honoIntegration', modules: ['hono'] }, { exportName: 'koaIntegration', modules: ['koa'] }, { exportName: 'expressIntegration', modules: ['express', 'router'] }, { exportName: 'graphqlIntegration', modules: ['graphql'] }, diff --git a/packages/server-utils/src/orchestrion/config/hono.ts b/packages/server-utils/src/orchestrion/config/hono.ts new file mode 100644 index 000000000000..eda1a8084ff3 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/hono.ts @@ -0,0 +1,32 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { getModuleNames } from './module-names'; + +// Hono is hooked through per-request internals only — never at module scope — which is what lets the +// same config run on Cloudflare Workers, where workerd forbids `diagnostics_channel` at module scope. +// The subscribers live in `honoIntegration`. +const honoInstrumentationConfig: InstrumentationConfig[] = [ + { + // `new Context()` runs once per request: where the Sentry middleware is injected and matched + // middleware handlers are wrapped for spans. + channelName: 'context', + module: { name: 'hono', versionRange: '>=4.0.0 <5', filePath: /^dist\/(?:cjs\/)?context\.js$/ }, + functionQuery: { className: 'Context' }, + }, + { + // `app.request(...)` — Hono's internal dispatch for sub-app-to-sub-app fetches. A class-field + // arrow, hence `astQuery` instead of `methodName`. + channelName: 'request', + module: { name: 'hono', versionRange: '>=4.0.0 <5', filePath: /^dist\/(?:cjs\/)?hono-base\.js$/ }, + astQuery: "PropertyDefinition[key.name='request'] > ArrowFunctionExpression", + functionQuery: { kind: 'Auto' }, + }, +]; + +export const honoConfig = honoInstrumentationConfig satisfies InstrumentationConfig[]; + +export const honoModuleNames = getModuleNames(honoConfig); + +export const honoChannels = { + HONO_CONTEXT: 'orchestrion:hono:context', + HONO_REQUEST: 'orchestrion:hono:request', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 2d44898747ed..5df88493737a 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -13,6 +13,7 @@ import { googleGenAiConfig } from './google-genai'; import { graphqlConfig } from './graphql'; import { groqConfig } from './groq'; import { hapiConfig } from './hapi'; +import { honoConfig } from './hono'; import { ioredisConfig } from './ioredis'; import { kafkajsConfig } from './kafkajs'; import { knexConfig } from './knex'; @@ -67,6 +68,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...graphqlConfig, ...groqConfig, ...hapiConfig, + ...honoConfig, ...ioredisConfig, ...kafkajsConfig, ...knexConfig, diff --git a/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts b/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts index cf2062390fe9..b0105ead6b02 100644 --- a/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts +++ b/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts @@ -1,13 +1,18 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setAsyncContextStrategy, withIsolationScope } from '@sentry/core'; +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; // Spy on the request/response handlers so we can assert exactly how many times they run. const requestHandler = vi.fn(); const responseHandler = vi.fn(); +const captureContextError = vi.fn(); vi.mock('../../../src/integrations/hono/middlewareHandlers', () => ({ requestHandler: (...args: unknown[]) => requestHandler(...args), responseHandler: (...args: unknown[]) => responseHandler(...args), + captureContextError: (...args: unknown[]) => captureContextError(...args), })); +// eslint-disable-next-line import/first +import { setAsyncLocalStorageAsyncContextStrategy } from '../../../src/async-context'; // eslint-disable-next-line import/first import { createHonoRequestMiddleware } from '../../../src/integrations/hono/createHonoMiddleware'; @@ -19,6 +24,7 @@ describe('createHonoRequestMiddleware — duplicate registration handling', () = beforeEach(() => { requestHandler.mockClear(); responseHandler.mockClear(); + captureContextError.mockClear(); }); it('runs request/response handling exactly once when two Sentry middlewares wrap the same request', async () => { @@ -35,23 +41,39 @@ describe('createHonoRequestMiddleware — duplicate registration handling', () = expect(responseHandler).toHaveBeenCalledTimes(1); }); - it('passes an already-handled context straight through to next()', async () => { + it('passes a deduplicated request straight through to next()', async () => { const context = fakeContext(); + const handler = vi.fn(); - // First middleware handles the request and marks the context. - await createHonoRequestMiddleware()(context, async () => {}); - requestHandler.mockClear(); - responseHandler.mockClear(); - - // A second (duplicate) middleware on the same context must not re-run the handlers. - let nextCalled = false; await createHonoRequestMiddleware()(context, async () => { - nextCalled = true; + await createHonoRequestMiddleware()(context, handler); }); - expect(nextCalled).toBe(true); - expect(requestHandler).not.toHaveBeenCalled(); - expect(responseHandler).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledTimes(1); + expect(requestHandler).toHaveBeenCalledTimes(1); + expect(responseHandler).toHaveBeenCalledTimes(1); + }); + + it('handles sequential requests sharing one isolation scope independently', async () => { + const userShouldHandleError = (): boolean => true; + const auto = createHonoRequestMiddleware(); + const manual = createHonoRequestMiddleware({ shouldHandleError: userShouldHandleError }); + setAsyncLocalStorageAsyncContextStrategy(); + onTestFinished(() => setAsyncContextStrategy(undefined)); + + await withIsolationScope(async () => { + const firstContext = fakeContext(); + await auto(firstContext, async () => { + await manual(firstContext, async () => {}); + }); + + const secondContext = fakeContext(); + await auto(secondContext, async () => {}); + + expect(requestHandler).toHaveBeenCalledTimes(2); + expect(responseHandler).toHaveBeenNthCalledWith(1, firstContext, userShouldHandleError); + expect(responseHandler).toHaveBeenNthCalledWith(2, secondContext, undefined); + }); }); it('handles independent requests independently (marker is per-context)', async () => { diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 85ed0e6e11b6..750ffeaa8d19 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -82,7 +82,7 @@ describe('module-injected transform', () => { // The helper is called with the REAL module name, so no reverse lookup is // needed at runtime and the lazy-subscription event matches what channel // integrations wait for. - expect(result!.code).toContain('orchestrionModuleInjected("mysql", mysqlIntegration)'); + expect(result!.code).toContain('orchestrionModuleInjected("mysql", () => mysqlIntegration())'); // The result is assigned to a global. `@sentry/server-utils` is `sideEffects: false` and the // helper returns `void`, so a bare call statement is one a bundler can prove droppable. // rollup >= 4.63.0 removes it, leaving the module instrumented but unsubscribed. @@ -107,7 +107,7 @@ describe('module-injected transform', () => { /import\s*\{\s*orchestrionModuleInjected,\s*postgresIntegration\s*\}\s*from\s*["']@sentry\/server-utils["']/, ); expect(result!.code).not.toContain('@sentry/core'); - expect(result!.code).toContain('orchestrionModuleInjected("pg", postgresIntegration)'); + expect(result!.code).toContain('orchestrionModuleInjected("pg", () => postgresIntegration())'); }); it('injects a helper-only snippet for a module with no subscriber factory', () => { @@ -157,7 +157,7 @@ describe('module-injected transform', () => { expect(result!.code).toMatch( /const\s*\{\s*orchestrionModuleInjected,\s*redisIntegration\s*\}\s*=\s*require\(["']@sentry\/server-utils["']\)/, ); - expect(result!.code).toContain('orchestrionModuleInjected("ioredis", redisIntegration)'); + expect(result!.code).toContain('orchestrionModuleInjected("ioredis", () => redisIntegration())'); // The library publishes its own channels, so nothing else is injected: no // diagnostics_channel import, no channel declaration, no function wrapper. expect(result!.code).not.toContain('diagnostics_channel'); @@ -173,7 +173,7 @@ describe('module-injected transform', () => { expect(result!.code).toMatch( /import\s*\{\s*orchestrionModuleInjected,\s*vercelAIIntegration\s*\}\s*from\s*["']@sentry\/server-utils["']/, ); - expect(result!.code).toContain('orchestrionModuleInjected("ai", vercelAIIntegration)'); + expect(result!.code).toContain('orchestrionModuleInjected("ai", () => vercelAIIntegration())'); expect(result!.code).not.toContain('diagnostics_channel'); expect(result!.code).not.toContain('tr_ch_apm'); }); diff --git a/packages/server-utils/test/orchestrion/webpack-loader.test.ts b/packages/server-utils/test/orchestrion/webpack-loader.test.ts index 99a5a9cb272f..6c7201738b87 100644 --- a/packages/server-utils/test/orchestrion/webpack-loader.test.ts +++ b/packages/server-utils/test/orchestrion/webpack-loader.test.ts @@ -65,7 +65,7 @@ describe('orchestrion webpack/Turbopack loader', () => { expect(code).toMatch( /const\s*\{\s*orchestrionModuleInjected,\s*mysqlIntegration\s*\}\s*=\s*require\(["']@sentry\/server-utils["']\)/, ); - expect(code).toContain('orchestrionModuleInjected("mysql", mysqlIntegration)'); + expect(code).toContain('orchestrionModuleInjected("mysql", () => mysqlIntegration())'); }); it('honors a fixed importSpecifier option', () => { @@ -116,7 +116,7 @@ describe('orchestrion webpack/Turbopack loader', () => { expect(error).toBeNull(); expect(code).toContain('orchestrion:@mastra/core:mastraConstructor'); expect(code).toContain('import {orchestrionModuleInjected, mastraIntegration} from "@sentry/server-utils"'); - expect(code).toContain('orchestrionModuleInjected("@mastra/core", mastraIntegration)'); + expect(code).toContain('orchestrionModuleInjected("@mastra/core", () => mastraIntegration())'); }); it('transforms a hashed `.mjs` `@mastra/core` chunk that contains `class Mastra`', () => { @@ -127,7 +127,7 @@ describe('orchestrion webpack/Turbopack loader', () => { expect(error).toBeNull(); expect(code).toContain('orchestrion:@mastra/core:mastraConstructor'); expect(code).toContain('import {orchestrionModuleInjected, mastraIntegration} from "@sentry/server-utils"'); - expect(code).toContain('orchestrionModuleInjected("@mastra/core", mastraIntegration)'); + expect(code).toContain('orchestrionModuleInjected("@mastra/core", () => mastraIntegration())'); }); it('does not transform the stable Mastra re-export that does not contain the class', () => { diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index ce66e7c5374f..bc01f6bb9906 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -62,6 +62,8 @@ export { isEnabled, knexIntegration, kafkaIntegration, + honoIntegration, + honoMiddleware, koaIntegration, lastEventId, linkedErrorsIntegration, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 0dc4dcc20c26..cff338b62ed0 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -60,6 +60,8 @@ export { isEnabled, knexIntegration, kafkaIntegration, + honoIntegration, + honoMiddleware, koaIntegration, lastEventId, linkedErrorsIntegration, From 06c015a762d7c46232b60df336720de7ba48ef1b Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 30 Sep 2026 14:19:48 +0200 Subject: [PATCH 44/65] test(e2e): Retry a hung SAM start in aws-serverless tests (#24879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aws-serverless(-layer) E2E tests occasionally fail with \`Failed to start SAM stack after 180000ms\`. A healthy SAM start takes 12–20s on CI, and in the failing runs the next worker's fresh SAM start is ready within seconds, so this is SAM hanging on start, not starting slowly. Raising the timeout further would not help. Each start attempt now waits at most 90s and SAM is restarted once. SAM's output was discarded, so the cause of the hang isn't visible yet; failed attempts now log it. Stopping SAM also removes the runtime containers a killed SAM leaves behind, which previously kept the docker network in use (the \`has active endpoints\` errors on teardown). SIGKILL is now actually sent when SAM ignores SIGTERM: \`ChildProcess.killed\` is already true once SIGTERM is sent, so the old check never fired. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../tests/lambda-fixtures.ts | 88 +++++++++++++++---- .../aws-serverless/tests/lambda-fixtures.ts | 88 +++++++++++++++---- 2 files changed, 140 insertions(+), 36 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts index 0c15ef39d04e..0d4d123f05c3 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts @@ -2,12 +2,18 @@ import { test as base, expect } from '@playwright/test'; import { App } from 'aws-cdk-lib'; import { LocalLambdaStack, SAM_PORT, getHostIp } from '../src/stack'; import { writeFileSync } from 'node:fs'; -import { execSync, spawn } from 'node:child_process'; +import { type ChildProcess, execSync, spawn } from 'node:child_process'; import { LambdaClient } from '@aws-sdk/client-lambda'; const DOCKER_NETWORK_NAME = 'lambda-test-network'; const SAM_TEMPLATE_FILE = 'sam.template.yml'; +// A healthy SAM start takes about 20s on CI, but SAM occasionally hangs on its first start while a +// fresh one comes up in seconds, so give up early and retry instead of waiting out one long timeout. +const SAM_START_TIMEOUT_MS = 90_000; +const SAM_START_ATTEMPTS = 2; +const SAM_OUTPUT_MAX_CHARS = 20_000; + /** Major Node for SAM `--invoke-image`; default matches root `package.json` `volta.node` and `pull-sam-image.sh`. */ const DEFAULT_NODE_VERSION_MAJOR = '20'; @@ -55,34 +61,23 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien console.log(`[testEnvironment fixture] Running SAM with args: ${args.join(' ')}`); - const samProcess = spawn('sam', args, { - stdio: process.env.DEBUG ? 'inherit' : 'ignore', - env: envForSamChild(), - }); + let samProcess: ChildProcess | undefined; try { - await LocalLambdaStack.waitForStack(); + samProcess = await startSam(args); await use(stack); } finally { console.log('[testEnvironment fixture] Tearing down AWS Lambda test infrastructure'); - samProcess.kill('SIGTERM'); - await new Promise(resolve => { - samProcess.once('exit', resolve); - setTimeout(() => { - if (!samProcess.killed) { - samProcess.kill('SIGKILL'); - } - resolve(void 0); - }, 5000); - }); - + if (samProcess) { + await stopSam(samProcess); + } removeDockerNetwork(); } }, // Own timeout so slow SAM stack startup does not eat into the first test's budget. - { scope: 'worker', auto: true, timeout: 240_000 }, + { scope: 'worker', auto: true, timeout: 300_000 }, ], lambdaClient: async ({}, use) => { const lambdaClient = new LambdaClient({ @@ -98,6 +93,54 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien }, }); +async function startSam(args: string[]): Promise { + for (let attempt = 1; ; attempt++) { + let output = ''; + const samProcess = spawn('sam', args, { + stdio: process.env.DEBUG ? 'inherit' : ['ignore', 'pipe', 'pipe'], + env: envForSamChild(), + }); + const collectOutput = (chunk: Buffer): void => { + output = (output + chunk.toString()).slice(-SAM_OUTPUT_MAX_CHARS); + }; + samProcess.stdout?.on('data', collectOutput); + samProcess.stderr?.on('data', collectOutput); + + try { + await LocalLambdaStack.waitForStack(SAM_START_TIMEOUT_MS); + return samProcess; + } catch (error) { + console.warn(`[testEnvironment fixture] SAM output of failed start attempt ${attempt}:\n${output}`); + await stopSam(samProcess); + + if (attempt >= SAM_START_ATTEMPTS) { + throw error; + } + console.warn(`[testEnvironment fixture] Restarting SAM (attempt ${attempt + 1}/${SAM_START_ATTEMPTS})`); + } + } +} + +async function stopSam(samProcess: ChildProcess): Promise { + if (samProcess.exitCode === null && samProcess.signalCode === null) { + samProcess.kill('SIGTERM'); + await new Promise(resolve => { + const timer = setTimeout(() => { + samProcess.kill('SIGKILL'); + resolve(void 0); + }, 5000); + samProcess.once('exit', () => { + clearTimeout(timer); + resolve(void 0); + }); + }); + } + + // A SAM process killed mid-start leaves its runtime containers behind, which would keep the + // docker network in use and hold on to their ports. + removeNetworkContainers(); +} + /** Avoid forcing linux/amd64 on Apple Silicon when `DOCKER_DEFAULT_PLATFORM` is set globally. */ function envForSamChild(): NodeJS.ProcessEnv { const env = { ...process.env }; @@ -128,6 +171,15 @@ function createDockerNetwork() { } } +function removeNetworkContainers() { + const containerIds = execSync(`docker ps -aq --filter network=${DOCKER_NETWORK_NAME}`, { encoding: 'utf-8' }) + .split('\n') + .filter(Boolean); + if (containerIds.length) { + execSync(`docker rm -f ${containerIds.join(' ')}`, { stdio: 'ignore' }); + } +} + function removeDockerNetwork() { try { execSync(`docker network rm ${DOCKER_NETWORK_NAME}`); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts index 0c15ef39d04e..0d4d123f05c3 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts @@ -2,12 +2,18 @@ import { test as base, expect } from '@playwright/test'; import { App } from 'aws-cdk-lib'; import { LocalLambdaStack, SAM_PORT, getHostIp } from '../src/stack'; import { writeFileSync } from 'node:fs'; -import { execSync, spawn } from 'node:child_process'; +import { type ChildProcess, execSync, spawn } from 'node:child_process'; import { LambdaClient } from '@aws-sdk/client-lambda'; const DOCKER_NETWORK_NAME = 'lambda-test-network'; const SAM_TEMPLATE_FILE = 'sam.template.yml'; +// A healthy SAM start takes about 20s on CI, but SAM occasionally hangs on its first start while a +// fresh one comes up in seconds, so give up early and retry instead of waiting out one long timeout. +const SAM_START_TIMEOUT_MS = 90_000; +const SAM_START_ATTEMPTS = 2; +const SAM_OUTPUT_MAX_CHARS = 20_000; + /** Major Node for SAM `--invoke-image`; default matches root `package.json` `volta.node` and `pull-sam-image.sh`. */ const DEFAULT_NODE_VERSION_MAJOR = '20'; @@ -55,34 +61,23 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien console.log(`[testEnvironment fixture] Running SAM with args: ${args.join(' ')}`); - const samProcess = spawn('sam', args, { - stdio: process.env.DEBUG ? 'inherit' : 'ignore', - env: envForSamChild(), - }); + let samProcess: ChildProcess | undefined; try { - await LocalLambdaStack.waitForStack(); + samProcess = await startSam(args); await use(stack); } finally { console.log('[testEnvironment fixture] Tearing down AWS Lambda test infrastructure'); - samProcess.kill('SIGTERM'); - await new Promise(resolve => { - samProcess.once('exit', resolve); - setTimeout(() => { - if (!samProcess.killed) { - samProcess.kill('SIGKILL'); - } - resolve(void 0); - }, 5000); - }); - + if (samProcess) { + await stopSam(samProcess); + } removeDockerNetwork(); } }, // Own timeout so slow SAM stack startup does not eat into the first test's budget. - { scope: 'worker', auto: true, timeout: 240_000 }, + { scope: 'worker', auto: true, timeout: 300_000 }, ], lambdaClient: async ({}, use) => { const lambdaClient = new LambdaClient({ @@ -98,6 +93,54 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien }, }); +async function startSam(args: string[]): Promise { + for (let attempt = 1; ; attempt++) { + let output = ''; + const samProcess = spawn('sam', args, { + stdio: process.env.DEBUG ? 'inherit' : ['ignore', 'pipe', 'pipe'], + env: envForSamChild(), + }); + const collectOutput = (chunk: Buffer): void => { + output = (output + chunk.toString()).slice(-SAM_OUTPUT_MAX_CHARS); + }; + samProcess.stdout?.on('data', collectOutput); + samProcess.stderr?.on('data', collectOutput); + + try { + await LocalLambdaStack.waitForStack(SAM_START_TIMEOUT_MS); + return samProcess; + } catch (error) { + console.warn(`[testEnvironment fixture] SAM output of failed start attempt ${attempt}:\n${output}`); + await stopSam(samProcess); + + if (attempt >= SAM_START_ATTEMPTS) { + throw error; + } + console.warn(`[testEnvironment fixture] Restarting SAM (attempt ${attempt + 1}/${SAM_START_ATTEMPTS})`); + } + } +} + +async function stopSam(samProcess: ChildProcess): Promise { + if (samProcess.exitCode === null && samProcess.signalCode === null) { + samProcess.kill('SIGTERM'); + await new Promise(resolve => { + const timer = setTimeout(() => { + samProcess.kill('SIGKILL'); + resolve(void 0); + }, 5000); + samProcess.once('exit', () => { + clearTimeout(timer); + resolve(void 0); + }); + }); + } + + // A SAM process killed mid-start leaves its runtime containers behind, which would keep the + // docker network in use and hold on to their ports. + removeNetworkContainers(); +} + /** Avoid forcing linux/amd64 on Apple Silicon when `DOCKER_DEFAULT_PLATFORM` is set globally. */ function envForSamChild(): NodeJS.ProcessEnv { const env = { ...process.env }; @@ -128,6 +171,15 @@ function createDockerNetwork() { } } +function removeNetworkContainers() { + const containerIds = execSync(`docker ps -aq --filter network=${DOCKER_NETWORK_NAME}`, { encoding: 'utf-8' }) + .split('\n') + .filter(Boolean); + if (containerIds.length) { + execSync(`docker rm -f ${containerIds.join(' ')}`, { stdio: 'ignore' }); + } +} + function removeDockerNetwork() { try { execSync(`docker network rm ${DOCKER_NETWORK_NAME}`); From 5940f914f9fe4fa604a3166cd08dc321bf83e133 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:20:28 +0200 Subject: [PATCH 45/65] test(nextjs): Add UI components for cached/dynamic content (#24874) Adds UI components and an index page so it's easier to know what's happening on each page when manually using the E2E tests. Example: image --- .../cached-mid-layout/[id]/layout.tsx | 16 ++- .../cached-mid-layout/[id]/page.tsx | 7 +- .../[id]/layout-cached-leaf/page.tsx | 9 +- .../dynamic-layouts/[id]/layout.tsx | 11 +- .../mixed-lifetimes/[id]/layout.tsx | 16 ++- .../mixed-lifetimes/[id]/page.tsx | 16 ++- .../shared-layout/[id]/a/page.tsx | 7 +- .../shared-layout/[id]/b/page.tsx | 7 +- .../shared-layout/[id]/layout.tsx | 16 ++- .../app/cached-sibling-components/page.tsx | 23 ++-- .../nextjs-16-cacheComponents/app/layout.tsx | 7 +- .../app/nested-caches/page.tsx | 25 +++- .../nextjs-16-cacheComponents/app/page.tsx | 119 +++++++++++++++++- .../app/use-cache-page/page.tsx | 19 ++- .../components/scenarioBox.tsx | 34 +++++ .../cached-mid-layout/[id]/layout.tsx | 16 ++- .../cached-mid-layout/[id]/page.tsx | 7 +- .../[id]/layout-cached-leaf/page.tsx | 9 +- .../dynamic-layouts/[id]/layout.tsx | 11 +- .../mixed-lifetimes/[id]/layout.tsx | 16 ++- .../mixed-lifetimes/[id]/page.tsx | 16 ++- .../shared-layout/[id]/a/page.tsx | 7 +- .../shared-layout/[id]/b/page.tsx | 7 +- .../shared-layout/[id]/layout.tsx | 16 ++- .../app/cached-sibling-components/page.tsx | 23 ++-- .../app/layout.tsx | 7 +- .../app/nested-caches/page.tsx | 25 +++- .../app/page.tsx | 114 ++++++++++++++++- .../components/scenarioBox.tsx | 34 +++++ 29 files changed, 548 insertions(+), 92 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/components/scenarioBox.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/components/scenarioBox.tsx diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx index a61f15ee7e3f..ed7d8d1716d8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // The awaited param puts the request id into the cache key, so a fresh id is a guaranteed miss // and the entry cannot come from a build-time fill. `children` passes through as an uncached hole. @@ -15,11 +16,14 @@ export default async function CachedMidLayout({ const { id } = await params; await new Promise(resolve => setTimeout(resolve, 100)); return ( -
-

- {id}:{Date.now()} -

- {children} -
+
+

Cached layout, dynamic page

+ +

+ {id}:{Date.now()} +

+ {children} +
+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx index 94196e6e5ee8..1edb269289ba 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx @@ -1,6 +1,11 @@ import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function Page() { await headers(); - return

Dynamic leaf: {Date.now()}

; + return ( + +

Dynamic leaf: {Date.now()}

+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx index 8e7de36d156a..494dee2483e2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx @@ -1,13 +1,16 @@ import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; async function CachedLeaf({ id }: { id: string }) { 'use cache'; cacheLife('hours'); await new Promise(resolve => setTimeout(resolve, 100)); return ( -

- {id}:{Date.now()} -

+ +

+ {id}:{Date.now()} +

+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx index 412587fd8f33..4b9b4cffc9bf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx @@ -1,7 +1,16 @@ import type { ReactNode } from 'react'; import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function DynamicLayout({ children }: { children: ReactNode }) { await headers(); - return
{children}
; + return ( +
+

Dynamic layout, cached leaf

+ +

request-time: {Date.now()}

+ {children} +
+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx index 2b0b77b6e3a9..7920ef335dc2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // Hard-expires after 2s while the page's cached component lives for hours, so one request can // refill the layout while hitting the component: two cached levels with different origin traces. @@ -14,11 +15,14 @@ export default async function ShortLivedLayout({ cacheLife({ revalidate: 1, expire: 2 }); const { id } = await params; return ( -
-

- {id}:{Date.now()} -

- {children} -
+
+

Mixed lifetimes

+ +

+ {id}:{Date.now()} +

+ {children} +
+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx index 18913cbe9790..c1c0e31494f6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx @@ -1,19 +1,27 @@ import { headers } from 'next/headers'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function LongLivedComponent({ id }: { id: string }) { 'use cache'; cacheLife('hours'); await new Promise(resolve => setTimeout(resolve, 100)); return ( -

- {id}:{Date.now()} -

+ +

+ {id}:{Date.now()} +

+
); } export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; await headers(); - return ; + return ( + +

request-time: {Date.now()}

+ +
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx index ec8d5e21d947..606b9f01a2b2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx @@ -1,6 +1,11 @@ import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function Page() { await headers(); - return

Route a: {Date.now()}

; + return ( + +

Route a: {Date.now()}

+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx index 9a1b84b2ca28..18bff4a0ed35 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx @@ -1,6 +1,11 @@ import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function Page() { await headers(); - return

Route b: {Date.now()}

; + return ( + +

Route b: {Date.now()}

+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx index 220fdcf92ca8..16fa37dbd71e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // One cache entry (keyed by id) shared by the sibling routes `a` and `b` below. export default async function SharedLayout({ @@ -14,11 +15,14 @@ export default async function SharedLayout({ const { id } = await params; await new Promise(resolve => setTimeout(resolve, 100)); return ( -
-

- {id}:{Date.now()} -

- {children} -
+
+

Shared layout across sibling routes

+ +

+ {id}:{Date.now()} +

+ {children} +
+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx index c8b8858f7785..c3e4050539bb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx @@ -1,14 +1,17 @@ import { Suspense } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function CachedSection({ id, label }: { id: string; label: string }) { 'use cache'; cacheLife('hours'); await new Promise(resolve => setTimeout(resolve, 100)); return ( -
- {label}:{id}:{Date.now()} -
+ +
+ {label}:{id}:{Date.now()} +
+
); } @@ -18,17 +21,21 @@ async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: s // separate entries (the props are part of the cache key). const { id = 'default-id' } = await searchParams; return ( - <> + +

request-time: {Date.now()}

- +
); } export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { return ( - Loading...}> - - +
+

Cached sibling components

+ Loading...}> + + +
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/layout.tsx index c8f9cee0b787..5ffd72655346 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/layout.tsx @@ -1,7 +1,12 @@ export default function Layout({ children }: { children: React.ReactNode }) { return ( - {children} + +
+ ← Cache scenarios +
+ {children} + ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx index 481d7e5e46e6..1115c4054b3f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function getSlowChangingValue(id: string): Promise<{ id: string; createdAt: number }> { 'use cache'; @@ -14,20 +15,34 @@ async function ShortLivedSection({ id }: { id: string }) { // delayed request refills the component and reads the nested entry as a hit inside that fill. cacheLife({ revalidate: 1, expire: 2 }); const nested = await getSlowChangingValue(id); - return
{JSON.stringify({ ...nested, renderedAt: Date.now() })}
; + return ( + + +
{JSON.stringify({ ...nested, renderedAt: Date.now() })}
+
+
+ ); } async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the // cache handler instead of serving prerendered output. const { id = 'default-id' } = await searchParams; - return ; + return ( + +

request-time: {Date.now()}

+ +
+ ); } export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { return ( - Loading...}> - - +
+

Nested caches with different lifetimes

+ Loading...}> + + +
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/page.tsx index 433db8283beb..bea2cf1d875f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/page.tsx @@ -1,3 +1,120 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type Scenario = { name: string; href: (id: string) => string; description: string }; + +const groups: { title: string; scenarios: Scenario[] }[] = [ + { + title: 'Route handlers (JSON)', + scenarios: [ + { + name: '/api/use-cache', + href: id => `/api/use-cache?id=${id}`, + description: 'cached function that lives for hours. The second request with the same id is a hit.', + }, + { + name: '/api/use-cache-expiring', + href: id => `/api/use-cache-expiring?id=${id}`, + description: 'the entry expires after 2 seconds. Request again after 3 seconds for a refill.', + }, + { + name: '/api/use-cache-swr', + href: id => `/api/use-cache-swr?id=${id}`, + description: + 'the entry goes stale after 2 seconds. A stale read serves the old value and refills in the background.', + }, + ], + }, + { + title: 'Rendered pages', + scenarios: [ + { + name: '/use-cache-page', + href: id => `/use-cache-page?id=${id}`, + description: 'a cached data function inside a dynamic hole.', + }, + { + name: '/cached-sibling-components', + href: id => `/cached-sibling-components?id=${id}`, + description: 'two cached sibling components. Each section is its own cache entry.', + }, + { + name: '/nested-caches', + href: id => `/nested-caches?id=${id}`, + description: + 'a cached component that expires after 2 seconds and calls a cached function that lives for hours.', + }, + ], + }, + { + title: 'Nested layouts', + scenarios: [ + { + name: '/cached-mid-layout/[id]', + href: id => `/cached-mid-layout/${id}`, + description: 'a cached layout above a dynamic page.', + }, + { + name: '/dynamic-layouts/[id]/layout-cached-leaf', + href: id => `/dynamic-layouts/${id}/layout-cached-leaf`, + description: 'dynamic layouts above a cached leaf component.', + }, + { + name: '/mixed-lifetimes/[id]', + href: id => `/mixed-lifetimes/${id}`, + description: 'a cached layout that expires after 2 seconds around a cached component that lives for hours.', + }, + { + name: '/shared-layout/[id]/a', + href: id => `/shared-layout/${id}/a`, + description: 'route a fills the layout entry that routes a and b share.', + }, + { + name: '/shared-layout/[id]/b', + href: id => `/shared-layout/${id}/b`, + description: 'route b hits the layout entry that route a filled.', + }, + ], + }, + { + title: 'Pageload', + scenarios: [ + { + name: '/pageload-tracing', + href: () => '/pageload-tracing', + description: 'a prerendered shell with a dynamic hole. Check the pageload and server trace connection here.', + }, + ], + }, +]; + +// The links are plain `` elements so that every click is a full document request with its own +// server trace. `` would navigate through the client-side router instead. export default function Page() { - return

Next 16 CacheComponents test app

; + const [id, setId] = useState(''); + // Generated after hydration so the prerendered shell does not bake in one fixed id. + useEffect(() => setId(crypto.randomUUID()), []); + + return ( +
+

Next 16 Cache Components scenarios

+

+ Every link carries a random id that becomes part of the cache key. The first click fills the cache. A second + click on the same link, or a reload of the target page, hits it. Reload this page to get fresh ids. +

+ {groups.map(group => ( +
+

{group.title}

+
+
+ ))} +
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx index 1e41b4436e20..3450928fe090 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function getCachedPageData(id: string): Promise<{ id: string; createdAt: number }> { 'use cache'; @@ -10,9 +11,12 @@ async function getCachedPageData(id: string): Promise<{ id: string; createdAt: n export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { return ( - Loading...}> - - +
+

Cached data function in a page

+ Loading...}> + + +
); } @@ -21,5 +25,12 @@ async function CachedContent({ searchParams }: { searchParams: Promise<{ id?: st // cache handler instead of serving prerendered output. const { id = 'default-id' } = await searchParams; const data = await getCachedPageData(id); - return
{JSON.stringify(data)}
; + return ( + +

request-time: {Date.now()}

+ +
{JSON.stringify(data)}
+
+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/components/scenarioBox.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/components/scenarioBox.tsx new file mode 100644 index 000000000000..cf2d318d64cc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/components/scenarioBox.tsx @@ -0,0 +1,34 @@ +import type { CSSProperties, ReactNode } from 'react'; + +const box: CSSProperties = { + padding: '8px 12px', + margin: '12px 0', + borderRadius: 8, +}; + +const badge: CSSProperties = { + display: 'inline-block', + fontSize: 12, + fontWeight: 600, + color: '#fff', + padding: '1px 8px', + borderRadius: 4, +}; + +export function CachedBox({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ Cached · {label} + {children} +
+ ); +} + +export function DynamicBox({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ Dynamic · {label} + {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx index a61f15ee7e3f..ed7d8d1716d8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // The awaited param puts the request id into the cache key, so a fresh id is a guaranteed miss // and the entry cannot come from a build-time fill. `children` passes through as an uncached hole. @@ -15,11 +16,14 @@ export default async function CachedMidLayout({ const { id } = await params; await new Promise(resolve => setTimeout(resolve, 100)); return ( -
-

- {id}:{Date.now()} -

- {children} -
+
+

Cached layout, dynamic page

+ +

+ {id}:{Date.now()} +

+ {children} +
+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx index 94196e6e5ee8..1edb269289ba 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx @@ -1,6 +1,11 @@ import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function Page() { await headers(); - return

Dynamic leaf: {Date.now()}

; + return ( + +

Dynamic leaf: {Date.now()}

+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx index 8e7de36d156a..494dee2483e2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx @@ -1,13 +1,16 @@ import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; async function CachedLeaf({ id }: { id: string }) { 'use cache'; cacheLife('hours'); await new Promise(resolve => setTimeout(resolve, 100)); return ( -

- {id}:{Date.now()} -

+ +

+ {id}:{Date.now()} +

+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx index 412587fd8f33..4b9b4cffc9bf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx @@ -1,7 +1,16 @@ import type { ReactNode } from 'react'; import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function DynamicLayout({ children }: { children: ReactNode }) { await headers(); - return
{children}
; + return ( +
+

Dynamic layout, cached leaf

+ +

request-time: {Date.now()}

+ {children} +
+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx index 2b0b77b6e3a9..7920ef335dc2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // Hard-expires after 2s while the page's cached component lives for hours, so one request can // refill the layout while hitting the component: two cached levels with different origin traces. @@ -14,11 +15,14 @@ export default async function ShortLivedLayout({ cacheLife({ revalidate: 1, expire: 2 }); const { id } = await params; return ( -
-

- {id}:{Date.now()} -

- {children} -
+
+

Mixed lifetimes

+ +

+ {id}:{Date.now()} +

+ {children} +
+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx index 18913cbe9790..c1c0e31494f6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx @@ -1,19 +1,27 @@ import { headers } from 'next/headers'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function LongLivedComponent({ id }: { id: string }) { 'use cache'; cacheLife('hours'); await new Promise(resolve => setTimeout(resolve, 100)); return ( -

- {id}:{Date.now()} -

+ +

+ {id}:{Date.now()} +

+
); } export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; await headers(); - return ; + return ( + +

request-time: {Date.now()}

+ +
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx index ec8d5e21d947..606b9f01a2b2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx @@ -1,6 +1,11 @@ import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function Page() { await headers(); - return

Route a: {Date.now()}

; + return ( + +

Route a: {Date.now()}

+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx index 9a1b84b2ca28..18bff4a0ed35 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx @@ -1,6 +1,11 @@ import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function Page() { await headers(); - return

Route b: {Date.now()}

; + return ( + +

Route b: {Date.now()}

+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx index 220fdcf92ca8..16fa37dbd71e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // One cache entry (keyed by id) shared by the sibling routes `a` and `b` below. export default async function SharedLayout({ @@ -14,11 +15,14 @@ export default async function SharedLayout({ const { id } = await params; await new Promise(resolve => setTimeout(resolve, 100)); return ( -
-

- {id}:{Date.now()} -

- {children} -
+
+

Shared layout across sibling routes

+ +

+ {id}:{Date.now()} +

+ {children} +
+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx index c8b8858f7785..c3e4050539bb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx @@ -1,14 +1,17 @@ import { Suspense } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function CachedSection({ id, label }: { id: string; label: string }) { 'use cache'; cacheLife('hours'); await new Promise(resolve => setTimeout(resolve, 100)); return ( -
- {label}:{id}:{Date.now()} -
+ +
+ {label}:{id}:{Date.now()} +
+
); } @@ -18,17 +21,21 @@ async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: s // separate entries (the props are part of the cache key). const { id = 'default-id' } = await searchParams; return ( - <> + +

request-time: {Date.now()}

- +
); } export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { return ( - Loading...}> - - +
+

Cached sibling components

+ Loading...}> + + +
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/layout.tsx index c8f9cee0b787..5ffd72655346 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/layout.tsx @@ -1,7 +1,12 @@ export default function Layout({ children }: { children: React.ReactNode }) { return ( - {children} + +
+ ← Cache scenarios +
+ {children} + ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx index 481d7e5e46e6..1115c4054b3f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function getSlowChangingValue(id: string): Promise<{ id: string; createdAt: number }> { 'use cache'; @@ -14,20 +15,34 @@ async function ShortLivedSection({ id }: { id: string }) { // delayed request refills the component and reads the nested entry as a hit inside that fill. cacheLife({ revalidate: 1, expire: 2 }); const nested = await getSlowChangingValue(id); - return
{JSON.stringify({ ...nested, renderedAt: Date.now() })}
; + return ( + + +
{JSON.stringify({ ...nested, renderedAt: Date.now() })}
+
+
+ ); } async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the // cache handler instead of serving prerendered output. const { id = 'default-id' } = await searchParams; - return ; + return ( + +

request-time: {Date.now()}

+ +
+ ); } export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { return ( - Loading...}> - - +
+

Nested caches with different lifetimes

+ Loading...}> + + +
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/page.tsx index 433db8283beb..8f7d5cf7558e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/page.tsx @@ -1,3 +1,115 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type Scenario = { name: string; href: (id: string) => string; description: string }; + +const groups: { title: string; scenarios: Scenario[] }[] = [ + { + title: 'Route handlers (JSON)', + scenarios: [ + { + name: '/api/use-cache', + href: id => `/api/use-cache?id=${id}`, + description: 'cached function that lives for hours. The second request with the same id is a hit.', + }, + { + name: '/api/use-cache-expiring', + href: id => `/api/use-cache-expiring?id=${id}`, + description: 'the entry expires after 2 seconds. Request again after 3 seconds for a refill.', + }, + { + name: '/api/use-cache-swr', + href: id => `/api/use-cache-swr?id=${id}`, + description: + 'the entry goes stale after 2 seconds. A stale read serves the old value and refills in the background.', + }, + ], + }, + { + title: 'Rendered pages', + scenarios: [ + { + name: '/cached-sibling-components', + href: id => `/cached-sibling-components?id=${id}`, + description: 'two cached sibling components. Each section is its own cache entry.', + }, + { + name: '/nested-caches', + href: id => `/nested-caches?id=${id}`, + description: + 'a cached component that expires after 2 seconds and calls a cached function that lives for hours.', + }, + ], + }, + { + title: 'Nested layouts', + scenarios: [ + { + name: '/cached-mid-layout/[id]', + href: id => `/cached-mid-layout/${id}`, + description: 'a cached layout above a dynamic page.', + }, + { + name: '/dynamic-layouts/[id]/layout-cached-leaf', + href: id => `/dynamic-layouts/${id}/layout-cached-leaf`, + description: 'dynamic layouts above a cached leaf component.', + }, + { + name: '/mixed-lifetimes/[id]', + href: id => `/mixed-lifetimes/${id}`, + description: 'a cached layout that expires after 2 seconds around a cached component that lives for hours.', + }, + { + name: '/shared-layout/[id]/a', + href: id => `/shared-layout/${id}/a`, + description: 'route a fills the layout entry that routes a and b share.', + }, + { + name: '/shared-layout/[id]/b', + href: id => `/shared-layout/${id}/b`, + description: 'route b hits the layout entry that route a filled.', + }, + ], + }, + { + title: 'Pageload', + scenarios: [ + { + name: '/pageload-tracing', + href: () => '/pageload-tracing', + description: 'a prerendered shell with a dynamic hole. Check the pageload and server trace connection here.', + }, + ], + }, +]; + +// The links are plain `` elements so that every click is a full document request with its own +// server trace. `` would navigate through the client-side router instead. export default function Page() { - return

Next 16 CacheComponents test app

; + const [id, setId] = useState(''); + // Generated after hydration so the prerendered shell does not bake in one fixed id. + useEffect(() => setId(crypto.randomUUID()), []); + + return ( +
+

Next 16 Cache Components scenarios (span streaming)

+

+ Every link carries a random id that becomes part of the cache key. The first click fills the cache. A second + click on the same link, or a reload of the target page, hits it. Reload this page to get fresh ids. +

+ {groups.map(group => ( +
+

{group.title}

+
+
+ ))} +
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/components/scenarioBox.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/components/scenarioBox.tsx new file mode 100644 index 000000000000..cf2d318d64cc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/components/scenarioBox.tsx @@ -0,0 +1,34 @@ +import type { CSSProperties, ReactNode } from 'react'; + +const box: CSSProperties = { + padding: '8px 12px', + margin: '12px 0', + borderRadius: 8, +}; + +const badge: CSSProperties = { + display: 'inline-block', + fontSize: 12, + fontWeight: 600, + color: '#fff', + padding: '1px 8px', + borderRadius: 4, +}; + +export function CachedBox({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ Cached · {label} + {children} +
+ ); +} + +export function DynamicBox({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ Dynamic · {label} + {children} +
+ ); +} From 325d89c9e506c966f122efb5ac922a9914eafe83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:40:05 +0200 Subject: [PATCH 46/65] feat(deps): bump fast-uri from 3.1.7 to 3.1.8 (#24889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.7 to 3.1.8.
Release notes

Sourced from fast-uri's releases.

v3.1.8

⚠️ Security Warning

This security release fixes the following medium-severity security advisory:

Users of the v3.x release line should upgrade to v3.1.8.

Full Changelog: https://github.com/fastify/fast-uri/compare/v3.1.7...v3.1.8

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=fast-uri&package-manager=npm_and_yarn&previous-version=3.1.7&new-version=3.1.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index ffb9bd242b2d..2da26a469d9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15761,14 +15761,14 @@ fast-text-encoding@^1.0.0: integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== fast-uri@^3.0.0, fast-uri@^3.0.1: - version "3.1.7" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.7.tgz#743157d957f3cbb4c65310e033dc2ad4ad7dc60a" - integrity sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg== + version "3.1.8" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.8.tgz#f7db8d942e20eead3cfe93b0beeb4eb0169e938b" + integrity sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg== fast-uri@^4.0.0: - version "4.1.4" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-4.1.4.tgz#2076a9d7bce86a86aa31ac40650b935fd5b1cd60" - integrity sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ== + version "4.2.1" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-4.2.1.tgz#816fb0fd61f511adcc35aadbbc1bcbf656d9e72a" + integrity sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg== fast-wrap-ansi@^0.2.0: version "0.2.2" From 307cefbacd4e8ca4b91b189949ffc86f81873e0c Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:01:52 +0200 Subject: [PATCH 47/65] fix(nuxt): Fix CommonJS interop for force-inlined instrumented packages (#24799) Since v11, build-time instrumentation force-inlines the instrumented drivers (mongoose, ioredis, mysql, ...) into the Nitro bundle while their CommonJS dependencies stay external. Since Node 23, CJS namespaces carry a `module.exports` key next to `default` (nodejs/node#53848), so Rollup's `'auto'` interop no longer unwraps those requires and the drivers crash at startup (`mquery is not a constructor`). No single interop mode (a `requireReturnsDefault` value) fixes this and works for all externals. `debug` needs the namespace, while `mquery` and `lodash.defaults` need `module.exports`. So we unwrap builtins plus a known list and keep `'auto'` for the rest. Fixes #24775 Fixes #24786 --- .../nuxt-4/docker-compose.yml | 16 +++++ .../test-applications/nuxt-4/global-setup.mjs | 2 +- .../test-applications/nuxt-4/package.json | 4 +- .../nuxt-4/server/api/db-mongoose.ts | 19 ++++++ .../nuxt-4/tests/db-drivers.test.ts | 40 +++++++++++++ .../nuxt-5/docker-compose.yml | 16 +++++ .../test-applications/nuxt-5/global-setup.mjs | 14 +++++ .../nuxt-5/global-teardown.mjs | 12 ++++ .../test-applications/nuxt-5/package.json | 2 +- .../nuxt-5/playwright.config.ts | 6 +- .../nuxt-5/server/api/db-mongoose.ts | 19 ++++++ .../nuxt-5/tests/db-mongoose.test.ts | 49 ++++++++++++++++ .../solidstart-static/package.json | 1 - .../test-applications/solidstart/package.json | 1 - packages/nuxt/src/vite/orchestrion.ts | 15 ++++- packages/nuxt/test/vite/orchestrion.test.ts | 30 +++++++++- .../src/orchestrion/bundler/rollup.ts | 49 ++++++++++++++++ .../test/orchestrion/rollup-plugin.test.ts | 58 ++++++++++++++++++- packages/solidstart/src/config/withSentry.ts | 15 ++++- .../solidstart/test/config/withSentry.test.ts | 35 ++++++++++- 20 files changed, 388 insertions(+), 15 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-mongoose.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/db-mongoose.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml index a50a0097e5ae..c87cae49aab6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml @@ -17,6 +17,22 @@ services: retries: 30 start_period: 10s + mongo: + image: mongo:8 + restart: always + container_name: e2e-tests-nuxt-4-mongo + ports: + - '27017:27017' + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'mongosh --quiet --eval "db.adminCommand(''ping'').ok"'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s + redis: image: redis:7 restart: always diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs index cb48d539c466..4d0386045cb1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); export default async function globalSetup() { - // Start MySQL + Redis via Docker Compose. `--wait` blocks until the + // Start MySQL, MongoDB, and Redis via Docker Compose. `--wait` blocks until the // healthchecks in docker-compose.yml pass, so the app can connect immediately. execSync('docker compose up -d --wait', { cwd: __dirname, 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 9eb23775a79b..ac1ea1939999 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -23,6 +23,7 @@ "@pinia/nuxt": "^0.5.5", "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", "ioredis": "5.10.1", + "mongoose": "^9.10.2", "mysql": "^2.18.1", "nuxt": "^4.1.2" }, @@ -31,8 +32,7 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json", - "node": "22.20.0" + "extends": "../../package.json" }, "sentryTest": { "variants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.ts new file mode 100644 index 000000000000..6a203cceaa9e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.ts @@ -0,0 +1,19 @@ +import { defineEventHandler } from '#imports'; +import mongoose from 'mongoose'; + +const BlogPost = mongoose.model('BlogPost', new mongoose.Schema({ title: String })); + +// SCRAM-SHA-1 on purpose: this handshake lazily `require()`s the `crypto` builtin inside a try +// block, which only works when the bundled driver gets a real interop for builtin requires (#24775). +const MONGO_URL = 'mongodb://root:docker@127.0.0.1:27017/test?authSource=admin&authMechanism=SCRAM-SHA-1'; + +export default defineEventHandler(async () => { + if (mongoose.connection.readyState !== mongoose.ConnectionStates.connected) { + await mongoose.connect(MONGO_URL); + } + + await new BlogPost({ title: 'test-post' }).save(); + const found = await BlogPost.findOne({ title: 'test-post' }); + + return { title: found?.title }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts index a093dd9e8d67..5b1bccedab4d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts @@ -67,6 +67,46 @@ test('Instruments ioredis automatically', async ({ baseURL }) => { ); }); +// The Nitro bundle force-inlines the instrumented drivers while their CommonJS dependencies and +// Node builtins stay external, and every `require()` across that boundary needs working interop. +test('Instruments mongoose automatically', async ({ baseURL }) => { + const spansPromise = collectRequestSpans('/api/db-mongoose'); + + const response = await fetch(`${baseURL}/api/db-mongoose`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ title: 'test-post' }); + + const spans = await spansPromise; + + const rootSpan = spans.find(span => span.is_segment); + expect(rootSpan).toBeDefined(); + expect(getSpanOp(rootSpan!)).toBe('http.server'); + + const mongooseSpans = spans.filter( + span => span.attributes['sentry.origin']?.value === 'auto.db.mongoose.diagnostic_channel', + ); + expect(mongooseSpans).toHaveLength(2); + + const saveSpan = mongooseSpans.find(span => span.name === 'save blogposts'); + expect(saveSpan?.status).toBe('ok'); + expect(saveSpan?.is_segment).toBe(false); + expect(saveSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(saveSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(saveSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(saveSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(saveSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'save' }); + + const findOneSpan = mongooseSpans.find(span => span.name === 'findOne blogposts'); + expect(findOneSpan?.status).toBe('ok'); + expect(findOneSpan?.is_segment).toBe(false); + expect(findOneSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(findOneSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(findOneSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(findOneSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(findOneSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'findOne' }); + expect(findOneSpan?.attributes['db.query.text']).toEqual({ type: 'string', value: '{"title":"?"}' }); +}); + test('Instruments mysql automatically', async ({ baseURL }) => { const spansPromise = collectRequestSpans('/api/db-mysql'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml new file mode 100644 index 000000000000..a392ebd433af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml @@ -0,0 +1,16 @@ +services: + mongo: + image: mongo:8 + restart: always + container_name: e2e-tests-nuxt-5-mongo + ports: + - '27017:27017' + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'mongosh --quiet --eval "db.adminCommand(''ping'').ok"'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs new file mode 100644 index 000000000000..fbc228dc4e55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start MongoDB via Docker Compose. `--wait` blocks until the + // healthcheck in docker-compose.yml passes, so the app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index cdf4885a9703..6a38a24d8f3c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -27,6 +27,7 @@ "dependencies": { "@pinia/nuxt": "^0.11.3", "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", + "mongoose": "^9.10.2", "ofetch": "^2.0.0-alpha.3", "nitro": "latest", "nuxt": "npm:nuxt-nightly@5x", @@ -41,7 +42,6 @@ }, "volta": { "extends": "../../package.json", - "node": "22.20.0", "pnpm": "11.10.0" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts index 80df201381ce..308691754a0b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts @@ -23,4 +23,8 @@ const config = getPlaywrightConfig({ startCommand: getStartCommand(), }); -export default config; +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-mongoose.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-mongoose.ts new file mode 100644 index 000000000000..5bed443ac90a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-mongoose.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; +import { defineHandler } from 'nitro'; + +const BlogPost = mongoose.model('BlogPost', new mongoose.Schema({ title: String })); + +// SCRAM-SHA-1 on purpose: this handshake lazily `require()`s the `crypto` builtin inside a try +// block, which only works when the bundled driver gets a real interop for builtin requires (#24775). +const MONGO_URL = 'mongodb://root:docker@127.0.0.1:27017/test?authSource=admin&authMechanism=SCRAM-SHA-1'; + +export default defineHandler(async () => { + if (mongoose.connection.readyState !== mongoose.ConnectionStates.connected) { + await mongoose.connect(MONGO_URL); + } + + await new BlogPost({ title: 'test-post' }).save(); + const found = await BlogPost.findOne({ title: 'test-post' }); + + return { title: found?.title }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/db-mongoose.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/db-mongoose.test.ts new file mode 100644 index 000000000000..e48c97ed1af6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/db-mongoose.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +// The Nitro bundle force-inlines the instrumented drivers while their CommonJS dependencies and +// Node builtins stay external, and every `require()` across that boundary needs working interop +// (#24775). mongoose covers both: `new mquery()`, and SCRAM-SHA-1 auth lazily requiring `crypto`. +async function collectRequestSpans() { + const spans = await collectStreamedSpansUntilSegment( + 'nuxt-5', + span => span.attributes['url.path']?.value === '/api/db-mongoose', + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-mongoose'); + + return spans.filter(span => span.trace_id === rootSpan?.trace_id); +} + +test('Instruments mongoose automatically', async ({ baseURL }) => { + const spansPromise = collectRequestSpans(); + + const response = await fetch(`${baseURL}/api/db-mongoose`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ title: 'test-post' }); + + const spans = await spansPromise; + + const mongooseSpans = spans.filter( + span => span.attributes['sentry.origin']?.value === 'auto.db.mongoose.diagnostic_channel', + ); + expect(mongooseSpans).toHaveLength(2); + + const saveSpan = mongooseSpans.find(span => span.name === 'save blogposts'); + expect(saveSpan?.status).toBe('ok'); + expect(saveSpan?.is_segment).toBe(false); + expect(saveSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(saveSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(saveSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(saveSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(saveSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'save' }); + + const findOneSpan = mongooseSpans.find(span => span.name === 'findOne blogposts'); + expect(findOneSpan?.status).toBe('ok'); + expect(findOneSpan?.is_segment).toBe(false); + expect(findOneSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(findOneSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(findOneSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(findOneSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(findOneSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'findOne' }); + expect(findOneSpan?.attributes['db.query.text']).toEqual({ type: 'string', value: '{"title":"?"}' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json index e1d92d3bd733..ebc880b29186 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json @@ -33,7 +33,6 @@ "vite-plugin-solid": "^2.11.6" }, "volta": { - "node": "20.19.5", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index a9c96734e0a2..ac2b8c393432 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -35,7 +35,6 @@ "vite-plugin-solid": "^2.11.6" }, "volta": { - "node": "20.19.5", "extends": "../../package.json" } } diff --git a/packages/nuxt/src/vite/orchestrion.ts b/packages/nuxt/src/vite/orchestrion.ts index 1a7f74ee62df..101545f018b9 100644 --- a/packages/nuxt/src/vite/orchestrion.ts +++ b/packages/nuxt/src/vite/orchestrion.ts @@ -1,6 +1,10 @@ import type { Nuxt } from '@nuxt/schema'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; -import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup'; +import { + commonJSInteropOptions, + sentryCommonJSInteropPlugin, + sentryOrchestrionPlugin, +} from '@sentry/server-utils/orchestrion/rollup'; import type { NitroConfig } from 'nitropack'; import { isCloudflarePreset } from './utils'; @@ -49,11 +53,18 @@ export function setupOrchestrion(nuxt: Nuxt, hasServerConfig: boolean, buildTime nitroConfig.rollupConfig.plugins = [nitroConfig.rollupConfig.plugins]; } - nitroConfig.rollupConfig.plugins.push(sentryOrchestrionPlugin({})); + nitroConfig.rollupConfig.plugins.push(sentryOrchestrionPlugin({}), sentryCommonJSInteropPlugin()); const externals = (nitroConfig.externals ||= {}); const inline = externals.inline; const existingInline = Array.isArray(inline) ? inline : inline ? [inline] : []; externals.inline = [...new Set([...existingInline, ...INSTRUMENTED_MODULE_NAMES, ...IORedisDependencies])]; + + // The inlined drivers `require()` CommonJS dependencies that stay external. Fix the interop + // for those requires (see `commonJSInteropOptions`). User-provided options win. + const commonJS = (nitroConfig.commonJS ||= {}); + const interop = commonJSInteropOptions(); + commonJS.requireReturnsDefault ??= interop.requireReturnsDefault; + commonJS.ignoreTryCatch ??= interop.ignoreTryCatch; }); } diff --git a/packages/nuxt/test/vite/orchestrion.test.ts b/packages/nuxt/test/vite/orchestrion.test.ts index 9461699412b6..5ab7ffd794d2 100644 --- a/packages/nuxt/test/vite/orchestrion.test.ts +++ b/packages/nuxt/test/vite/orchestrion.test.ts @@ -2,6 +2,10 @@ import type { Nuxt } from '@nuxt/schema'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const mockSentryOrchestrionPlugin = vi.fn(() => ({ name: 'sentry-orchestrion-plugin' })); +const mockCommonJSInteropOptions = { + requireReturnsDefault: vi.fn(), + ignoreTryCatch: vi.fn(), +}; function createMockNuxt(options: { _prepare?: boolean; dev?: boolean } = {}) { const hooks: Record void | Promise>> = {}; @@ -27,6 +31,8 @@ describe('setupOrchestrion', () => { })); vi.doMock('@sentry/server-utils/orchestrion/rollup', () => ({ sentryOrchestrionPlugin: mockSentryOrchestrionPlugin, + sentryCommonJSInteropPlugin: () => ({ name: 'sentry-commonjs-interop' }), + commonJSInteropOptions: () => mockCommonJSInteropOptions, })); // The module reaches `@sentry/core` and `@nuxt/kit` through `./utils`. Transforming those // charged the first test, which timed out on slower CI runners. The tests never reset the @@ -56,7 +62,11 @@ describe('setupOrchestrion', () => { await mockNuxt.triggerHook('nitro:config', nitroConfig); expect(mockSentryOrchestrionPlugin).toHaveBeenCalledOnce(); - expect(nitroConfig.rollupConfig.plugins).toEqual([existingPlugin, { name: 'sentry-orchestrion-plugin' }]); + expect(nitroConfig.rollupConfig.plugins).toEqual([ + existingPlugin, + { name: 'sentry-orchestrion-plugin' }, + { name: 'sentry-commonjs-interop' }, + ]); expect(nitroConfig.externals.inline).toEqual(['ioredis', 'custom-dependency', 'mysql', 'standard-as-callback']); }); @@ -103,8 +113,24 @@ describe('setupOrchestrion', () => { await mockNuxt.triggerHook('nitro:config', nitroConfig); expect(nitroConfig).toEqual({ - rollupConfig: { plugins: [{ name: 'sentry-orchestrion-plugin' }] }, + rollupConfig: { plugins: [{ name: 'sentry-orchestrion-plugin' }, { name: 'sentry-commonjs-interop' }] }, externals: { inline: ['mysql', 'ioredis', 'standard-as-callback'] }, + commonJS: mockCommonJSInteropOptions, + }); + }); + + it('preserves user-provided CommonJS options', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt(); + const userRequireReturnsDefault = vi.fn(); + const nitroConfig = { commonJS: { requireReturnsDefault: userRequireReturnsDefault } }; + + setupOrchestrion(mockNuxt as unknown as Nuxt, true); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(nitroConfig.commonJS).toEqual({ + requireReturnsDefault: userRequireReturnsDefault, + ignoreTryCatch: mockCommonJSInteropOptions.ignoreTryCatch, }); }); diff --git a/packages/server-utils/src/orchestrion/bundler/rollup.ts b/packages/server-utils/src/orchestrion/bundler/rollup.ts index 87bac2e1e00c..fc0403927a11 100644 --- a/packages/server-utils/src/orchestrion/bundler/rollup.ts +++ b/packages/server-utils/src/orchestrion/bundler/rollup.ts @@ -1,3 +1,5 @@ +import { isBuiltin } from 'node:module'; + import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; import type { ExternalOption, @@ -30,6 +32,53 @@ function rawExternalMatchesModule(external: ExternalOption, name: string): boole ); } +/** + * Structural subset of `@rollup/plugin-commonjs` options, so this package needs no dependency on + * the plugin for its types. + */ +export interface CommonJSInteropOptions { + requireReturnsDefault: (id: string) => boolean | 'auto' | 'preferred' | 'namespace'; + ignoreTryCatch: (id: string) => boolean; +} + +/** + * `@rollup/plugin-commonjs` options for builds that bundle CommonJS packages while their + * dependencies stay external. Builtin `require()`s unwrap to the module (a namespace breaks + * direct calls) and convert inside `try` blocks (a bare `require` throws in ESM output). + * Externals keep `'auto'`, which {@link sentryCommonJSInteropPlugin} fixes for Node >= 23. + */ +export function commonJSInteropOptions(): CommonJSInteropOptions { + return { + requireReturnsDefault: id => (isBuiltin(id) ? true : 'auto'), + ignoreTryCatch: id => !isBuiltin(id), + }; +} + +const COMMONJS_HELPERS_ID = '\0commonjsHelpers.js'; + +// The plugin's `'auto'` helper unwraps a required external module only when `default` is the +// sole key on its namespace. Node >= 23 adds a `'module.exports'` key to every CommonJS +// namespace (nodejs/node#53848), so the check never passes. Ignoring that key restores it. +const BROKEN_NAMESPACE_CHECK = 'Object.keys(n).length === 1'; +const FIXED_NAMESPACE_CHECK = "Object.keys(n).filter(k => k !== 'module.exports').length === 1"; + +/** + * Fixes `@rollup/plugin-commonjs`' `'auto'` interop for Node >= 23 by patching the broken + * namespace check in its helpers module. The patch applies only while the exact broken + * expression exists, so it retires itself once the plugin is fixed upstream. + */ +export function sentryCommonJSInteropPlugin(): Plugin { + return { + name: 'sentry-commonjs-interop', + transform(code: string, id: string) { + if (id !== COMMONJS_HELPERS_ID || !code.includes(BROKEN_NAMESPACE_CHECK)) { + return null; + } + return { code: code.replace(BROKEN_NAMESPACE_CHECK, FIXED_NAMESPACE_CHECK), map: null }; + }, + }; +} + /** * Rollup plugin that runs the orchestrion code transform on the bundled output. * diff --git a/packages/server-utils/test/orchestrion/rollup-plugin.test.ts b/packages/server-utils/test/orchestrion/rollup-plugin.test.ts index 6002e141a835..6f7ef28041bb 100644 --- a/packages/server-utils/test/orchestrion/rollup-plugin.test.ts +++ b/packages/server-utils/test/orchestrion/rollup-plugin.test.ts @@ -1,6 +1,10 @@ import type { InputOptions, NormalizedInputOptions, PluginContext } from 'rollup'; import { describe, expect, it, vi } from 'vitest'; -import { sentryOrchestrionPlugin } from '../../src/orchestrion/bundler/rollup'; +import { + commonJSInteropOptions, + sentryCommonJSInteropPlugin, + sentryOrchestrionPlugin, +} from '../../src/orchestrion/bundler/rollup'; type OptionsHook = (this: unknown, inputOptions: InputOptions) => null; type BuildStartHook = (this: Pick, rollupOptions: NormalizedInputOptions) => void; @@ -47,3 +51,55 @@ describe('sentryOrchestrionPlugin (rollup) externalized-modules warning', () => }); }); }); + +describe('commonJSInteropOptions', () => { + const { requireReturnsDefault, ignoreTryCatch } = commonJSInteropOptions(); + + it('resolves builtins to their default export directly', () => { + expect(requireReturnsDefault('crypto')).toBe(true); + expect(requireReturnsDefault('node:crypto')).toBe(true); + }); + + it('keeps auto for external packages', () => { + expect(requireReturnsDefault('mquery')).toBe('auto'); + expect(requireReturnsDefault('debug')).toBe('auto'); + }); + + it('converts only builtin requires inside try blocks', () => { + expect(ignoreTryCatch('crypto')).toBe(false); + expect(ignoreTryCatch('node:crypto')).toBe(false); + expect(ignoreTryCatch('kerberos')).toBe(true); + }); +}); + +describe('sentryCommonJSInteropPlugin', () => { + const transform = sentryCommonJSInteropPlugin().transform as ( + code: string, + id: string, + ) => { code: string; map: null } | null; + + const brokenHelper = + 'export function getDefaultExportFromNamespaceIfNotNamed (n) {\n' + + "\treturn n && Object.prototype.hasOwnProperty.call(n, 'default') && Object.keys(n).length === 1 ? n['default'] : n;\n" + + '}\n'; + + it('patches the namespace check in the commonjs helpers module', () => { + const result = transform(brokenHelper, '\0commonjsHelpers.js'); + + expect(result?.code).toContain("Object.keys(n).filter(k => k !== 'module.exports').length === 1"); + expect(result?.code).not.toContain('Object.keys(n).length === 1'); + }); + + it('ignores other modules', () => { + expect(transform(brokenHelper, '/app/node_modules/mongoose/lib/index.js')).toBeNull(); + }); + + it('leaves an already-fixed helpers module unchanged', () => { + const fixedHelper = brokenHelper.replace( + 'Object.keys(n).length === 1', + "Object.keys(n).filter(k => k !== 'module.exports').length === 1", + ); + + expect(transform(fixedHelper, '\0commonjsHelpers.js')).toBeNull(); + }); +}); diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index 3caef9cdab82..e4b0f6d08b49 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,7 +1,11 @@ import { debug } from '@sentry/core'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; -import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup'; -import type { Nitro } from 'nitropack'; +import { + commonJSInteropOptions, + sentryCommonJSInteropPlugin, + sentryOrchestrionPlugin, +} from '@sentry/server-utils/orchestrion/rollup'; +import type { Nitro, NitroConfig } from 'nitropack'; import { addSentryPluginToVite } from '../vite/sentrySolidStartVite'; import type { SentrySolidStartPluginOptions } from '../vite/types'; import { @@ -60,6 +64,7 @@ export function withSentry( if (addBuildTimeInstrumentation) { sentryRollupConfig.plugins.push( sentryOrchestrionPlugin({ buildTimeInstrumentation: sentryPluginOptions.buildTimeInstrumentation }), + sentryCommonJSInteropPlugin(), ); } @@ -90,12 +95,17 @@ export function withSentry( // the instrumented modules. This has to be set statically on the Nitro config (not in a hook) // because externalization is a resolution-time decision made before Rollup normalizes `external`. let externals = (server as SolidStartInlineServerConfig & { externals?: { inline?: string[] } }).externals; + let commonJS = (server as SolidStartInlineServerConfig & { commonJS?: NitroConfig['commonJS'] }).commonJS; if (addBuildTimeInstrumentation) { const existingInline = externals?.inline || []; externals = { ...externals, inline: [...new Set([...existingInline, ...INSTRUMENTED_MODULE_NAMES, ...IORedisDependencies])], }; + + // The inlined drivers `require()` CommonJS dependencies that stay external. Fix the interop + // for those requires (see `commonJSInteropOptions`). User-provided options win. + commonJS = { ...commonJSInteropOptions(), ...commonJS }; } return { @@ -104,6 +114,7 @@ export function withSentry( server: { ...server, externals, + commonJS, modules: [...existingModules, sentryNitroModule], }, }; diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index c9b242962c53..3afba804811d 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -17,8 +17,14 @@ vi.mock('../../src/config/addInstrumentation', () => ({ const orchestrionRollupMock = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => ({ name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-plugin', })); +const commonJSInteropOptionsMock = { + requireReturnsDefault: vi.fn(), + ignoreTryCatch: vi.fn(), +}; vi.mock('@sentry/server-utils/orchestrion/rollup', () => ({ sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionRollupMock(options), + sentryCommonJSInteropPlugin: () => ({ name: 'sentry-commonjs-interop' }), + commonJSInteropOptions: () => commonJSInteropOptionsMock, })); vi.mock('@sentry/server-utils/orchestrion/config', () => ({ INSTRUMENTED_MODULE_NAMES: ['mysql', 'ioredis'], @@ -192,7 +198,7 @@ describe('withSentry()', () => { const plugins: Array<{ name: string }> = []; await hookFn(nitroOptions, { plugins }); expect(orchestrionRollupMock).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined }); - expect(plugins.map(plugin => plugin.name)).toContain('sentry-orchestrion-plugin'); + expect(plugins.map(plugin => plugin.name)).toEqual(['sentry-orchestrion-plugin', 'sentry-commonjs-interop']); }); it('force-inlines the instrumented modules into server.externals by default', () => { @@ -216,6 +222,31 @@ describe('withSentry()', () => { expect(externals?.inline).toEqual(['ioredis', 'custom-dependency', 'mysql', 'standard-as-callback']); }); + it('sets the CommonJS interop options for the inlined drivers by default', () => { + const config = withSentry(solidStartConfig, {}); + const commonJS = (config?.server as { commonJS?: Record })?.commonJS; + expect(commonJS).toEqual(commonJSInteropOptionsMock); + }); + + it('preserves user-provided CommonJS options', () => { + const userRequireReturnsDefault = vi.fn(); + const config = withSentry( + { + ...solidStartConfig, + server: { + ...solidStartConfig.server, + commonJS: { requireReturnsDefault: userRequireReturnsDefault }, + }, + } as Parameters[0], + {}, + ); + const commonJS = (config?.server as { commonJS?: Record })?.commonJS; + expect(commonJS).toEqual({ + requireReturnsDefault: userRequireReturnsDefault, + ignoreTryCatch: commonJSInteropOptionsMock.ignoreTryCatch, + }); + }); + it('adds an inert orchestrion plugin and skips externals when buildTimeInstrumentation is false', async () => { const config = withSentry(solidStartConfig, { buildTimeInstrumentation: false }); const { hookFn } = callSentryNitroModule(config); @@ -225,6 +256,8 @@ describe('withSentry()', () => { expect(plugins).toHaveLength(0); const externals = (config?.server as { externals?: { inline?: string[] } })?.externals; expect(externals).toBeUndefined(); + const commonJS = (config?.server as { commonJS?: Record })?.commonJS; + expect(commonJS).toBeUndefined(); }); }); }); From 98ec6866b7db799bbc7458422b4e8dbb48d29ddf Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 30 Sep 2026 15:55:53 +0200 Subject: [PATCH 48/65] feat(elysia): Export `bunRuntimeMetricsIntegration` (#24892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@sentry/elysia` re-exports an explicit list from `@sentry/bun`, and `bunRuntimeMetricsIntegration` (#19979) landed in `@sentry/bun` a few days after the Elysia SDK (#19509), so it was never added to that list. This re-exports it along with `BunRuntimeMetricsOptions`, matching how the Node-based SDKs re-export `nodeRuntimeMetricsIntegration` and its options type. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- packages/elysia/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 05179daba6e6..cf35f6de4eb8 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -173,6 +173,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, bunServerIntegration, + bunRuntimeMetricsIntegration, + type BunRuntimeMetricsOptions, makeFetchTransport, } from '@sentry/bun'; From 639f65ed4008d3f0836bb9365d654101ad98af46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 30 Sep 2026 17:18:35 +0300 Subject: [PATCH 49/65] docs(core): Clarify error object dedupe and `dedupeIntegration` (#24893) The client always drops a repeated `captureException` of the same error object, also when `dedupeIntegration` is not active. `dedupeIntegration` drops an event that is equal to the previous event but comes from a different error object. The old JSDoc did not show this difference. Co-authored-by: Claude Opus 5.5 --- packages/core/src/integrations/dedupe.ts | 7 ++++++- packages/core/src/utils/misc.ts | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core/src/integrations/dedupe.ts b/packages/core/src/integrations/dedupe.ts index f815cd869515..357f6f52959a 100644 --- a/packages/core/src/integrations/dedupe.ts +++ b/packages/core/src/integrations/dedupe.ts @@ -34,7 +34,12 @@ const _dedupeIntegration = (() => { }) satisfies IntegrationFn; /** - * Deduplication filter. + * Drops an error or message event if it is equal to the previous error or message event. + * Two events are equal if they have the same message (or the same exception type and value), + * the same stack trace and the same fingerprint. + * + * A repeated `captureException` of the same error object is dropped by the client, also without + * this integration (see `checkOrSetAlreadyCaught`). */ export const dedupeIntegration = defineIntegration(_dedupeIntegration); diff --git a/packages/core/src/utils/misc.ts b/packages/core/src/utils/misc.ts index 445fe1b12e10..454e4a9ab95d 100644 --- a/packages/core/src/utils/misc.ts +++ b/packages/core/src/utils/misc.ts @@ -223,6 +223,9 @@ export function addContextToFrame(lines: string[], frame: StackFrame, linesOfCon * function helps us ensure that even if we encounter the same error more than once, we only record it the first time we * see it. * + * The client runs this check for every captured exception, also when `dedupeIntegration` is not active. + * `dedupeIntegration` drops equal events that come from different error objects. + * * Note: It will ignore primitives (always return `false` and not mark them as seen), as properties can't be set on * them. {@link: Object.objectify} can be used on exceptions to convert any that are primitives into their equivalent * object wrapper forms so that this check will always work. However, because we need to flag the exact object which From bd211bba0d5faef468357f116096ec284c5f5837 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 30 Sep 2026 16:19:48 +0200 Subject: [PATCH 50/65] test(e2e): Mark supabase-nextjs as optional (#24898) The `supabase-nextjs` E2E test is blocking CI. Marking it optional until it's fixed. Co-authored-by: Claude Opus 5.5 --- .../e2e-tests/test-applications/supabase-nextjs/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json index 7b0c34c4587d..2f4e79c2bfaf 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json @@ -36,5 +36,8 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "optional": true } } From 0a39acfbecab9bf6bca2e367f7713b87acbbc1dc Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 30 Sep 2026 16:43:50 +0200 Subject: [PATCH 51/65] fix(browser-utils): Stop leaking DOM instrumentation listeners on mismatched removals (#24727) This PR fixes two (related) problems in our `instrumentDOM` event listener instrumentation: TIL about event listener [capture](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#usecapture) modes. 1. We didn't differentiate between `addEventListener(fn, {capture: true})` and `addEventListener(fn, {capture: false})` calls, causing leakage of our event listeners when event listeners were removed with different options. => Fixed by checking the `capture` option, registering our own listeners in the same capture config and keeping the capture option on the meta object of the event target so that we can then remove it in the correct capture config 2. More generally fixes an issue with `refCount` where e.g. calling `removeEventListener` with a callback that was never added via `addEventListener`: Browsers just ignore this call but our refCount was decremented anyway. => Fixed by replacing the general ref count with two sets of callbacks (for both capture modes) and only removing our listener if all user-set listeners were removed Fixes #24702 supersedes https://github.com/getsentry/sentry-javascript/pull/24725 supersedes https://github.com/getsentry/sentry-javascript/pull/24723 --- .../browser-utils/src/instrumentation/dom.ts | 60 +++++-- .../test/instrumentation/dom.test.ts | 163 +++++++++++++++++- 2 files changed, 209 insertions(+), 14 deletions(-) diff --git a/packages/browser-utils/src/instrumentation/dom.ts b/packages/browser-utils/src/instrumentation/dom.ts index d326fd281ef2..9a9ca9ad265a 100644 --- a/packages/browser-utils/src/instrumentation/dom.ts +++ b/packages/browser-utils/src/instrumentation/dom.ts @@ -19,8 +19,19 @@ type InstrumentedElement = Element & { __sentry_instrumentation_handlers__?: { [key in 'click' | 'keypress']?: { handler?: unknown; - /** The number of custom listeners attached to this element */ - refCount: number; + capture?: boolean; + // listeners added with `capture: true`, meaning the listener is invoked before other + // listeners inside the element's hierarchy. + captureListeners: WeakSet; + // listeners added with `capture: false` (default), meaning the listener is invoked + // after other listeners inside the element's hierarchy (i.e. the event bubbles up) + bubbleListeners: WeakSet; + // Total number of listeners in `captureListeners` and `bubbleListeners`. WeakSets have no `size`, but we use them + // so listeners removed without going through our `removeEventListener` patch aren't retained by us. + listenerCount: number; + // Set once a `once` or `signal` listener was added. The browser removes those without going + // through `removeEventListener`, so we can't tell when they're gone and must keep our handler attached. + sticky?: boolean; }; }; }; @@ -31,6 +42,10 @@ let debounceTimerID: number | undefined; let lastCapturedEventType: string | undefined; let lastCapturedEventTargetId: string | undefined; +function getCapture(options: boolean | EventListenerOptions | undefined): boolean { + return typeof options === 'boolean' ? options : !!options?.capture; +} + /** * Add an instrumentation handler for when a click or a keypress happens. * @@ -73,19 +88,42 @@ export function instrumentDOM(): void { fill(proto, 'addEventListener', function (originalAddEventListener: AddEventListener): AddEventListener { return function (this: InstrumentedElement, type, listener, options): AddEventListener { - if (type === 'click' || type == 'keypress') { + // The browser ignores `null` listeners, so there's nothing for our handler to accompany. + if ((type === 'click' || type == 'keypress') && listener) { try { const handlers = (this.__sentry_instrumentation_handlers__ = this.__sentry_instrumentation_handlers__ || {}); - const handlerForType = (handlers[type] = handlers[type] || { refCount: 0 }); + + const handlerForType = (handlers[type] = handlers[type] || { + captureListeners: new WeakSet(), + bubbleListeners: new WeakSet(), + listenerCount: 0, + }); + + const capture = getCapture(options); if (!handlerForType.handler) { const handler = makeDOMEventHandler(triggerDOMHandler); handlerForType.handler = handler; - originalAddEventListener.call(this, type, handler, options); + // Track the user-set `capture` option because it changes the identity of the registration of the + // event listener callback function (addEL(fn, true) vs addEL(fn, false) are two different registrations). + // Our listener needs to have the same capture setting, so that subsequent calls or removeEventListener + // calls correspond to the correct handler function. + handlerForType.capture = capture; + originalAddEventListener.call(this, type, handler, handlerForType.capture); } - handlerForType.refCount++; + const listeners = handlerForType[capture ? 'captureListeners' : 'bubbleListeners']; + // Adding the same listener twice in the same phase is a no-op in the browser. + if (!listeners.has(listener)) { + if (typeof options === 'object' && (options?.once || options?.signal)) { + // Not tracked to avoid retaining listeners the browser auto-removes. + handlerForType.sticky = true; + } else { + listeners.add(listener); + handlerForType.listenerCount++; + } + } } catch { // Accessing dom properties is always fragile. // Also allows us to skip `addEventListeners` calls with no proper `this` context. @@ -101,16 +139,16 @@ export function instrumentDOM(): void { 'removeEventListener', function (originalRemoveEventListener: RemoveEventListener): RemoveEventListener { return function (this: InstrumentedElement, type, listener, options): () => void { - if (type === 'click' || type == 'keypress') { + if ((type === 'click' || type == 'keypress') && listener) { try { const handlers = this.__sentry_instrumentation_handlers__ || {}; const handlerForType = handlers[type]; - if (handlerForType) { - handlerForType.refCount--; + // Removing a listener that was never added is a no-op in the browser, so it mustn't count for ours either. + if (handlerForType?.[getCapture(options) ? 'captureListeners' : 'bubbleListeners'].delete(listener)) { // If there are no longer any custom handlers of the current type on this element, we can remove ours, too. - if (handlerForType.refCount <= 0) { - originalRemoveEventListener.call(this, type, handlerForType.handler, options); + if (!--handlerForType.listenerCount && !handlerForType.sticky) { + originalRemoveEventListener.call(this, type, handlerForType.handler, handlerForType.capture); handlerForType.handler = undefined; delete handlers[type]; // eslint-disable-line @typescript-eslint/no-dynamic-delete } diff --git a/packages/browser-utils/test/instrumentation/dom.test.ts b/packages/browser-utils/test/instrumentation/dom.test.ts index 23681014150b..3fc235776158 100644 --- a/packages/browser-utils/test/instrumentation/dom.test.ts +++ b/packages/browser-utils/test/instrumentation/dom.test.ts @@ -1,12 +1,169 @@ -import { describe, expect, it } from 'vitest'; +/** + * @vitest-environment jsdom + */ +import { afterEach, describe, expect, it } from 'vitest'; import { instrumentDOM } from '../../src/instrumentation/dom'; import { WINDOW } from '../../src/types'; // @ts-expect-error - idk WINDOW.XMLHttpRequest = undefined; -describe('instrumentXHR', () => { - it('it does not throw if XMLHttpRequest is a key on window but not defined', () => { +type InstrumentedDocument = Document & { __sentry_instrumentation_handlers__?: Record }; + +describe('instrumentDOM', () => { + const { addEventListener: nativeAdd, removeEventListener: nativeRemove } = EventTarget.prototype; + + // `instrumentDOM` patches `EventTarget.prototype` and isn't idempotent, so restore the native methods after every test. + afterEach(() => { + EventTarget.prototype.addEventListener = nativeAdd; + EventTarget.prototype.removeEventListener = nativeRemove; + delete (document as InstrumentedDocument).__sentry_instrumentation_handlers__; + }); + + /** Runs `instrumentDOM` and returns a function counting the click listeners actually attached to `document`. */ + function instrumentAndTrackDocumentClickListeners(): () => number { + const documentClickListeners = { capture: new Set(), bubble: new Set() }; + + const phase = (options?: boolean | EventListenerOptions): Set => + (typeof options === 'boolean' ? options : !!options?.capture) + ? documentClickListeners.capture + : documentClickListeners.bubble; + + // Installed before `instrumentDOM` so these sit underneath the SDK and also see the listeners it attaches itself. + EventTarget.prototype.addEventListener = function (type, listener, options) { + if (this === document && type === 'click') { + phase(options).add(listener); + } + return nativeAdd.call(this, type, listener, options); + }; + + EventTarget.prototype.removeEventListener = function (type, listener, options) { + if (this === document && type === 'click') { + phase(options).delete(listener); + } + return nativeRemove.call(this, type, listener, options); + }; + + instrumentDOM(); + + return () => documentClickListeners.capture.size + documentClickListeners.bubble.size; + } + + it('does not throw if XMLHttpRequest is a key on window but not defined', () => { expect(instrumentDOM).not.toThrow(); }); + + it('does not leak document click listeners when removeEventListener uses mismatched capture options', () => { + const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners(); + + // baseline listenercount is 1 which comes from the SDK's global click handler registered + // in instrumentDOM(). + const baseline = countDocumentClickListeners(); + + const never = (): void => {}; + const onCapture = (): void => {}; + const onBubble = (): void => {}; + + for (let i = 0; i < 20; i++) { + document.addEventListener('click', onCapture, true); + document.addEventListener('click', onBubble); + document.removeEventListener('click', never); + document.removeEventListener('click', never); + document.removeEventListener('click', onCapture, true); + document.removeEventListener('click', onBubble); + } + + expect(countDocumentClickListeners() - baseline).toBe(0); + }); + + it('keeps its handler attached while listeners remain, even after removing listeners that were never added', () => { + const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners(); + const baseline = countDocumentClickListeners(); + + const never = (): void => {}; + const onClick = (): void => {}; + + document.addEventListener('click', onClick); + document.removeEventListener('click', never); + document.removeEventListener('click', onClick, true); + + // `onClick` plus the SDK's handler for it + expect(countDocumentClickListeners() - baseline).toBe(2); + + document.removeEventListener('click', onClick); + + expect(countDocumentClickListeners() - baseline).toBe(0); + }); + + it('removes its handler when listeners are added and removed with `null` options', () => { + const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners(); + const baseline = countDocumentClickListeners(); + + const onClick = (): void => {}; + + // @ts-expect-error - `null` is valid at runtime and treated like default options + document.addEventListener('click', onClick, null); + // @ts-expect-error - see above + document.removeEventListener('click', onClick, null); + + expect(countDocumentClickListeners() - baseline).toBe(0); + }); + + it('does not retain listeners added with `once` or `signal`, which the browser removes on its own', () => { + instrumentDOM(); + + for (let i = 0; i < 20; i++) { + const controller = new AbortController(); + document.addEventListener('click', () => {}, { signal: controller.signal }); + document.addEventListener('click', () => {}, { once: true, capture: true }); + controller.abort(); + } + document.dispatchEvent(new MouseEvent('click')); + + const clickHandlers = (document as InstrumentedDocument).__sentry_instrumentation_handlers__?.click; + expect(clickHandlers.listenerCount).toBe(0); + expect(clickHandlers.handler).toBeDefined(); + }); + + it('keeps its handler attached after removing tracked listeners if a `once` or `signal` listener was added', () => { + const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners(); + const baseline = countDocumentClickListeners(); + + const onClick = (): void => {}; + const onceClick = (): void => {}; + + document.addEventListener('click', onClick); + document.addEventListener('click', onceClick, { once: true }); + document.removeEventListener('click', onClick); + + // `onceClick` plus the SDK's handler, which must still see the pending `once` listener's event + expect(countDocumentClickListeners() - baseline).toBe(2); + }); + + it('counts a listener added twice in the same phase only once', () => { + const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners(); + const baseline = countDocumentClickListeners(); + + const onClick = (): void => {}; + + document.addEventListener('click', onClick); + document.addEventListener('click', onClick); + document.removeEventListener('click', onClick); + + expect(countDocumentClickListeners() - baseline).toBe(0); + }); + + it('removes its handler if a listener is re-added with `once` while already registered', () => { + const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners(); + const baseline = countDocumentClickListeners(); + + const onClick = (): void => {}; + + document.addEventListener('click', onClick); + // no-op in the browser, since `onClick` is already registered for the bubble phase + document.addEventListener('click', onClick, { once: true }); + document.removeEventListener('click', onClick); + + expect(countDocumentClickListeners() - baseline).toBe(0); + }); }); From 741135e82a916151fa571f3db6a61515f3a023c0 Mon Sep 17 00:00:00 2001 From: Dio Briggs <116220816+diobriggs@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:04:49 -0400 Subject: [PATCH 52/65] fix(node): End Express layer spans when `next` is called (#24854) - [x] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (scoped to the affected packages, see Testing below). - [x] Link an issue if there is one related to your pull request. Closes #24853 Express layer spans were ended on the tracing channel's `asyncEnd`, which only fires once `next()` *returns*. Express runs the rest of the chain synchronously inside `next()`, so every layer on the synchronous chain kept its span open, together with the response `finish` listener that `getSpanForLayer` registers, until the whole chain unwound. As a result: - Requests that pass through roughly 9 or more synchronous layers log `MaxListenersExceededWarning: ... 11 finish listeners added to [ServerResponse]`, one per request. Unsampled requests are affected too. - Each middleware span absorbs the synchronous work of every downstream layer. A no-op middleware reported 50ms when the route handler after it did 50ms of synchronous work. This PR ends the layer span on `asyncStart` instead: when the layer calls `next()`, before the downstream layer runs. That matches what the surrounding comments and the orchestrion config already describe. - Layers that never call `next()` still end on the response `finish`. These are route handlers and routers that handle the request themselves, so router spans still enclose the handler they dispatch. - The helper's later `asyncEnd` is a no-op, because `span.end()` is idempotent. - `next(err)` still marks the span as errored, because the channel publishes `error` before `asyncStart`. ### Testing New `node-integration-tests` suite `express/layer-span-end`, with 12 synchronous middleware and a handler that does 50ms of synchronous work. It asserts that: - every middleware span ends before the request handler span starts - the response has fewer `finish` listeners than `EventEmitter.defaultMaxListeners` Both tests fail without the change (16 listeners, and middleware spans ending about 51ms after the handler starts) and pass with it. Ran locally on Node 24.18: - `suites/express/**`: 15 files, 132 tests pass - the other 44 suite directories that use Express: 51 files, 395 tests pass - `@sentry/server-utils` unit tests: 1152 pass - `oxfmt --check` and `oxlint` on the changed files I also checked Express 5 against the published 11.1.0 build with this change applied: listeners went from 12 to 2, no warning, and span names were unchanged. I couldn't run the Bun and Deno node-suite projects locally. --------- Co-authored-by: Nicolas Hrubec --- .../express/layer-span-end/instrument.mjs | 9 ++++ .../express/layer-span-end/scenario.mjs | 29 +++++++++++ .../suites/express/layer-span-end/test.ts | 48 +++++++++++++++++++ .../integrations/express/instrumentation.ts | 41 ++++++++++++---- 4 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/express/layer-span-end/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/express/layer-span-end/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/express/layer-span-end/test.ts diff --git a/dev-packages/node-integration-tests/suites/express/layer-span-end/instrument.mjs b/dev-packages/node-integration-tests/suites/express/layer-span-end/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/layer-span-end/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/express/layer-span-end/scenario.mjs b/dev-packages/node-integration-tests/suites/express/layer-span-end/scenario.mjs new file mode 100644 index 000000000000..ef383e7eb933 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/layer-span-end/scenario.mjs @@ -0,0 +1,29 @@ +import express from 'express'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const MIDDLEWARE_COUNT = 12; + +const app = express(); + +// More synchronous middleware than Node's default of 10 listeners per event, so +// layer spans that stayed open until `next()` returned would pile up a response +// `finish` listener per layer. +for (let i = 0; i < MIDDLEWARE_COUNT; i++) { + app.use(function syncMiddleware(_req, _res, next) { + next(); + }); +} + +app.get('/test/express', (_req, res) => { + const finishListeners = res.listenerCount('finish'); + + // Without the fix, this work is included in every preceding middleware's span. + const until = Date.now() + 50; + while (Date.now() < until) { + // busy-wait + } + + res.send({ finishListeners }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/layer-span-end/test.ts b/dev-packages/node-integration-tests/suites/express/layer-span-end/test.ts new file mode 100644 index 000000000000..cb5512f134a1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/layer-span-end/test.ts @@ -0,0 +1,48 @@ +import { EventEmitter } from 'node:events'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +const MIDDLEWARE_COUNT = 12; + +describe('express layer span end', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('ends each middleware span when it calls `next`, before the route handler runs', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const middlewareSpans = container.items.filter( + item => item.attributes['express.type']?.value === 'middleware', + ); + const handlerSpan = container.items.find( + item => item.attributes['express.type']?.value === 'request_handler', + ); + + // Express 4 also runs its built-in `query` and `expressInit` middleware. + expect(middlewareSpans.filter(span => span.name === 'syncMiddleware')).toHaveLength(MIDDLEWARE_COUNT); + expect(handlerSpan).toBeDefined(); + + for (const middlewareSpan of middlewareSpans) { + expect(middlewareSpan.end_timestamp).toBeLessThanOrEqual(handlerSpan!.start_timestamp); + } + }, + }) + .start(); + + runner.makeRequest('get', '/test/express'); + await runner.completed(); + }); + + test('does not accumulate a response `finish` listener per layer', async () => { + const runner = createRunner().start(); + + const response = await runner.makeRequest<{ finishListeners: number }>('get', '/test/express'); + + expect(response?.finishListeners).toBeLessThan(EventEmitter.defaultMaxListeners); + }); + }); +}); diff --git a/packages/server-utils/src/integrations/express/instrumentation.ts b/packages/server-utils/src/integrations/express/instrumentation.ts index 5dd568b2efe5..ae442c454602 100644 --- a/packages/server-utils/src/integrations/express/instrumentation.ts +++ b/packages/server-utils/src/integrations/express/instrumentation.ts @@ -94,17 +94,21 @@ export function instrumentExpress( }, }); - // Pop the layer path when the layer hands off via `next`. `asyncStart` fires - // when `next` is called and *before* the downstream layer runs, so the - // per-request path chain reflects only the current chain when each layer - // reconstructs its route. The `error` event captures throws at the layer - // level (see `captureLayerError`), before any user error-handling middleware. + // Pop the layer path and end the layer span when the layer hands off via + // `next`. `asyncStart` fires when `next` is called and *before* the + // downstream layer runs, so the per-request path chain reflects only the + // current chain when each layer reconstructs its route, and each span covers + // only its own layer. The `error` event captures throws at the layer level + // (see `captureLayerError`), before any user error-handling middleware. channel.subscribe({ start: NOOP, asyncEnd: NOOP, end: NOOP, error: data => captureLayerError(data, options.shouldHandleError), - asyncStart: popLayerPathForLayer, + asyncStart: data => { + popLayerPathForLayer(data); + endLayerSpanOnNext(data); + }, }); } } @@ -202,6 +206,25 @@ function popLayerPathForLayer(data: HandleChannelContext): void { } } +/** + * End a layer's span once it hands control onward via `next`. + * + * The tracing-channel helper would otherwise end it on `asyncEnd`, which only + * fires once `next` *returns*. Express runs the rest of the chain synchronously + * inside `next`, so that would keep every layer on the synchronous chain open + * (each holding a response `finish` listener) until the whole chain unwinds, + * and inflate each span with the downstream layers' synchronous work. The + * helper's later `asyncEnd` is then a no-op, since `span.end()` is idempotent. + */ +function endLayerSpanOnNext(data: HandleChannelContext): void { + const span = data._sentrySpan; + if (!span) { + return; + } + data._sentryCleanup?.(); + span.end(); +} + /** * Open a span for one layer invocation. Returns `undefined` to opt the layer * out (error handlers, or a layer with no active parent trace) — the helper @@ -326,9 +349,9 @@ function getSpanForLayer(data: HandleChannelContext, options: ExpressIntegration }); // A layer that sends the response (route handlers, typically) never calls - // `next`, so the channel's `asyncEnd` never fires. End on the response's - // `finish` in that case. When `next` *is* called, the helper ends the span - // (via `asyncEnd`) and `beforeSpanEnd` removes this now-redundant listener. + // `next`, so the channel's `asyncStart` never fires. End on the response's + // `finish` in that case. When `next` *is* called, `endLayerSpanOnNext` ends + // the span and removes this now-redundant listener. if (res && typeof res.once === 'function') { const onFinish = (): void => { span.end(); From 50984aaad23dc2dff09c46c5bf916f32264840fc Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:09:14 +0000 Subject: [PATCH 53/65] chore: Add external contributor to CHANGELOG.md (#24914) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #24854 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 0fbf2d36a92d..01353e7ccd9d 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 @nabi-noor, @LuccaRebelloToledo, @andasan, @breken-ai, and @EmileBrunelle. Thank you for your contributions! +Work in this release was contributed by @nabi-noor, @LuccaRebelloToledo, @andasan, @breken-ai, @EmileBrunelle, and @diobriggs. Thank you for your contributions! ## 11.1.0 From c5fd4ec6d76828a2cd47b23ebcf30f43e086b4c0 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 1 Oct 2026 10:12:37 +0200 Subject: [PATCH 54/65] feat(remix): Instrument Remix 3 server requests via fetch-router (#24801) Patches `createRouter` through orchestrion to prepend a Sentry middleware to every router. The middleware opens no span: Remix 3 serves over `node:http`, so `httpIntegration` has already opened the `http.server` span. It only enriches that span with `http.route`, the response status and a low cardinality name. The SDK supplies the router's matcher, because the router never exposes one and the request context carries no pattern at middleware entry. The route is resolved by re-running the match against that matcher. Remix 3 has no build step, so no bundler plugin can apply the transform. The `--import @sentry/remix/v3/node` entry registers the module hook and subscribes before the app's own modules are imported, and replaces `--import remix/node-tsx` rather than adding a second flag. Error handling is a separate pull request. Fixes #24663 Co-authored-by: Claude Opus 5 --- .../remix-v3/app/actions/controller.tsx | 6 ++ .../test-applications/remix-v3/app/router.ts | 6 ++ .../test-applications/remix-v3/app/routes.ts | 2 + .../remix-v3/tests/server-spans.test.ts | 51 +++++++++++ .../remix-v3/tests/smoke.test.ts | 13 --- packages/remix/package.json | 1 + packages/remix/src/v3/index.server.ts | 8 +- packages/remix/src/v3/node.mjs | 21 ++++- packages/remix/src/v3/remix.d.ts | 10 ++ packages/remix/src/v3/server/instrument.ts | 91 +++++++++++++++++++ packages/remix/src/v3/server/integration.ts | 22 +++++ packages/remix/src/v3/server/middleware.ts | 69 ++++++++++++++ packages/remix/src/v3/server/route.ts | 37 ++++++++ packages/remix/src/v3/server/sdk.ts | 32 +++++++ packages/remix/src/v3/types.ts | 39 ++++++++ packages/remix/test/v3/instrument.test.ts | 70 ++++++++++++++ packages/remix/test/v3/route.test.ts | 81 +++++++++++++++++ packages/remix/vitest.config.unit.ts | 6 ++ .../src/orchestrion/config/index.ts | 4 + .../src/orchestrion/config/remix-v3.ts | 26 ++++++ yarn.lock | 5 + 21 files changed, 582 insertions(+), 18 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/remix-v3/tests/server-spans.test.ts create mode 100644 packages/remix/src/v3/remix.d.ts create mode 100644 packages/remix/src/v3/server/instrument.ts create mode 100644 packages/remix/src/v3/server/integration.ts create mode 100644 packages/remix/src/v3/server/middleware.ts create mode 100644 packages/remix/src/v3/server/route.ts create mode 100644 packages/remix/src/v3/server/sdk.ts create mode 100644 packages/remix/src/v3/types.ts create mode 100644 packages/remix/test/v3/instrument.test.ts create mode 100644 packages/remix/test/v3/route.test.ts create mode 100644 packages/server-utils/src/orchestrion/config/remix-v3.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx index 9325143b071d..756115577e68 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -30,5 +30,11 @@ export default createController(routes, { home(context) { return context.render(); }, + user(context) { + return Response.json({ id: context.params.id }); + }, + teapot() { + return new Response("I'm a teapot", { status: 418 }); + }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts index aef480456066..03724dd7ea27 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts @@ -19,3 +19,9 @@ export const router = createRouter({ }); router.map(routes, controller); + +// Mounted rather than added to the route map, so the tests cover a route whose pattern carries a mount +// prefix. +router.mount('/api', api => { + api.get('/items/:itemId', context => Response.json({ itemId: context.params.itemId })); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts index 1ebc77927332..77b32281374b 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts @@ -3,4 +3,6 @@ import { get, route } from 'remix/routes'; export const routes = route({ assets: get('/assets/*path'), home: '/', + user: get('/users/:id'), + teapot: get('/teapot'), }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/server-spans.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/server-spans.test.ts new file mode 100644 index 000000000000..96e618b7b7f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/server-spans.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +const APP_NAME = 'remix-v3'; + +/** + * Selecting the span by `http.route` rather than by name is what makes the name assertions below mean + * something: a request that never resolved its route would not match, instead of matching and then + * passing a name check against whatever it happened to be called. + */ +function waitForServerSpan(route: string): Promise { + return waitForStreamedSpan( + APP_NAME, + span => + getSpanOp(span) === 'http.server' && span.is_segment === true && span.attributes?.['http.route']?.value === route, + ); +} + +test('names a parameterized route after its pattern', async ({ baseURL }) => { + const spanPromise = waitForServerSpan('/users/:id'); + + await fetch(`${baseURL}/users/12345`); + + const span = await spanPromise; + expect(span.name).toBe('GET /users/:id'); + // An id in the name would make every request its own transaction. + expect(span.name).not.toContain('12345'); + expect(span.attributes?.['sentry.segment.name.source']?.value).toBe('route'); +}); + +test('includes the mount prefix in the name of a mounted route', async ({ baseURL }) => { + const spanPromise = waitForServerSpan('/api/items/:itemId'); + + await fetch(`${baseURL}/api/items/abc`); + + const span = await spanPromise; + expect(span.name).toBe('GET /api/items/:itemId'); + expect(span.name).not.toContain('abc'); +}); + +test('records the response status', async ({ baseURL }) => { + const spanPromise = waitForServerSpan('/teapot'); + + await fetch(`${baseURL}/teapot`); + + const span = await spanPromise; + expect(span.attributes?.['http.response.status_code']?.value).toBe(418); + // OpenTelemetry leaves a 4xx server span unset, so the error status is the SDK's own doing. + expect(span.status).toBe('error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/smoke.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/smoke.test.ts index 85e4619e49c4..3b005133749a 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/tests/smoke.test.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/smoke.test.ts @@ -1,20 +1,7 @@ import { expect, test } from '@playwright/test'; -import { waitForStreamedSpan } from '@sentry-internal/test-utils'; -// There is no instrumentation yet. This app exists so later pull requests add instrumentation and its -// tests together, rather than also introducing a new CI surface. test('the app boots under the Sentry --import entry', async ({ page }) => { await page.goto('/'); await expect(page.locator('#home')).toBeVisible(); }); - -test('Sentry.init from the v3 subpath reports a server span', async ({ baseURL }) => { - // Names are still URL based. This only proves the subpath resolves and the SDK is live. - const spanPromise = waitForStreamedSpan('remix-v3', span => span.is_segment === true); - - await fetch(`${baseURL}/`); - - const span = await spanPromise; - expect(span.attributes?.['http.request.method']?.value).toBe('GET'); -}); diff --git a/packages/remix/package.json b/packages/remix/package.json index 24599aee449e..f1e0531c2f63 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -89,6 +89,7 @@ "devDependencies": { "@remix-run/node": "^2.17.5", "@remix-run/react": "^2.17.5", + "@remix-run/route-pattern": "^0.24.0", "@remix-run/server-runtime": "^2.17.4", "@types/express": "^4.17.14", "react": "^18.3.1", diff --git a/packages/remix/src/v3/index.server.ts b/packages/remix/src/v3/index.server.ts index 107a51657aca..870ab16899cb 100644 --- a/packages/remix/src/v3/index.server.ts +++ b/packages/remix/src/v3/index.server.ts @@ -1,4 +1,6 @@ -// Placeholder until the Remix 3 server instrumentation lands. `@sentry/node`'s `init` already emits -// `http.server` spans, so this is useful on its own; route parameterisation and router error capture -// are what is still missing. export * from '@sentry/node'; + +export { getDefaultIntegrations, init } from './server/sdk'; +export { remixV3Integration } from './server/integration'; +export { sentryRemixMiddleware } from './server/middleware'; +export { instrumentRemixV3 } from './server/instrument'; diff --git a/packages/remix/src/v3/node.mjs b/packages/remix/src/v3/node.mjs index 4923bc078d97..59d77f99fd19 100644 --- a/packages/remix/src/v3/node.mjs +++ b/packages/remix/src/v3/node.mjs @@ -1,3 +1,20 @@ -// Replaces `--import remix/node-tsx` rather than adding a second flag. Sentry's module hook is -// registered here once the server instrumentation lands, so for now nothing is instrumented. +// Replaces `--import remix/node-tsx` rather than adding a second flag. +// +// This has to happen here, not in `Sentry.init()`: the module hook must be in place before +// `@remix-run/fetch-router` is imported, and the subscription before `createRouter()` runs, which is +// while the app's own modules are still being imported. +import { registerDiagnosticsChannelInjection } from '@sentry/server-runtime-injection/register'; + +registerDiagnosticsChannelInjection(); + +// A failure to load the SDK must not stop the app: this runs before anything of the app has, and an +// uncaught error here means Remix never starts. +try { + const { instrumentRemixV3 } = await import('@sentry/remix/v3'); + instrumentRemixV3(); +} catch (error) { + // oxlint-disable-next-line no-console + console.warn('[Sentry] Could not load @sentry/remix/v3. The app starts without Sentry instrumentation.', error); +} + await import('remix/node-tsx'); diff --git a/packages/remix/src/v3/remix.d.ts b/packages/remix/src/v3/remix.d.ts new file mode 100644 index 000000000000..48197a586fb5 --- /dev/null +++ b/packages/remix/src/v3/remix.d.ts @@ -0,0 +1,10 @@ +// `remix` is an optional peer, so it is not installed here. Only the one subpath the SDK imports is +// declared, with the shape the SDK relies on. Importing it from `remix` rather than from +// `@remix-run/route-pattern` gives the SDK the copy the app's router uses, and adds no dependency for +// Remix 2 apps. +declare module 'remix/route-pattern/match' { + export function createMultiMatcher(): { + add(pattern: unknown, data: unknown): void; + matchAll(url: string | URL): unknown[]; + }; +} diff --git a/packages/remix/src/v3/server/instrument.ts b/packages/remix/src/v3/server/instrument.ts new file mode 100644 index 000000000000..8fc254055588 --- /dev/null +++ b/packages/remix/src/v3/server/instrument.ts @@ -0,0 +1,91 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { createMultiMatcher } from 'remix/route-pattern/match'; +import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; + +import type { MatcherLike, RouterOptionsLike } from '../types'; +import { sentryRemixMiddleware } from './middleware'; + +const NOOP = (): void => {}; + +/** The call's arguments, as orchestrion's transform attaches them to a tracing channel context. */ +interface ChannelContext { + arguments: unknown[]; +} + +// Marks an options object already injected into, so the same mutation cannot be applied twice. +const INJECTED = Symbol.for('SentryRemixV3Injected'); + +// `subscribe()` takes a fresh object literal and the channel keys handlers by identity, so nothing else +// stops a second call adding a second set. The documented setup calls this twice, from the `--import` +// entry and from `setupOnce()`. +let subscribed = false; + +/** + * Prepend the Sentry middleware to every router an app builds. + * + * Injection happens on the channel's `start` event, before `createRouter` reads its options. + * Orchestrion's transform collects the arguments into an array and spreads them back into the call, so + * assigning at an index the caller never passed works. That is what covers `createRouter()` with no + * arguments at all. + */ +export function instrumentRemixV3(): void { + if (subscribed || !diagnosticsChannel.tracingChannel) { + return; + } + subscribed = true; + + diagnosticsChannel.tracingChannel(remixV3Channels.REMIX_V3_CREATE_ROUTER).subscribe({ + start(data) { + // Node rethrows anything this handler throws as an uncaught exception, which would kill an app + // that runs fine without Sentry. Frozen options, a non-writable property and a `route-pattern` + // copy that cannot build a matcher all reach here, so the router is left uninstrumented instead. + try { + injectRouterMiddleware(ensureOptions(data.arguments)); + } catch { + // Ignored on purpose. + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +/** + * The options object, created when the caller omitted it. `undefined` when the caller passed something + * that is not an options object, which must not be overwritten. + */ +function ensureOptions(args: unknown[]): Record | undefined { + const existing = args[0]; + + if (existing === undefined || existing === null) { + const created: Record = {}; + args[0] = created; + return created; + } + + return typeof existing === 'object' ? (existing as Record) : undefined; +} + +function injectRouterMiddleware(raw: Record | undefined): void { + if (!raw) { + return; + } + + const marker = raw as { [INJECTED]?: boolean }; + if (marker[INJECTED]) { + return; + } + marker[INJECTED] = true; + + const options = raw as RouterOptionsLike; + + // The router never exposes its matcher, and resolving the route pattern needs one, so supplying it is + // the only way to hold a reference. An app that supplied its own keeps it. + const matcher: MatcherLike = options.matcher ?? (createMultiMatcher() as MatcherLike); + options.matcher = matcher; + + // Prepended rather than appended, so the Sentry middleware wraps the app's own. + options.middleware = [sentryRemixMiddleware(matcher), ...(options.middleware ?? [])]; +} diff --git a/packages/remix/src/v3/server/integration.ts b/packages/remix/src/v3/server/integration.ts new file mode 100644 index 000000000000..84af41b67156 --- /dev/null +++ b/packages/remix/src/v3/server/integration.ts @@ -0,0 +1,22 @@ +import { defineIntegration, type IntegrationFn } from '@sentry/core'; + +import { instrumentRemixV3 } from './instrument'; + +const INTEGRATION_NAME = 'RemixV3' as const; + +const _remixV3Integration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // Usually a no-op: `createRouter()` runs while the app's modules are imported, before any + // `init()`, so `--import @sentry/remix/v3/node` has already subscribed. This covers setups that + // register the module hook from `init()` instead. + instrumentRemixV3(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Names the `http.server` spans `@sentry/node` opens after the matched Remix 3 route. + */ +export const remixV3Integration = defineIntegration(_remixV3Integration); diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts new file mode 100644 index 000000000000..0409d48f992e --- /dev/null +++ b/packages/remix/src/v3/server/middleware.ts @@ -0,0 +1,69 @@ +import { HTTP_RESPONSE_STATUS_CODE, HTTP_ROUTE } from '@sentry/conventions/attributes'; +import { + getActiveSpan, + getIsolationScope, + getRootSpan, + getSpanStatusFromHttpCode, + INTERNAL_setSegmentNameSourceIfSegment, + type Scope, + updateSpanName, + winterCGRequestToRequestData, +} from '@sentry/core'; + +import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; +import { resolveRoutePattern } from './route'; + +/** + * The middleware orchestrion prepends to every router. + * + * It opens no span. Remix 3 serves over `node:http`, so `httpIntegration` has already opened the + * `http.server` span and forked an isolation scope by the time this runs. Opening another would double + * count every request, so this only enriches what exists, as `@sentry/hono` does. + */ +export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { + return async function sentryMiddleware(context: RequestContextLike, next: NextFunctionLike): Promise { + const isolationScope = getIsolationScope(); + isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(context.request) }); + + // Applied before `next()` so anything captured while the handler runs already carries the route. + applyRoute(isolationScope, matcher, context); + + const response = await next(); + setResponseStatus(response); + + return response; + }; +} + +function applyRoute(isolationScope: Scope, matcher: MatcherLike, context: RequestContextLike): void { + const route = resolveRoutePattern(matcher, context); + if (!route) { + // Nothing matched, so the router falls through to its 404 handler. Leaving the name alone keeps the + // raw URL out of it, which span streaming requires. + return; + } + + const name = `${context.method} ${route}`; + isolationScope.setTransactionName(name); + + const activeSpan = getActiveSpan(); + if (!activeSpan) { + return; + } + + const rootSpan = getRootSpan(activeSpan); + updateSpanName(rootSpan, name); + INTERNAL_setSegmentNameSourceIfSegment(rootSpan, 'route'); + rootSpan.setAttribute(HTTP_ROUTE, route); +} + +function setResponseStatus(response: Response): void { + const activeSpan = getActiveSpan(); + if (!activeSpan || typeof response?.status !== 'number') { + return; + } + + const rootSpan = getRootSpan(activeSpan); + rootSpan.setAttribute(HTTP_RESPONSE_STATUS_CODE, response.status); + rootSpan.setStatus(getSpanStatusFromHttpCode(response.status)); +} diff --git a/packages/remix/src/v3/server/route.ts b/packages/remix/src/v3/server/route.ts new file mode 100644 index 000000000000..89ee52166b87 --- /dev/null +++ b/packages/remix/src/v3/server/route.ts @@ -0,0 +1,37 @@ +import type { MatcherLike, RequestContextLike } from '../types'; + +/** + * Resolve the low cardinality route pattern for a request. + * + * The request context never carries the matched pattern: the router matches after its middleware is + * entered, and only writes `params` back. So this re-runs the match against the router's own matcher, + * applying the router's own selection rules. + */ +export function resolveRoutePattern(matcher: MatcherLike, context: RequestContextLike): string | undefined { + let matches; + try { + matches = matcher.matchAll(context.url); + } catch { + // A matcher from a mismatched `@remix-run/route-pattern` copy can throw on a shape it does not + // recognise. Losing the route name is not worth failing the request over. + return undefined; + } + + let headFallback: string | undefined; + + for (const match of matches) { + const method = match.data?.method; + + if (method === context.method || method === 'ANY') { + return match.data?.pattern?.source; + } + + // A GET route also serves HEAD. The router compares specificity to pick between several; for a + // span name either pattern is equally correct, so the first one wins here. + if (context.method === 'HEAD' && method === 'GET') { + headFallback ??= match.data?.pattern?.source; + } + } + + return headFallback; +} diff --git a/packages/remix/src/v3/server/sdk.ts b/packages/remix/src/v3/server/sdk.ts new file mode 100644 index 000000000000..e375a11361ac --- /dev/null +++ b/packages/remix/src/v3/server/sdk.ts @@ -0,0 +1,32 @@ +import { applySdkMetadata, type Integration } from '@sentry/core'; +import { + getDefaultIntegrations as getNodeDefaultIntegrations, + init as nodeInit, + type NodeClient, + type NodeOptions, +} from '@sentry/node'; + +import { remixV3Integration } from './integration'; + +/** Default integrations for the Remix 3 server SDK. */ +export function getDefaultIntegrations(options: NodeOptions): Integration[] { + return [...getNodeDefaultIntegrations(options), remixV3Integration()]; +} + +/** + * Initialize the Sentry Remix 3 SDK on the server. + * + * Start the app with `--import @sentry/remix/v3/node` so the module hook is registered before + * `@remix-run/fetch-router` is imported. Remix 3 has no build step, so no bundler plugin can apply the + * transform, and imports are hoisted above any `init()` call in the server entry. + */ +export function init(options: NodeOptions): NodeClient | undefined { + const opts = { + ...options, + defaultIntegrations: options.defaultIntegrations ?? getDefaultIntegrations(options), + }; + + applySdkMetadata(opts, 'remix', ['remix', 'node']); + + return nodeInit(opts); +} diff --git a/packages/remix/src/v3/types.ts b/packages/remix/src/v3/types.ts new file mode 100644 index 000000000000..87dff35d797c --- /dev/null +++ b/packages/remix/src/v3/types.ts @@ -0,0 +1,39 @@ +// Structural copies of the `@remix-run/fetch-router` types the SDK touches, so it builds whether or +// not Remix 3 is installed. + +export interface RoutePatternLike { + source: string; +} + +/** What the router stores in its matcher, reachable as `match.data`. */ +export interface RouteEntryLike { + pattern: RoutePatternLike; + method: string; +} + +export interface MatchLike { + data: RouteEntryLike; + params: Record; +} + +/** Only these two, because they are all the router calls on whatever matcher it is given. */ +export interface MatcherLike { + add(pattern: unknown, data: unknown): void; + matchAll(url: string | URL): MatchLike[]; +} + +export interface RequestContextLike { + request: Request; + url: URL; + method: string; + params: Record; +} + +export type NextFunctionLike = () => Promise; + +export type MiddlewareLike = (context: RequestContextLike, next: NextFunctionLike) => Promise | Response; + +export interface RouterOptionsLike { + middleware?: MiddlewareLike[]; + matcher?: MatcherLike; +} diff --git a/packages/remix/test/v3/instrument.test.ts b/packages/remix/test/v3/instrument.test.ts new file mode 100644 index 000000000000..7eddc88c84d3 --- /dev/null +++ b/packages/remix/test/v3/instrument.test.ts @@ -0,0 +1,70 @@ +import { channel } from 'node:diagnostics_channel'; +import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { instrumentRemixV3 } from '../../src/v3/server/instrument'; + +const startChannel = channel(`tracing:${remixV3Channels.REMIX_V3_CREATE_ROUTER}:start`); + +/** What orchestrion's transform publishes: the call's arguments, collected into a real array. */ +function publishCreateRouter(args: unknown[]): void { + startChannel.publish({ arguments: args }); +} + +describe('instrumentRemixV3', () => { + beforeAll(() => { + // Called twice on purpose: the `--import` entry and `setupOnce()` both call it, and a second set + // of channel handlers would inject the middleware twice. + instrumentRemixV3(); + instrumentRemixV3(); + }); + + it('prepends the middleware and supplies a matcher', () => { + const appMiddleware = (): Response => new Response(); + const options: Record = { middleware: [appMiddleware] }; + + publishCreateRouter([options]); + + expect(options.middleware).toEqual([expect.any(Function), appMiddleware]); + expect(options.matcher).toEqual( + expect.objectContaining({ add: expect.any(Function), matchAll: expect.any(Function) }), + ); + }); + + it('creates the options object when createRouter is called with no arguments', () => { + const args: unknown[] = []; + + publishCreateRouter(args); + + expect(args[0]).toEqual(expect.objectContaining({ middleware: [expect.any(Function)] })); + }); + + it('keeps a matcher the app supplied', () => { + const appMatcher = { add: () => {}, matchAll: () => [] }; + const options: Record = { matcher: appMatcher }; + + publishCreateRouter([options]); + + expect(options.matcher).toBe(appMatcher); + }); + + it('leaves an options object it cannot write to alone, without crashing the app', async () => { + // Node rethrows an exception from a channel subscriber as an uncaught exception, so an unguarded + // write here would take down an app that runs fine without Sentry. + const uncaught: Error[] = []; + const onUncaught = (error: Error): void => void uncaught.push(error); + process.on('uncaughtException', onUncaught); + + const options = Object.freeze({ middleware: [] }); + + try { + publishCreateRouter([options]); + await new Promise(resolve => setImmediate(resolve)); + } finally { + process.off('uncaughtException', onUncaught); + } + + expect(uncaught).toEqual([]); + expect(options.middleware).toEqual([]); + }); +}); diff --git a/packages/remix/test/v3/route.test.ts b/packages/remix/test/v3/route.test.ts new file mode 100644 index 000000000000..fa4e6dbb8044 --- /dev/null +++ b/packages/remix/test/v3/route.test.ts @@ -0,0 +1,81 @@ +import { createMultiMatcher } from '@remix-run/route-pattern/match'; +import { RoutePattern } from '@remix-run/route-pattern'; +import { describe, expect, it } from 'vitest'; + +import { resolveRoutePattern } from '../../src/v3/server/route'; +import type { MatcherLike, RequestContextLike } from '../../src/v3/types'; + +/** + * Built with the real matcher rather than a stub, because what is being tested is that the SDK reads + * the same shape the router stores and applies the same selection rules. + */ +function matcherWith(routes: Array<[method: string, pattern: string]>): MatcherLike { + const matcher = createMultiMatcher(); + + for (const [method, source] of routes) { + const pattern = RoutePattern.parse(source); + matcher.add(pattern, { pattern, method }); + } + + return matcher as unknown as MatcherLike; +} + +function contextFor(method: string, url: string): RequestContextLike { + return { request: new Request(url, { method }), url: new URL(url), method, params: {} }; +} + +describe('resolveRoutePattern', () => { + it('returns the pattern rather than the concrete path', () => { + const matcher = matcherWith([['GET', '/users/:id']]); + + expect(resolveRoutePattern(matcher, contextFor('GET', 'http://x/users/12345'))).toBe('/users/:id'); + }); + + it('returns the prefixed pattern for a mounted route', () => { + // `router.mount()` applies its prefix when the route is registered, so a mounted route is already + // stored under its full pattern. + const matcher = matcherWith([['GET', '/api/items/:itemId']]); + + expect(resolveRoutePattern(matcher, contextFor('GET', 'http://x/api/items/abc'))).toBe('/api/items/:itemId'); + }); + + it('skips a route whose method does not match', () => { + // The more specific POST route is matched first, so a resolver that ignored the method would + // return it instead of the GET route the router would actually dispatch to. + const matcher = matcherWith([ + ['POST', '/things'], + ['GET', '/*rest'], + ]); + + expect(resolveRoutePattern(matcher, contextFor('GET', 'http://x/things'))).toBe('/*rest'); + }); + + it('treats an ANY route as a match for every method', () => { + const matcher = matcherWith([['ANY', '/anything']]); + + expect(resolveRoutePattern(matcher, contextFor('DELETE', 'http://x/anything'))).toBe('/anything'); + }); + + it('falls back to a GET route for a HEAD request, as the router does', () => { + const matcher = matcherWith([['GET', '/page']]); + + expect(resolveRoutePattern(matcher, contextFor('HEAD', 'http://x/page'))).toBe('/page'); + }); + + it('returns undefined when nothing matches, so the name is left alone', () => { + const matcher = matcherWith([['GET', '/users/:id']]); + + expect(resolveRoutePattern(matcher, contextFor('GET', 'http://x/nope'))).toBeUndefined(); + }); + + it('returns undefined when the matcher throws', () => { + const broken: MatcherLike = { + add: () => {}, + matchAll: () => { + throw new Error('mismatched route-pattern copy'); + }, + }; + + expect(resolveRoutePattern(broken, contextFor('GET', 'http://x/users/1'))).toBeUndefined(); + }); +}); diff --git a/packages/remix/vitest.config.unit.ts b/packages/remix/vitest.config.unit.ts index ae119071e469..a6d894d160a0 100644 --- a/packages/remix/vitest.config.unit.ts +++ b/packages/remix/vitest.config.unit.ts @@ -4,6 +4,12 @@ import baseConfig from '../../vite/vite.config'; export default defineConfig({ ...baseConfig, + resolve: { + ...baseConfig.resolve, + // `remix` is an optional peer and not installed here. The SDK imports the matcher through it so an + // app gets the copy its router uses; tests get it from the package that provides it. + alias: { ...baseConfig.resolve?.alias, 'remix/route-pattern/match': '@remix-run/route-pattern/match' }, + }, test: { ...baseConfig.test, include: ['test/**/*.test.ts'], diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 5df88493737a..9ed93c98c0bf 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -36,6 +36,7 @@ import { postgresJsConfig } from './postgres'; import { prismaConfig } from './prisma'; import { redisConfig } from './redis'; import { remixConfig } from './remix'; +import { remixV3Config } from './remix-v3'; import { tediousConfig } from './tedious'; import { togetherAiConfig } from './together-ai'; import { typesafeConfig } from './typesafe'; @@ -91,6 +92,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...prismaConfig, ...redisConfig, ...remixConfig, + ...remixV3Config, ...tediousConfig, ...togetherAiConfig, ...typesafeConfig, @@ -193,3 +195,5 @@ export function withoutInstrumentedExternals( export { nestjsChannels } from './nestjs'; // This is exported so that the remix package can use it to subscribe to the channels. export { remixChannels } from './remix'; +// This is exported so the remix package can subscribe to the Remix 3 channels. +export { remixV3Channels } from './remix-v3'; diff --git a/packages/server-utils/src/orchestrion/config/remix-v3.ts b/packages/server-utils/src/orchestrion/config/remix-v3.ts new file mode 100644 index 000000000000..ee30e3ffdcf7 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/remix-v3.ts @@ -0,0 +1,26 @@ +import type { InstrumentationConfig } from '../apmTypes'; + +// Remix 3 is a ground up rewrite sharing no modules with Remix 2, so it gets its own config rather +// than a widened `versionRange` on `./remix.ts`. The two never collide: this matches the +// `@remix-run/*` 0.x packages, that one `@remix-run/server-runtime`. +// +// `remix/router` is a one line `export * from '@remix-run/fetch-router'`, so matching the real module +// covers both import styles. +export const remixV3Config: InstrumentationConfig[] = [ + // The only construction point routing needs: `router.mount()` does not create a sub-router, it + // builds a prefixed route builder over the same matcher and dispatch. + { + channelName: 'createRouter', + module: { + name: '@remix-run/fetch-router', + // Still 0.x during the Remix 3 release candidate, so the range is deliberately narrow. + versionRange: '>=0.21.0 <1', + filePath: 'dist/lib/router.js', + }, + functionQuery: { functionName: 'createRouter', kind: 'Sync' }, + }, +]; + +export const remixV3Channels = { + REMIX_V3_CREATE_ROUTER: 'orchestrion:@remix-run/fetch-router:createRouter', +} as const; diff --git a/yarn.lock b/yarn.lock index 2da26a469d9e..86bafcc8fde9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7578,6 +7578,11 @@ react-router-dom "6.30.4" turbo-stream "2.4.1" +"@remix-run/route-pattern@^0.24.0": + version "0.24.1" + resolved "https://sfw.security.sentry.io/npm/@remix-run/route-pattern/-/route-pattern-0.24.1.tgz#cee709b2f946940777bb4775a8334598f3c4b909" + integrity sha512-HRee7tz2Ct7QdTQGDErHcVTAlXa5BBV6GOLEa/INw21ceTWTWlaU13FJupYVX0k1mh608rod880+SA5NlTPPfw== + "@remix-run/router@1.23.3": version "1.23.3" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.23.3.tgz#957c098d4393d301a8aa7dccf3ef28ea5430e36a" From 7780ab318ea7ef0ebf4857ca1516608af5401116 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 1 Oct 2026 10:40:53 +0200 Subject: [PATCH 55/65] fix(server-utils): Preserve response bodies for OpenAI parse helpers (#24783) OpenAI's `chat.completions.parse()` / `response.parse()` helpers could fail because instrumentation triggered an extra response body read. Use synchronous instrumentation and observe OpenAI's internal parser to preserve lazy parsing while still recording spans. Fixes #24773. --------- Co-authored-by: GPT-6 --- .../suites/tracing/openai/scenario-chat.mjs | 11 + .../suites/tracing/openai/test.ts | 369 +++++++++++++++++- .../server-utils/src/ai/openai/response.ts | 82 ++++ .../server-utils/src/integrations/openai.ts | 21 +- .../src/orchestrion/config/openai.ts | 8 +- 5 files changed, 466 insertions(+), 25 deletions(-) create mode 100644 packages/server-utils/src/ai/openai/response.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs index 6031b6861f5b..6b8bc76b582b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs @@ -270,6 +270,17 @@ async function run() { } catch { // Error is expected and handled } + + // scenario: parse responses + await client.chat.completions.parse({ + model: 'gpt-4o', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }); + + await client.responses.parse({ + model: 'gpt-4o', + input: 'What is the capital of France?', + }); }); server.close(); 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 0a2be19dbc3f..3962f7c3a9fd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -1,4 +1,5 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { GEN_AI_CONVERSATION_ID, @@ -36,9 +37,11 @@ describe('OpenAI integration', () => { .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { - expect(container.items).toHaveLength(6); + expect(container.items).toHaveLength(8); const chatCompletionSpan = container.items.find( - span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123', + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo', ); expect(chatCompletionSpan).toBeDefined(); expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo'); @@ -93,7 +96,9 @@ describe('OpenAI integration', () => { }); const responsesSpan = container.items.find( - span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456', + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo', ); expect(responsesSpan).toBeDefined(); expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo'); @@ -323,6 +328,109 @@ describe('OpenAI integration', () => { value: 'auto.ai.openai', }); + const parsedChatCompletionSpan = container.items.find( + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o', + ); + expect(parsedChatCompletionSpan).toBeDefined(); + expect(parsedChatCompletionSpan!.name).toBe('chat gpt-4o'); + expect(parsedChatCompletionSpan!.status).toBe('ok'); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ + type: 'string', + value: 'chat', + }); + expect(parsedChatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.chat', + }); + expect(parsedChatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: 'auto.ai.openai', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ + type: 'string', + value: 'openai', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ + type: 'string', + value: 'gpt-4o', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({ + type: 'string', + value: 'gpt-4o', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ + type: 'string', + value: 'chatcmpl-mock123', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({ + type: 'string', + value: '["stop"]', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ + type: 'integer', + value: 10, + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ + type: 'integer', + value: 15, + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ + type: 'integer', + value: 25, + }); + + const parsedResponsesSpan = container.items.find( + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o', + ); + expect(parsedResponsesSpan).toBeDefined(); + expect(parsedResponsesSpan!.name).toBe('chat gpt-4o'); + expect(parsedResponsesSpan!.status).toBe('ok'); + expect(parsedResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ + type: 'string', + value: 'chat', + }); + expect(parsedResponsesSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.chat', + }); + expect(parsedResponsesSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: 'auto.ai.openai', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' }); + expect(parsedResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ + type: 'string', + value: 'gpt-4o', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({ + type: 'string', + value: 'gpt-4o', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ + type: 'string', + value: 'resp_mock456', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({ + type: 'string', + value: '["completed"]', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ + type: 'integer', + value: 5, + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ + type: 'integer', + value: 8, + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ + type: 'integer', + value: 13, + }); + // GenAI inputs and outputs must never be recorded when `dataCollection.genAI` disables them. // Asserting over every span in the envelope catches attributes leaking onto // spans which are not individually inspected above. @@ -345,9 +453,11 @@ describe('OpenAI integration', () => { .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { - expect(container.items).toHaveLength(6); + expect(container.items).toHaveLength(8); const chatCompletionSpan = container.items.find( - span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123', + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo', ); expect(chatCompletionSpan).toBeDefined(); expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo'); @@ -414,7 +524,9 @@ describe('OpenAI integration', () => { }); const responsesSpan = container.items.find( - span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456', + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo', ); expect(responsesSpan).toBeDefined(); expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo'); @@ -679,6 +791,126 @@ describe('OpenAI integration', () => { type: 'string', value: 'auto.ai.openai', }); + + const parsedChatCompletionSpan = container.items.find( + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o', + ); + expect(parsedChatCompletionSpan).toBeDefined(); + expect(parsedChatCompletionSpan!.name).toBe('chat gpt-4o'); + expect(parsedChatCompletionSpan!.status).toBe('ok'); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ + type: 'string', + value: 'chat', + }); + expect(parsedChatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.chat', + }); + expect(parsedChatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: 'auto.ai.openai', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ + type: 'string', + value: 'openai', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ + type: 'string', + value: 'gpt-4o', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({ + type: 'string', + value: '[{"role":"user","content":"What is the capital of France?"}]', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined(); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({ + type: 'string', + value: 'gpt-4o', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ + type: 'string', + value: 'chatcmpl-mock123', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({ + type: 'string', + value: '["stop"]', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({ + type: 'string', + value: '["Hello from OpenAI mock!"]', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ + type: 'integer', + value: 10, + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ + type: 'integer', + value: 15, + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ + type: 'integer', + value: 25, + }); + + const parsedResponsesSpan = container.items.find( + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o', + ); + expect(parsedResponsesSpan).toBeDefined(); + expect(parsedResponsesSpan!.name).toBe('chat gpt-4o'); + expect(parsedResponsesSpan!.status).toBe('ok'); + expect(parsedResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ + type: 'string', + value: 'chat', + }); + expect(parsedResponsesSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.chat', + }); + expect(parsedResponsesSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: 'auto.ai.openai', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' }); + expect(parsedResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ + type: 'string', + value: 'gpt-4o', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({ + type: 'string', + value: 'What is the capital of France?', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({ + type: 'string', + value: 'Response to: What is the capital of France?', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({ + type: 'string', + value: '["completed"]', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({ + type: 'string', + value: 'gpt-4o', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ + type: 'string', + value: 'resp_mock456', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ + type: 'integer', + value: 5, + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ + type: 'integer', + value: 8, + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ + type: 'integer', + value: 13, + }); }, }) .start() @@ -692,9 +924,11 @@ describe('OpenAI integration', () => { .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { - expect(container.items).toHaveLength(6); + expect(container.items).toHaveLength(8); const chatCompletionSpan = container.items.find( - span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123', + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo', ); expect(chatCompletionSpan).toBeDefined(); expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined(); @@ -723,6 +957,38 @@ describe('OpenAI integration', () => { type: 'string', value: expect.any(String), }); + + const parsedChatCompletionSpan = container.items.find( + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o', + ); + expect(parsedChatCompletionSpan).toBeDefined(); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined(); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({ + type: 'string', + value: expect.any(String), + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({ + type: 'string', + value: expect.any(String), + }); + + const parsedResponsesSpan = container.items.find( + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o', + ); + expect(parsedResponsesSpan).toBeDefined(); + expect(parsedResponsesSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined(); + expect(parsedResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({ + type: 'string', + value: expect.any(String), + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({ + type: 'string', + value: expect.any(String), + }); }, }) .start() @@ -1356,17 +1622,24 @@ describe('OpenAI integration', () => { span: container => { expect(container.items).toHaveLength(2); - // Both calls should produce spans with the same response ID for (const span of container.items) { expect(span!.name).toBe('chat gpt-4'); expect(span!.status).toBe('ok'); expect(span!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' }); expect(span!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ type: 'string', value: 'gpt-4' }); - expect(span!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ - type: 'string', - value: 'chatcmpl-withresponse', - }); } + + const parsedSpans = container.items.filter(span => span.attributes[GEN_AI_RESPONSE_ID] !== undefined); + expect(parsedSpans).toHaveLength(1); + expect(parsedSpans[0]!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ + type: 'string', + value: 'chatcmpl-withresponse', + }); + + const rawSpans = container.items.filter(span => span.attributes[GEN_AI_RESPONSE_ID] === undefined); + expect(rawSpans).toHaveLength(1); + expect(rawSpans[0]!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + expect(rawSpans[0]!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined(); }, }) .start() @@ -1376,11 +1649,17 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates openai related spans with span streaming enabled', async () => { + const spans: SerializedStreamedSpanContainer['items'] = []; + await createRunner() + .unordered() .expect({ span: container => { - const chatCompletionSpan = container.items.find( - span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123', + spans.push(...container.items); + const chatCompletionSpan = spans.find( + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo', ); expect(chatCompletionSpan).toBeDefined(); expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo'); @@ -1403,6 +1682,66 @@ describe('OpenAI integration', () => { value: '[{"role":"user","content":"What is the capital of France?"}]', }); expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' }); + + const parsedChatCompletionSpan = spans.find( + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o', + ); + expect(parsedChatCompletionSpan).toBeDefined(); + expect(parsedChatCompletionSpan!.name).toBe('chat gpt-4o'); + expect(parsedChatCompletionSpan!.status).toBe('ok'); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ + type: 'string', + value: 'chat', + }); + expect(parsedChatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.chat', + }); + expect(parsedChatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: 'auto.ai.openai', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ + type: 'string', + value: 'gpt-4o', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({ + type: 'string', + value: '[{"role":"user","content":"What is the capital of France?"}]', + }); + expect(parsedChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ + type: 'string', + value: 'openai', + }); + + const parsedResponsesSpan = spans.find( + span => + span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' && + span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o', + ); + expect(parsedResponsesSpan).toBeDefined(); + expect(parsedResponsesSpan!.name).toBe('chat gpt-4o'); + expect(parsedResponsesSpan!.status).toBe('ok'); + expect(parsedResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' }); + expect(parsedResponsesSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.chat', + }); + expect(parsedResponsesSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: 'auto.ai.openai', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ + type: 'string', + value: 'gpt-4o', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({ + type: 'string', + value: 'What is the capital of France?', + }); + expect(parsedResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' }); }, }) .start() diff --git a/packages/server-utils/src/ai/openai/response.ts b/packages/server-utils/src/ai/openai/response.ts new file mode 100644 index 000000000000..07805ef11861 --- /dev/null +++ b/packages/server-utils/src/ai/openai/response.ts @@ -0,0 +1,82 @@ +import { isObjectLike } from '@sentry/core'; + +interface OpenAiPromise { + parseResponse: (...args: unknown[]) => unknown; + asResponse: () => Promise; + _thenUnwrap?: (...args: unknown[]) => OpenAiPromise; +} + +function isOpenAiPromise(value: unknown): value is OpenAiPromise { + return isObjectLike(value) && typeof value.parseResponse === 'function' && typeof value.asResponse === 'function'; +} + +// OpenAI returns an APIPromise that extends the native promise objects and redefines .then() in a way that internally triggers body parsing. +// This can lead to double parsing if our instrumentation triggers .then on this promise. +// Instead, we need to avoid triggering .then on the APIPromise and instead observe the internal parsing process to get the response body. +// APIPromise implementation: https://github.com/openai/openai-node/blob/main/src/core/api-promise.ts +export function onOpenAiResponse( + result: unknown, + onResponse: (response: unknown) => void, + onError: (error: unknown) => void, +): boolean { + // e.g. embeddings.create() uses Auto in its orchestrion config so we get the resolved response, not its APIPromise + // therefore it's fine to end the span immediately + if (!isOpenAiPromise(result)) { + return false; + } + + let parsing = false; + + // observe internal parsing method on the APIPromise to read the response body + result.parseResponse = new Proxy(result.parseResponse, { + async apply(original, thisArg, args): Promise { + parsing = true; + try { + const response = await Reflect.apply(original, thisArg, args); + onResponse(response); + return response; + } catch (error) { + onError(error); + throw error; + } + }, + }); + + // end the span in case of request failures before the internal parsing process is triggered + void result.asResponse().then(undefined, onError); + + // asResponse() calls .then() on the native response promise, not on APIPromise so parseResponse never runs + // therefore we need to handle this path separately + function wrapRawResponse(apiPromise: OpenAiPromise): void { + apiPromise.asResponse = new Proxy(apiPromise.asResponse, { + apply(original, thisArg, args): Promise { + return (Reflect.apply(original, thisArg, args) as Promise).then(response => { + if (!parsing) { + onResponse(undefined); + } + return response; + }); + }, + }); + + // thenUnwrap internally creates a new APIPromise + // asResponse() on the derived promise would still be uninstrumented, so we need to recursively wrap the derived promises + // thenUnwrap is for instance internally used by chat.completions.parse() / responses.parse() + // so this would cover for instance responses.parse().asResponse() calls + const thenUnwrap = apiPromise._thenUnwrap; + if (thenUnwrap) { + // parse(...).asResponse() + apiPromise._thenUnwrap = new Proxy(thenUnwrap, { + apply(original, thisArg, args): OpenAiPromise { + const derivedPromise = Reflect.apply(original, thisArg, args); + wrapRawResponse(derivedPromise); + return derivedPromise; + }, + }); + } + } + + wrapRawResponse(result); + + return true; +} diff --git a/packages/server-utils/src/integrations/openai.ts b/packages/server-utils/src/integrations/openai.ts index 138cad048f93..5d4cb2e7b042 100644 --- a/packages/server-utils/src/integrations/openai.ts +++ b/packages/server-utils/src/integrations/openai.ts @@ -10,6 +10,7 @@ import { } from '@sentry/core'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; import { addRequestAttributes, extractRequestAttributes } from '../ai/openai'; +import { onOpenAiResponse } from '../ai/openai/response'; import { instrumentStream } from '../ai/openai/streaming'; import type { OpenAiOptions } from '../ai/openai/types'; import { addResponseAttributes } from '../ai/openai/utils'; @@ -32,8 +33,8 @@ const INSTRUMENTED_CHANNELS = [ /** * The context object orchestrion shares across the tracing-channel lifecycle hooks: `arguments` is the - * live args array passed to `Completions.create(body, options)`, and Node's `tracingChannel` attaches - * `result` when the returned promise settles. + * live args array passed to `Completions.create(body, options)`. `result` holds the returned + * `APIPromise` for sync instrumentation, or the resolved response for promise instrumentation. */ interface OpenAiChatChannelContext { arguments: unknown[]; @@ -58,8 +59,18 @@ function instrumentOpenai(options: OpenAiOptions): void { beforeSpanEnd: (span, data) => { addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); }, - // Streaming: the result is a `Stream` consumed later, so instrument it and let it end the span. - deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), + deferSpanEnd: ({ span, data, end }) => + onOpenAiResponse( + data.result, + response => { + data.result = response; + // stream responses should end only after iteration is completed + if (!wrapStreamResult(span, data, options)) { + end(); + } + }, + end, + ) || wrapStreamResult(span, data, options), }, ); } @@ -109,7 +120,7 @@ function isAsyncIterable(value: unknown): value is AsyncIterableStream { /** * For a streaming `create({ stream: true })` the result is a `Stream` the caller consumes later. We can't * swap what `create` returns, but the `Stream` in `data.result` is the same instance the caller holds and - * `asyncEnd` fires before the caller iterates — so we patch its async iterator in place to run through + * we observe it before the caller iterates — so we patch its async iterator in place to run through * `instrumentStream`, which accumulates the streamed attributes and ends the span when iteration finishes. * Only a streaming call resolves to an async-iterable, so that check alone distinguishes it. Returns `true` * to hand span-ending ownership to `instrumentStream`; `false` for non-streaming/errored results, which end diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index c43313cbb5af..facd8b3154e6 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -3,19 +3,17 @@ import type { InstrumentationConfig } from '../apmTypes'; import { getModuleNames } from './module-names'; export const openaiConfig = [ - // OpenAI chat completions. `Completions.create` returns a thenable `APIPromise` with no callback arg, - // so `kind: 'Auto'` resolves to `wrapPromise`. openai ships dual CJS/ESM and the matcher compares - // `filePath` exactly, hence one entry per built file (`.js` for `require`, `.mjs` for `import`). + // Sync tracing preserves APIPromise's lazy body parsing; Auto would trigger it via .then(). ...['resources/chat/completions/completions.js', 'resources/chat/completions/completions.mjs'].map(filePath => ({ channelName: 'chat', module: { name: 'openai', versionRange: '>=4.0.0 <8', filePath }, - functionQuery: { className: 'Completions', methodName: 'create', kind: 'Auto' as const }, + functionQuery: { className: 'Completions', methodName: 'create', kind: 'Sync' as const }, })), // OpenAI responses API — same `create(body, options)` shape as chat completions. ...['resources/responses/responses.js', 'resources/responses/responses.mjs'].map(filePath => ({ channelName: 'chat', module: { name: 'openai', versionRange: '>=4.0.0 <8', filePath }, - functionQuery: { className: 'Responses', methodName: 'create', kind: 'Auto' as const }, + functionQuery: { className: 'Responses', methodName: 'create', kind: 'Sync' as const }, })), // OpenAI embeddings API — same `create(body, options)` shape as chat completions. ...['resources/embeddings.js', 'resources/embeddings.mjs'].map(filePath => ({ From d2d1dcf63e4b3460e1428b6e2b8f65d0eb842102 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 1 Oct 2026 10:40:53 +0200 Subject: [PATCH 56/65] fix(server-utils): Preserve raw Anthropic response bodies (#24902) Anthropic automatic instrumentation could consume response bodies before callers read them through `.asResponse()`. Share OpenAI's lazy response observer to preserve raw bodies while still completing request and streaming spans. Stacked on #24783. Addresses the Anthropic portion of [JS-3856](https://linear.app/getsentry/issue/JS-3856). --------- Co-authored-by: GPT-6 --- .../anthropic/scenario-with-response.mjs | 40 +++++++++++++++++++ .../suites/tracing/anthropic/test.ts | 19 ++++++++- .../response.ts => core/apiPromise.ts} | 16 ++++---- .../src/integrations/anthropic.ts | 13 +++++- .../server-utils/src/integrations/openai.ts | 4 +- .../src/orchestrion/config/anthropic-ai.ts | 6 +-- 6 files changed, 82 insertions(+), 16 deletions(-) rename packages/server-utils/src/ai/{openai/response.ts => core/apiPromise.ts} (85%) diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs index c7e0a4f5a9ce..704453d9b158 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs @@ -6,6 +6,16 @@ function startMockAnthropicServer() { const app = express(); app.use(express.json()); + app.post('/anthropic/v1/complete', (req, res) => { + res.send({ + id: 'compl_withresponse', + type: 'completion', + model: req.body.model, + completion: 'Testing .asResponse() method!', + stop_reason: 'stop_sequence', + }); + }); + app.post('/anthropic/v1/messages', (req, res) => { const model = req.body.model; @@ -135,6 +145,8 @@ async function run() { // Call .asResponse() and verify it returns raw Response const rawResponse = await result2.asResponse(); + await rawResponse.json(); + // Verify response is a Response object with correct headers if (!(rawResponse instanceof Response)) { throw new Error('.asResponse() did not return a Response object'); @@ -188,6 +200,34 @@ async function run() { for await (const _ of streamWithResponse.data) { void _; } + + const betaResponse = await client.beta.messages + .create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + messages: [{ role: 'user', content: 'Test beta asResponse' }], + }) + .asResponse(); + await betaResponse.json(); + + const completionResponse = await client.completions + .create({ + model: 'claude-2', + max_tokens_to_sample: 100, + prompt: '\n\nHuman: Test asResponse\n\nAssistant:', + }) + .asResponse(); + await completionResponse.json(); + + const rawStreamResponse = await client.messages + .create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + messages: [{ role: 'user', content: 'Test streaming asResponse' }], + stream: true, + }) + .asResponse(); + await rawStreamResponse.text(); }); // Wait for the stream event handler to finish diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index ee2bb16b37dc..937ae389ca30 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -40,11 +40,11 @@ describe('Anthropic integration', () => { }) .expect({ span: container => { - expect(container.items).toHaveLength(3); + expect(container.items).toHaveLength(6); const nonStreamingSpans = container.items.filter( span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_withresponse', ); - expect(nonStreamingSpans).toHaveLength(2); + expect(nonStreamingSpans).toHaveLength(1); for (const span of nonStreamingSpans) { expect(span.name).toBe('chat claude-3-haiku-20240307'); expect(span.status).toBe('ok'); @@ -59,6 +59,21 @@ describe('Anthropic integration', () => { expect(streamingSpan!.name).toBe('chat claude-3-haiku-20240307'); expect(streamingSpan!.status).toBe('ok'); expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true); + + const rawResponseSpans = container.items.filter(span => span.attributes[GEN_AI_RESPONSE_ID] === undefined); + expect(rawResponseSpans).toHaveLength(4); + for (const span of rawResponseSpans) { + expect(span.name).toBe(`chat ${span.attributes[GEN_AI_REQUEST_MODEL].value}`); + expect(span.status).toBe('ok'); + expect(span.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat'); + expect(span.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined(); + } + expect(rawResponseSpans.map(span => span.attributes[GEN_AI_REQUEST_MODEL].value)).toEqual([ + 'claude-3-haiku-20240307', + 'claude-3-haiku-20240307', + 'claude-2', + 'claude-3-haiku-20240307', + ]); }, }) .start() diff --git a/packages/server-utils/src/ai/openai/response.ts b/packages/server-utils/src/ai/core/apiPromise.ts similarity index 85% rename from packages/server-utils/src/ai/openai/response.ts rename to packages/server-utils/src/ai/core/apiPromise.ts index 07805ef11861..b45363f30ba0 100644 --- a/packages/server-utils/src/ai/openai/response.ts +++ b/packages/server-utils/src/ai/core/apiPromise.ts @@ -1,27 +1,27 @@ import { isObjectLike } from '@sentry/core'; -interface OpenAiPromise { +interface ApiPromise { parseResponse: (...args: unknown[]) => unknown; asResponse: () => Promise; - _thenUnwrap?: (...args: unknown[]) => OpenAiPromise; + _thenUnwrap?: (...args: unknown[]) => ApiPromise; } -function isOpenAiPromise(value: unknown): value is OpenAiPromise { +function isApiPromise(value: unknown): value is ApiPromise { return isObjectLike(value) && typeof value.parseResponse === 'function' && typeof value.asResponse === 'function'; } -// OpenAI returns an APIPromise that extends the native promise objects and redefines .then() in a way that internally triggers body parsing. +// OpenAI and Anthropic return an APIPromise that extends the native promise objects and redefines .then() in a way that internally triggers body parsing. // This can lead to double parsing if our instrumentation triggers .then on this promise. // Instead, we need to avoid triggering .then on the APIPromise and instead observe the internal parsing process to get the response body. // APIPromise implementation: https://github.com/openai/openai-node/blob/main/src/core/api-promise.ts -export function onOpenAiResponse( +export function onApiPromiseResponse( result: unknown, onResponse: (response: unknown) => void, onError: (error: unknown) => void, ): boolean { // e.g. embeddings.create() uses Auto in its orchestrion config so we get the resolved response, not its APIPromise // therefore it's fine to end the span immediately - if (!isOpenAiPromise(result)) { + if (!isApiPromise(result)) { return false; } @@ -47,7 +47,7 @@ export function onOpenAiResponse( // asResponse() calls .then() on the native response promise, not on APIPromise so parseResponse never runs // therefore we need to handle this path separately - function wrapRawResponse(apiPromise: OpenAiPromise): void { + function wrapRawResponse(apiPromise: ApiPromise): void { apiPromise.asResponse = new Proxy(apiPromise.asResponse, { apply(original, thisArg, args): Promise { return (Reflect.apply(original, thisArg, args) as Promise).then(response => { @@ -67,7 +67,7 @@ export function onOpenAiResponse( if (thenUnwrap) { // parse(...).asResponse() apiPromise._thenUnwrap = new Proxy(thenUnwrap, { - apply(original, thisArg, args): OpenAiPromise { + apply(original, thisArg, args): ApiPromise { const derivedPromise = Reflect.apply(original, thisArg, args); wrapRawResponse(derivedPromise); return derivedPromise; diff --git a/packages/server-utils/src/integrations/anthropic.ts b/packages/server-utils/src/integrations/anthropic.ts index c8638acc0a28..be7421fb3bcd 100644 --- a/packages/server-utils/src/integrations/anthropic.ts +++ b/packages/server-utils/src/integrations/anthropic.ts @@ -10,6 +10,7 @@ import { startInactiveSpan, } from '@sentry/core'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; +import { onApiPromiseResponse } from '../ai/core/apiPromise'; import { addPrivateRequestAttributes, addResponseAttributes, extractRequestAttributes } from '../ai/anthropic-ai'; import { instrumentAsyncIterableStream, instrumentMessageStream } from '../ai/anthropic-ai/streaming'; import type { AnthropicAiOptions, AnthropicAiResponse } from '../ai/anthropic-ai/types'; @@ -63,7 +64,17 @@ function instrumentAnthropic(options: AnthropicAiOptions): void { resolveAIRecordingOptions(options).recordOutputs, ); }, - deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, stream, options), + deferSpanEnd: ({ span, data, end }) => + onApiPromiseResponse( + data.result, + response => { + data.result = response; + if (!wrapStreamResult(span, data, stream, options)) { + end(); + } + }, + end, + ) || wrapStreamResult(span, data, stream, options), }, ); } diff --git a/packages/server-utils/src/integrations/openai.ts b/packages/server-utils/src/integrations/openai.ts index 5d4cb2e7b042..c972cd7e7c10 100644 --- a/packages/server-utils/src/integrations/openai.ts +++ b/packages/server-utils/src/integrations/openai.ts @@ -10,7 +10,7 @@ import { } from '@sentry/core'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; import { addRequestAttributes, extractRequestAttributes } from '../ai/openai'; -import { onOpenAiResponse } from '../ai/openai/response'; +import { onApiPromiseResponse } from '../ai/core/apiPromise'; import { instrumentStream } from '../ai/openai/streaming'; import type { OpenAiOptions } from '../ai/openai/types'; import { addResponseAttributes } from '../ai/openai/utils'; @@ -60,7 +60,7 @@ function instrumentOpenai(options: OpenAiOptions): void { addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); }, deferSpanEnd: ({ span, data, end }) => - onOpenAiResponse( + onApiPromiseResponse( data.result, response => { data.result = response; diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts index 40cbdc515072..0f530e1db778 100644 --- a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts +++ b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts @@ -7,17 +7,17 @@ export const anthropicAiConfig = [ ...['resources/messages/messages.js', 'resources/messages/messages.mjs'].map(filePath => ({ channelName: 'chat', module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, - functionQuery: { className: 'Messages', methodName: 'create', kind: 'Auto' as const }, + functionQuery: { className: 'Messages', methodName: 'create', kind: 'Sync' as const }, })), ...['resources/completions.js', 'resources/completions.mjs'].map(filePath => ({ channelName: 'chat', module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, - functionQuery: { className: 'Completions', methodName: 'create', kind: 'Auto' as const }, + functionQuery: { className: 'Completions', methodName: 'create', kind: 'Sync' as const }, })), ...['resources/beta/messages/messages.js', 'resources/beta/messages/messages.mjs'].map(filePath => ({ channelName: 'chat', module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, - functionQuery: { className: 'Messages', methodName: 'create', kind: 'Auto' as const }, + functionQuery: { className: 'Messages', methodName: 'create', kind: 'Sync' as const }, })), // `messages.stream()` returns a synchronous emitter, not a promise, so `kind: 'Sync'` is required: // `Auto`'s promise wrapper never publishes `end` for a non-thenable return, so the span would never end. From 43e293e096f85183291aef06ebad863cca7ddc0b Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 1 Oct 2026 10:40:53 +0200 Subject: [PATCH 57/65] fix(server-utils): Preserve raw Groq and Together response bodies (#24906) Groq and Together automatic instrumentation could consume `.asResponse()` bodies before application code read them. Reuse the shared API-promise observer to preserve raw chat, streaming, and embeddings responses while completing their spans. Stacked on #24902. Part of #24882. Co-authored-by: GPT-6 --- .../suites/tracing/groq/scenario.mjs | 25 ++++++++++++ .../suites/tracing/groq/test.ts | 39 +++++++++++++++++++ .../suites/tracing/together-ai/scenario.mjs | 25 ++++++++++++ .../suites/tracing/together-ai/test.ts | 39 +++++++++++++++++++ .../src/integrations/openai-compatible.ts | 19 ++++++--- .../orchestrion/config/openai-compatible.ts | 8 ++-- 6 files changed, 146 insertions(+), 9 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/groq/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/groq/scenario.mjs index 03f656eb1ee4..e7f5561190ce 100644 --- a/dev-packages/node-integration-tests/suites/tracing/groq/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/groq/scenario.mjs @@ -130,6 +130,31 @@ async function run() { model: 'nomic-embed-text-v1_5', input: 'Embedding test!', }); + + const rawChatResponse = await client.chat.completions + .create({ + model: 'llama-3.3-70b-versatile', + messages: [{ role: 'user', content: 'Raw response test!' }], + }) + .asResponse(); + await rawChatResponse.json(); + + const rawStreamResponse = await client.chat.completions + .create({ + model: 'llama-3.1-8b-instant', + messages: [{ role: 'user', content: 'Raw stream test!' }], + stream: true, + }) + .asResponse(); + await rawStreamResponse.text(); + + const rawEmbeddingsResponse = await client.embeddings + .create({ + model: 'nomic-embed-text-v1_5', + input: 'Raw embedding test!', + }) + .asResponse(); + await rawEmbeddingsResponse.json(); }); await Sentry.flush(2000); diff --git a/dev-packages/node-integration-tests/suites/tracing/groq/test.ts b/dev-packages/node-integration-tests/suites/tracing/groq/test.ts index c494401817b0..5797f38d2d1b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/groq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/groq/test.ts @@ -74,6 +74,25 @@ describe('Groq integration', () => { expect(embeddingsSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); expect(embeddingsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(8); expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined(); + + const rawSpans = container.items.filter( + s => + s.attributes[GEN_AI_PROVIDER_NAME]?.value === PROVIDER && + s.status === 'ok' && + s.attributes[GEN_AI_RESPONSE_ID] === undefined, + ); + expect(rawSpans).toHaveLength(3); + expect(rawSpans.map(s => s.name)).toEqual([ + 'chat llama-3.3-70b-versatile', + 'chat llama-3.1-8b-instant', + 'embeddings nomic-embed-text-v1_5', + ]); + for (const span of rawSpans) { + expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(span.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined(); + expect(span.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined(); + expect(span.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + } }, }) .start() @@ -100,6 +119,26 @@ describe('Groq integration', () => { ); expect(embeddingsSpan).toBeDefined(); expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value).toContain('Embedding test!'); + + const rawChatSpan = container.items.find( + s => + s.attributes[GEN_AI_REQUEST_MODEL]?.value === 'llama-3.3-70b-versatile' && + s.attributes[GEN_AI_RESPONSE_ID] === undefined, + ); + expect(rawChatSpan).toBeDefined(); + expect(rawChatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe( + '[{"role":"user","content":"Raw response test!"}]', + ); + expect(rawChatSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + + const rawEmbeddingsSpan = container.items.find( + s => + s.attributes[GEN_AI_REQUEST_MODEL]?.value === 'nomic-embed-text-v1_5' && + s.attributes[GEN_AI_RESPONSE_ID] === undefined, + ); + expect(rawEmbeddingsSpan).toBeDefined(); + expect(rawEmbeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value).toContain('Raw embedding test!'); + expect(rawEmbeddingsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toBeUndefined(); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/together-ai/scenario.mjs index 6ef405ed2abf..0fc375370abd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/together-ai/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/scenario.mjs @@ -129,6 +129,31 @@ async function run() { model: 'togethercomputer/m2-bert-80M-8k-retrieval', input: 'Embedding test!', }); + + const rawChatResponse = await client.chat.completions + .create({ + model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', + messages: [{ role: 'user', content: 'Raw response test!' }], + }) + .asResponse(); + await rawChatResponse.json(); + + const rawStreamResponse = await client.chat.completions + .create({ + model: 'meta-llama/Llama-3.1-8B-Instruct-Turbo', + messages: [{ role: 'user', content: 'Raw stream test!' }], + stream: true, + }) + .asResponse(); + await rawStreamResponse.text(); + + const rawEmbeddingsResponse = await client.embeddings + .create({ + model: 'togethercomputer/m2-bert-80M-8k-retrieval', + input: 'Raw embedding test!', + }) + .asResponse(); + await rawEmbeddingsResponse.json(); }); await Sentry.flush(2000); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/test.ts b/dev-packages/node-integration-tests/suites/tracing/together-ai/test.ts index c33de6ebfb53..2e7c3b94dc7b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/together-ai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/test.ts @@ -74,6 +74,25 @@ describe('Together integration', () => { expect(embeddingsSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); expect(embeddingsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(8); expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined(); + + const rawSpans = container.items.filter( + s => + s.attributes[GEN_AI_PROVIDER_NAME]?.value === PROVIDER && + s.status === 'ok' && + s.attributes[GEN_AI_RESPONSE_ID] === undefined, + ); + expect(rawSpans).toHaveLength(3); + expect(rawSpans.map(s => s.name)).toEqual([ + 'chat meta-llama/Llama-3.3-70B-Instruct-Turbo', + 'chat meta-llama/Llama-3.1-8B-Instruct-Turbo', + 'embeddings togethercomputer/m2-bert-80M-8k-retrieval', + ]); + for (const span of rawSpans) { + expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(span.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined(); + expect(span.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined(); + expect(span.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + } }, }) .start() @@ -100,6 +119,26 @@ describe('Together integration', () => { ); expect(embeddingsSpan).toBeDefined(); expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value).toContain('Embedding test!'); + + const rawChatSpan = container.items.find( + s => + s.attributes[GEN_AI_REQUEST_MODEL]?.value === 'meta-llama/Llama-3.3-70B-Instruct-Turbo' && + s.attributes[GEN_AI_RESPONSE_ID] === undefined, + ); + expect(rawChatSpan).toBeDefined(); + expect(rawChatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe( + '[{"role":"user","content":"Raw response test!"}]', + ); + expect(rawChatSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + + const rawEmbeddingsSpan = container.items.find( + s => + s.attributes[GEN_AI_REQUEST_MODEL]?.value === 'togethercomputer/m2-bert-80M-8k-retrieval' && + s.attributes[GEN_AI_RESPONSE_ID] === undefined, + ); + expect(rawEmbeddingsSpan).toBeDefined(); + expect(rawEmbeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value).toContain('Raw embedding test!'); + expect(rawEmbeddingsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toBeUndefined(); }, }) .start() diff --git a/packages/server-utils/src/integrations/openai-compatible.ts b/packages/server-utils/src/integrations/openai-compatible.ts index b62764bb9473..fd0b0ccbc918 100644 --- a/packages/server-utils/src/integrations/openai-compatible.ts +++ b/packages/server-utils/src/integrations/openai-compatible.ts @@ -9,6 +9,7 @@ import { } from '@sentry/core'; import { GEN_AI_PROVIDER_NAME } from '@sentry/conventions/attributes'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; +import { onApiPromiseResponse } from '../ai/core/apiPromise'; import { addRequestAttributes, extractRequestAttributes } from '../ai/openai'; import { instrumentStream } from '../ai/openai/streaming'; import type { OpenAiOptions } from '../ai/openai/types'; @@ -35,8 +36,7 @@ export interface OpenAiCompatibleProvider { /** * The context orchestrion shares across the tracing-channel lifecycle hooks: `arguments` is the live args - * array passed to `Completions.create(body, options)`, and Node's `tracingChannel` attaches `result` when - * the returned promise settles. + * array passed to `Completions.create(body, options)`, and `result` holds its returned `APIPromise`. */ interface OpenAiCompatibleChannelContext { arguments: unknown[]; @@ -67,8 +67,17 @@ export function createOpenAiCompatibleIntegration { addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); }, - // Streaming: the result is a `Stream` consumed later, so instrument it and let it end the span. - deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), + deferSpanEnd: ({ span, data, end }) => + onApiPromiseResponse( + data.result, + response => { + data.result = response; + if (!wrapStreamResult(span, data, options)) { + end(); + } + }, + end, + ) || wrapStreamResult(span, data, options), }, ); } @@ -135,7 +144,7 @@ function isAsyncIterable(value: unknown): value is AsyncIterableStream { /** * For a streaming `create({ stream: true })` the result is a `Stream` the caller consumes later. We can't * swap what `create` returns, but the `Stream` in `data.result` is the same instance the caller holds and - * `asyncEnd` fires before the caller iterates — so we patch its async iterator in place to run through + * we observe it before the caller iterates — so we patch its async iterator in place to run through * `instrumentStream`, which accumulates the streamed attributes and ends the span when iteration finishes. * Only a streaming call resolves to an async-iterable, so that check alone distinguishes it. Returns `true` * to hand span-ending ownership to `instrumentStream`; `false` for non-streaming/errored results, which end diff --git a/packages/server-utils/src/orchestrion/config/openai-compatible.ts b/packages/server-utils/src/orchestrion/config/openai-compatible.ts index 1c4e8fad0370..7decaedb3feb 100644 --- a/packages/server-utils/src/orchestrion/config/openai-compatible.ts +++ b/packages/server-utils/src/orchestrion/config/openai-compatible.ts @@ -4,8 +4,8 @@ import type { InstrumentationConfig } from '../apmTypes'; * Many providers (Groq, Together, ...) ship a Stainless-generated SDK that mirrors the classic `openai` * file layout: `resources/chat/completions.{js,mjs}` (class `Completions`) and * `resources/embeddings.{js,mjs}` (class `Embeddings`), each with a `create(body, options)` that returns - * a thenable `APIPromise` — so `kind: 'Auto'` resolves to `wrapPromise`, and a streaming call resolves to - * the same OpenAI-style async-iterable `Stream` the openai integration already knows how to consume. These + * a thenable `APIPromise`. Sync tracing preserves its lazy body parsing; Auto would trigger it via `.then()`. + * A streaming call resolves to the same OpenAI-style async-iterable `Stream` the openai integration consumes. These * SDKs ship dual CJS/ESM and the matcher compares `filePath` exactly, hence one entry per built file. * * Because the wire format is OpenAI-compatible, the span building, streaming and response parsing are all @@ -16,12 +16,12 @@ export function openAiCompatibleConfig(module: { name: string; versionRange: str ...['resources/chat/completions.js', 'resources/chat/completions.mjs'].map(filePath => ({ channelName: 'chat', module: { ...module, filePath }, - functionQuery: { className: 'Completions', methodName: 'create', kind: 'Auto' as const }, + functionQuery: { className: 'Completions', methodName: 'create', kind: 'Sync' as const }, })), ...['resources/embeddings.js', 'resources/embeddings.mjs'].map(filePath => ({ channelName: 'embeddings', module: { ...module, filePath }, - functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Auto' as const }, + functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Sync' as const }, })), ]; } From 012e9bbe1bc9eb6ac9fdc0e1661963b14832f134 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 1 Oct 2026 10:40:54 +0200 Subject: [PATCH 58/65] fix(server-utils): Preserve raw OpenAI embeddings and conversation responses (#24908) OpenAI embeddings and conversations still used eager promise instrumentation, which consumed `.asResponse()` bodies before callers could read them. Switch these remaining methods to synchronous instrumentation so the shared API-promise observer preserves lazy parsing and completes their spans. Stacked on #24906. Fixes #24882 together with the preceding PRs. Co-authored-by: GPT-6 --- .../tracing/openai/scenario-conversation.mjs | 3 + .../tracing/openai/scenario-embeddings.mjs | 8 ++ .../suites/tracing/openai/test.ts | 90 ++++++++++++++++++- .../server-utils/src/ai/core/apiPromise.ts | 3 +- .../server-utils/src/integrations/openai.ts | 2 +- .../src/orchestrion/config/openai.ts | 4 +- 6 files changed, 102 insertions(+), 8 deletions(-) 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 index 7088a6ca9cbe..1075bd80bf2b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs @@ -87,6 +87,9 @@ async function run() { input: 'Explain why that is funny', previous_response_id: firstResponse.id, }); + + const rawResponse = await client.conversations.create().asResponse(); + await rawResponse.json(); }); server.close(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs index 42c6a94c5199..688f407873b1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs @@ -73,6 +73,14 @@ async function run() { input: ['First input text', 'Second input text', 'Third input text'], model: 'text-embedding-3-small', }); + + const rawResponse = await client.embeddings + .create({ + input: 'Raw embedding test!', + model: 'text-embedding-3-large', + }) + .asResponse(); + await rawResponse.json(); }); server.close(); 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 3962f7c3a9fd..a27e3308179a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -1006,7 +1006,7 @@ describe('OpenAI integration', () => { }) .expect({ span: container => { - expect(container.items).toHaveLength(3); + expect(container.items).toHaveLength(4); const singleEmbeddingSpan = container.items.find( span => span.name === 'embeddings text-embedding-3-small' && @@ -1131,6 +1131,34 @@ describe('OpenAI integration', () => { expect(span.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); expect(span.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined(); } + + const rawEmbeddingSpan = container.items.find(span => span.name === 'embeddings text-embedding-3-large'); + expect(rawEmbeddingSpan).toBeDefined(); + expect(rawEmbeddingSpan!.name).toBe('embeddings text-embedding-3-large'); + expect(rawEmbeddingSpan!.status).toBe('ok'); + expect(rawEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ + type: 'string', + value: 'embeddings', + }); + expect(rawEmbeddingSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.embeddings', + }); + expect(rawEmbeddingSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: 'auto.ai.openai', + }); + expect(rawEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ + type: 'string', + value: 'openai', + }); + expect(rawEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ + type: 'string', + value: 'text-embedding-3-large', + }); + expect(rawEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined(); + expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toBeUndefined(); + expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined(); }, }) .start() @@ -1148,7 +1176,7 @@ describe('OpenAI integration', () => { }) .expect({ span: container => { - expect(container.items).toHaveLength(3); + expect(container.items).toHaveLength(4); const singleEmbeddingSpan = container.items.find( span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Embedding test!', ); @@ -1275,6 +1303,38 @@ describe('OpenAI integration', () => { type: 'integer', value: 10, }); + + const rawEmbeddingSpan = container.items.find(span => span.name === 'embeddings text-embedding-3-large'); + expect(rawEmbeddingSpan).toBeDefined(); + expect(rawEmbeddingSpan!.name).toBe('embeddings text-embedding-3-large'); + expect(rawEmbeddingSpan!.status).toBe('ok'); + expect(rawEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ + type: 'string', + value: 'embeddings', + }); + expect(rawEmbeddingSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.embeddings', + }); + expect(rawEmbeddingSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: 'auto.ai.openai', + }); + expect(rawEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ + type: 'string', + value: 'openai', + }); + expect(rawEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ + type: 'string', + value: 'text-embedding-3-large', + }); + expect(rawEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined(); + expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toBeUndefined(); + expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined(); + expect(rawEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({ + type: 'string', + value: 'Raw embedding test!', + }); }, }) .start() @@ -1377,7 +1437,7 @@ describe('OpenAI integration', () => { }) .expect({ span: container => { - expect(container.items).toHaveLength(4); + expect(container.items).toHaveLength(5); const conversationCreateSpan = container.items.find(span => span.name === 'chat unknown'); expect(conversationCreateSpan).toBeDefined(); expect(conversationCreateSpan!.name).toBe('chat unknown'); @@ -1478,6 +1538,30 @@ describe('OpenAI integration', () => { type: 'string', value: 'resp_mock_conv_123', }); + + const rawConversationSpan = container.items.find( + span => span.name === 'chat unknown' && span.attributes[GEN_AI_CONVERSATION_ID] === undefined, + ); + expect(rawConversationSpan).toBeDefined(); + expect(rawConversationSpan!.name).toBe('chat unknown'); + expect(rawConversationSpan!.status).toBe('ok'); + expect(rawConversationSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ + type: 'string', + value: 'chat', + }); + expect(rawConversationSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.chat', + }); + expect(rawConversationSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: 'auto.ai.openai', + }); + expect(rawConversationSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ + type: 'string', + value: 'openai', + }); + expect(rawConversationSpan!.attributes[GEN_AI_CONVERSATION_ID]).toBeUndefined(); }, }) .start() diff --git a/packages/server-utils/src/ai/core/apiPromise.ts b/packages/server-utils/src/ai/core/apiPromise.ts index b45363f30ba0..245a8f4bd9ec 100644 --- a/packages/server-utils/src/ai/core/apiPromise.ts +++ b/packages/server-utils/src/ai/core/apiPromise.ts @@ -19,8 +19,7 @@ export function onApiPromiseResponse( onResponse: (response: unknown) => void, onError: (error: unknown) => void, ): boolean { - // e.g. embeddings.create() uses Auto in its orchestrion config so we get the resolved response, not its APIPromise - // therefore it's fine to end the span immediately + // e.g. Anthropic's messages.stream() returns an emitter instead of an APIPromise if (!isApiPromise(result)) { return false; } diff --git a/packages/server-utils/src/integrations/openai.ts b/packages/server-utils/src/integrations/openai.ts index c972cd7e7c10..cf16af786953 100644 --- a/packages/server-utils/src/integrations/openai.ts +++ b/packages/server-utils/src/integrations/openai.ts @@ -34,7 +34,7 @@ const INSTRUMENTED_CHANNELS = [ /** * The context object orchestrion shares across the tracing-channel lifecycle hooks: `arguments` is the * live args array passed to `Completions.create(body, options)`. `result` holds the returned - * `APIPromise` for sync instrumentation, or the resolved response for promise instrumentation. + * `APIPromise` for sync instrumentation. */ interface OpenAiChatChannelContext { arguments: unknown[]; diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index facd8b3154e6..e68d3a961262 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -19,13 +19,13 @@ export const openaiConfig = [ ...['resources/embeddings.js', 'resources/embeddings.mjs'].map(filePath => ({ channelName: 'embeddings', module: { name: 'openai', versionRange: '>=4.0.0 <8', filePath }, - functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Auto' as const }, + functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Sync' as const }, })), // OpenAI conversations API — same `create(body, options)` shape as chat completions. ...['resources/conversations/conversations.js', 'resources/conversations/conversations.mjs'].map(filePath => ({ channelName: 'chat', module: { name: 'openai', versionRange: '>=4.0.0 <8', filePath }, - functionQuery: { className: 'Conversations', methodName: 'create', kind: 'Auto' as const }, + functionQuery: { className: 'Conversations', methodName: 'create', kind: 'Sync' as const }, })), ] satisfies InstrumentationConfig[]; From 29fc37cf86093a2bde4a9b7f0e223de76a85752b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:43:18 +0200 Subject: [PATCH 59/65] feat(deps): bump axios from 1.18.0 to 1.20.0 (#24918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [axios](https://github.com/axios/axios) from 1.18.0 to 1.20.0.
Release notes

Sourced from axios's releases.

v1.20.0 — August 19, 2026

This release hardens runtime option handling, adds RFC 9110 status-code aliases, fixes Node.js and XHR reliability issues, and refreshes project tooling and documentation.

⚠️ Breaking Changes & Deprecations

  • HTTP Status Naming: Added ContentTooLarge (413) and UnprocessableContent (422), while retaining PayloadTooLarge and UnprocessableEntity as backward-compatible deprecated aliases. (#11082)

🔒 Security Fixes

  • Runtime Option Handling: Hardened behavioral configuration reads against shared and foreign prototype pollution and normalized unsafe interceptor replacement objects. This also clarifies Fetch redirect and custom implementation behavior, HTTP/2 DNS and proxy handling, CIDR-based NO_PROXY matching, and malformed data URI rejection; see the PR for documented compatibility effects. (#11141)

🐛 Bug Fixes

  • Interceptor Lifecycle: Prevented unbounded handler-array growth by trimming trailing ejected interceptors without changing iteration semantics, and kept interceptor operations safe when the public handlers field is nullish. (#11087, #11118)
  • Request Error Preservation: Prevented custom Error.prepareStackTrace implementations that return non-string values from replacing the original request failure with an unrelated TypeError. (#11109)
  • XHR Reliability: Navigation-canceled requests now reject with ECONNABORTED instead of resolving with status 0, while successful downloads flush their final progress callback during the live loadend dispatch. (#11094, #11121)
  • Node.js Socket Memory: Removed request-context retention from per-socket error listeners, preventing completed response data from being pinned for the lifetime of pooled keep-alive sockets. (#11091)
  • Core Methods and HTTP Errors: Prevented structural method-header buckets from leaking into outgoing headers, standardized invalid DNS lookup and httpVersion failures as AxiosError.ERR_BAD_OPTION_VALUE, and corrected the timeoutErrorMessage merge strategy. (#11096)

🔧 Maintenance & Chores

  • Dependencies: Updated fast-uri, postcss, js-yaml, mocha, development-tooling groups, and GitHub Actions dependencies. (#11092, #11098, #11099, #11106, #11107, #11122, #11123, #11126, #11127, #11133, #11140, #11143, #11144)
  • Documentation: Applied the v1.19.0 documentation updates, added the missing fs import to the README stream example, introduced localized global search, and repaired the interceptor test link. (#11101, #11113, #11097, #11119)
  • Sponsorship: Updated sponsorship links and data and added ScrapingBee as a sponsor. (#11124, #11136, #11137)
  • CI and Release: Switched ESM smoke tests to locked dependencies and synchronized package and runtime version metadata for v1.20.0. (#11128, #11152)

🌟 New Contributors

We are thrilled to welcome our new contributors. Thank you for helping improve axios:

Full Changelog (https://github.com/axios/axios/compare/v1.19.0...v1.20.0)

v1.19.0 - July 22, 2026

This release raises the form-data security floor, adds configuration and type-system capabilities, and fixes NO_PROXY matching, interceptor errors, progress reporting, and serialization edge cases.

🔒 Security Fixes

... (truncated)

Changelog

Sourced from axios's changelog.

Changelog

v1.19.0 — July 22, 2026

This release raises the form-data security floor, adds configuration and type-system capabilities, and fixes NO_PROXY matching, interceptor errors, progress reporting, and serialization edge cases.

🔒 Security Fixes

🚀 New Features

  • Configuration Extensibility: Preserved own-enumerable symbol-keyed fields through mergeConfig and added a generic params type across public TypeScript declarations, responses, errors, adapters, and serializers. (#11043, #11081)
  • Header Parameter Parsing: Added the opt-in AxiosHeaders.parseParameters() parser for quote-aware, RFC-style HTTP parameter parsing while preserving legacy parsing behavior. (#11051)
  • HTTP Status Codes: Added the missing Cloudflare 520 WebServerReturnsAnUnknownError status and matching ESM/CJS declarations. (#11067)

🐛 Bug Fixes

  • Form Data Conversion: Limited formDataToJSON path splitting to dot and bracket notation, preserving literal punctuation in keys, and removed browser-facing Buffer.from usage from toFormData to avoid unnecessary polyfills. (#11006, #11018)

  • Proxy Bypass: Canonicalized IPv4 shorthand, octal, and hexadecimal forms during NO_PROXY matching and honored * entries within comma- or space-separated bypass lists. (#11029, #11053)

  • Cancellation: Propagated already-aborted input signals immediately when composing abort signals. (#11035)

  • Header Handling: Preserved empty first values for duplicate singleton headers and made AxiosHeaders#getSetCookie() consistently return arrays for present values. (#11036, #11037)

  • URL Handling: Included normalized, safely redacted offending URLs in malformed-protocol errors and removed repeated trailing slashes when combining base URLs. (#11008, #11038)

  • Progress Events: Clamped malformed negative progress values to zero and ensured final Node.js download progress events are delivered before streamed responses close. (#11039, #11040)

  • Error and JSON Serialization: Serialized Set values as arrays in JSON-compatible snapshots and synthesized useful AxiosError messages from otherwise-empty AggregateError instances. (#11044, #11059)

  • Content-Length Enforcement: Corrected base64 data: URL size estimation so maxContentLength is enforced consistently by the HTTP and Fetch adapters. (#11061)

  • Synchronous Interceptors: Prevented requests from being dispatched after synchronous request interceptors fail unless their paired rejection handler resolves successfully. (#11071)

🔧 Maintenance & Chores

  • Dependencies: Updated development and test tooling, the docs fixture's Axios version, and GitHub Actions integrations including Checkout, Setup Node, Setup Deno, and Zizmor. (#11031, #11055, #11056, #11058, #11079, #11080, #11088, #11089, #11090)
  • Build Outputs: Limited sourcemap generation to published minified bundles, removing broken map references from non-minified builds. (#11054)
  • Form Data Internals: Centralized FormData header handling and made the Node.js adapter tolerate getHeaders() returning undefined under the content-only policy. (#11062)
  • Developer Experience: Ignored common local AI-tooling directories and fixed a constant-reassignment crash when the development sandbox serves its root path. (#11032, #11073)
  • Documentation: Updated sponsor information, clarified that baseURL is not a path-security boundary, scoped provenance claims to attested releases, and corrected the configuration-defaults documentation. (#11041, #11068, #11076, #11078)
  • Publishing: Simplified v1 publishing to use the npm version bundled with Node.js 26 and updated package metadata for the 1.19.0 release. (#11083, #11095)

🌟 New Contributors

We are thrilled to welcome our new contributors. Thank you for helping improve Axios:

... (truncated)

Commits
  • 84a9f3b chore(release): prepare release 1.20.0 (#11152)
  • e6824ee fix: core methodList, HTTP adapter errors, and add tests (#11096)
  • d8a919f fix(xhr): flush final progress during the live loadend dispatch (#11121)
  • 2d2a21a fix(interceptors): tolerate nullish handlers in syncHandlerEntries (#11118)
  • d19040b fix: harden runtime option handling (#11141)
  • e0a02dd chore(deps): bump zizmorcore/zizmor-action from 0.6.1 to 0.6.2 in the github-...
  • d10cb3a chore(deps-dev): bump the development_dependencies group with 4 updates (#11143)
  • 2c94646 chore(deps): bump js-yaml and mocha in /tests/smoke/cjs (#11133)
  • 76c12bc chore(deps-dev): bump js-yaml from 4.3.0 to 4.3.1 (#11140)
  • ba98559 docs: add ScrapingBee sponsor (#11137)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=axios&package-manager=npm_and_yarn&previous-version=1.18.0&new-version=1.20.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/browser-integration-tests/package.json | 2 +- yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 07c0b4bccd47..36f08e3e384b 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -65,7 +65,7 @@ "@sentry/opentelemetry": "11.1.0", "@sentry/conventions": "0.24.0", "@supabase/supabase-js": "2.109.0", - "axios": "1.18.0", + "axios": "1.20.0", "babel-loader": "^10.1.1", "fflate": "0.8.3", "html-webpack-plugin": "^5.5.0", diff --git a/yarn.lock b/yarn.lock index 86bafcc8fde9..c6f1c89882a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11585,13 +11585,13 @@ axios@1.16.0: form-data "^4.0.5" proxy-from-env "^2.1.0" -axios@1.18.0: - version "1.18.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.18.0.tgz#8a7f8854af280fcaae063272df2ed9f3837d2398" - integrity sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw== +axios@1.20.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.20.0.tgz#515513445aa60e71d04b6521ca6210829ccb4786" + integrity sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg== dependencies: follow-redirects "^1.16.0" - form-data "^4.0.5" + form-data "^4.0.6" https-proxy-agent "^5.0.1" proxy-from-env "^2.1.0" @@ -16097,7 +16097,7 @@ foreground-child@^3.1.0: cross-spawn "^7.0.6" signal-exit "^4.0.1" -form-data@4.0.6, form-data@^4.0.0, form-data@^4.0.4, form-data@^4.0.5: +form-data@4.0.6, form-data@^4.0.0, form-data@^4.0.4, form-data@^4.0.5, form-data@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== From 8de203601c26da867fcd8049db949e4dff23cab8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:46:25 +0000 Subject: [PATCH 60/65] feat(deps): bump fastify from 5.12.1 to 5.12.5 (#24917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [fastify](https://github.com/fastify/fastify) from 5.12.1 to 5.12.5.
Release notes

Sourced from fastify's releases.

v5.12.5

⚠️ Security release

What's Changed

Full Changelog: https://github.com/fastify/fastify/compare/v5.12.4...v5.12.5

v5.12.4

Fixed the fastify.js version mismatch.

Full Changelog: https://github.com/fastify/fastify/compare/v5.12.2...v5.12.4

v5.12.2

⚠️ Security release

What's Changed

Full Changelog: https://github.com/fastify/fastify/compare/v5.12.1...v5.12.2

Commits
  • ba235fd Bumped v5.12.5
  • ad06a4c Merge commit from fork
  • 7af0d77 [Backport 5.x] perf: avoid redundant request-part reads during validation (#7...
  • 990ebef [Backport 5.x] perf: reduce content-type parser overhead (#7019)
  • 1690e35 Bumped v5.12.4
  • 1c991c4 Bumped v5.12.3
  • 942a2be Bumped v5.12.2
  • 853f6e2 test(validation): cover normalization and async branches
  • f02d8d4 fix(validation): do not unwrap async validator results
  • 93c239a fix: reject malformed URLs before custom 404 handlers
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=fastify&package-manager=npm_and_yarn&previous-version=5.12.1&new-version=5.12.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 show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/node-integration-tests/package.json | 2 +- yarn.lock | 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 cef002121320..6cf5e9b4e688 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -68,7 +68,7 @@ "cron": "^3.1.6", "dataloader": "2.2.2", "express": "^4.21.2", - "fastify": "^5.12.1", + "fastify": "^5.12.5", "generic-pool": "^3.9.0", "graphql": "^16.11.0", "groq-sdk": "1.6.0", diff --git a/yarn.lock b/yarn.lock index c6f1c89882a8..a6781071c9c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15807,10 +15807,10 @@ fast-xml-parser@^4.4.1: dependencies: strnum "^1.0.5" -fastify@^5.12.1: - version "5.12.1" - resolved "https://registry.yarnpkg.com/fastify/-/fastify-5.12.1.tgz#d3a736ed0d656d8ffeed909c7099809b24b9f008" - integrity sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q== +fastify@^5.12.5: + version "5.12.5" + resolved "https://registry.yarnpkg.com/fastify/-/fastify-5.12.5.tgz#e3a6937e154a51332889a30ff3b8f97db52d7355" + integrity sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A== dependencies: "@fastify/ajv-compiler" "^4.0.5" "@fastify/error" "^4.0.0" From 2651a8f5cab1252485653d990d3edab80136777b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 1 Oct 2026 10:46:43 +0200 Subject: [PATCH 61/65] test(sveltekit): Add missing prerender e2e test (#24921) Forgot to push an e2e test in #24777 for Sveltekit 2. Since the added plugin there has different logic for Kit 2 and 3 I think we should still add this test for Kit 2. There is an e2e test for Kit 2 in static mode but once we get rid of transactions, this test might be removed. so can't hurt to add it here. --- .../src/routes/prerendered/+page.svelte | 1 + .../src/routes/prerendered/+page.ts | 1 + .../tests/prerender.test.ts | 14 ++++++++++++++ 3 files changed, 16 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/routes/prerendered/+page.svelte create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/routes/prerendered/+page.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/prerender.test.ts diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/routes/prerendered/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/routes/prerendered/+page.svelte new file mode 100644 index 000000000000..46d423a3693d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/routes/prerendered/+page.svelte @@ -0,0 +1 @@ +

Prerendered page

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/routes/prerendered/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/routes/prerendered/+page.ts new file mode 100644 index 000000000000..189f71e2e1b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/routes/prerendered/+page.ts @@ -0,0 +1 @@ +export const prerender = true; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/prerender.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/prerender.test.ts new file mode 100644 index 000000000000..9e9a5bbf4be8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/prerender.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from '@playwright/test'; + +test("Doesn't add trace meta tags to prerendered pages", async ({ request }) => { + const prerenderedHtml = await (await request.get('/prerendered')).text(); + const ssrHtml = await (await request.get('/')).text(); + + expect(prerenderedHtml).toContain('Prerendered page'); + expect(prerenderedHtml).not.toContain(' Date: Thu, 1 Oct 2026 08:47:12 +0000 Subject: [PATCH 62/65] feat(deps): bump hono from 4.13.5 to 4.13.7 (#24916) Bumps [hono](https://github.com/honojs/hono) from 4.13.5 to 4.13.7.
Release notes

Sourced from hono's releases.

v4.13.7

Security fixes

This release includes a fix for the following security issue:

hono/jsx renders plain strings unescaped in boundary components, leading to XSS

Affects: Suspense, ErrorBoundary, and Context.Provider in hono/jsx, and renderToString() / renderToReadableStream() in hono/jsx/dom/server. Fixes missing HTML escaping for a plain string placed directly as a child or fallback of these components, or as the root value of the server rendering functions, so untrusted strings could be emitted as markup. GHSA-hxh3-vqpv-xpqv


Users who render untrusted strings inside Suspense, ErrorBoundary, or Context.Provider, or pass them directly to hono/jsx/dom/server, are strongly encouraged to upgrade to this version.

v4.13.6

What's Changed

Full Changelog: https://github.com/honojs/hono/compare/v4.13.5...v4.13.6

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=hono&package-manager=npm_and_yarn&previous-version=4.13.5&new-version=4.13.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/bun-integration-tests/package.json | 2 +- dev-packages/cloudflare-integration-tests/package.json | 2 +- dev-packages/node-integration-tests/package.json | 2 +- yarn.lock | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index 4264288b9368..7e492c8ce9ee 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -16,7 +16,7 @@ "dependencies": { "@sentry/bun": "11.1.0", "@sentry/hono": "11.1.0", - "hono": "^4.13.5", + "hono": "^4.13.7", "mysql": "^2.18.1", "pg": "8.16.0" }, diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index d211f87fe30b..c0478cd4fcc5 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -25,7 +25,7 @@ "@sentry/cloudflare": "11.1.0", "@sentry/core": "11.1.0", "@sentry/hono": "11.1.0", - "hono": "^4.13.5", + "hono": "^4.13.7", "openai": "5.18.1" }, "devDependencies": { diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 6cf5e9b4e688..8451a12a86d6 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -73,7 +73,7 @@ "graphql": "^16.11.0", "groq-sdk": "1.6.0", "graphql-tag": "^2.12.7", - "hono": "^4.13.5", + "hono": "^4.13.7", "http-terminator": "^3.2.0", "ioredis": "5.10.1", "ioredis-5": "npm:ioredis@^5.11.0", diff --git a/yarn.lock b/yarn.lock index a6781071c9c9..cdf6df0f7ae8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17109,10 +17109,10 @@ homedir-polyfill@^1.0.1: dependencies: parse-passwd "^1.0.0" -hono@^4.13.5: - version "4.13.5" - resolved "https://registry.yarnpkg.com/hono/-/hono-4.13.5.tgz#3df9463c4668a0321c39515309f5891e388e9709" - integrity sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw== +hono@^4.13.7: + version "4.13.7" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.13.7.tgz#80fdab8326f03d02f049117fd968101933493600" + integrity sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ== hookable@^5.5.3: version "5.5.3" From b45e5b884cc4e0c6840c83fe4dd741234b47fe47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:49:39 +0000 Subject: [PATCH 63/65] feat(deps): bump moment from 2.30.1 to 2.31.0 (#24890) Bumps [moment](https://github.com/moment/moment) from 2.30.1 to 2.31.0.
Release notes

Sourced from moment's releases.

2.31.0

Released Sep 14, 2026

Security fixes

Bug fixes

  • #6376 Prevent object prototype properties from being used as format tokens
  • #6386 Normalize lazy-loaded locale names
  • #6404 Fix parsing issue with eHHmm format
  • #6433 Ignore non-Moment arguments in min and max
  • #6434 Fix inherited lowercase long date formats
  • #6436 Reset locale parsing caches after updates
  • #6437 Fix weekday mismatch when the format only has part of a date
  • #6442 Fix locale('__proto__') corrupting the global locale
  • #6443 Avoid Object.assign in duration.humanize
  • #6446 Validate range when parsing a time zone offset
  • #6447 Include metadata in all-locales bundle
  • #6448 Apply postformat to locale relative time methods
  • #6450 Add stack traces to conditional deprecation warnings

New features

  • #6451 Add internal date-default hook for Moment Timezone
New locales

Updates to existing locales

  • #5404 Portuguese (Brazil) ('pt-br'): Fix wrong plural usage for time
  • #6197 Indonesian ('id'): Correct the abbreviation for August
  • #6217 Georgian ('ka') and Dutch (Belgium) ('nl-be'): Correct L date formats
  • #6289 Swedish ('sv'): Correct the abbreviation for Thursday
  • #6306 Catalan ('ca'): Use typographic apostrophes in relative time
  • #6347 Swahili ('sw'): Correct the spelling of hour in calendar output
  • #6360 Ukrainian ('uk'): Use ISO week numbering
  • #6370 Ukrainian ('uk'): Use U+02BC apostrophes in Friday names
  • #6371 Hungarian ('hu'): Preserve numeric values in relative seconds
  • #6391 Swahili ('sw'): Fix weekday and relative-time grammar
  • #6396 German ('de', 'de-at', 'de-ch'): Parse short months without trailing dots
  • #6409 Uzbek ('uz', 'uz-latn'): Fix past relative-time formatting
  • #6410 Polish ('pl'): Use genitive month names in dotted day formats
Changelog

Sourced from moment's changelog.

2.31.0

Released Sep 14, 2026

Security fixes

Bug fixes

  • #6376 Prevent object prototype properties from being used as format tokens
  • #6386 Normalize lazy-loaded locale names
  • #6404 Fix parsing issue with eHHmm format
  • #6433 Ignore non-Moment arguments in min and max
  • #6434 Fix inherited lowercase long date formats
  • #6436 Reset locale parsing caches after updates
  • #6437 Fix weekday mismatch when the format only has part of a date
  • #6442 Fix locale('__proto__') corrupting the global locale
  • #6443 Avoid Object.assign in duration.humanize
  • #6446 Validate range when parsing a time zone offset
  • #6447 Include metadata in all-locales bundle
  • #6448 Apply postformat to locale relative time methods
  • #6450 Add stack traces to conditional deprecation warnings

New features

  • #6451 Add internal date-default hook for Moment Timezone
New locales

Updates to existing locales

  • #5404 Portuguese (Brazil) ('pt-br'): Fix wrong plural usage for time
  • #6197 Indonesian ('id'): Correct the abbreviation for August
  • #6217 Georgian ('ka') and Dutch (Belgium) ('nl-be'): Correct L date formats
  • #6289 Swedish ('sv'): Correct the abbreviation for Thursday
  • #6306 Catalan ('ca'): Use typographic apostrophes in relative time
  • #6347 Swahili ('sw'): Correct the spelling of hour in calendar output
  • #6360 Ukrainian ('uk'): Use ISO week numbering
  • #6370 Ukrainian ('uk'): Use U+02BC apostrophes in Friday names
  • #6371 Hungarian ('hu'): Preserve numeric values in relative seconds
  • #6391 Swahili ('sw'): Fix weekday and relative-time grammar
  • #6396 German ('de', 'de-at', 'de-ch'): Parse short months without trailing dots
  • #6409 Uzbek ('uz', 'uz-latn'): Fix past relative-time formatting
  • #6410 Polish ('pl'): Use genitive month names in dotted day formats
Commits
  • 15b45d4 [pkg] Build 2.31.0 (#6452)
  • 631cd81 [pkg] Update changelog for upcoming release (#6394)
  • 6caff9e Merge commit from fork
  • 710703b [feature] Add internal date-default hook for Moment Timezone (#6451)
  • 863ed94 [bugfix] Add stack traces to conditional deprecation warnings (#6450)
  • 2c7abe1 [bugfix] Apply postformat to locale relative time methods (#6448)
  • 9c45ac3 [bugfix] Include metadata in all-locales bundle (#6447)
  • f6eefc5 [bugfix] Validate timezone offset range (#6446)
  • 136b441 [bugfix] Avoid Object.assign in duration.humanize (#6443)
  • 0d10504 [bugfix] Fix locale('proto') corrupting the global locale (#6442)
  • Additional commits viewable in compare view
Maintainer changes

This version was pushed to npm by GitHub Actions, a new releaser for moment since your current version.


Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index cdf6df0f7ae8..eb211a624bb4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20224,9 +20224,9 @@ module-lookup-amd@^9.0.3: requirejs-config-file "^4.0.0" moment@^2.30.1: - version "2.30.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" - integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + version "2.31.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.31.0.tgz#6e19184e8005a9dfc61c9351263d4d6ea2ace7aa" + integrity sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw== mongodb-connection-string-url@^2.6.0: version "2.6.0" From a0faac6b337df5fb756a34f5d74f2ed6f7b29e06 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:57:47 +0200 Subject: [PATCH 64/65] fix(deps): runtime dependency security fixes (#24911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Batched **runtime** dependency security fixes. One commit per vulnerability. ### Fixes - `brace-expansion` 1.1.18 → 1.1.21 — GHSA-q2hr-2g5m-vwhr / CVE-2026-102277 (medium) — https://github.com/getsentry/sentry-javascript/security/dependabot/2599 ### Notes `brace-expansion` is not a direct dependency anywhere in the monorepo — the only vulnerable instance was the `brace-expansion@^1.1.7` lockfile entry pulled in via `minimatch@3.x`. That range already permits the patched `1.1.21`, so this is a **lockfile-only re-resolution**: no `package.json` change and no `resolutions` override. Re-resolving the lockfile also moved two non-vulnerable entries forward within their existing semver ranges: - `brace-expansion@^2.0.1, ^2.0.2`: 2.0.2 → 2.1.7 - `brace-expansion@^5.0.5`: 5.0.6 → 5.0.12 `yarn dedupe-deps:check` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- yarn.lock | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/yarn.lock b/yarn.lock index eb211a624bb4..004333c9dcd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12040,7 +12040,7 @@ boxen@8.0.1, boxen@^8.0.1: widest-line "^5.0.0" wrap-ansi "^9.0.0" -brace-expansion@5.0.6, brace-expansion@^5.0.5: +brace-expansion@5.0.6: version "5.0.6" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.6.tgz#ec68fe0a641a29d8711579caf641d05bae1f2285" integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g== @@ -12048,20 +12048,27 @@ brace-expansion@5.0.6, brace-expansion@^5.0.5: balanced-match "^4.0.2" brace-expansion@^1.1.7: - version "1.1.18" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.18.tgz#3ce74d89885136be1535341f8c3d4425c29a5cab" - integrity sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw== + version "1.1.21" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.21.tgz#edf4fab5c64d051aea5a8def49aba1c7522279f3" + integrity sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace-expansion@^2.0.1, brace-expansion@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" - integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + version "2.1.7" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.7.tgz#14ea3836731823bb24740225ea43b941b436f9d1" + integrity sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g== dependencies: balanced-match "^1.0.0" +brace-expansion@^5.0.5: + version "5.0.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec" + integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ== + dependencies: + balanced-match "^4.0.2" + braces@^3.0.3, braces@~3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" From b8a90a409c29d6b58f21fcd4ab52df332424343d Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 1 Oct 2026 11:28:38 +0200 Subject: [PATCH 65/65] meta(changelog): Update changelog for 11.2.0 --- CHANGELOG.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01353e7ccd9d..ec252e25122e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,91 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 11.2.0 + +### Important Changes + +- **feat(hono): add orchestrion-based auto-instrumentation ([#24497](https://github.com/getsentry/sentry-javascript/pull/24497))** + + Hono is now instrumented automatically. Request spans are named after the matched Hono route, each middleware gets its own span, and errors thrown in handlers are captured. + +- **feat(node/bun): Enable dedupeIntegration by default ([#24794](https://github.com/getsentry/sentry-javascript/pull/24794))** + + `@sentry/node` and `@sentry/bun` now include `dedupeIntegration` in their default integrations, like the browser, Deno, Vercel Edge and Cloudflare SDKs. When the same error is captured two times in a row, only the first event is sent. To keep the previous behavior, remove the integration: `integrations: defaults => defaults.filter(integration => integration.name !== 'Dedupe')`. + +- **feat(remix): Instrument Remix 3 server requests via fetch-router ([#24801](https://github.com/getsentry/sentry-javascript/pull/24801))** + + On Remix 3, the SDK now adds the matched route as `http.route`, the response status and a low-cardinality name to the `http.server` span of each `fetch-router` request. Start the app with `--import @sentry/remix/v3/node` in place of `--import remix/node-tsx`. + +### Other Changes + +- chore(bundler-plugins): Allow magic-string 1.x ([#24768](https://github.com/getsentry/sentry-javascript/pull/24768)) +- feat(deps): Bump oxc-parser to 0.152.0 and sentry to 0.45.0 ([#24823](https://github.com/getsentry/sentry-javascript/pull/24823)) +- feat(elysia): Export `bunRuntimeMetricsIntegration` ([#24892](https://github.com/getsentry/sentry-javascript/pull/24892)) +- feat(react-router): Support request-scoped CSP nonces in createSentryHandleRequest ([#24826](https://github.com/getsentry/sentry-javascript/pull/24826)) +- fix(aws-serverless): Keep Lambda extension polling past 300s ([#24811](https://github.com/getsentry/sentry-javascript/pull/24811)) +- fix(browser-utils): Stop leaking DOM instrumentation listeners on mismatched removals ([#24727](https://github.com/getsentry/sentry-javascript/pull/24727)) +- fix(core): Resolve escape sequences in `fmt` / `parameterize` messages ([#24770](https://github.com/getsentry/sentry-javascript/pull/24770)) +- fix(deno): Flush buffered metrics and logs before process exit ([#24807](https://github.com/getsentry/sentry-javascript/pull/24807)) +- fix(node): End Express layer spans when `next` is called ([#24854](https://github.com/getsentry/sentry-javascript/pull/24854)) +- fix(node): Flush buffered metrics on process exit ([#24806](https://github.com/getsentry/sentry-javascript/pull/24806)) +- fix(nuxt): Fix CommonJS interop for force-inlined instrumented packages ([#24799](https://github.com/getsentry/sentry-javascript/pull/24799)) +- fix(server-utils): Preserve raw Anthropic response bodies ([#24902](https://github.com/getsentry/sentry-javascript/pull/24902)) +- fix(server-utils): Preserve raw Groq and Together response bodies ([#24906](https://github.com/getsentry/sentry-javascript/pull/24906)) +- fix(server-utils): Preserve raw OpenAI embeddings and conversation responses ([#24908](https://github.com/getsentry/sentry-javascript/pull/24908)) +- fix(server-utils): Preserve response bodies for OpenAI parse helpers ([#24783](https://github.com/getsentry/sentry-javascript/pull/24783)) +- fix(solidstart): Support `rolldownOptions` in instrumentation file plugin ([#24863](https://github.com/getsentry/sentry-javascript/pull/24863)) +- fix(sveltekit): Skip trace meta tags when prerendering ([#24777](https://github.com/getsentry/sentry-javascript/pull/24777)) +- fix(vue): Share one root render span debounce timer across components ([#24868](https://github.com/getsentry/sentry-javascript/pull/24868)) +- perf(server-utils): Avoid quadratic SQL sanitizer output handling ([#24834](https://github.com/getsentry/sentry-javascript/pull/24834)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#24822](https://github.com/getsentry/sentry-javascript/pull/24822)) +- chore: Add external contributor to CHANGELOG.md ([#24827](https://github.com/getsentry/sentry-javascript/pull/24827)) +- chore: Add external contributor to CHANGELOG.md ([#24835](https://github.com/getsentry/sentry-javascript/pull/24835)) +- chore: Add external contributor to CHANGELOG.md ([#24850](https://github.com/getsentry/sentry-javascript/pull/24850)) +- chore: Add external contributor to CHANGELOG.md ([#24851](https://github.com/getsentry/sentry-javascript/pull/24851)) +- chore: Add external contributor to CHANGELOG.md ([#24914](https://github.com/getsentry/sentry-javascript/pull/24914)) +- chore(deps): Update to Vitest 4 and Vite 8 ([#24746](https://github.com/getsentry/sentry-javascript/pull/24746)) +- chore(github): Add `external` label on PRs of external contributors ([#24825](https://github.com/getsentry/sentry-javascript/pull/24825)) +- chore(solidstart): Remove unused Vitest setup from e2e apps ([#24789](https://github.com/getsentry/sentry-javascript/pull/24789)) +- ci: shard Bun integration tests ([#24771](https://github.com/getsentry/sentry-javascript/pull/24771)) +- ci: shard Deno shared integration tests ([#24772](https://github.com/getsentry/sentry-javascript/pull/24772)) +- docs(core): Clarify error object dedupe and `dedupeIntegration` ([#24893](https://github.com/getsentry/sentry-javascript/pull/24893)) +- feat(deps): bump axios from 1.18.0 to 1.20.0 ([#24918](https://github.com/getsentry/sentry-javascript/pull/24918)) +- feat(deps): bump fast-uri from 3.1.7 to 3.1.8 ([#24889](https://github.com/getsentry/sentry-javascript/pull/24889)) +- feat(deps): bump fastify from 5.12.1 to 5.12.5 ([#24917](https://github.com/getsentry/sentry-javascript/pull/24917)) +- feat(deps): bump hono from 4.13.5 to 4.13.7 ([#24916](https://github.com/getsentry/sentry-javascript/pull/24916)) +- feat(deps): bump ip-address from 10.4.0 to 10.7.2 ([#24810](https://github.com/getsentry/sentry-javascript/pull/24810)) +- feat(deps): bump moment from 2.30.1 to 2.31.0 ([#24890](https://github.com/getsentry/sentry-javascript/pull/24890)) +- fix(deps): runtime dependency security fixes ([#24911](https://github.com/getsentry/sentry-javascript/pull/24911)) +- ref(hono): move shared Hono instrumentation into @sentry/server-utils ([#24496](https://github.com/getsentry/sentry-javascript/pull/24496)) +- test(browser): Fix flaky reportPageLoaded duration assertion ([#24814](https://github.com/getsentry/sentry-javascript/pull/24814)) +- test(bun): Run the auto-instrumentation suites with bundled scenarios ([#24612](https://github.com/getsentry/sentry-javascript/pull/24612)) +- test(cloudflare): Add shared streamed-span helpers to the integration test runner ([#24176](https://github.com/getsentry/sentry-javascript/pull/24176)) +- test(cloudflare): Assert Workers AI gen_ai spans in the send-to-sentry E2E test ([#24515](https://github.com/getsentry/sentry-javascript/pull/24515)) +- test(cloudflare): Port the binding suites to span streaming ([#24190](https://github.com/getsentry/sentry-javascript/pull/24190)) +- test(cloudflare): Port the http-server integration suites to span streaming ([#24195](https://github.com/getsentry/sentry-javascript/pull/24195)) +- test(cloudflare): Port the request handler suites to span streaming ([#24196](https://github.com/getsentry/sentry-javascript/pull/24196)) +- test(cloudflare): Port the tracing propagation suites to span streaming ([#24185](https://github.com/getsentry/sentry-javascript/pull/24185)) +- test(cloudflare): Port the tracing suites to span streaming ([#24179](https://github.com/getsentry/sentry-javascript/pull/24179)) +- test(cloudflare): Port the vite-autoinstrument suites to span streaming ([#24189](https://github.com/getsentry/sentry-javascript/pull/24189)) +- test(e2e): Add Bun Express test app ([#24306](https://github.com/getsentry/sentry-javascript/pull/24306)) +- test(e2e): Add trace test for background use cache revalidation ([#24740](https://github.com/getsentry/sentry-javascript/pull/24740)) +- test(e2e): Mark supabase-nextjs as optional ([#24898](https://github.com/getsentry/sentry-javascript/pull/24898)) +- test(e2e): Retry a hung SAM start in aws-serverless tests ([#24879](https://github.com/getsentry/sentry-javascript/pull/24879)) +- test(nextjs): Add tests for low-cardinality span names for cache spans ([#24819](https://github.com/getsentry/sentry-javascript/pull/24819)) +- test(nextjs): Add UI components for cached/dynamic content ([#24874](https://github.com/getsentry/sentry-javascript/pull/24874)) +- test(nuxt): Remove version pin for @nuxt/cli in Nuxt 5 E2E ([#24798](https://github.com/getsentry/sentry-javascript/pull/24798)) +- test(replay): De-flake mutationLimit large-mutations test ([#24784](https://github.com/getsentry/sentry-javascript/pull/24784)) +- test(sveltekit): Add missing prerender e2e test ([#24921](https://github.com/getsentry/sentry-javascript/pull/24921)) +- test(test-utils): Add fetchSpanAttributes to read span attributes via the sentry CLI ([#24514](https://github.com/getsentry/sentry-javascript/pull/24514)) +- test(test-utils): Extract Cloudflare Worker deploys into `test-utils/cloudflare` ([#24815](https://github.com/getsentry/sentry-javascript/pull/24815)) + +
+ Work in this release was contributed by @nabi-noor, @LuccaRebelloToledo, @andasan, @breken-ai, @EmileBrunelle, and @diobriggs. Thank you for your contributions! ## 11.1.0