diff --git a/.agents/skills/fix-security-vulnerability/SKILL.md b/.agents/skills/fix-security-vulnerability/SKILL.md index ca37ed5d558e..db1d3e72d5d5 100644 --- a/.agents/skills/fix-security-vulnerability/SKILL.md +++ b/.agents/skills/fix-security-vulnerability/SKILL.md @@ -92,7 +92,7 @@ git pull origin develop git checkout -b fix/dependabot-alert- ``` -Then apply the fix commands from Step 5 of the single-alert workflow (edit `package.json`, `yarn install`, `yarn dedupe-deps:fix`, verify) — but **skip the "Do NOT commit" instruction**, since user approval was already obtained in Step 2b. After applying: +Then apply the fix commands from Step 5 of the single-alert workflow (`npx yarn-update-dependency@latest `, `yarn dedupe-deps:fix`, verify) — but **skip the "Do NOT commit" instruction**, since user approval was already obtained in Step 2b. After applying: ```bash # 3. Stage and commit the changes @@ -263,8 +263,8 @@ Present findings and **wait for user approval** before making changes: ### Proposed Fix -1. Update : "": "" -2. yarn install && yarn dedupe-deps:fix +1. npx yarn-update-dependency@latest +2. yarn dedupe-deps:fix 3. Verify with: yarn why Proceed? @@ -273,15 +273,14 @@ Proceed? ### Step 5: Apply Fix (After Approval) ```bash -# 1. Edit package.json -# 2. Update lockfile -yarn install -# 3. Deduplicate +# 1. Upgrade the package (updates package.json + lockfile) +npx yarn-update-dependency@latest +# 2. Deduplicate yarn dedupe-deps:fix -# 4. Verify +# 3. Verify yarn dedupe-deps:check yarn why -# 5. Show changes +# 4. Show changes git diff ``` @@ -325,6 +324,7 @@ gh api --method PATCH repos/getsentry/sentry-javascript/dependabot/alerts/` | Upgrade package across repo | | `yarn why ` | Show dependency tree | | `yarn dedupe-deps:fix` | Fix duplicates in yarn.lock | | `yarn dedupe-deps:check` | Verify no duplicate issues | diff --git a/.agents/skills/write-tests/SKILL.md b/.agents/skills/write-tests/SKILL.md index 94fa4dee89df..924388b13215 100644 --- a/.agents/skills/write-tests/SKILL.md +++ b/.agents/skills/write-tests/SKILL.md @@ -22,7 +22,17 @@ Follow these steps in order before writing any test code. 1. **Decide the framework.** Testing a function's return value, side effects, or module interactions → Vitest (lives under `packages//test/`). Testing that a real HTTP request to a running app produces the correct Sentry envelope → Playwright (lives under - `dev-packages/e2e-tests/test-applications//tests/`). + `dev-packages/e2e-tests/test-applications//tests/`). Testing Node SDK instrumentation + against real envelope output → node-integration-tests (lives under + `dev-packages/node-integration-tests/suites/`). + + **Parameterization differs by framework — pick the right one:** + + | Framework | How to parameterize | + | ---------------------- | ------------------------------------------------------------- | + | Vitest | `it.each` / `it.for` (runner-integrated, one test each) | + | Playwright E2E | `.forEach()` outside `test()` (registers separate tests) | + | Node integration tests | Loops **inside** a single `test()` body (one Node.js process) | 2. **Read 2–3 existing test files** in the target `test/` directory. Specifically note: - Which `vi.mock` style they use (string path or import form) @@ -299,6 +309,64 @@ describe('patchRoute', () => { --- +## Writing node-integration-tests + +Node integration tests (`dev-packages/node-integration-tests/`) use `createEsmAndCjsTests` to +run a real Node scenario file and assert on captured Sentry envelopes. + +### Minimize `test()` calls — each one spawns a separate Node process + +**This is the opposite of the Playwright rule.** In Playwright, each `test()` is cheap — use +`.forEach()` to register many tests. In node-integration-tests, each `test()` forks a fresh Node +process with full startup cost. A `describe.each` matrix that looks reasonable in a unit test +context balloons into dozens of cold starts and slows CI by a large factor. + +**Rule: loop inside the test body, not around `test()` calls.** + +```typescript +// Bad: 2 routes × 5 methods = 10 separate Node processes +createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + describe.each(['/sync', '/async'])('when using %s route', route => { + describe.each(['get', 'post', 'put', 'delete', 'patch'])('when using %s method', method => { + test('handles transaction', async () => { + // ... + }); + }); + }); +}); +``` + +```typescript +// Good: one Node process, all combinations asserted in a single test run +createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('handles transactions for all route/method/path combinations', async () => { + const runner = createRunner(); + const requests: Array<{ method: string; url: string }> = []; + + for (const route of ['/sync', '/async']) { + for (const method of ['get', 'post', 'put', 'delete', 'patch']) { + const fullPath = `${route}${path}`; + runner.expect({ + transaction: { transaction: `${method.toUpperCase()} ${fullPath}` }, + }); + requests.push({ method, url: fullPath }); + } + } + + const started = runner.start(); + for (const req of requests) { + await started.makeRequest(req.method, req.url); + } + await started.completed(); + }, 60_000); +}); +``` + +If a subset of cases has meaningfully different expectations (e.g., error vs. success), split +into two tests — not thirty. + +--- + ## Writing Playwright E2E tests ### When to write E2E tests @@ -366,17 +434,35 @@ expect(mechanism?.type).toBe('auto.http.hono.context_error'); ### Parameterized E2E tests -For Playwright tests (unlike Vitest), `for...of` loops are the established codebase convention. -Use `for...of` (not `.forEach()`) so Playwright's test registration works correctly: +For Playwright tests (unlike Vitest), use standard JS `.forEach()` as this is recommended by Playwright, +**not** `it.each` or `it.for`, which are Vitest-only APIs. The `.forEach()` runs at discovery time, registering +each case as its own independent test. All cases then run separately at execution time. ```typescript -for (const { name, prefix } of SCENARIOS) { - test.describe(name, () => { - test('captures named middleware span', async ({ baseURL }) => { - // ... - }); +[ + { a: 1, b: 1, expected: 2 }, + { a: 1, b: 2, expected: 3 }, + { a: 2, b: 1, expected: 3 }, +].forEach(({ a, b, expected }) => { + test(`given ${a} and ${b} as arguments, returns ${expected}`, ({ page }) => { + expect(a + b).toEqual(expected); }); -} +}); +``` + +**Don't put the loop inside a single test.** That collapses all cases into one test body — a +failure in one iteration aborts the rest, and the runner reports a single failure with no +per-case visibility: + +```typescript +// Bad: all routes tested in one test — a failure on /users skips /posts entirely +test('captures transactions for all routes', async ({ baseURL }) => { + for (const route of ['/users', '/posts', '/comments']) { + const txn = await waitForTransaction(APP_NAME, e => e.transaction === `GET ${route}`); + await fetch(`${baseURL}${route}`); + expect(txn.contexts?.trace?.op).toBe('http.server'); + } +}); ``` ### Common pitfalls diff --git a/.github/FLAKY_CI_FAILURE_TEMPLATE.md b/.github/FLAKY_CI_FAILURE_TEMPLATE.md index c105f6928d27..a293cf4bcd8a 100644 --- a/.github/FLAKY_CI_FAILURE_TEMPLATE.md +++ b/.github/FLAKY_CI_FAILURE_TEMPLATE.md @@ -1,6 +1,6 @@ --- title: '[Flaky CI]: {{ env.JOB_NAME }} - {{ env.TEST_NAME }}' -labels: Tests +labels: Tests, Bug --- ### Flakiness Type diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 241900f4b6ff..52aaf1cc6cc3 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} @@ -51,7 +51,7 @@ jobs: node-version-file: 'package.json' - name: Prepare release - uses: getsentry/craft@013a7b2113c2cac0ff32d5180cfeaefc7c9ce5b6 # v2.24.1 + uses: getsentry/craft@3dc647fee3586e57c7c31eb900fdec7cbb44f23f # v2.26.2 if: github.event.pull_request.merged == true && steps.version-regex.outputs.match != '' && steps.get_version.outputs.version != '' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ffcfe94821b4..daaf5effc2a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -274,7 +274,7 @@ jobs: pull-requests: write steps: - name: PR is opened against master - uses: mshick/add-pr-comment@e7516d74559b5514092f5b096ed29a629a1237c6 + uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 if: ${{ github.base_ref == 'master' && !startsWith(github.head_ref, 'prepare-release/') }} with: message: | @@ -533,7 +533,7 @@ jobs: with: node-version-file: 'package.json' - name: Set up Deno - uses: denoland/setup-deno@v2.0.3 + uses: denoland/setup-deno@v2.0.4 with: deno-version: v2.1.5 - name: Restore caches @@ -1057,7 +1057,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' - uses: denoland/setup-deno@v2.0.3 + uses: denoland/setup-deno@v2.0.4 with: deno-version: v2.1.5 - name: Restore caches diff --git a/.github/workflows/bump-size-limits.yml b/.github/workflows/bump-size-limits.yml index d837fc254bf2..b79c01f61c05 100644 --- a/.github/workflows/bump-size-limits.yml +++ b/.github/workflows/bump-size-limits.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.GITFLOW_APP_ID }} private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml index 64a6f82478e5..5b0eb76351bc 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -37,7 +37,7 @@ jobs: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.GITFLOW_APP_ID }} private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} diff --git a/.github/workflows/gitflow-sync-develop.yml b/.github/workflows/gitflow-sync-develop.yml index 1ff55f46a008..5e5c8e1d8f7c 100644 --- a/.github/workflows/gitflow-sync-develop.yml +++ b/.github/workflows/gitflow-sync-develop.yml @@ -27,7 +27,7 @@ jobs: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.GITFLOW_APP_ID }} private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml index 3eda72221948..a4534dad16dc 100644 --- a/.github/workflows/pr-review-reminder.yml +++ b/.github/workflows/pr-review-reminder.yml @@ -7,14 +7,12 @@ on: # Saturday/Sunday are never counted as business days. - cron: '0 10 * * 1-5' -# pulls.* list + listRequestedReviewers → pull-requests: read -# issues timeline + comments + createComment → issues: write +# pulls.* list + listRequestedReviewers + createComment on PRs → pull-requests: write # repos.listCollaborators (outside) → Metadata read on the token (see GitHub App permission map) # checkout → contents: read permissions: contents: read - issues: write - pull-requests: read + pull-requests: write concurrency: group: ${{ github.workflow }} @@ -27,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Remind pending reviewers uses: actions/github-script@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d966e35e9671..c88e2aad22fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} @@ -36,7 +36,7 @@ jobs: with: node-version-file: 'package.json' - name: Prepare release - uses: getsentry/craft@013a7b2113c2cac0ff32d5180cfeaefc7c9ce5b6 # v2.24.1 + uses: getsentry/craft@3dc647fee3586e57c7c31eb900fdec7cbb44f23f # v2.26.2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index 91ba709d0e7f..da50021f4431 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -131,13 +131,17 @@ } }, { - "files": [ - "**/scenarios/**", - "**/rollup-utils/**", - "**/bundle-analyzer-scenarios/**", - "**/bundle-analyzer-scenarios/*.cjs", - "**/bundle-analyzer-scenarios/*.js" - ], + "files": ["**/integrations/tracing/redis/vendored/**/*.ts"], + "rules": { + "typescript/no-explicit-any": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-this-alias": "off", + "max-lines": "off", + "no-bitwise": "off" + } + }, + { + "files": ["**/scenarios/**", "**/rollup-utils/**"], "rules": { "no-console": "off" } diff --git a/.size-limit.js b/.size-limit.js index 34ea3e254e90..c1be177bc5a2 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -226,7 +226,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '53 KB', + limit: '54 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -240,14 +240,14 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '89 KB', + limit: '90 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '90 KB', + limit: '91 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -278,7 +278,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '145 KB', + limit: '146 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -318,7 +318,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '266 KB', + limit: '267 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -326,7 +326,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '276 KB', + limit: '277 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -334,7 +334,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '280 KB', + limit: '281 KB', disablePlugins: ['@size-limit/esbuild'], }, // Next.js SDK (ESM) @@ -364,7 +364,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '64 KB', + limit: '65 KB', disablePlugins: ['@size-limit/esbuild'], }, // Node SDK (ESM) @@ -382,7 +382,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '103 KB', + limit: '102 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '120 KB', + limit: '119 KB', disablePlugins: ['@size-limit/esbuild'], }, // Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output @@ -437,7 +437,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '412 KiB', + limit: '420 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/CHANGELOG.md b/CHANGELOG.md index face72452b64..f23fe92ce89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,92 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.52.0 + +### Important Changes + +- **Beta release of the official Hono Sentry SDK** + + This release marks the beta release of the `@sentry/hono` Sentry SDK. For details on how to use it, check out the + [Sentry Hono SDK docs](https://docs.sentry.io/platforms/javascript/guides/hono/). Please reach out on + [GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have any feedback or concerns. + +- **feat(browser): Add `ingest_settings` to v2 log envelope payload ([#20453](https://github.com/getsentry/sentry-javascript/pull/20453))** + + Inference of user data (e.g. IP address, browser name/version) on log events is now gated behind the `sendDefaultPii` option. Previously, this data was always inferred by default. + +### Other Changes + +- docs(hono): Add new docs link and move to BETA release ([#20666](https://github.com/getsentry/sentry-javascript/pull/20666)) +- feat(browser): Add `ingest_settings` to v2 metrics envelope payload ([#20454](https://github.com/getsentry/sentry-javascript/pull/20454)) +- feat(browser): Migrate spotlight event processor to `ignoreSpans` ([#20595](https://github.com/getsentry/sentry-javascript/pull/20595)) +- feat(cloudflare): Capture request body via httpServerIntegration ([#20614](https://github.com/getsentry/sentry-javascript/pull/20614)) +- feat(cloudflare): Support rpc trace propagation for WorkerEntrypoint ([#20523](https://github.com/getsentry/sentry-javascript/pull/20523)) +- feat(cloudflare): Support tracing for queue producer ([#20529](https://github.com/getsentry/sentry-javascript/pull/20529)) +- feat(core): Apply request data to segment spans in span streaming ([#20654](https://github.com/getsentry/sentry-javascript/pull/20654)) +- feat(core): Migrate Vercel AI event processor to span streaming ([#20608](https://github.com/getsentry/sentry-javascript/pull/20608)) +- feat(deno): Add `processSegmentSpan` to Deno context integration ([#20613](https://github.com/getsentry/sentry-javascript/pull/20613)) +- feat(http): Portable node:http client instrumentation ([#20393](https://github.com/getsentry/sentry-javascript/pull/20393)) +- feat(nitro): Add unstorage tracing channel instrumentation ([#20615](https://github.com/getsentry/sentry-javascript/pull/20615)) +- feat(node-core): Add `processSegmentSpan` to node context integration ([#20678](https://github.com/getsentry/sentry-javascript/pull/20678)) +- feat(node): Use diagnostics_channel for redis >= 5.12.0 ([#20573](https://github.com/getsentry/sentry-javascript/pull/20573)) +- feat(node): Vendor ioredis, redis instrumentations ([#20510](https://github.com/getsentry/sentry-javascript/pull/20510)) +- feat(replay): Reset replay id from DSC on session expiry/refresh ([#20129](https://github.com/getsentry/sentry-javascript/pull/20129)) +- fix: Bump fast-xml-parser to fix vulnerability ([#20644](https://github.com/getsentry/sentry-javascript/pull/20644)) +- fix: Bump vite versions to fix vulnerability ([#20646](https://github.com/getsentry/sentry-javascript/pull/20646)) +- fix(core): Drain buffers in flush() when there is no transport ([#20207](https://github.com/getsentry/sentry-javascript/pull/20207)) +- fix(core): Guard against undefined chained in copyProps ([#20637](https://github.com/getsentry/sentry-javascript/pull/20637)) +- fix(deps): Bump rollup-plugin-license to fix lodash vulnerabilities ([#20636](https://github.com/getsentry/sentry-javascript/pull/20636)) +- fix(deps): Bump transitive deps for medium security fixes ([#20683](https://github.com/getsentry/sentry-javascript/pull/20683)) +- fix(hono): Do not capture 3xx and 4xx errors and add tests ([#20640](https://github.com/getsentry/sentry-javascript/pull/20640)) +- fix(nextjs): Skip build modification when SRI is enabled ([#20694](https://github.com/getsentry/sentry-javascript/pull/20694)) +- fix(opentelemetry): Respect OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES ([#20509](https://github.com/getsentry/sentry-javascript/pull/20509)) + +
+ Internal Changes + +- chore: Remove `bundle-analyzer-scenarios` dev packages ([#20680](https://github.com/getsentry/sentry-javascript/pull/20680)) +- chore(deps): Bump @hono/node-server from 1.19.10 to 1.19.13 ([#20117](https://github.com/getsentry/sentry-javascript/pull/20117)) +- chore(deps): Bump @nestjs packages to fix path-to-regexp ReDoS ([#20642](https://github.com/getsentry/sentry-javascript/pull/20642)) +- chore(deps): Bump axios from 1.15.0 to 1.15.2 ([#20665](https://github.com/getsentry/sentry-javascript/pull/20665)) +- chore(deps): Bump ip-address from 10.1.0 to 10.2.0 ([#20695](https://github.com/getsentry/sentry-javascript/pull/20695)) +- chore(deps): Bump simple-git from 3.33.0 to 3.36.0 ([#20696](https://github.com/getsentry/sentry-javascript/pull/20696)) +- chore(deps): Bump vulnerable testem version ([#20634](https://github.com/getsentry/sentry-javascript/pull/20634)) +- ci(deps): Bump actions/checkout from 4 to 6 ([#20620](https://github.com/getsentry/sentry-javascript/pull/20620)) +- ci(deps): Bump actions/create-github-app-token from 2 to 3 ([#20079](https://github.com/getsentry/sentry-javascript/pull/20079)) +- ci(deps): Bump denoland/setup-deno from 2.0.3 to 2.0.4 ([#20080](https://github.com/getsentry/sentry-javascript/pull/20080)) +- ci(deps): Bump getsentry/craft from 2.24.1 to 2.26.2 ([#20621](https://github.com/getsentry/sentry-javascript/pull/20621)) +- feat(deps): Bump @xmldom/xmldom from 0.8.12 to 0.8.13 ([#20457](https://github.com/getsentry/sentry-javascript/pull/20457)) +- feat(deps): Bump follow-redirects from 1.15.11 to 1.16.0 ([#20267](https://github.com/getsentry/sentry-javascript/pull/20267)) +- feat(deps): Bump hono from 4.12.12 to 4.12.14 ([#20340](https://github.com/getsentry/sentry-javascript/pull/20340)) +- fix(tests): Use stable instrumentations api in rr tests ([#20690](https://github.com/getsentry/sentry-javascript/pull/20690)) +- ref(tests): Rename streamed http.client span test folders ([#20602](https://github.com/getsentry/sentry-javascript/pull/20602)) +- test(browser): Fix browserTracingIntegration unit test ([#20604](https://github.com/getsentry/sentry-javascript/pull/20604)) +- test(browser): Fix flaky browser integration test for profiles ([#20587](https://github.com/getsentry/sentry-javascript/pull/20587)) +- test(browser): Fix flaky loader test ([#20596](https://github.com/getsentry/sentry-javascript/pull/20596)) +- test(browser): Fix flaky loader test ([#20655](https://github.com/getsentry/sentry-javascript/pull/20655)) +- test(browser): Make browser profiling test less flaky ([#20664](https://github.com/getsentry/sentry-javascript/pull/20664)) +- test(cloudflare): Add e2e test for MCPAgent with DurableObject instrumentation ([#20601](https://github.com/getsentry/sentry-javascript/pull/20601)) +- test(cloudflare): Add integration tests for scheduled, D1, and workflow ([#20609](https://github.com/getsentry/sentry-javascript/pull/20609)) +- test(cloudflare): Reduce flakiness for cloudflare with sub workers ([#20632](https://github.com/getsentry/sentry-javascript/pull/20632)) +- test(cloudflare): Use Node v24 for Cloudflare e2e tests ([#20628](https://github.com/getsentry/sentry-javascript/pull/20628)) +- test(deps): Bump Next.js in E2E test apps to fix Server Components DoS ([#20633](https://github.com/getsentry/sentry-javascript/pull/20633)) +- test(e2e): Add node-express-streaming E2E test app ([#20684](https://github.com/getsentry/sentry-javascript/pull/20684)) +- test(e2e): Add span streaming test app for Cloudflare Workers ([#20681](https://github.com/getsentry/sentry-javascript/pull/20681)) +- test(e2e): Add span streaming test app for next 16 ([#20648](https://github.com/getsentry/sentry-javascript/pull/20648)) +- test(e2e): Add span streaming test app for React Router 7 SPA ([#20677](https://github.com/getsentry/sentry-javascript/pull/20677)) +- test(e2e): Remove remaining `npmrc` pointing to Verdaccio ([#20611](https://github.com/getsentry/sentry-javascript/pull/20611)) +- test(nextjs): Fix flaky node runtime metrics E2E tests ([#20624](https://github.com/getsentry/sentry-javascript/pull/20624)) +- test(node): Fix ANR test for flakiness ([#20656](https://github.com/getsentry/sentry-javascript/pull/20656)) +- test(node): Fix flaky node cron test ([#20661](https://github.com/getsentry/sentry-javascript/pull/20661)) +- test(node): Unflake mongodb test ([#20662](https://github.com/getsentry/sentry-javascript/pull/20662)) +- test(react-router): Fix flaky E2E tests ([#20630](https://github.com/getsentry/sentry-javascript/pull/20630)) +- test(test-utils): Add MemoryProfiler for heap snapshot testing via CDP ([#20555](https://github.com/getsentry/sentry-javascript/pull/20555)) + +
+ +Work in this release was contributed by @sbs44. Thank you for your contribution! + ## 10.51.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/subject.js b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/subject.js index 46296b3b8c05..a446728e6995 100644 --- a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/subject.js +++ b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/subject.js @@ -1,6 +1,8 @@ setTimeout(() => { const cdnScript = document.createElement('script'); - cdnScript.src = '/cdn.bundle.js'; + // Distinct URL from the loader's `/cdn.bundle.js` so Chromium cannot satisfy this via memory-cache + // (would skip `page.route` and make CDN load counts flaky). + cdnScript.src = `/cdn.bundle.js?sentryInjected=1`; cdnScript.addEventListener('load', () => { Sentry.init({ diff --git a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts index 132281668fda..276bda3227ac 100644 --- a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts +++ b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/sdkLoadedInMeanwhile/test.ts @@ -30,8 +30,11 @@ sentryTest('it does not download the SDK if the SDK was loaded in the meanwhile' const tmpDir = await getLocalTestUrl({ testDir: __dirname, skipRouteHandler: true, skipDsnRouteHandler: true }); await page.route(`${TEST_HOST}/*.*`, route => { - const file = route.request().url().split('/').pop(); + const pathname = new URL(route.request().url()).pathname; + const file = pathname.split('/').pop() || ''; + // Loader + subject both fetch the CDN bundle. Chromium may not hit `page.route` twice for the same URL + // (memory cache); subject.js uses a cache-busted URL so we reliably observe two network loads. if (file === 'cdn.bundle.js') { cdnLoadedCount++; } @@ -47,13 +50,12 @@ sentryTest('it does not download the SDK if the SDK was loaded in the meanwhile' const eventData = envelopeRequestParser(req); - await waitForFunction(() => cdnLoadedCount === 2); - // Still loaded the CDN bundle twice - expect(cdnLoadedCount).toBe(2); + await expect.poll(() => cdnLoadedCount, { timeout: 15_000 }).toBe(2); - // But only sent to Sentry once - expect(sentryEventCount).toBe(1); + // But only sent to Sentry once (`waitForErrorRequest` can resolve before the DSN + // `page.route` handler increments — poll until the intercept has run) + await expect.poll(() => sentryEventCount, { timeout: 15_000 }).toBe(1); // Ensure loader does not overwrite init/config const options = await page.evaluate(() => (window as any).Sentry.getClient()?.getOptions()); @@ -62,10 +64,3 @@ sentryTest('it does not download the SDK if the SDK was loaded in the meanwhile' expect(eventData.exception?.values?.length).toBe(1); expect(eventData.exception?.values?.[0]?.value).toBe('window.doSomethingWrong is not a function'); }); - -async function waitForFunction(cb: () => boolean, timeout = 2000, increment = 100) { - while (timeout > 0 && !cb()) { - await new Promise(resolve => setTimeout(resolve, increment)); - await waitForFunction(cb, timeout - increment, increment); - } -} diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 0a7c2a5d59e7..8514553c1f1a 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -64,7 +64,7 @@ "@sentry-internal/replay": "10.51.0", "@sentry/opentelemetry": "10.51.0", "@supabase/supabase-js": "2.49.3", - "axios": "1.15.0", + "axios": "1.15.2", "babel-loader": "^10.1.1", "fflate": "0.8.2", "html-webpack-plugin": "^5.5.0", diff --git a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts index 39e6d2ca20b7..a5acab8b9de3 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts @@ -7,6 +7,9 @@ interface ValidateProfileOptions { isChunkFormat?: boolean; } +/** Seconds — consecutive chunk timestamps can jitter slightly below float precision (see profiling flakes). */ +const CHUNK_SAMPLE_TIMESTAMP_EPSILON_SEC = 1e-5; + /** * Validates the metadata of a profile chunk envelope. * https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ @@ -66,9 +69,9 @@ export function validateProfile( const ts = chunkProfileSample.timestamp; expect(Number.isFinite(ts)).toBe(true); expect(ts).toBeGreaterThan(0); - // Monotonic non-decreasing timestamps - expect(ts).toBeGreaterThanOrEqual(previousTimestamp); - previousTimestamp = ts; + // Monotonic non-decreasing timestamps (epsilon: jitter / IEEE754 around ~1e9 epoch seconds) + expect(ts).toBeGreaterThanOrEqual(previousTimestamp - CHUNK_SAMPLE_TIMESTAMP_EPSILON_SEC); + previousTimestamp = Math.max(previousTimestamp, ts); } else { // Legacy format uses elapsed_since_start_ns as a string const legacyProfileSample = sample as ThreadCpuProfile['samples'][number]; diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js index 071afe1ed059..6b24c64541c3 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js @@ -3,7 +3,7 @@ import { browserProfilingIntegration } from '@sentry/browser'; window.Sentry = Sentry; -Sentry.init({ +const client = Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [browserProfilingIntegration()], tracesSampleRate: 1, @@ -11,7 +11,7 @@ Sentry.init({ profileLifecycle: 'trace', }); -function largeSum(amount = 1000000) { +function largeSum(amount) { let sum = 0; for (let i = 0; i < amount; i++) { sum += Math.sqrt(i) * Math.sin(i); @@ -28,7 +28,8 @@ function fibonacci(n) { let firstSpan; Sentry.startSpanManual({ name: 'root-largeSum-1', parentSpan: null, forceTransaction: true }, span => { - largeSum(); + // Enough iterations that largeSum stays on-stack across several profiler ticks (10ms interval); otherwise sampling can miss it entirely. + largeSum(2_500_000); firstSpan = span; }); @@ -39,14 +40,13 @@ await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, force console.log('child span'); }); - // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled - await new Promise(resolve => setTimeout(resolve, 21)); + // Profiler uses a 10ms sample interval — wait long enough for multiple ticks + await new Promise(resolve => setTimeout(resolve, 40)); span.end(); }); -await new Promise(r => setTimeout(r, 21)); +await new Promise(r => setTimeout(r, 40)); firstSpan.end(); -const client = Sentry.getClient(); await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts index de4bddd69f57..076c31cc7c39 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -43,7 +43,7 @@ sentryTest( const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( page, 1, - { url, envelopeType: 'profile_chunk', timeout: 5000 }, + { url, envelopeType: 'profile_chunk', timeout: 15_000 }, properFullEnvelopeRequestParser, ); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index 7315e8cf4f36..8312c2a13e4d 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -23,6 +23,8 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page content_type: 'application/vnd.sentry.items.log+json', }, { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts index 4d7970945436..07af615712ff 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts @@ -22,6 +22,8 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page content_type: 'application/vnd.sentry.items.log+json', }, { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts index db6d174820d7..0a464d896c5d 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts @@ -23,6 +23,8 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page content_type: 'application/vnd.sentry.items.log+json', }, { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts index 66f44878ac86..a50d6c8b2b78 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts @@ -27,6 +27,8 @@ sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) content_type: 'application/vnd.sentry.items.trace-metric+json', }, { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/init.js new file mode 100644 index 000000000000..cf9618aeaf23 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + enableLongTask: false, + _experiments: { + enableInteractions: true, + }, + }), + Sentry.spanStreamingIntegration(), + Sentry.spotlightBrowserIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/subject.js new file mode 100644 index 000000000000..cae57f7a9167 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/subject.js @@ -0,0 +1,12 @@ +// Block the main thread for 70ms so the PerformanceObserver registers +// a click event entry, which triggers `ui.interaction.click` child spans. +const simulateSlowClick = e => { + const startTime = Date.now(); + while (Date.now() - startTime < 70) { + // + } + e.target.classList.add('clicked'); +}; + +document.querySelector('[data-test-id=spotlight-button]').addEventListener('click', simulateSlowClick); +document.querySelector('[data-test-id=regular-button]').addEventListener('click', simulateSlowClick); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/template.html new file mode 100644 index 000000000000..9348e00e7db7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/template.html @@ -0,0 +1,12 @@ + + + + + + +
+ +
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/test.ts new file mode 100644 index 000000000000..e9c27f682272 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/test.ts @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipCdnBundleTest, shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, observeStreamedSpan, waitForStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'filters ui.interaction.click spans for spotlight elements via ignoreSpans in streaming mode', + async ({ getLocalTestUrl, page }) => { + // spotlightBrowserIntegration is not available in CDN bundles + if (shouldSkipTracingTest() || shouldSkipCdnBundleTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Set up an observer that fails if a spotlight interaction span is ever sent + let sawSpotlightInteractionSpan = false; + await observeStreamedSpan(page, span => { + if (getSpanOp(span) === 'ui.interaction.click' && span.name?.includes('#sentry-spotlight')) { + sawSpotlightInteractionSpan = true; + return true; + } + return false; + }); + + await page.goto(url); + + // Wait for pageload to finish before clicking + await waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + // Click on the spotlight element — its ui.interaction.click child should be filtered + await page.locator('[data-test-id=spotlight-button]').click(); + await page.locator('.clicked[data-test-id=spotlight-button]').isVisible(); + + // Wait for the spotlight click's segment span to arrive + await waitForStreamedSpans(page, spans => + spans.some(span => span.is_segment && getSpanOp(span) === 'ui.action.click'), + ); + + // Click on the regular button — its ui.interaction.click child should be kept + const regularInteractionSpansPromise = waitForStreamedSpans(page, spans => + spans.some(span => getSpanOp(span) === 'ui.interaction.click' && !span.name?.includes('#sentry-spotlight')), + ); + + await page.locator('[data-test-id=regular-button]').click(); + await page.locator('.clicked[data-test-id=regular-button]').isVisible(); + + const regularSpans = await regularInteractionSpansPromise; + const regularInteractionSpan = regularSpans.find( + span => getSpanOp(span) === 'ui.interaction.click' && !span.name?.includes('#sentry-spotlight'), + ); + expect(regularInteractionSpan).toBeDefined(); + expect(regularInteractionSpan!.name).toContain('button'); + + // Verify no spotlight interaction span was ever sent + expect(sawSpotlightInteractionSpan).toBe(false); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/init.js new file mode 100644 index 000000000000..1125cb73618b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/init.js @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + enableLongTask: false, + _experiments: { + enableInteractions: true, + }, + }), + Sentry.spotlightBrowserIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/subject.js new file mode 100644 index 000000000000..cae57f7a9167 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/subject.js @@ -0,0 +1,12 @@ +// Block the main thread for 70ms so the PerformanceObserver registers +// a click event entry, which triggers `ui.interaction.click` child spans. +const simulateSlowClick = e => { + const startTime = Date.now(); + while (Date.now() - startTime < 70) { + // + } + e.target.classList.add('clicked'); +}; + +document.querySelector('[data-test-id=spotlight-button]').addEventListener('click', simulateSlowClick); +document.querySelector('[data-test-id=regular-button]').addEventListener('click', simulateSlowClick); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/template.html new file mode 100644 index 000000000000..9348e00e7db7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/template.html @@ -0,0 +1,12 @@ + + + + + + +
+ +
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/test.ts new file mode 100644 index 000000000000..d0480062c10a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter/test.ts @@ -0,0 +1,57 @@ +import { expect } from '@playwright/test'; +import type { TransactionEvent } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + envelopeRequestParser, + shouldSkipCdnBundleTest, + shouldSkipTracingTest, + waitForTransactionRequest, +} from '../../../../utils/helpers'; + +sentryTest( + 'filters ui.interaction.click spans for spotlight elements via ignoreSpans', + async ({ getLocalTestUrl, page }) => { + // spotlightBrowserIntegration is not available in CDN bundles + if (shouldSkipTracingTest() || shouldSkipCdnBundleTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + // Wait for the pageload transaction to complete + await waitForTransactionRequest(page); + + // Click on the spotlight element — interaction span should be filtered + const spotlightTxnPromise = waitForTransactionRequest(page, txn => txn.contexts?.trace?.op === 'ui.action.click'); + await page.locator('[data-test-id=spotlight-button]').click(); + await page.locator('.clicked[data-test-id=spotlight-button]').isVisible(); + const spotlightTransaction = envelopeRequestParser(await spotlightTxnPromise); + + expect(spotlightTransaction.contexts?.trace?.op).toBe('ui.action.click'); + + const spotlightInteractionSpans = spotlightTransaction.spans?.filter(span => span.op === 'ui.interaction.click'); + expect(spotlightInteractionSpans).toHaveLength(0); + + // Let the first idle span fully settle before clicking again + await page.waitForTimeout(1000); + + // Click on the regular button — wait specifically for a transaction that contains + // a ui.interaction.click child span, since the PerformanceObserver may deliver + // the event entry asynchronously + const regularTxnPromise = waitForTransactionRequest( + page, + txn => + txn.contexts?.trace?.op === 'ui.action.click' && + (txn.spans?.some(span => span.op === 'ui.interaction.click') ?? false), + ); + await page.locator('[data-test-id=regular-button]').click(); + await page.locator('.clicked[data-test-id=regular-button]').isVisible(); + const regularTransaction = envelopeRequestParser(await regularTxnPromise); + + const regularInteractionSpans = regularTransaction.spans?.filter(span => span.op === 'ui.interaction.click'); + expect(regularInteractionSpans?.length).toBeGreaterThanOrEqual(1); + expect(regularInteractionSpans![0]!.description).toContain('button'); + expect(regularInteractionSpans![0]!.description).not.toContain('#sentry-spotlight'); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/init.js rename to dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/init.js diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/subject.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/subject.js rename to dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/subject.js diff --git a/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts new file mode 100644 index 000000000000..79290f65f3cf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts @@ -0,0 +1,31 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../utils/spanUtils'; + +sentryTest( + 'sends http.client span for fetch requests without an active span when span streaming is enabled', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + await page.route('http://sentry-test-site.example/api/test', route => { + route.fulfill({ + status: 200, + body: 'ok', + headers: { 'Content-Type': 'text/plain' }, + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'http.client'); + + await page.goto(url); + + const span = await spanPromise; + + expect(span.name).toMatch(/^GET /); + expect(span.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser' }); + expect(span.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts deleted file mode 100644 index 609df6f551a3..000000000000 --- a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { expect } from '@playwright/test'; -import type { ClientReport } from '@sentry/core'; -import { sentryTest } from '../../../utils/fixtures'; -import { - envelopeRequestParser, - hidePage, - shouldSkipTracingTest, - waitForClientReportRequest, -} from '../../../utils/helpers'; - -sentryTest( - 'records no_parent_span client report for fetch requests without an active span', - async ({ getLocalTestUrl, page }) => { - sentryTest.skip(shouldSkipTracingTest()); - - await page.route('http://sentry-test-site.example/api/test', route => { - route.fulfill({ - status: 200, - body: 'ok', - headers: { 'Content-Type': 'text/plain' }, - }); - }); - - const url = await getLocalTestUrl({ testDir: __dirname }); - - const clientReportPromise = waitForClientReportRequest(page, report => { - return report.discarded_events.some(e => e.reason === 'no_parent_span'); - }); - - await page.goto(url); - - await hidePage(page); - - const clientReport = envelopeRequestParser(await clientReportPromise); - - expect(clientReport.discarded_events).toEqual([ - { - category: 'span', - quantity: 1, - reason: 'no_parent_span', - }, - ]); - }, -); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index ff0398d6b209..91e5339ff550 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -421,6 +421,14 @@ export function shouldSkipFeedbackTest(): boolean { * @returns `true` if we should skip the feature flags test */ export function shouldSkipFeatureFlagsTest(): boolean { + return shouldSkipCdnBundleTest(); +} + +/** + * Returns true if we're running in a CDN bundle environment (not ESM/CJS). + * Use this to skip tests for integrations that are only available via npm, not CDN bundles. + */ +export function shouldSkipCdnBundleTest(): boolean { const bundle = process.env.PW_BUNDLE; return bundle != null && !bundle.includes('esm') && !bundle.includes('cjs'); } diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index 2a9eeaf6cc63..de6829ee6274 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -15,7 +15,7 @@ "dependencies": { "@sentry/bun": "10.51.0", "@sentry/hono": "10.51.0", - "hono": "^4.12.12" + "hono": "^4.12.14" }, "devDependencies": { "@sentry-internal/test-utils": "10.51.0", diff --git a/dev-packages/bundle-analyzer-scenarios/README.md b/dev-packages/bundle-analyzer-scenarios/README.md deleted file mode 100644 index 97bd3033d1bb..000000000000 --- a/dev-packages/bundle-analyzer-scenarios/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Bundle Analyzer Scenarios - -This repository contains a set of scenarios to check the SDK against webpack bundle analyzer. - -You can run the scenarios by running `yarn analyze` and selecting the scenario you want to run. - -If you want to have more granular analysis of modules, you can build the SDK packages with with `preserveModules` set to -`true`. You can do this via the `SENTRY_BUILD_PRESERVE_MODULES`. - -```bash -SENTRY_BUILD_PRESERVE_MODULES=true yarn build -``` - -Please note that `preserveModules` has different behaviour with regards to tree-shaking, so you will get different total -bundle size results. diff --git a/dev-packages/bundle-analyzer-scenarios/browser-basic/index.js b/dev-packages/bundle-analyzer-scenarios/browser-basic/index.js deleted file mode 100644 index f3d47c97f7a2..000000000000 --- a/dev-packages/bundle-analyzer-scenarios/browser-basic/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import { init } from '@sentry/browser'; - -init({ - dsn: 'https://00000000000000000000000000000000@o000000.ingest.sentry.io/0000000', -}); diff --git a/dev-packages/bundle-analyzer-scenarios/browser-basic/package.json b/dev-packages/bundle-analyzer-scenarios/browser-basic/package.json deleted file mode 100644 index 07aec65d5a4f..000000000000 --- a/dev-packages/bundle-analyzer-scenarios/browser-basic/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "module", - "main": "index.js" -} diff --git a/dev-packages/bundle-analyzer-scenarios/package.json b/dev-packages/bundle-analyzer-scenarios/package.json deleted file mode 100644 index 492b9070fcab..000000000000 --- a/dev-packages/bundle-analyzer-scenarios/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@sentry-internal/bundle-analyzer-scenarios", - "version": "10.51.0", - "description": "Scenarios to test bundle analysis with", - "repository": "git://github.com/getsentry/sentry-javascript.git", - "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/dev-packages/bundle-analyzer-scenarios", - "author": "Sentry", - "license": "MIT", - "private": true, - "dependencies": { - "html-webpack-plugin": "^5.6.0", - "webpack": "^5.95.0", - "webpack-bundle-analyzer": "^4.10.2" - }, - "devDependencies": { - "eslint-plugin-regexp": "^1.15.0" - }, - "scripts": { - "analyze": "node webpack.cjs" - }, - "volta": { - "extends": "../../package.json" - }, - "type": "module" -} diff --git a/dev-packages/bundle-analyzer-scenarios/webpack.cjs b/dev-packages/bundle-analyzer-scenarios/webpack.cjs deleted file mode 100644 index f5874a607473..000000000000 --- a/dev-packages/bundle-analyzer-scenarios/webpack.cjs +++ /dev/null @@ -1,88 +0,0 @@ -const path = require('node:path'); -const { promises } = require('node:fs'); -const { parseArgs } = require('node:util'); - -const webpack = require('webpack'); -const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; -const HtmlWebpackPlugin = require('html-webpack-plugin'); - -async function init() { - const scenarios = await getScenariosFromDirectories(); - - const { values } = parseArgs({ - args: process.argv.slice(2), - options: { scenario: { type: 'string', short: 's' }, list: { type: 'boolean', short: 'l' } }, - }); - - if (values.list) { - console.log('Available scenarios:', scenarios); - process.exit(0); - } - - if (!scenarios.some(scenario => scenario === values.scenario)) { - console.error('Invalid scenario:', values.scenario); - console.error('Available scenarios:', scenarios); - process.exit(1); - } - - console.log(`Bundling scenario: ${values.scenario}`); - - await runWebpack(values.scenario); -} - -async function runWebpack(scenario) { - const alias = await generateAlias(); - - webpack( - { - mode: 'production', - entry: path.resolve(__dirname, scenario), - output: { - filename: 'main.js', - path: path.resolve(__dirname, 'dist', scenario), - }, - plugins: [new BundleAnalyzerPlugin({ analyzerMode: 'static' }), new HtmlWebpackPlugin()], - resolve: { - alias, - }, - }, - (err, stats) => { - if (err || stats.hasErrors()) { - console.log(err); - } - - // console.log('DONE', stats); - }, - ); -} - -const PACKAGE_PATH = '../../packages'; - -/** - * Generate webpack aliases based on packages in monorepo - * Example of an alias: '@sentry/serverless': 'path/to/sentry-javascript/packages/serverless', - */ -async function generateAlias() { - const dirents = await promises.readdir(PACKAGE_PATH); - - return Object.fromEntries( - await Promise.all( - dirents.map(async d => { - const packageJSON = JSON.parse(await promises.readFile(path.resolve(PACKAGE_PATH, d, 'package.json'))); - return [packageJSON['name'], path.resolve(PACKAGE_PATH, d)]; - }), - ), - ); -} - -/** - * Generates an array of available scenarios - */ -async function getScenariosFromDirectories() { - const exclude = ['node_modules', 'dist', '~', 'package.json', 'yarn.lock', 'README.md', '.DS_Store', 'webpack.cjs']; - - const dirents = await promises.readdir(path.join(__dirname), { withFileTypes: true }); - return dirents.map(dirent => dirent.name).filter(mape => !exclude.includes(mape)); -} - -init(); diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 7f51d30a3454..a2b92c4e7d67 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -16,7 +16,7 @@ "@langchain/langgraph": "^1.0.1", "@sentry/cloudflare": "10.51.0", "@sentry/hono": "10.51.0", - "hono": "^4.12.12" + "hono": "^4.12.14" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250922.0", diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index 542ffe82b802..0d19e2e4384b 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -208,22 +208,33 @@ export function createRunner(...paths: string[]) { if (process.env.DEBUG) log('Starting scenario', testPath); - const stdio: ('inherit' | 'ipc' | 'ignore')[] = process.env.DEBUG - ? ['inherit', 'inherit', 'inherit', 'ipc'] - : ['ignore', 'ignore', 'ignore', 'ipc']; - const onChildError = (e: Error) => { // eslint-disable-next-line no-console console.error('Error starting child process:', e); reject(e); }; - function onChildMessage(message: string, onReady?: (port: number) => void): void { - const msg = JSON.parse(message) as { event: string; port?: number }; - if (msg.event === 'DEV_SERVER_READY' && typeof msg.port === 'number') { - if (process.env.DEBUG) log('worker ready on port', msg.port); - onReady?.(msg.port); - } + // Inspired by workers-sdk: https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/e2e/helpers/wrangler.ts + function waitForReady(childProcess: ReturnType): Promise { + return new Promise((resolve, reject) => { + const stdout = childProcess.stdout; + if (!stdout) { + reject(new Error('No stdout available')); + return; + } + + let output = ''; + stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + if (process.env.DEBUG) process.stdout.write(text); + output += text; + + const match = output.match(/Ready on (https?:\/\/[^\s]+)/); + if (match?.[1]) { + resolve(parseInt(new URL(match[1]).port, 10)); + } + }); + }); } if (existsSync(join(testPath, 'wrangler-sub-worker.jsonc'))) { @@ -242,17 +253,15 @@ export function createRunner(...paths: string[]) { '--inspector-port', '0', ], - { stdio, signal }, + { stdio: ['ignore', 'pipe', 'inherit'], signal }, ); - // Wait for the sub-worker to be ready before starting the main worker - await new Promise((resolveSubWorker, rejectSubWorker) => { - childSubWorker!.on('message', (msg: string) => onChildMessage(msg, () => resolveSubWorker())); - childSubWorker!.on('error', rejectSubWorker); - childSubWorker!.on('exit', code => { - rejectSubWorker(new Error(`Sub-worker exited with code ${code}`)); - }); + childSubWorker.on('error', onChildError); + childSubWorker.on('exit', code => { + onChildError(new Error(`Sub-worker exited with code ${code}`)); }); + + await waitForReady(childSubWorker); } child = spawn( @@ -273,7 +282,7 @@ export function createRunner(...paths: string[]) { '0', ...extraWranglerArgs, ], - { stdio, signal }, + { stdio: ['ignore', 'pipe', 'inherit'], signal }, ); CLEANUP_STEPS.add(() => { @@ -283,7 +292,10 @@ export function createRunner(...paths: string[]) { childSubWorker?.on('error', onChildError); child.on('error', onChildError); - child.on('message', (msg: string) => onChildMessage(msg, setWorkerPort)); + + const workerPort = await waitForReady(child); + + setWorkerPort(workerPort); }) .catch(e => reject(e)); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/index.ts new file mode 100644 index 000000000000..05ba6b6a7c71 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/index.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + integrations: [Sentry.httpServerIntegration({ maxRequestBodySize: 'none' })], + }), + { + async fetch(_request, _env, _ctx) { + Sentry.captureMessage('POST with disabled body capture'); + return new Response('ok'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/test.ts new file mode 100644 index 000000000000..20275fb47500 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('Does not capture request body when maxRequestBodySize is none', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('POST with disabled body capture'); + expect(event.request).toEqual( + expect.objectContaining({ + method: 'POST', + url: expect.any(String), + }), + ); + // Body should NOT be captured + expect((event.request as Record).data).toBeUndefined(); + }) + .start(signal); + + await runner.makeRequest('post', '/', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ secret: 'should-not-be-captured' }), + }); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/index.ts new file mode 100644 index 000000000000..a5aea20ed2d0 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/index.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + integrations: integrations => integrations.filter(i => i.name !== 'HttpServer'), + }), + { + async fetch(_request, _env, _ctx) { + Sentry.captureMessage('POST with filtered integration'); + return new Response('ok'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/test.ts new file mode 100644 index 000000000000..d8016550770a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('Does not capture request body when httpServerIntegration is filtered out', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('POST with filtered integration'); + expect(event.request).toEqual( + expect.objectContaining({ + method: 'POST', + url: expect.any(String), + }), + ); + // Body should NOT be captured when integration is filtered out + expect((event.request as Record).data).toBeUndefined(); + }) + .start(signal); + + await runner.makeRequest('post', '/', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ secret: 'should-not-be-captured' }), + }); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/index.ts new file mode 100644 index 000000000000..44bd88fe8044 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/index.ts @@ -0,0 +1,38 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + integrations: [ + Sentry.httpServerIntegration({ + ignoreRequestBody: url => url.includes('/health') || url.includes('/upload'), + }), + ], + }), + { + async fetch(request, _env, _ctx) { + const url = new URL(request.url); + + if (url.pathname === '/health') { + Sentry.captureMessage('Health check'); + return new Response('ok'); + } + + if (url.pathname === '/upload') { + Sentry.captureMessage('Upload request'); + return new Response('ok'); + } + + if (url.pathname === '/api') { + Sentry.captureMessage('API request'); + return new Response('ok'); + } + + return new Response('Not found', { status: 404 }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/test.ts new file mode 100644 index 000000000000..6ceab52c96c0 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/test.ts @@ -0,0 +1,56 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('Does not capture body for ignored URLs (health check)', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('Health check'); + // Body should NOT be captured because URL contains /health + expect((event.request as Record).data).toBeUndefined(); + }) + .start(signal); + + await runner.makeRequest('post', '/health', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ status: 'checking' }), + }); + + await runner.completed(); +}); + +it('Does not capture body for ignored URLs (upload)', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('Upload request'); + // Body should NOT be captured because URL contains /upload + expect((event.request as Record).data).toBeUndefined(); + }) + .start(signal); + + await runner.makeRequest('post', '/upload', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ file: 'large-data' }), + }); + + await runner.completed(); +}); + +it('Captures body for non-ignored URLs', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('API request'); + // Body SHOULD be captured because URL does not match ignore pattern + expect((event.request as Record).data).toBe('{"action":"submit"}'); + }) + .start(signal); + + await runner.makeRequest('post', '/api', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ action: 'submit' }), + }); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/index.ts new file mode 100644 index 000000000000..b4257939cfaf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/index.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + integrations: [Sentry.httpServerIntegration({ maxRequestBodySize: 'small' })], + }), + { + async fetch(request, _env, _ctx) { + const url = new URL(request.url); + + if (url.pathname === '/small-body') { + Sentry.captureMessage('Small body request'); + return new Response('ok'); + } + + if (url.pathname === '/large-body') { + Sentry.captureMessage('Large body request'); + return new Response('ok'); + } + + return new Response('Not found', { status: 404 }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/test.ts new file mode 100644 index 000000000000..173561f2fbb5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/test.ts @@ -0,0 +1,44 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('Captures request body under 1000 bytes with maxRequestBodySize: small', async ({ signal }) => { + const smallBody = JSON.stringify({ data: 'x'.repeat(100) }); + + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('Small body request'); + expect((event.request as Record).data).toBe(smallBody); + }) + .start(signal); + + await runner.makeRequest('post', '/small-body', { + headers: { 'content-type': 'application/json' }, + data: smallBody, + }); + + await runner.completed(); +}); + +it('Truncates request body over 1000 bytes with maxRequestBodySize: small', async ({ signal }) => { + const largeBody = JSON.stringify({ data: 'x'.repeat(2000) }); + + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Record; + expect(event.message).toBe('Large body request'); + const capturedBody = (event.request as Record).data as string; + // Body should be truncated to ~1000 bytes + "..." + expect(capturedBody).toBeDefined(); + expect(capturedBody.endsWith('...')).toBe(true); + expect(capturedBody.length).toBeLessThanOrEqual(1000); + }) + .start(signal); + + await runner.makeRequest('post', '/large-body', { + headers: { 'content-type': 'application/json' }, + data: largeBody, + }); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/index.ts new file mode 100644 index 000000000000..d8da65ad2e1a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/index.ts @@ -0,0 +1,38 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + }), + { + async fetch(request, _env, _ctx) { + const url = new URL(request.url); + + if (url.pathname === '/post-json') { + Sentry.captureMessage('POST JSON request'); + return new Response('ok'); + } + + if (url.pathname === '/post-form') { + Sentry.captureMessage('POST form request'); + return new Response('ok'); + } + + if (url.pathname === '/post-text') { + Sentry.captureMessage('POST text request'); + return new Response('ok'); + } + + if (url.pathname === '/post-no-body') { + Sentry.captureMessage('POST no body request'); + return new Response('ok'); + } + + return new Response('Not found', { status: 404 }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts new file mode 100644 index 000000000000..6773a4cf297e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts @@ -0,0 +1,95 @@ +import { expect, it } from 'vitest'; +import { eventEnvelope } from '../../../expect'; +import { createRunner } from '../../../runner'; + +it('Captures JSON request body', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect( + eventEnvelope({ + level: 'info', + message: 'POST JSON request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-json'), + data: '{"username":"test","action":"login"}', + }, + }), + ) + .start(signal); + + await runner.makeRequest('post', '/post-json', { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ username: 'test', action: 'login' }), + }); + + await runner.completed(); +}); + +it('Captures form-urlencoded request body', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect( + eventEnvelope({ + level: 'info', + message: 'POST form request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-form'), + data: 'username=test&password=secret', + }, + }), + ) + .start(signal); + + await runner.makeRequest('post', '/post-form', { + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + data: 'username=test&password=secret', + }); + + await runner.completed(); +}); + +it('Captures plain text request body', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect( + eventEnvelope({ + level: 'info', + message: 'POST text request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-text'), + data: 'This is plain text content', + }, + }), + ) + .start(signal); + + await runner.makeRequest('post', '/post-text', { + headers: { 'content-type': 'text/plain' }, + data: 'This is plain text content', + }); + + await runner.completed(); +}); + +it('Does not capture body for POST without content', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect( + eventEnvelope({ + level: 'info', + message: 'POST no body request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-no-body'), + }, + }), + ) + .start(signal); + + await runner.makeRequest('post', '/post-no-body', {}); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/queue/index.ts b/dev-packages/cloudflare-integration-tests/suites/queue/index.ts new file mode 100644 index 000000000000..e06560ccf690 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/queue/index.ts @@ -0,0 +1,47 @@ +import type { MessageBatch, Queue } from '@cloudflare/workers-types'; +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + MY_QUEUE: Queue<{ trigger?: 'error'; payload?: string }>; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/enqueue/error') { + await env.MY_QUEUE.send({ trigger: 'error' }); + return new Response('enqueued error'); + } + + if (url.pathname === '/enqueue/ok') { + await env.MY_QUEUE.send({ payload: 'hello' }); + return new Response('enqueued ok'); + } + + if (url.pathname === '/enqueue/batch') { + await env.MY_QUEUE.sendBatch([ + { body: { payload: 'one' } }, + { body: { payload: 'two' } }, + { body: { payload: 'three' } }, + ]); + return new Response('enqueued batch'); + } + + return new Response('not found', { status: 404 }); + }, + async queue(batch: MessageBatch<{ trigger?: 'error'; payload?: string }>) { + for (const message of batch.messages) { + if (message.body.trigger === 'error') { + throw new Error('Boom from queue handler'); + } + } + }, + } as ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/queue/test.ts b/dev-packages/cloudflare-integration-tests/suites/queue/test.ts new file mode 100644 index 000000000000..f0886ba3f37c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/queue/test.ts @@ -0,0 +1,128 @@ +import type { Envelope } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +function envelopeItemType(envelope: Envelope): string | undefined { + return envelope[1][0]?.[0]?.type as string | undefined; +} + +function envelopeItem(envelope: Envelope): Record { + return envelope[1][0]![1] as Record; +} + +function findPublishSpan(envelope: Envelope): Record | undefined { + if (envelopeItemType(envelope) !== 'transaction') return undefined; + const tx = envelopeItem(envelope); + const spans = (tx.spans as Array>) || []; + return spans.find(s => (s.op as string) === 'queue.publish'); +} + +function isConsumerTransaction(envelope: Envelope): boolean { + if (envelopeItemType(envelope) !== 'transaction') return false; + const tx = envelopeItem(envelope); + return tx.transaction === 'process test-queue'; +} + +it('captures errors thrown by the queue handler with the correct mechanism', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('transaction') + .expect((envelope: Envelope) => { + expect(envelopeItemType(envelope)).toBe('event'); + const event = envelopeItem(envelope); + expect(event).toMatchObject({ + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'Boom from queue handler', + mechanism: { type: 'auto.faas.cloudflare.queue', handled: false }, + }, + ], + }, + }); + }) + .start(signal); + + await runner.makeRequest('post', '/enqueue/error'); + await runner.completed(); +}); + +it('emits a queue.publish span on env.MY_QUEUE.send and a queue.process transaction on the consumer', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .unordered() + .expect((envelope: Envelope) => { + // Producer transaction must contain a queue.publish child span + const publishSpan = findPublishSpan(envelope); + expect(publishSpan).toBeDefined(); + expect(publishSpan).toMatchObject({ + op: 'queue.publish', + description: 'send MY_QUEUE', + data: expect.objectContaining({ + 'messaging.system': 'cloudflare', + 'messaging.destination.name': 'MY_QUEUE', + 'messaging.operation.type': 'send', + 'messaging.operation.name': 'send', + 'sentry.origin': 'auto.faas.cloudflare.queue', + }), + }); + }) + .expect((envelope: Envelope) => { + expect(isConsumerTransaction(envelope)).toBe(true); + const tx = envelopeItem(envelope); + const trace = (tx.contexts as Record>).trace as Record; + expect(trace).toMatchObject({ + op: 'queue.process', + origin: 'auto.faas.cloudflare.queue', + data: expect.objectContaining({ + 'messaging.system': 'cloudflare', + 'messaging.destination.name': 'test-queue', + 'messaging.operation.type': 'process', + 'messaging.operation.name': 'process', + 'messaging.batch.message_count': 1, + 'faas.trigger': 'pubsub', + }), + }); + }) + .start(signal); + + await runner.makeRequest('post', '/enqueue/ok'); + await runner.completed(); +}); + +it('emits a queue.publish span with batch attributes on env.MY_QUEUE.sendBatch', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect((envelope: Envelope) => { + const publishSpan = findPublishSpan(envelope); + expect(publishSpan).toBeDefined(); + expect(publishSpan).toMatchObject({ + op: 'queue.publish', + description: 'send MY_QUEUE', + data: expect.objectContaining({ + 'messaging.system': 'cloudflare', + 'messaging.destination.name': 'MY_QUEUE', + 'messaging.operation.type': 'send', + 'messaging.operation.name': 'send', + 'messaging.batch.message_count': 3, + 'sentry.origin': 'auto.faas.cloudflare.queue', + }), + }); + }) + .expect((envelope: Envelope) => { + expect(isConsumerTransaction(envelope)).toBe(true); + const tx = envelopeItem(envelope); + const trace = (tx.contexts as Record>).trace as Record; + expect(trace).toMatchObject({ + data: expect.objectContaining({ + 'messaging.batch.message_count': 3, + }), + }); + }) + .start(signal); + + await runner.makeRequest('post', '/enqueue/batch'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/queue/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/queue/wrangler.jsonc new file mode 100644 index 000000000000..d731714bffe0 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/queue/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "queues": { + "producers": [ + { + "queue": "test-queue", + "binding": "MY_QUEUE", + }, + ], + "consumers": [ + { + "queue": "test-queue", + "max_batch_size": 10, + "max_batch_timeout": 1, + "max_retries": 0, + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts new file mode 100644 index 000000000000..a0bc792645ec --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + DB: D1Database; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env, _ctx) { + const url = new URL(request.url); + const db = Sentry.instrumentD1WithSentry(env.DB); + + if (url.pathname === '/init') { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)'); + await db.prepare('INSERT INTO users (name) VALUES (?)').bind('Alice').run(); + return new Response('Initialized'); + } + + if (url.pathname === '/query') { + const result = await db.prepare('SELECT * FROM users WHERE name = ?').bind('Alice').first(); + return Response.json(result); + } + + return new Response('OK'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts new file mode 100644 index 000000000000..e921b23ce1a2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -0,0 +1,67 @@ +import { expect, it } from 'vitest'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('D1 database queries create spans with correct attributes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'GET /init', + spans: [ + { + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', + 'cloudflare.d1.query_type': 'run', + 'cloudflare.d1.duration': expect.any(Number), + 'cloudflare.d1.rows_read': expect.any(Number), + 'cloudflare.d1.rows_written': expect.any(Number), + }, + description: 'INSERT INTO users (name) VALUES (?)', + op: 'db.query', + origin: 'auto.db.cloudflare.d1', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }, + ], + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'GET /query', + spans: [ + { + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', + 'cloudflare.d1.query_type': 'first', + }, + description: 'SELECT * FROM users WHERE name = ?', + op: 'db.query', + origin: 'auto.db.cloudflare.d1', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }, + ], + }), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/init'); + await runner.makeRequest('get', '/query'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/wrangler.jsonc new file mode 100644 index 000000000000..0ae1692d6726 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "d1-worker", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "d1_databases": [ + { + "binding": "DB", + "database_name": "test-db", + "database_id": "local-test-db", + }, + ], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts index eb21c2918155..941f988971bc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts @@ -1,24 +1,25 @@ import * as Sentry from '@sentry/cloudflare'; import { DurableObject } from 'cloudflare:workers'; -import type { RpcTarget } from 'cloudflare:workers'; interface Env { SENTRY_DSN: string; MY_DURABLE_OBJECT: DurableObjectNamespace; } -class MyDurableObjectBase extends DurableObject implements RpcTarget { - async sayHello(name: string): Promise { - return `Hello, ${name}!`; +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === '/hello') { + return new Response('Hello, World!'); + } + return new Response('Not found', { status: 404 }); } } -// enableRpcTracePropagation is NOT enabled, so RPC methods won't be instrumented export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, - // enableRpcTracePropagation: false (default) }), MyDurableObjectBase, ); @@ -34,9 +35,11 @@ export default Sentry.withSentry( const id = env.MY_DURABLE_OBJECT.idFromName('test'); const stub = env.MY_DURABLE_OBJECT.get(id); - if (url.pathname === '/rpc/hello') { - const result = await stub.sayHello('World'); - return new Response(result); + if (url.pathname === '/do/hello') { + // Call DO via fetch instead of RPC + const doResponse = await stub.fetch(new Request('http://do/hello')); + const text = await doResponse.text(); + return new Response(text); } return new Response('Not found', { status: 404 }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts index cba40af5a43d..4fe2b98956d5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts @@ -2,39 +2,65 @@ import { expect, it } from 'vitest'; import type { Event } from '@sentry/core'; import { createRunner } from '../../../../runner'; -it('does not create RPC transaction when enableRpcTracePropagation is disabled', async ({ signal }) => { - let receivedTransactions: string[] = []; +it('does not propagate trace when enableRpcTracePropagation is disabled', async ({ signal }) => { + let workerTraceId: string | undefined; + let doTraceId: string | undefined; const runner = createRunner(__dirname) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as Event; - // Should only receive the worker HTTP transaction, not the DO RPC transaction expect(transactionEvent).toEqual( expect.objectContaining({ contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', }), }), - transaction: 'GET /rpc/hello', }), ); - receivedTransactions.push(transactionEvent.transaction as string); + + const txName = transactionEvent.transaction as string; + const traceId = transactionEvent.contexts?.trace?.trace_id as string; + + if (txName === 'GET /do/hello') { + workerTraceId = traceId; + } else if (txName === 'GET /hello') { + doTraceId = traceId; + } + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + }), + ); + + const txName = transactionEvent.transaction as string; + const traceId = transactionEvent.contexts?.trace?.trace_id as string; + + if (txName === 'GET /do/hello') { + workerTraceId = traceId; + } else if (txName === 'GET /hello') { + doTraceId = traceId; + } }) + .unordered() .start(signal); - // The RPC call should still work, just not be instrumented - const response = await runner.makeRequest('get', '/rpc/hello'); + const response = await runner.makeRequest('get', '/do/hello'); expect(response).toBe('Hello, World!'); await runner.completed(); - // Verify we only got the worker transaction, no RPC transaction - expect(receivedTransactions).toEqual(['GET /rpc/hello']); - expect(receivedTransactions).not.toContain('sayHello'); + // Both transactions should exist but have different trace IDs (no propagation) + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).not.toBe(doTraceId); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts new file mode 100644 index 000000000000..5e59441803e5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/cloudflare'; +import { WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; +} + +class MySubWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === '/answer') { + return new Response('The answer is 42'); + } + + if (url.pathname === '/greet') { + const name = url.searchParams.get('name') || 'Anonymous'; + return new Response(`Hello, ${name}!`); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MySubWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts new file mode 100644 index 000000000000..e46d7ffd4daf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SUB_WORKER: Fetcher; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/call-entrypoint') { + const response = await env.SUB_WORKER.fetch(new Request('http://fake-host/answer')); + const text = await response.text(); + return new Response(text); + } + + if (url.pathname === '/call-entrypoint-greet') { + const response = await env.SUB_WORKER.fetch(new Request('http://fake-host/greet?name=World')); + const text = await response.text(); + return new Response(text); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts new file mode 100644 index 000000000000..3b76e28e9e88 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts @@ -0,0 +1,129 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from Worker (ExportedHandler) to WorkerEntrypoint via service binding fetch', async ({ + signal, +}) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let entrypointTraceId: string | undefined; + let entrypointParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Main worker HTTP server transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /call-entrypoint', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // WorkerEntrypoint HTTP server transaction (from service binding fetch) + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /answer', + }), + ); + entrypointTraceId = transactionEvent.contexts?.trace?.trace_id as string; + entrypointParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/call-entrypoint'); + expect(response).toBe('The answer is 42'); + + await runner.completed(); + + // Both transactions should share the same trace_id + expect(workerTraceId).toBeDefined(); + expect(entrypointTraceId).toBeDefined(); + expect(workerTraceId).toBe(entrypointTraceId); + + // Verify the parent-child relationship: Worker -> WorkerEntrypoint + expect(workerSpanId).toBeDefined(); + expect(entrypointParentSpanId).toBeDefined(); + expect(entrypointParentSpanId).toBe(workerSpanId); +}); + +it('propagates trace for request with query params from Worker to WorkerEntrypoint', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let entrypointTraceId: string | undefined; + let entrypointParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + transaction: 'GET /call-entrypoint-greet', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + transaction: 'GET /greet', + }), + ); + entrypointTraceId = transactionEvent.contexts?.trace?.trace_id as string; + entrypointParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/call-entrypoint-greet'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(entrypointTraceId).toBeDefined(); + expect(workerTraceId).toBe(entrypointTraceId); + + expect(workerSpanId).toBeDefined(); + expect(entrypointParentSpanId).toBeDefined(); + expect(entrypointParentSpanId).toBe(workerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler-sub-worker.jsonc new file mode 100644 index 000000000000..13de99007e1f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler-sub-worker.jsonc @@ -0,0 +1,6 @@ +{ + "name": "cloudflare-worker-workerentrypoint-rpc-sub", + "main": "index-sub-worker.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc new file mode 100644 index 000000000000..1638b8a00a18 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "cloudflare-worker-workerentrypoint-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "services": [ + { + "binding": "SUB_WORKER", + "service": "cloudflare-worker-workerentrypoint-rpc-sub", + }, + ], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts new file mode 100644 index 000000000000..222ac72599b2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === '/hello') { + return new Response('Hello, World!'); + } + return new Response('Not found', { status: 404 }); + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyDurableObjectBase, +); + +class MyWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const id = this.env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = this.env.MY_DURABLE_OBJECT.get(id); + + if (url.pathname === '/do/hello') { + const doResponse = await stub.fetch(new Request('http://do/hello')); + const text = await doResponse.text(); + return new Response(text); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts new file mode 100644 index 000000000000..4882f09ccaaa --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts @@ -0,0 +1,66 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('does not propagate trace when enableRpcTracePropagation is disabled (WorkerEntrypoint)', async ({ signal }) => { + let workerTraceId: string | undefined; + let doTraceId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + }), + ); + + const txName = transactionEvent.transaction as string; + const traceId = transactionEvent.contexts?.trace?.trace_id as string; + + if (txName === 'GET /do/hello') { + workerTraceId = traceId; + } else if (txName === 'GET /hello') { + doTraceId = traceId; + } + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + }), + ); + + const txName = transactionEvent.transaction as string; + const traceId = transactionEvent.contexts?.trace?.trace_id as string; + + if (txName === 'GET /do/hello') { + workerTraceId = traceId; + } else if (txName === 'GET /hello') { + doTraceId = traceId; + } + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/do/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + // Both transactions should exist but have different trace IDs (no propagation) + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).not.toBe(doTraceId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/wrangler.jsonc new file mode 100644 index 000000000000..78303e091bf4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-workerentrypoint-do-rpc-disabled", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts new file mode 100644 index 000000000000..63876045722b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts @@ -0,0 +1,55 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject { + async sayHello(name: string): Promise { + return `Hello, ${name}!`; + } + + async multiply(a: number, b: number): Promise { + return a * b; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +class MyWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const id = this.env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = this.env.MY_DURABLE_OBJECT.get(id); + + if (url.pathname === '/rpc/hello') { + const result = await stub.sayHello('World'); + return new Response(result); + } + + if (url.pathname === '/rpc/multiply') { + const result = await stub.multiply(6, 7); + return new Response(String(result)); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts new file mode 100644 index 000000000000..2dd17269ae23 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts @@ -0,0 +1,123 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from WorkerEntrypoint to durable object via this.env RPC call', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'sayHello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /rpc/hello', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); + +it('propagates trace for RPC method with multiple arguments via this.env', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + }), + }), + transaction: 'multiply', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + transaction: 'GET /rpc/multiply', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/multiply'); + expect(response).toBe('42'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..e0d4024f8b8b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-workerentrypoint-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts new file mode 100644 index 000000000000..4ff513ccfd03 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts @@ -0,0 +1,47 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async computeAnswer(): Promise { + return 42; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +class MySubWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === '/call-do') { + const id = this.env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = this.env.MY_DURABLE_OBJECT.get(id); + const result = await stub.computeAnswer(); + return new Response(`The answer is ${result}`); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MySubWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts new file mode 100644 index 000000000000..19ebc32abc55 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/cloudflare'; +import { WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + SUB_WORKER: Fetcher; +} + +class MyWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === '/chain') { + const response = await this.env.SUB_WORKER.fetch(new Request('http://fake-host/call-do')); + const text = await response.text(); + return new Response(text); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts new file mode 100644 index 000000000000..474624fa2145 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts @@ -0,0 +1,105 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from WorkerEntrypoint to WorkerEntrypoint to durable object (3 levels deep)', async ({ + signal, +}) => { + let mainWorkerTraceId: string | undefined; + let mainWorkerSpanId: string | undefined; + let subWorkerTraceId: string | undefined; + let subWorkerSpanId: string | undefined; + let subWorkerParentSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Main worker HTTP server transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /chain', + }), + ); + mainWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + mainWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Sub-worker HTTP server transaction (from service binding fetch) + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /call-do', + }), + ); + subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + subWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Durable Object RPC transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'computeAnswer', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/chain'); + expect(response).toBe('The answer is 42'); + + await runner.completed(); + + // All three transactions should share the same trace_id + expect(mainWorkerTraceId).toBeDefined(); + expect(subWorkerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(mainWorkerTraceId).toBe(subWorkerTraceId); + expect(subWorkerTraceId).toBe(doTraceId); + + // Verify the parent-child relationships form a chain: + // Main WorkerEntrypoint -> Sub WorkerEntrypoint -> DO + expect(mainWorkerSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBe(mainWorkerSpanId); + + expect(subWorkerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(subWorkerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler-sub-worker.jsonc new file mode 100644 index 000000000000..873f66317fc8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler-sub-worker.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-workerentrypoint-workerentrypoint-do-rpc-sub", + "main": "index-sub-worker.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..45dfacf580a1 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "cloudflare-workerentrypoint-workerentrypoint-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "services": [ + { + "binding": "SUB_WORKER", + "service": "cloudflare-workerentrypoint-workerentrypoint-do-rpc-sub", + }, + ], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts new file mode 100644 index 000000000000..75341f09eeef --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(_request, _env, _ctx) { + return new Response('OK'); + }, + async scheduled(_controller, _env, _ctx) { + // Successful scheduled handler - just does some work + await new Promise(resolve => setTimeout(resolve, 10)); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts new file mode 100644 index 000000000000..462f4f046b78 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts @@ -0,0 +1,45 @@ +import { expect, it } from 'vitest'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, +} from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('Scheduled handler creates transaction with correct attributes', async ({ signal }) => { + const runner = createRunner(__dirname) + .withWranglerArgs('--test-scheduled') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: expect.stringMatching(/^Scheduled Cron/), + transaction_info: { source: 'task' }, + spans: [], + contexts: expect.objectContaining({ + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'faas.cron', + origin: 'auto.faas.cloudflare.scheduled', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'faas.cron', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.scheduled', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + 'faas.cron': expect.any(String), + 'faas.time': expect.any(String), + 'faas.trigger': 'timer', + }, + }, + }), + }), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/__scheduled'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/wrangler.jsonc new file mode 100644 index 000000000000..12630676aa01 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "name": "scheduled-worker", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "triggers": { + "crons": ["* * * * *"], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts new file mode 100644 index 000000000000..dce6c1d58ced --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts @@ -0,0 +1,60 @@ +import * as Sentry from '@sentry/cloudflare'; +import { WorkflowEntrypoint } from 'cloudflare:workers'; +import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_WORKFLOW: Workflow; +} + +class MyWorkflowBase extends WorkflowEntrypoint { + async run(_event: WorkflowEvent, step: WorkflowStep): Promise { + await step.do('step-one', async () => { + return 'Step one completed'; + }); + + await step.do('step-two', async () => { + return 'Step two completed'; + }); + } +} + +export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyWorkflowBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === '/workflow/trigger') { + const instance = await env.MY_WORKFLOW.create(); + for (let i = 0; i < 15; i++) { + try { + const s = await instance.status(); + if (s.status === 'complete' || s.status === 'errored') { + return new Response(JSON.stringify({ id: instance.id, ...s }), { + headers: { 'content-type': 'application/json' }, + }); + } + } catch { + // status() may not be available in local dev + } + await new Promise(r => setTimeout(r, 500)); + } + return new Response(JSON.stringify({ id: instance.id, status: 'timeout' }), { + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('OK'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts new file mode 100644 index 000000000000..568b744f9555 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts @@ -0,0 +1,69 @@ +import { expect, it } from 'vitest'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, +} from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('Workflow steps create transactions with correct attributes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'step-one', + transaction_info: { source: 'task' }, + spans: [], + contexts: expect.objectContaining({ + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'function.step.do', + origin: 'auto.faas.cloudflare.workflow', + status: 'ok', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.step.do', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + }, + }), + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'step-two', + transaction_info: { source: 'task' }, + spans: [], + contexts: expect.objectContaining({ + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'function.step.do', + origin: 'auto.faas.cloudflare.workflow', + status: 'ok', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.step.do', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + }, + }), + }), + ); + }) + .unordered() + .start(signal); + + await runner.makeRequest('get', '/workflow/trigger'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/wrangler.jsonc new file mode 100644 index 000000000000..b8d729d16591 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "workflow-worker", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "workflows": [ + { + "name": "my-workflow", + "binding": "MY_WORKFLOW", + "class_name": "MyWorkflow", + }, + ], +} diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json index fabe0ce2333b..9d7bac9204cc 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json @@ -21,6 +21,7 @@ "wrangler": "^4.63.0" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json index 4869975f7519..722ed1d8c71e 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json @@ -22,7 +22,7 @@ "wrangler": "^4.72.0" }, "volta": { - "node": "22.22.0", + "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts index 560f676cfd07..35ed4b64428c 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts @@ -59,7 +59,7 @@ test.describe('Lambda layer', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', @@ -127,7 +127,7 @@ test.describe('Lambda layer', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts index 943d5a2ab0f3..3f07fdd9b696 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts @@ -45,7 +45,7 @@ test.describe('NPM package', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', @@ -113,7 +113,7 @@ test.describe('NPM package', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json index 1c391eb7cf5e..71cae14a0120 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json @@ -15,7 +15,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.3.2" }, "dependencies": { "@sentry/browser": "file:../../packed/sentry-browser-packed.tgz", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json index b3b8695148fb..5c4cbe43a5d0 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json @@ -12,16 +12,17 @@ }, "dependencies": { "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", - "hono": "4.12.12" + "hono": "4.12.14" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.31", "@cloudflare/workers-types": "^4.20250521.0", "typescript": "^5.9.3", "vitest": "3.1.0", - "wrangler": "4.61.0" + "wrangler": "^4.61.0" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json index 7433244fc417..0fa111d91e5d 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json @@ -28,6 +28,7 @@ "ws": "^8.18.3" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/.gitignore new file mode 100644 index 000000000000..e71378008bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/.gitignore @@ -0,0 +1 @@ +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json new file mode 100644 index 000000000000..3571edc1fad7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json @@ -0,0 +1,33 @@ +{ + "name": "cloudflare-mcp-agent", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\"", + "build": "wrangler deploy --dry-run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "agents": "0.11.9", + "zod": "^4.3.6" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260426.0", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^6.0.3", + "wrangler": "^4.86.0" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/playwright.config.ts new file mode 100644 index 000000000000..5f22d56bb19c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/playwright.config.ts @@ -0,0 +1,15 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38788; + +const config = getPlaywrightConfig({ + startCommand: `pnpm dev --port ${APP_PORT}`, + port: APP_PORT, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/env.d.ts new file mode 100644 index 000000000000..a936f6586952 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/env.d.ts @@ -0,0 +1,4 @@ +interface Env { + E2E_TEST_DSN: string; + MCP_AGENT: DurableObjectNamespace; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts new file mode 100644 index 000000000000..964ab22cce55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts @@ -0,0 +1,78 @@ +import * as Sentry from '@sentry/cloudflare'; +import { McpAgent } from 'agents/mcp'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as z from 'zod'; + +class MyMCPAgentBase extends McpAgent> { + #mcpServer = new McpServer({ + name: 'cloudflare-mcp-agent', + version: '1.0.0', + }); + + get server() { + return Sentry.wrapMcpServerWithSentry(this.#mcpServer); + } + + async init(): Promise { + this.#mcpServer.registerTool( + 'my-tool', + { + title: 'My Tool', + description: 'My Tool Description', + inputSchema: { + message: z.string(), + }, + }, + async ({ message }) => { + const span = Sentry.getActiveSpan(); + + await new Promise(resolve => setTimeout(resolve, 500)); + + if (span) { + span.setAttribute('mcp.tool.name', 'my-tool'); + span.setAttribute('mcp.tool.extra', 'from-mcpagent'); + span.setAttribute('mcp.tool.input', JSON.stringify({ message })); + } + + return { + content: [ + { + type: 'text' as const, + text: `Tool my-tool: ${message}`, + }, + ], + }; + }, + ); + } +} + +export const MyMCPAgent = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, + debug: true, + transportOptions: { + bufferSize: 1000, + }, + }), + MyMCPAgentBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, + debug: true, + transportOptions: { + bufferSize: 1000, + }, + }), + MyMCPAgent.serve('/mcp', { binding: 'MCP_AGENT' }), +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/start-event-proxy.mjs new file mode 100644 index 000000000000..946988f3fdc7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-mcp-agent', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts new file mode 100644 index 000000000000..cde74a76aa27 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from '@playwright/test'; +import { waitForRequest } from '@sentry-internal/test-utils'; + +test('sends spans for MCP tool calls via MCPAgent (DurableObject)', async ({ baseURL }) => { + const mcpToolWaiter = waitForRequest('cloudflare-mcp-agent', event => { + const transaction = event.envelope[1][0][1]; + return ( + typeof transaction !== 'string' && + 'transaction' in transaction && + transaction.transaction === 'tools/call my-tool' + ); + }); + + // Step 1: Initialize the MCP session + const initResponse = await fetch(`${baseURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { + name: 'test-client', + version: '1.0.0', + }, + }, + }), + }); + + expect(initResponse.status).toBe(200); + const sessionId = initResponse.headers.get('Mcp-Session-Id'); + expect(sessionId).toBeTruthy(); + + // Step 2: Send initialized notification + await fetch(`${baseURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Session-Id': sessionId!, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }), + }); + + // Step 3: Call the tool with the session ID + const response = await fetch(`${baseURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Session-Id': sessionId!, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'my-tool', + arguments: { + message: 'hello from MCPAgent test', + }, + }, + }), + }); + + expect(response.status).toBe(200); + + const mcpData = await mcpToolWaiter; + const mcpEvent = mcpData.envelope[1][0][1]; + + expect(mcpEvent.contexts?.trace?.trace_id).toBe(mcpData.envelope[0].trace.trace_id); + expect(mcpEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + parent_span_id: expect.any(String), + span_id: expect.any(String), + op: 'mcp.server', + origin: 'auto.function.mcp_server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.op': 'mcp.server', + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'my-tool', + 'mcp.tool.extra': 'from-mcpagent', + 'mcp.tool.input': '{"message":"hello from MCPAgent test"}', + }), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tsconfig.json new file mode 100644 index 000000000000..2e9384f1b328 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "jsx": "react-jsx", + "module": "es2022", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "exclude": ["test"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/wrangler.jsonc new file mode 100644 index 000000000000..a29277811225 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/wrangler.jsonc @@ -0,0 +1,21 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-mcp-agent", + "main": "src/index.ts", + "compatibility_date": "2025-03-21", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { + "name": "MCP_AGENT", + "class_name": "MyMCPAgent", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["MyMCPAgent"], + }, + ], +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json index bae4b4ffd272..282c0353d120 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json @@ -31,6 +31,7 @@ "ws": "^8.18.3" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/.gitignore new file mode 100644 index 000000000000..e71378008bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/.gitignore @@ -0,0 +1 @@ +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/package.json new file mode 100644 index 000000000000..4f314f5f4396 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/package.json @@ -0,0 +1,39 @@ +{ + "name": "cloudflare-workers-streaming", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "build": "wrangler deploy --dry-run", + "test": "vitest --run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@cloudflare/vitest-pool-workers": "^0.8.19", + "@cloudflare/workers-types": "^4.20240725.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.5.2", + "vitest": "~3.2.0", + "wrangler": "^4.61.0", + "ws": "^8.18.3" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "strip-literal": "~2.0.0" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/playwright.config.ts new file mode 100644 index 000000000000..5c49d7c8e302 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/playwright.config.ts @@ -0,0 +1,23 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38787; +export const INSPECTOR_PORT = 9230; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm dev --port ${APP_PORT} --inspector-port ${INSPECTOR_PORT}`, + port: APP_PORT, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + retries: 0, + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/env.d.ts new file mode 100644 index 000000000000..1701ed9f621a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/env.d.ts @@ -0,0 +1,7 @@ +// Generated by Wrangler on Mon Jul 29 2024 21:44:31 GMT-0400 (Eastern Daylight Time) +// by running `wrangler types` + +interface Env { + E2E_TEST_DSN: ''; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts new file mode 100644 index 000000000000..b8bf4529a602 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts @@ -0,0 +1,137 @@ +/** + * Welcome to Cloudflare Workers! This is your first worker. + * + * - Run `npm run dev` in your terminal to start a development server + * - Open a browser tab at http://localhost:8787/ to see your worker in action + * - Run `npm run deploy` to publish your worker + * + * Bind resources to your worker in `wrangler.toml`. After adding bindings, a type definition for the + * `Env` object can be regenerated with `npm run cf-typegen`. + * + * Learn more at https://developers.cloudflare.com/workers/ + */ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +class MyDurableObjectBase extends DurableObject { + private throwOnExit = new WeakMap(); + async throwException(): Promise { + throw new Error('Should be recorded in Sentry.'); + } + + async alarm(): Promise { + const action = await this.ctx.storage.get('alarm-action'); + if (action === 'throw') { + throw new Error('Alarm error captured by Sentry'); + } + } + + async fetch(request: Request) { + const url = new URL(request.url); + switch (url.pathname) { + case '/throwException': { + await this.throwException(); + break; + } + case '/ws': { + const webSocketPair = new WebSocketPair(); + const [client, server] = Object.values(webSocketPair); + this.ctx.acceptWebSocket(server); + return new Response(null, { status: 101, webSocket: client }); + } + case '/setAlarm': { + const action = url.searchParams.get('action') || 'succeed'; + await this.ctx.storage.put('alarm-action', action); + await this.ctx.storage.setAlarm(Date.now() + 500); + return new Response('Alarm set'); + } + case '/storage/put': { + await this.ctx.storage.put('test-key', 'test-value'); + return new Response('Stored'); + } + case '/storage/get': { + const value = await this.ctx.storage.get('test-key'); + return new Response(`Got: ${value}`); + } + } + return new Response('DO is fine'); + } + + webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void | Promise { + if (message === 'throwException') { + throw new Error('Should be recorded in Sentry: webSocketMessage'); + } else if (message === 'throwOnExit') { + this.throwOnExit.set(ws, new Error('Should be recorded in Sentry: webSocketClose')); + } + } + + webSocketClose(ws: WebSocket): void | Promise { + if (this.throwOnExit.has(ws)) { + const error = this.throwOnExit.get(ws)!; + this.throwOnExit.delete(ws); + throw error; + } + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + switch (url.pathname) { + case '/rpc/throwException': + { + const id = env.MY_DURABLE_OBJECT.idFromName('foo'); + const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub; + try { + await stub.throwException(); + } catch (e) { + //We will catch this to be sure not to log inside withSentry + return new Response(null, { status: 500 }); + } + } + break; + case '/throwException': + throw new Error('To be recorded in Sentry.'); + default: + if (url.pathname.startsWith('/pass-to-object/')) { + const id = env.MY_DURABLE_OBJECT.idFromName('foo'); + const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub; + url.pathname = url.pathname.replace('/pass-to-object/', ''); + return stub.fetch(new Request(url, request)); + } + } + return new Response('Hello World!'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..58f1fbfb123c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-workers-streaming', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/index.test.ts new file mode 100644 index 000000000000..e984247a01d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/index.test.ts @@ -0,0 +1,163 @@ +import { expect, test } from '@playwright/test'; +import { + getSpanOp, + waitForError, + waitForRequest, + waitForStreamedSpan, + waitForStreamedSpans, +} from '@sentry-internal/test-utils'; +import { SDK_VERSION } from '@sentry/cloudflare'; +import { WebSocket } from 'ws'; + +test('Index page', async ({ baseURL }) => { + const result = await fetch(baseURL!); + expect(result.status).toBe(200); + await expect(result.text()).resolves.toBe('Hello World!'); +}); + +test('Sends a streamed span for a basic request', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan('cloudflare-workers-streaming', span => { + return getSpanOp(span) === 'http.server' && span.is_segment; + }); + + await fetch(baseURL!); + + const span = await spanPromise; + + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.status).toBe('ok'); +}); + +test("worker's withSentry", async ({ baseURL }) => { + const eventWaiter = waitForError('cloudflare-workers-streaming', event => { + return event.exception?.values?.[0]?.mechanism?.type === 'auto.http.cloudflare'; + }); + const response = await fetch(`${baseURL}/throwException`); + expect(response.status).toBe(500); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('To be recorded in Sentry.'); +}); + +test('RPC method which throws an exception to be logged to sentry', async ({ baseURL }) => { + const eventWaiter = waitForError('cloudflare-workers-streaming', event => { + return event.exception?.values?.[0]?.mechanism?.type === 'auto.faas.cloudflare.durable_object'; + }); + const response = await fetch(`${baseURL}/rpc/throwException`); + expect(response.status).toBe(500); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.'); +}); + +test("Request processed by DurableObject's fetch is recorded", async ({ baseURL }) => { + const eventWaiter = waitForError('cloudflare-workers-streaming', event => { + return event.exception?.values?.[0]?.mechanism?.type === 'auto.faas.cloudflare.durable_object'; + }); + const response = await fetch(`${baseURL}/pass-to-object/throwException`); + expect(response.status).toBe(500); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.'); +}); + +test('Websocket.webSocketMessage', async ({ baseURL }) => { + const eventWaiter = waitForError('cloudflare-workers-streaming', event => { + return !!event.exception?.values?.[0]; + }); + const url = new URL('/pass-to-object/ws', baseURL); + url.protocol = url.protocol.replace('http', 'ws'); + const socket = new WebSocket(url.toString()); + socket.addEventListener('open', () => { + socket.send('throwException'); + }); + const event = await eventWaiter; + socket.close(); + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketMessage'); + expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object'); +}); + +test('Websocket.webSocketClose', async ({ baseURL }) => { + const eventWaiter = waitForError('cloudflare-workers-streaming', event => { + return !!event.exception?.values?.[0]; + }); + const url = new URL('/pass-to-object/ws', baseURL); + url.protocol = url.protocol.replace('http', 'ws'); + const socket = new WebSocket(url.toString()); + socket.addEventListener('open', () => { + socket.send('throwOnExit'); + socket.close(); + }); + const event = await eventWaiter; + expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketClose'); + expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object'); +}); + +test('sends user-agent header with SDK name and version in envelope requests', async ({ baseURL }) => { + const requestPromise = waitForRequest('cloudflare-workers-streaming', () => true); + + await fetch(`${baseURL}/throwException`); + + const request = await requestPromise; + + expect(request.rawProxyRequestHeaders).toMatchObject({ + 'user-agent': `sentry.javascript.cloudflare/${SDK_VERSION}`, + }); +}); + +test('Storage operations create spans in Durable Object', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('cloudflare-workers-streaming', spans => { + return spans.some(span => span.name === 'durable_object_storage_put' && getSpanOp(span) === 'db'); + }); + + const response = await fetch(`${baseURL}/pass-to-object/storage/put`); + expect(response.status).toBe(200); + + const spans = await spansPromise; + const putSpan = spans.find(span => span.name === 'durable_object_storage_put' && getSpanOp(span) === 'db'); + + expect(putSpan).toBeDefined(); + expect(putSpan?.attributes?.['db.system.name']?.value).toBe('cloudflare.durable_object.storage'); + expect(putSpan?.attributes?.['db.operation.name']?.value).toBe('put'); +}); + +test.describe('Alarm instrumentation', () => { + test.describe.configure({ mode: 'serial' }); + + test('captures error from alarm handler', async ({ baseURL }) => { + const errorWaiter = waitForError('cloudflare-workers-streaming', event => { + return event.exception?.values?.[0]?.value === 'Alarm error captured by Sentry'; + }); + + const response = await fetch(`${baseURL}/pass-to-object/setAlarm?action=throw`); + expect(response.status).toBe(200); + + const event = await errorWaiter; + expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object'); + }); + + test('creates a streamed span for alarm with new trace linked to setAlarm', async ({ baseURL }) => { + const setAlarmSpanPromise = waitForStreamedSpan('cloudflare-workers-streaming', span => { + return span.name === 'durable_object_storage_setAlarm' && span.is_segment === false; + }); + + const alarmSpanPromise = waitForStreamedSpan('cloudflare-workers-streaming', span => { + return span.name === 'alarm' && getSpanOp(span) === 'function' && span.is_segment; + }); + + const response = await fetch(`${baseURL}/pass-to-object/setAlarm`); + expect(response.status).toBe(200); + + const setAlarmSpan = await setAlarmSpanPromise; + const alarmSpan = await alarmSpanPromise; + + // Alarm creates a streamed span with correct attributes + expect(getSpanOp(alarmSpan)).toBe('function'); + expect(alarmSpan.attributes?.['sentry.origin']?.value).toBe('auto.faas.cloudflare.durable_object'); + + // Alarm starts a new trace (different trace ID from the request that called setAlarm) + expect(alarmSpan.trace_id).not.toBe(setAlarmSpan.trace_id); + + // Alarm links to the trace that called setAlarm via sentry.previous_trace attribute + const previousTrace = alarmSpan.attributes?.['sentry.previous_trace']?.value; + expect(previousTrace).toBeDefined(); + expect(previousTrace).toContain(setAlarmSpan.trace_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/memory.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/memory.test.ts new file mode 100644 index 000000000000..740961b3083f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/memory.test.ts @@ -0,0 +1,31 @@ +import { MemoryProfiler } from '@sentry-internal/test-utils'; +import { expect, test } from '@playwright/test'; +import { INSPECTOR_PORT } from '../playwright.config'; + +test.describe('Worker V8 isolate memory tests', () => { + test('worker memory is reclaimed after GC', async ({ baseURL }) => { + const profiler = new MemoryProfiler({ port: INSPECTOR_PORT }); + + // Warm up: make initial requests and let the runtime settle + for (let i = 0; i < 5; i++) { + await fetch(baseURL!); + } + + await profiler.connect(); + + const baselineSnapshot = await profiler.takeHeapSnapshot(); + + for (let i = 0; i < 50; i++) { + const res = await fetch(baseURL!); + expect(res.status).toBe(200); + await res.text(); + } + + const finalSnapshot = await profiler.takeHeapSnapshot(); + const result = profiler.compareSnapshots(baselineSnapshot, finalSnapshot); + + expect(result.nodeGrowthPercent).toBeLessThan(1); + + await profiler.close(); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/tsconfig.json new file mode 100644 index 000000000000..80bfbd97acc1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-pool-workers"] + }, + "include": ["./**/*.ts"], + "exclude": [] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tsconfig.json new file mode 100644 index 000000000000..87d4bbd5fab8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2021", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["es2021"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "es2022", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "Bundler", + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "exclude": ["test"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/vitest.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/vitest.config.mts new file mode 100644 index 000000000000..931e5113e0c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; + +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: './wrangler.toml' }, + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/wrangler.toml new file mode 100644 index 000000000000..d86500477814 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/wrangler.toml @@ -0,0 +1,111 @@ +#:schema node_modules/wrangler/config-schema.json +name = "cloudflare-workers-streaming" +main = "src/index.ts" +compatibility_date = "2024-07-25" +compatibility_flags = ["nodejs_compat"] + +# [vars] +# E2E_TEST_DSN = "" + +# Automatically place your workloads in an optimal location to minimize latency. +# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure +# rather than the end user may result in better performance. +# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement +# [placement] +# mode = "smart" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Docs: +# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables +# Note: Use secrets to store sensitive data. +# - https://developers.cloudflare.com/workers/configuration/secrets/ +# [vars] +# MY_VARIABLE = "production_value" + +# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai +# [ai] +# binding = "AI" + +# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets +# [[analytics_engine_datasets]] +# binding = "MY_DATASET" + +# Bind a headless browser instance running on Cloudflare's global network. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering +# [browser] +# binding = "MY_BROWSER" + +# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases +# [[d1_databases]] +# binding = "MY_DB" +# database_name = "my-database" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms +# [[dispatch_namespaces]] +# binding = "MY_DISPATCHER" +# namespace = "my-namespace" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects +[[durable_objects.bindings]] +name = "MY_DURABLE_OBJECT" +class_name = "MyDurableObject" + +# Durable Object migrations. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations +[[migrations]] +tag = "v1" +new_sqlite_classes = ["MyDurableObject"] + +# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive +# [[hyperdrive]] +# binding = "MY_HYPERDRIVE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates +# [[mtls_certificates]] +# binding = "MY_CERTIFICATE" +# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.consumers]] +# queue = "my-queue" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes +# [[vectorize]] +# binding = "MY_INDEX" +# index_name = "my-index" diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json index b8b028797805..689637868455 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json @@ -28,6 +28,7 @@ "ws": "^8.18.3" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts index 73abbd951b90..5c49d7c8e302 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts @@ -6,10 +6,11 @@ if (!testEnv) { } const APP_PORT = 38787; +export const INSPECTOR_PORT = 9230; const config = getPlaywrightConfig( { - startCommand: `pnpm dev --port ${APP_PORT}`, + startCommand: `pnpm dev --port ${APP_PORT} --inspector-port ${INSPECTOR_PORT}`, port: APP_PORT, }, { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/memory.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/memory.test.ts new file mode 100644 index 000000000000..740961b3083f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/memory.test.ts @@ -0,0 +1,31 @@ +import { MemoryProfiler } from '@sentry-internal/test-utils'; +import { expect, test } from '@playwright/test'; +import { INSPECTOR_PORT } from '../playwright.config'; + +test.describe('Worker V8 isolate memory tests', () => { + test('worker memory is reclaimed after GC', async ({ baseURL }) => { + const profiler = new MemoryProfiler({ port: INSPECTOR_PORT }); + + // Warm up: make initial requests and let the runtime settle + for (let i = 0; i < 5; i++) { + await fetch(baseURL!); + } + + await profiler.connect(); + + const baselineSnapshot = await profiler.takeHeapSnapshot(); + + for (let i = 0; i < 50; i++) { + const res = await fetch(baseURL!); + expect(res.status).toBe(200); + await res.text(); + } + + const finalSnapshot = await profiler.takeHeapSnapshot(); + const result = profiler.compareSnapshots(baselineSnapshot, finalSnapshot); + + expect(result.nodeGrowthPercent).toBeLessThan(1); + + await profiler.close(); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json index 83ac7bce286d..4270a204cdad 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json @@ -28,6 +28,7 @@ "ws": "^8.18.3" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/package.json b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json index 7bbaeaf631f8..282f8abb6492 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/package.json +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "scripts": { - "start": "deno run --allow-net --allow-env --allow-read src/app.ts", + "start": "deno run --allow-net --allow-env --allow-read --allow-sys src/app.ts", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install", diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts index 023429b07f41..15d7eaf99d9a 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts @@ -3,6 +3,10 @@ import { waitForStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; const SEGMENT_SPAN = { attributes: { + 'app.start_time': { + type: 'string', + value: expect.any(String), + }, 'client.address': { type: 'string', value: expect.any(String), @@ -11,6 +15,11 @@ const SEGMENT_SPAN = { type: 'integer', value: expect.any(Number), }, + // TODO: 'device.archs' is set but arrays are not yet serialized in span attributes + 'device.processor_count': { + type: 'integer', + value: expect.any(Number), + }, 'http.request.header.accept': { type: 'string', value: '*/*', @@ -51,6 +60,14 @@ const SEGMENT_SPAN = { type: 'integer', value: expect.any(Number), }, + 'os.name': { + type: 'string', + value: expect.any(String), + }, + 'os.version': { + type: 'string', + value: expect.any(String), + }, 'sentry.environment': { type: 'string', value: 'qa', @@ -115,6 +132,14 @@ const SEGMENT_SPAN = { type: 'string', value: 'node', }, + 'process.runtime.engine.name': { + type: 'string', + value: 'v8', + }, + 'process.runtime.engine.version': { + type: 'string', + value: expect.any(String), + }, }, end_timestamp: expect.any(Number), is_segment: true, diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/.npmrc b/dev-packages/e2e-tests/test-applications/effect-3-node/.npmrc deleted file mode 100644 index 070f80f05092..000000000000 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -@sentry:registry=http://127.0.0.1:4873 -@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/effect-4-browser/.npmrc b/dev-packages/e2e-tests/test-applications/effect-4-browser/.npmrc deleted file mode 100644 index 070f80f05092..000000000000 --- a/dev-packages/e2e-tests/test-applications/effect-4-browser/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -@sentry:registry=http://127.0.0.1:4873 -@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/hono-4/.npmrc b/dev-packages/e2e-tests/test-applications/hono-4/.npmrc deleted file mode 100644 index 070f80f05092..000000000000 --- a/dev-packages/e2e-tests/test-applications/hono-4/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -@sentry:registry=http://127.0.0.1:4873 -@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index ba07bb7db4ca..b4b1a901e95e 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -28,6 +28,7 @@ "wrangler": "^4.61.0" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts new file mode 100644 index 000000000000..b8f2fd96fe93 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts @@ -0,0 +1,45 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +const errorRoutes = new Hono(); + +// Middleware that throws a 5xx HTTPException (should be captured) +errorRoutes.use('/middleware-http-exception/*', async (_c, _next) => { + throw new HTTPException(503, { message: 'Service Unavailable from middleware' }); +}); + +errorRoutes.get('/middleware-http-exception', c => c.text('should not reach')); + +// Middleware that throws a 4xx HTTPException (should NOT be captured) +errorRoutes.use('/middleware-http-exception-4xx/*', async (_c, _next) => { + throw new HTTPException(401, { message: 'Unauthorized from middleware' }); +}); + +errorRoutes.get('/middleware-http-exception-4xx', c => c.text('should not reach')); + +// Sub-app with a custom onError handler that swallows errors +const subAppWithOnError = new Hono(); + +subAppWithOnError.onError((err, c) => { + return c.text(`Handled by onError: ${err.message}`, 500); +}); + +subAppWithOnError.get('/fail', () => { + throw new Error('Error caught by custom onError'); +}); + +errorRoutes.route('/custom-on-error', subAppWithOnError); + +// Nested sub-apps: parent mounts child, child route throws +const childApp = new Hono(); + +childApp.get('/error', () => { + throw new Error('Nested child app error'); +}); + +const parentApp = new Hono(); +parentApp.route('/child', childApp); + +errorRoutes.route('/nested', parentApp); + +export { errorRoutes }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts index e32662fb3b18..598943cad868 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts @@ -1,5 +1,4 @@ import { Hono } from 'hono'; -import { HTTPException } from 'hono/http-exception'; const routePatterns = new Hono(); @@ -24,32 +23,4 @@ METHODS.forEach(method => { routePatterns.on(method, '/on', c => c.text(`${method} on response`)); }); -// Error routes for direct method registration -METHODS.forEach(method => { - routePatterns[method]('/500', () => { - throw new HTTPException(500, { message: 'response 500' }); - }); - routePatterns[method]('/401', () => { - throw new HTTPException(401, { message: 'response 401' }); - }); - routePatterns[method]('/402', () => { - throw new HTTPException(402, { message: 'response 402' }); - }); - routePatterns[method]('/403', () => { - throw new HTTPException(403, { message: 'response 403' }); - }); -}); - -// Error routes for .all() -routePatterns.all('/all/500', () => { - throw new HTTPException(500, { message: 'response 500' }); -}); - -// Error routes for .on() -METHODS.forEach(method => { - routePatterns.on(method, '/on/500', () => { - throw new HTTPException(500, { message: 'response 500' }); - }); -}); - export { routePatterns }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts index f6efc6dde03c..cfb13146b6f7 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts @@ -1,6 +1,7 @@ import type { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { failingMiddleware, middlewareA, middlewareB } from './middleware'; +import { errorRoutes } from './route-groups/test-errors'; import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware'; import { routePatterns } from './route-groups/test-route-patterns'; @@ -43,4 +44,7 @@ export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): v // Route patterns: HTTP methods, .all(), .on(), sync/async, errors app.route('/test-routes', routePatterns); + + // Error-specific routes: onError handler, nested sub-apps, middleware HTTPException + app.route('/test-errors', errorRoutes); } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts index 832204237946..98c81d30afeb 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts @@ -1,53 +1,250 @@ import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; -import { APP_NAME } from './constants'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME, RUNTIME } from './constants'; -test('captures error thrown in route handler', async ({ baseURL }) => { - const errorWaiter = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'This is a test error for Sentry!'; - }); +test.describe('route handler errors', () => { + test('captures error with mechanism and trace correlation', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'This is a test error for Sentry!'; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/error/'); + }); + + const response = await fetch(`${baseURL}/error/test-cause`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + const transactionEvent = await transactionPromise; + + expect(transactionEvent.transaction).toBe('GET /error/:cause'); + + expect(errorEvent.exception?.values).toHaveLength(1); + + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is a test error for Sentry!'); + expect(exception?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); - const response = await fetch(`${baseURL}/error/test-cause`); - expect(response.status).toBe(500); + expect(errorEvent.transaction).toBe('GET /error/:cause'); + expect(errorEvent.request?.method).toBe('GET'); + expect(errorEvent.request?.url).toContain('/error/test-cause'); - const event = await errorWaiter; - expect(event.exception?.values?.[0]?.value).toBe('This is a test error for Sentry!'); + expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + }); }); -test('captures HTTPException with 502 status', async ({ baseURL }) => { - const errorWaiter = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'HTTPException 502'; +test.describe('HTTPException errors', () => { + test('captures 5xx HTTPException', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'HTTPException 500'; + }); + + const response = await fetch(`${baseURL}/http-exception/500`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('HTTPException 500'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); }); - const response = await fetch(`${baseURL}/http-exception/502`); - expect(response.status).toBe(502); + // On Node/Bun, httpServerSpansIntegration drops transactions for 3xx/4xx responses (ignoreStatusCodes), so we just use a request guard. + // On Cloudflare the transaction is available, and we additionally verify its name. + [301, 302].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { + errorEventOccurred = true; + } + return false; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return RUNTIME === 'cloudflare' + ? event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/http-exception/') + : event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /'; + }); + + const response = await fetch(`${baseURL}/http-exception/${code}`, { redirect: 'manual' }); + expect(response.status).toBe(code); + + if (RUNTIME !== 'cloudflare') { + // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses + await fetch(`${baseURL}/`); + } + + const transaction = await transactionPromise; + + if (RUNTIME === 'cloudflare') { + expect(transaction.transaction).toBe('GET /http-exception/:code'); + } + + expect(errorEventOccurred).toBe(false); + }); + }); + + [401, 403, 404].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { + errorEventOccurred = true; + } + return false; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return RUNTIME === 'cloudflare' + ? event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/http-exception/') + : event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /'; + }); + + const response = await fetch(`${baseURL}/http-exception/${code}`); + expect(response.status).toBe(code); + + if (RUNTIME !== 'cloudflare') { + // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses + await fetch(`${baseURL}/`); + } + + const transaction = await transactionPromise; + + if (RUNTIME === 'cloudflare') { + expect(transaction.transaction).toBe('GET /http-exception/:code'); + } - const event = await errorWaiter; - expect(event.exception?.values?.[0]?.value).toBe('HTTPException 502'); + expect(errorEventOccurred).toBe(false); + }); + }); }); -// TODO: 401 and 404 HTTPExceptions should not be captured by Sentry by default, -// but currently they are. Fix the filtering and update these tests accordingly. -test('captures HTTPException with 401 status', async ({ baseURL }) => { - const errorWaiter = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'HTTPException 401'; +test.describe('middleware errors', () => { + test('captures 5xx HTTPException thrown in middleware with error span status', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Service Unavailable from middleware'; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return ( + event.contexts?.trace?.op === 'http.server' && + !!event.transaction?.includes('/test-errors/middleware-http-exception') + ); + }); + + const response = await fetch(`${baseURL}/test-errors/middleware-http-exception`); + expect(response.status).toBe(503); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('Service Unavailable from middleware'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.middleware.hono'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false); + + const transaction = await transactionPromise; + const middlewareSpan = (transaction.spans || []).find(s => s.op === 'middleware.hono'); + expect(middlewareSpan?.status).toBe('internal_error'); }); - const response = await fetch(`${baseURL}/http-exception/401`); - expect(response.status).toBe(401); + test('does not capture 4xx HTTPException thrown in middleware', async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === 'Unauthorized from middleware') { + errorEventOccurred = true; + } + return false; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + if (RUNTIME === 'cloudflare') { + return ( + event.contexts?.trace?.op === 'http.server' && + !!event.transaction?.includes('/test-errors/middleware-http-exception-4xx') + ); + } + return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /'; + }); - const event = await errorWaiter; - expect(event.exception?.values?.[0]?.value).toBe('HTTPException 401'); + const response = await fetch(`${baseURL}/test-errors/middleware-http-exception-4xx`); + expect(response.status).toBe(401); + + if (RUNTIME !== 'cloudflare') { + await fetch(`${baseURL}/`); + } + + const transaction = await transactionPromise; + + if (RUNTIME === 'cloudflare') { + expect(transaction.transaction).toBe('GET /test-errors/middleware-http-exception-4xx/*'); + + const middlewareSpan = (transaction.spans || []).find(s => s.op === 'middleware.hono'); + expect(middlewareSpan?.status).not.toBe('internal_error'); + } + + expect(errorEventOccurred).toBe(false); + }); }); -test('captures HTTPException with 404 status', async ({ baseURL }) => { - const errorWaiter = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'HTTPException 404'; +test.describe('nested sub-app errors', () => { + test('captures error from nested child sub-app', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Nested child app error'; + }); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/nested/child/error'); + }); + + const response = await fetch(`${baseURL}/test-errors/nested/child/error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + const transaction = await transactionPromise; + + expect(transaction.transaction).toBe('GET /test-errors/nested/child/error'); + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Nested child app error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + expect(errorEvent.request?.url).toContain('/test-errors/nested/child/error'); }); +}); - const response = await fetch(`${baseURL}/http-exception/404`); - expect(response.status).toBe(404); +test.describe('custom onError handler', () => { + test('captures error even when onError handles the response', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Error caught by custom onError'; + }); - const event = await errorWaiter; - expect(event.exception?.values?.[0]?.value).toBe('HTTPException 404'); + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/custom-on-error/fail'); + }); + + const response = await fetch(`${baseURL}/test-errors/custom-on-error/fail`); + expect(response.status).toBe(500); + + const body = await response.text(); + expect(body).toContain('Handled by onError'); + + const errorEvent = await errorPromise; + const transaction = await transactionPromise; + + expect(transaction.transaction).toBe('GET /test-errors/custom-on-error/fail'); + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Error caught by custom onError'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts index e8431bed67ce..d984ac0d38a8 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts @@ -18,13 +18,15 @@ for (const { name, prefix } of SCENARIOS) { test.describe(name, () => { test('creates a span for named middleware', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/named`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${prefix}/named`); }); const response = await fetch(`${baseURL}${prefix}/named`); expect(response.status).toBe(200); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${prefix}/named`); + const spans = transaction.spans || []; const middlewareSpan = spans.find( @@ -37,9 +39,9 @@ for (const { name, prefix } of SCENARIOS) { description: 'middlewareA', op: 'middleware.hono', origin: 'auto.middleware.hono', - status: 'ok', }), ); + expect(middlewareSpan?.status).not.toBe('internal_error'); // @ts-expect-error timestamp is defined const durationMs = (middlewareSpan?.timestamp - middlewareSpan?.start_timestamp) * 1000; @@ -48,34 +50,37 @@ for (const { name, prefix } of SCENARIOS) { test('creates a span for anonymous middleware', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/anonymous`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${prefix}/anonymous`); }); const response = await fetch(`${baseURL}${prefix}/anonymous`); expect(response.status).toBe(200); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${prefix}/anonymous`); + const spans = transaction.spans || []; - expect(spans).toContainEqual( - expect.objectContaining({ - description: '', - op: 'middleware.hono', - origin: 'auto.middleware.hono', - status: 'ok', - }), + const anonymousSpan = spans.find( + (span: { description?: string; op?: string }) => + span.op === 'middleware.hono' && span.description === '', ); + expect(anonymousSpan).toBeDefined(); + expect(anonymousSpan?.origin).toBe('auto.middleware.hono'); + expect(anonymousSpan?.status).not.toBe('internal_error'); }); test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/multi`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${prefix}/multi`); }); const response = await fetch(`${baseURL}${prefix}/multi`); expect(response.status).toBe(200); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${prefix}/multi`); + const spans = transaction.spans || []; const middlewareSpans = spans.sort((a, b) => (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0)); @@ -115,12 +120,14 @@ for (const { name, prefix } of SCENARIOS) { test('sets error status on middleware span when middleware throws', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/error/*`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${prefix}/error`); }); await fetch(`${baseURL}${prefix}/error`); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${prefix}/error/*`); + const spans = transaction.spans || []; const failingSpan = spans.find( @@ -153,7 +160,8 @@ test.describe('.all() handler in sub-app', () => { test('does not create middleware span for .all() route handler', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { return ( - event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-subapp-middleware/all-handler' + event.contexts?.trace?.op === 'http.server' && + !!event.transaction?.includes('/test-subapp-middleware/all-handler') ); }); @@ -164,6 +172,8 @@ test.describe('.all() handler in sub-app', () => { expect(body).toEqual({ handler: 'all' }); const transaction = await transactionPromise; + expect(transaction.transaction).toBe('GET /test-subapp-middleware/all-handler'); + const spans = transaction.spans || []; // No middleware is called for this route, so there should be no spans. @@ -191,13 +201,14 @@ test.describe('inline middleware spans (sub-app)', () => { const fullPath = `${INLINE_PREFIX}${regPath}${mwPath}`; const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${fullPath}`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(fullPath); }); const response = await fetch(`${baseURL}${fullPath}`); expect(response.status).toBe(200); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${fullPath}`); const EXPECTED_DESCRIPTIONS: Record> = { '/direct': { '': 'inlineMiddleware', '/separately': 'inlineSeparateMiddleware' }, @@ -206,14 +217,11 @@ test.describe('inline middleware spans (sub-app)', () => { }; const expectedDescription = EXPECTED_DESCRIPTIONS[regPath]![mwPath]!; - expect(transaction.spans).toContainEqual( - expect.objectContaining({ - description: expectedDescription, - op: 'middleware.hono', - origin: 'auto.middleware.hono', - status: 'ok', - }), - ); + const inlineSpan = (transaction.spans || []).find(s => s.description === expectedDescription); + expect(inlineSpan).toBeDefined(); + expect(inlineSpan?.op).toBe('middleware.hono'); + expect(inlineSpan?.origin).toBe('auto.middleware.hono'); + expect(inlineSpan?.status).not.toBe('internal_error'); }); } } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts index fd6579fe3b17..decd1049b6c9 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForTransaction } from '@sentry-internal/test-utils'; import { APP_NAME } from './constants'; const PREFIX = '/test-routes'; @@ -11,45 +11,45 @@ const REGISTRATION_STYLES = [ ] as const; test.describe('HTTP methods', () => { - for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + ['POST', 'PUT', 'DELETE', 'PATCH'].forEach(method => { test(`sends transaction for ${method}`, async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `${method} ${PREFIX}`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(PREFIX); }); const response = await fetch(`${baseURL}${PREFIX}`, { method }); expect(response.status).toBe(200); const transaction = await transactionPromise; - expect(transaction.contexts?.trace?.op).toBe('http.server'); expect(transaction.transaction).toBe(`${method} ${PREFIX}`); + expect(transaction.contexts?.trace?.op).toBe('http.server'); }); - } + }); }); test.describe('route registration styles', () => { - for (const { name, path } of REGISTRATION_STYLES) { + REGISTRATION_STYLES.forEach(({ name, path }) => { test(`${name} sends transaction`, async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}${path}`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${PREFIX}${path}`); }); const response = await fetch(`${baseURL}${PREFIX}${path}`); expect(response.status).toBe(200); const transaction = await transactionPromise; - expect(transaction.contexts?.trace?.op).toBe('http.server'); expect(transaction.transaction).toBe(`GET ${PREFIX}${path}`); + expect(transaction.contexts?.trace?.op).toBe('http.server'); }); - } + }); - for (const { name, path } of [ + [ { name: '.all()', path: '/all' }, { name: '.on()', path: '/on' }, - ]) { + ].forEach(({ name, path }) => { test(`${name} responds to POST`, async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `POST ${PREFIX}${path}`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${PREFIX}${path}`); }); const response = await fetch(`${baseURL}${PREFIX}${path}`, { method: 'POST' }); @@ -58,87 +58,18 @@ test.describe('route registration styles', () => { const transaction = await transactionPromise; expect(transaction.transaction).toBe(`POST ${PREFIX}${path}`); }); - } + }); }); test('async handler sends transaction', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}/async`; + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${PREFIX}/async`); }); const response = await fetch(`${baseURL}${PREFIX}/async`); expect(response.status).toBe(200); const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`GET ${PREFIX}/async`); expect(transaction.contexts?.trace?.op).toBe('http.server'); }); - -test.describe('500 HTTPException capture', () => { - for (const { name, path } of REGISTRATION_STYLES) { - test(`captures 500 from ${name} route with correct mechanism`, async ({ baseURL }) => { - const fullPath = `${PREFIX}${path}/500`; - - const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'response 500' && !!event.request?.url?.includes(fullPath); - }); - - const response = await fetch(`${baseURL}${fullPath}`); - expect(response.status).toBe(500); - - const errorEvent = await errorPromise; - expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500'); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( - expect.objectContaining({ - handled: false, - type: 'auto.http.hono.context_error', - }), - ); - }); - } - - test('captures 500 error with POST method', async ({ baseURL }) => { - const errorPromise = waitForError(APP_NAME, event => { - return ( - event.exception?.values?.[0]?.value === 'response 500' && - !!event.request?.url?.includes(`${PREFIX}/500`) && - event.request?.method === 'POST' - ); - }); - - const response = await fetch(`${baseURL}${PREFIX}/500`, { method: 'POST' }); - expect(response.status).toBe(500); - - const errorEvent = await errorPromise; - expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500'); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( - expect.objectContaining({ - handled: false, - type: 'auto.http.hono.context_error', - }), - ); - }); -}); - -test.describe('4xx HTTPException capture', () => { - for (const code of [401, 402, 403]) { - test(`captures ${code} HTTPException`, async ({ baseURL }) => { - const fullPath = `${PREFIX}/${code}`; - - const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === `response ${code}` && !!event.request?.url?.includes(fullPath); - }); - - const response = await fetch(`${baseURL}${fullPath}`); - expect(response.status).toBe(code); - - const errorEvent = await errorPromise; - expect(errorEvent.exception?.values?.[0]?.value).toBe(`response ${code}`); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( - expect.objectContaining({ - handled: false, - type: 'auto.http.hono.context_error', - }), - ); - }); - } -}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts index 1c33943f38f8..4d9644312913 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts @@ -3,38 +3,40 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; import { APP_NAME } from './constants'; test('sends a transaction for the index route', async ({ baseURL }) => { - const transactionWaiter = waitForTransaction(APP_NAME, event => { - return event.transaction === 'GET /'; + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /'; }); const response = await fetch(`${baseURL}/`); expect(response.status).toBe(200); - const transaction = await transactionWaiter; + const transaction = await transactionPromise; + expect(transaction.transaction).toBe('GET /'); expect(transaction.contexts?.trace?.op).toBe('http.server'); }); test('sends a transaction for a parameterized route', async ({ baseURL }) => { - const transactionWaiter = waitForTransaction(APP_NAME, event => { - return event.transaction === 'GET /test-param/:paramId'; + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/test-param/'); }); const response = await fetch(`${baseURL}/test-param/123`); expect(response.status).toBe(200); - const transaction = await transactionWaiter; - expect(transaction.contexts?.trace?.op).toBe('http.server'); + const transaction = await transactionPromise; expect(transaction.transaction).toBe('GET /test-param/:paramId'); + expect(transaction.contexts?.trace?.op).toBe('http.server'); }); test('sends a transaction for a route that throws', async ({ baseURL }) => { - const transactionWaiter = waitForTransaction(APP_NAME, event => { - return event.transaction === 'GET /error/:cause'; + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/error/'); }); await fetch(`${baseURL}/error/test-cause`); - const transaction = await transactionWaiter; + const transaction = await transactionPromise; + expect(transaction.transaction).toBe('GET /error/:cause'); expect(transaction.contexts?.trace?.op).toBe('http.server'); expect(transaction.contexts?.trace?.status).toBe('internal_error'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts index 2446ffa68659..66752e7c2e41 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts @@ -28,7 +28,7 @@ test('Should send a transaction with a fetch span', async ({ page }) => { data: expect.objectContaining({ 'http.method': 'GET', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }), description: 'GET https://github.com/', }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json index b0c7f2852e01..fc3c7f813b5f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -15,7 +15,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.5.14", + "next": "15.5.15", "next-intl": "^4.3.12", "react": "latest", "react-dom": "latest", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 9e453cf0edf5..acbe56d0b5f1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -20,7 +20,7 @@ "@types/react": "18.0.26", "@types/react-dom": "18.0.9", "ai": "^3.0.0", - "next": "15.5.14", + "next": "15.5.15", "react": "latest", "react-dom": "latest", "typescript": "~5.0.0", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json index deb955b58daf..509c2b2c3a9f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json @@ -15,7 +15,7 @@ "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "import-in-the-middle": "^2", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^8" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json index c1070677f383..22beb292ca79 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json @@ -26,7 +26,7 @@ "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "import-in-the-middle": "^1", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^7", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json index 59f192d9bd1b..04cbff8d9eed 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json @@ -36,6 +36,7 @@ "wrangler": "^4.61.0" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/.gitignore new file mode 100644 index 000000000000..dd146b53d966 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/layout.tsx new file mode 100644 index 000000000000..ace0c2f086b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

Layout

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

DynamicLayout

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

Dynamic Page

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

Layout

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

Hello World!

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

AI Test Results

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

AI Test Results

+
{JSON.stringify(results, null, 2)}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test-error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test-error/route.ts new file mode 100644 index 000000000000..4826ffa16b15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test-error/route.ts @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export async function GET() { + throw new Error('Cron job error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test/route.ts new file mode 100644 index 000000000000..e70938cbe491 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + // Simulate some work + await new Promise(resolve => setTimeout(resolve, 100)); + return NextResponse.json({ message: 'Cron job executed successfully' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/endpoint-behind-middleware/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/endpoint-behind-middleware/route.ts new file mode 100644 index 000000000000..2733cc918f44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/endpoint-behind-middleware/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ name: 'John Doe' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queue-send/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queue-send/route.ts new file mode 100644 index 000000000000..fe49a990921b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queue-send/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { send } from '../../../lib/queue'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request) { + const body = await request.json(); + const topic = body.topic ?? 'orders'; + const payload = body.payload ?? body; + + const { messageId } = await send(topic, payload); + + return NextResponse.json({ messageId }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queues/process-order/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queues/process-order/route.ts new file mode 100644 index 000000000000..41cec36d5d8a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queues/process-order/route.ts @@ -0,0 +1,10 @@ +import { handleCallback } from '../../../../lib/queue'; + +export const dynamic = 'force-dynamic'; + +// The @vercel/queue handleCallback return type (CallbackRequestInput) doesn't match +// Next.js's strict route handler type check with webpack builds, so we cast it. +export const POST = handleCallback(async (message, _metadata) => { + // Simulate some async work + await new Promise(resolve => setTimeout(resolve, 50)); +}) as unknown as (req: Request) => Promise; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/v3/topic/[...params]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/v3/topic/[...params]/route.ts new file mode 100644 index 000000000000..51dfa2e656db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/v3/topic/[...params]/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server'; + +/** + * Mock Vercel Queues API server. + * + * This route handler simulates the Vercel Queues HTTP API so that the real + * @vercel/queue SDK can be used in E2E tests without Vercel infrastructure. + * + * Handled endpoints: + * POST /api/v3/topic/{topic} → SendMessage + * POST /api/v3/topic/{topic}/consumer/{consumer}/id/{messageId} → ReceiveMessageById + * DELETE /api/v3/topic/{topic}/consumer/{consumer}/lease/{handle} → AcknowledgeMessage + * PATCH /api/v3/topic/{topic}/consumer/{consumer}/lease/{handle} → ExtendLease + */ + +export const dynamic = 'force-dynamic'; + +let messageCounter = 0; + +function generateMessageId(): string { + return `msg_test_${++messageCounter}_${Date.now()}`; +} + +function generateReceiptHandle(): string { + return `rh_test_${Date.now()}_${Math.random().toString(36).slice(2)}`; +} + +// Encode a file path into a consumer-group name, matching the SDK's algorithm. +function filePathToConsumerGroup(filePath: string): string { + let result = ''; + for (const char of filePath) { + if (char === '_') result += '__'; + else if (char === '/') result += '_S'; + else if (char === '.') result += '_D'; + else if (/[A-Za-z0-9-]/.test(char)) result += char; + else result += '_' + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'); + } + return result; +} + +// Topic → consumer route path (mirrors vercel.json experimentalTriggers). +const TOPIC_ROUTES: Record = { + orders: '/api/queues/process-order', +}; + +// The file path key used in vercel.json for each consumer route. +const ROUTE_FILE_PATHS: Record = { + '/api/queues/process-order': 'app/api/queues/process-order/route.ts', +}; + +export async function POST(request: Request, { params }: { params: Promise<{ params: string[] }> }) { + const { params: segments } = await params; + + // POST /api/v3/topic/{topic} → SendMessage + if (segments.length === 1) { + const topic = segments[0]; + const body = await request.arrayBuffer(); + const messageId = generateMessageId(); + const receiptHandle = generateReceiptHandle(); + const now = new Date(); + const createdAt = now.toISOString(); + const expiresAt = new Date(now.getTime() + 86_400_000).toISOString(); + const visibilityDeadline = new Date(now.getTime() + 300_000).toISOString(); + + const consumerRoute = TOPIC_ROUTES[topic]; + if (consumerRoute) { + const filePath = ROUTE_FILE_PATHS[consumerRoute] ?? consumerRoute; + const consumerGroup = filePathToConsumerGroup(filePath); + const port = process.env.PORT || 3030; + + // Simulate Vercel infrastructure pushing the message to the consumer. + // Fire-and-forget so the SendMessage response returns immediately. + void fetch(`http://localhost:${port}${consumerRoute}`, { + method: 'POST', + headers: { + 'ce-type': 'com.vercel.queue.v2beta', + 'ce-vqsqueuename': topic, + 'ce-vqsconsumergroup': consumerGroup, + 'ce-vqsmessageid': messageId, + 'ce-vqsreceipthandle': receiptHandle, + 'ce-vqsdeliverycount': '1', + 'ce-vqscreatedat': createdAt, + 'ce-vqsexpiresat': expiresAt, + 'ce-vqsregion': 'test1', + 'ce-vqsvisibilitydeadline': visibilityDeadline, + 'content-type': request.headers.get('content-type') || 'application/json', + }, + body: Buffer.from(body), + }).catch(err => console.error('[mock-queue] Failed to push to consumer:', err)); + } + + return NextResponse.json({ messageId }, { status: 201, headers: { 'Vqs-Message-Id': messageId } }); + } + + // POST /api/v3/topic/{topic}/consumer/{consumer}/id/{messageId} → ReceiveMessageById + // Not used in binary-mode push flow, but handled for completeness. + if (segments.length >= 4 && segments[1] === 'consumer') { + return new Response(null, { status: 204 }); + } + + return NextResponse.json({ error: 'Unknown endpoint' }, { status: 404 }); +} + +// DELETE /api/v3/topic/{topic}/consumer/{consumer}/lease/{receiptHandle} → AcknowledgeMessage +export async function DELETE() { + return new Response(null, { status: 204 }); +} + +// PATCH /api/v3/topic/{topic}/consumer/{consumer}/lease/{receiptHandle} → ExtendLease +export async function PATCH() { + return NextResponse.json({ success: true }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/component-annotation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/component-annotation/page.tsx new file mode 100644 index 000000000000..8ac6973dc5c8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/component-annotation/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +export default function ComponentAnnotationTestPage() { + return ( +
+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/favicon.ico new file mode 100644 index 000000000000..718d6fea4835 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/global-error.tsx new file mode 100644 index 000000000000..20c175015b03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/global-error.tsx @@ -0,0 +1,23 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import NextError from 'next/error'; +import { useEffect } from 'react'; + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/[product]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/[product]/page.tsx new file mode 100644 index 000000000000..cd1e085e2763 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/[product]/page.tsx @@ -0,0 +1,17 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams + +export async function generateStaticParams(): Promise> { + return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }]; +} + +export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) { + const { product } = await params; + + return ( +
+

ISR Product: {product}

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

ISR Static Page

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

Metrics page

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

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

unreachable

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

Non-ISR Dynamic Page: {item}

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

Next 16 test app

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

I am page 2

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

Hello

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

Redirect Destination

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

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

test

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/third-party-filter/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/third-party-filter/page.tsx new file mode 100644 index 000000000000..b6b4bea80def --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/third-party-filter/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +function throwFirstPartyError(): void { + throw new Error('first-party-error'); +} + +export default function Page() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/eslint.config.mjs @@ -0,0 +1,19 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'], + }, +]; + +export default eslintConfig; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation-client.ts new file mode 100644 index 000000000000..77e1e79967e4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation-client.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/nextjs'; +import type { Log } from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + integrations: [ + Sentry.thirdPartyErrorFilterIntegration({ + filterKeys: ['nextjs-16-streaming-e2e'], + behaviour: 'apply-tag-if-contains-third-party-frames', + }), + Sentry.spanStreamingIntegration(), + ], + beforeSendLog(log: Log) { + return log; + }, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/lib/queue.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/lib/queue.ts new file mode 100644 index 000000000000..8dc8ce0ad5ed --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/lib/queue.ts @@ -0,0 +1,12 @@ +import { QueueClient } from '@vercel/queue'; + +// For E2E testing, point the SDK at a local mock server running within Next.js. +// The mock API lives at app/api/v3/topic/[...params]/route.ts +const queue = new QueueClient({ + region: 'test1', + resolveBaseUrl: () => new URL(`http://localhost:${process.env.PORT || 3030}`), + token: 'mock-token', + deploymentId: null, +}); + +export const { send, handleCallback } = queue; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/next.config.ts new file mode 100644 index 000000000000..6067696c7d16 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/next.config.ts @@ -0,0 +1,18 @@ +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +// Simulate Vercel environment for cron monitoring tests +process.env.VERCEL = '1'; + +const nextConfig: NextConfig = {}; + +export default withSentryConfig(nextConfig, { + silent: true, + _experimental: { + vercelCronsMonitoring: true, + turbopackApplicationKey: 'nextjs-16-streaming-e2e', + turbopackReactComponentAnnotation: { + enabled: true, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json new file mode 100644 index 000000000000..8e254f4b4657 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json @@ -0,0 +1,41 @@ +{ + "name": "nextjs-16-streaming", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "start": "next start", + "lint": "eslint", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@vercel/queue": "^0.1.3", + "ai": "^3.0.0", + "import-in-the-middle": "^2", + "next": "16.2.4", + "react": "19.1.0", + "react-dom": "19.1.0", + "require-in-the-middle": "^8", + "zod": "^3.22.4" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "^16", + "typescript": "^5" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/playwright.config.mjs new file mode 100644 index 000000000000..797418b8cf7d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/playwright.config.mjs @@ -0,0 +1,29 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development-webpack') { + return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'development') { + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/proxy.ts new file mode 100644 index 000000000000..60722f329fa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/proxy.ts @@ -0,0 +1,24 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function proxy(request: NextRequest) { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + + if (request.headers.has('x-should-throw')) { + throw new Error('Middleware Error'); + } + + if (request.headers.has('x-should-make-request')) { + await fetch('http://localhost:3030/'); + } + + return NextResponse.next(); +} + +// See "Matching Paths" below to learn more +export const config = { + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/file.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/file.svg new file mode 100644 index 000000000000..004145cddf3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/globe.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/globe.svg new file mode 100644 index 000000000000..567f17b0d7c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/next.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/next.svg new file mode 100644 index 000000000000..5174b28c565c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/vercel.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/vercel.svg new file mode 100644 index 000000000000..77053960334e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/window.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/window.svg new file mode 100644 index 000000000000..b2b2a44f6ebc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.edge.config.ts new file mode 100644 index 000000000000..f2e946f81728 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.edge.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + traceLifecycle: 'stream', + integrations: [Sentry.spanStreamingIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.server.config.ts new file mode 100644 index 000000000000..d44e4da73818 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.server.config.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/nextjs'; +import { Log } from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + traceLifecycle: 'stream', + integrations: [ + Sentry.vercelAIIntegration(), + Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }), + Sentry.spanStreamingIntegration(), + ], + beforeSendLog(log: Log) { + return log; + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..9b3556d402af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-16-streaming', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-16-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/nested-rsc-error.test.ts new file mode 100644 index 000000000000..280f0ef4e33b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/nested-rsc-error.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ + page, +}) => { + const errorEventPromise = waitForError('nextjs-16-streaming', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable'); + }); + + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /nested-rsc-error/[param]' && span.is_segment; + }); + + await page.goto(`/nested-rsc-error/123`); + const errorEvent = await errorEventPromise; + const rootSpan = await rootSpanPromise; + + expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan.trace_id); + + expect(errorEvent.request).toMatchObject({ + headers: expect.any(Object), + method: 'GET', + }); + + expect(errorEvent.contexts?.nextjs).toEqual({ + route_type: 'render', + router_kind: 'App Router', + router_path: '/nested-rsc-error/[param]', + request_path: '/nested-rsc-error/123', + }); + + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.nextjs.on_request_error', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts new file mode 100644 index 000000000000..b64307ad9202 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('Server and client pageload spans should share the same trace', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/pageload-tracing`); + + const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); + + expect(pageloadSpan.trace_id).toBeTruthy(); + expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts new file mode 100644 index 000000000000..7990953bf7a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('should create a parameterized streamed span when the `app` directory is used', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/:one' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/cappuccino`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/:one'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('should create a static streamed span when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/static' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/static`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/static'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('url'); +}); + +test('should create a partially parameterized streamed span when the `app` directory is used', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/:one/beep' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/:one/beep'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('should create a nested parameterized streamed span when the `app` directory is used.', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/:one/beep/:two' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/:one/beep/:two'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/route-handler.test.ts new file mode 100644 index 000000000000..be6be4c220b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/route-handler.test.ts @@ -0,0 +1,59 @@ +import test, { expect } from '@playwright/test'; +import { waitForError, waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('Should create a streamed span for node route handlers', async ({ request }) => { + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /route-handler/[xoxo]/node' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + const response = await request.get('/route-handler/123/node', { headers: { 'x-charly': 'gomez' } }); + expect(await response.json()).toStrictEqual({ message: 'Hello Node Route Handler' }); + + const rootSpan = await rootSpanPromise; + + expect(rootSpan.status).toBe('ok'); + expect(getSpanOp(rootSpan)).toBe('http.server'); +}); + +test('Should report an error linked to the correct trace for a throwing route handler', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-16-streaming', errorEvent => { + return errorEvent?.exception?.values?.some(value => value.value === 'route-handler-error') ?? false; + }); + + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /route-handler/[xoxo]/error' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + request.get('/route-handler/456/error').catch(() => {}); + + const errorEvent = await errorEventPromise; + const rootSpan = await rootSpanPromise; + + expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan.trace_id); + expect(errorEvent.transaction).toBe('GET /route-handler/[xoxo]/error'); + expect(rootSpan.status).toBe('error'); +}); + +test('Should set a parameterized transaction name on a captureMessage event in a route handler', async ({ + request, +}) => { + const messageEventPromise = waitForError('nextjs-16-streaming', event => { + return event?.message === 'route-handler-message'; + }); + + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return ( + span.name === 'GET /route-handler/[xoxo]/capture-message' && getSpanOp(span) === 'http.server' && span.is_segment + ); + }); + + const response = await request.get('/route-handler/789/capture-message'); + expect(await response.json()).toStrictEqual({ message: 'Message captured' }); + + const messageEvent = await messageEventPromise; + const rootSpan = await rootSpanPromise; + + expect(messageEvent.contexts?.trace?.trace_id).toBe(rootSpan.trace_id); + expect(messageEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-message'); + expect(rootSpan.status).toBe('ok'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts new file mode 100644 index 000000000000..ba64953678b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, waitForStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +test('Sends a streamed span for a request to app router with URL', async ({ page }) => { + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /parameterized/[one]/beep/[two]' && span.is_segment; + }); + + await page.goto('/parameterized/1337/beep/42'); + + const rootSpan = await rootSpanPromise; + + expect(getSpanOp(rootSpan)).toBe('http.server'); + expect(rootSpan.status).toBe('ok'); +}); + +test('Will create streamed spans for every server component and metadata generation functions when visiting a page', async ({ + page, +}) => { + const spansPromise = waitForStreamedSpans('nextjs-16-streaming', spans => { + return spans.some(span => span.name === 'GET /nested-layout' && span.is_segment); + }); + + await page.goto('/nested-layout'); + + const spans = await spansPromise; + const spanNames = spans.map(span => span.name); + + expect(spanNames).toContainEqual('render route (app) /nested-layout'); + expect(spanNames).toContainEqual('build component tree'); + expect(spanNames).toContainEqual('resolve root layout server component'); + expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); + expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); + expect(spanNames).toContainEqual('resolve page server component "/nested-layout"'); + expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); + expect(spanNames).toContainEqual('start response'); + expect(spanNames).toContainEqual('NextNodeServer.clientComponentLoading'); +}); + +test('Will create streamed spans for every server component and metadata generation functions when visiting a dynamic page', async ({ + page, +}) => { + const spansPromise = waitForStreamedSpans('nextjs-16-streaming', spans => { + return spans.some(span => span.name === 'GET /nested-layout/[dynamic]' && span.is_segment); + }); + + await page.goto('/nested-layout/123'); + + const spans = await spansPromise; + const spanNames = spans.map(span => span.name); + + expect(spanNames).toContainEqual('resolve page components'); + expect(spanNames).toContainEqual('render route (app) /nested-layout/[dynamic]'); + expect(spanNames).toContainEqual('build component tree'); + expect(spanNames).toContainEqual('resolve root layout server component'); + expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); + expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); + expect(spanNames).toContainEqual('resolve layout server component "[dynamic]"'); + expect(spanNames).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); + expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); + expect(spanNames).toContainEqual('start response'); + expect(spanNames).toContainEqual('NextNodeServer.clientComponentLoading'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/vercel.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/vercel.json new file mode 100644 index 000000000000..58730a0978fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/vercel.json @@ -0,0 +1,17 @@ +{ + "crons": [ + { + "path": "/api/cron-test", + "schedule": "0 * * * *" + }, + { + "path": "/api/cron-test-error", + "schedule": "30 * * * *" + } + ], + "functions": { + "app/api/queues/process-order/route.ts": { + "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "orders" }] + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json index 03035b9ddb33..98896eae5d1b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json @@ -16,7 +16,7 @@ "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "import-in-the-middle": "^2", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^8" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index 0821c63d43f5..7dfa8f923f6e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -27,7 +27,7 @@ "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "ai": "^3.0.0", "import-in-the-middle": "^1", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^7", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/page.tsx new file mode 100644 index 000000000000..94e17a8bb4ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/sri-test/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +export default function SriTestPage() { + const [count, setCount] = useState(0); + + return ( +
+

SRI Test Page

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

SRI Target Page

+ + + Go back + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts index 41814b8152d0..ee93730e8d1d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts @@ -4,7 +4,13 @@ import type { NextConfig } from 'next'; // Simulate Vercel environment for cron monitoring tests process.env.VERCEL = '1'; -const nextConfig: NextConfig = {}; +const nextConfig: NextConfig = { + experimental: { + sri: { + algorithm: 'sha256', + }, + }, +}; export default withSentryConfig(nextConfig, { silent: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 944102e188b3..beda2252d915 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -28,7 +28,7 @@ "@vercel/queue": "^0.1.3", "ai": "^3.0.0", "import-in-the-middle": "^2", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^8", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts index 0efd0d8f7d79..5295101f1e20 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts @@ -8,7 +8,7 @@ const EXPECTED_ATTRIBUTES = { 'sentry.origin': { value: 'auto.node.runtime_metrics', type: 'string' }, }; -test('Should emit node runtime memory metrics', async ({ request }) => { +test('Should emit node runtime memory metrics', async ({ baseURL }) => { const rssPromise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.mem.rss'; }); @@ -22,7 +22,7 @@ test('Should emit node runtime memory metrics', async ({ request }) => { }); // Trigger a request to ensure the server is running and metrics start being collected - await request.get('/'); + await fetch(`${baseURL}/`); const rss = await rssPromise; const heapUsed = await heapUsedPromise; @@ -59,12 +59,12 @@ test('Should emit node runtime memory metrics', async ({ request }) => { }); }); -test('Should emit node runtime CPU utilization metric', async ({ request }) => { +test('Should emit node runtime CPU utilization metric', async ({ baseURL }) => { const cpuUtilPromise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.cpu.utilization'; }); - await request.get('/'); + await fetch(`${baseURL}/`); const cpuUtil = await cpuUtilPromise; @@ -78,7 +78,7 @@ test('Should emit node runtime CPU utilization metric', async ({ request }) => { }); }); -test('Should emit node runtime event loop metrics', async ({ request }) => { +test('Should emit node runtime event loop metrics', async ({ baseURL }) => { const elDelayP50Promise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.event_loop.delay.p50'; }); @@ -91,7 +91,7 @@ test('Should emit node runtime event loop metrics', async ({ request }) => { return metric.name === 'node.runtime.event_loop.utilization'; }); - await request.get('/'); + await fetch(`${baseURL}/`); const elDelayP50 = await elDelayP50Promise; const elDelayP99 = await elDelayP99Promise; @@ -127,12 +127,12 @@ test('Should emit node runtime event loop metrics', async ({ request }) => { }); }); -test('Should emit node runtime uptime counter', async ({ request }) => { +test('Should emit node runtime uptime counter', async ({ baseURL }) => { const uptimePromise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.process.uptime'; }); - await request.get('/'); + await fetch(`${baseURL}/`); const uptime = await uptimePromise; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/sri.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/sri.test.ts new file mode 100644 index 000000000000..c68d23c21de6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/sri.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; + +const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); + +test.describe('Subresource Integrity (SRI)', () => { + test('page with client components loads correctly with SRI enabled', async ({ page }) => { + // SRI is only relevant for production builds + test.skip(isDevMode, 'SRI only applies to production builds'); + + const consoleErrors: string[] = []; + page.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await page.goto('/sri-test'); + + const heading = page.locator('#sri-test-heading'); + await expect(heading).toBeVisible(); + + // Verify client-side interactivity works (scripts loaded correctly) + const button = page.locator('#counter-button'); + await expect(button).toContainText('Count: 0'); + await button.click(); + await expect(button).toContainText('Count: 1'); + + expect(consoleErrors.filter(e => e.includes('integrity'))).toHaveLength(0); + }); + + test('client-side navigation works with SRI enabled', async ({ page }) => { + test.skip(isDevMode, 'SRI only applies to production builds'); + + const consoleErrors: string[] = []; + page.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await page.goto('/sri-test'); + await expect(page.locator('#sri-test-heading')).toBeVisible(); + + // Navigate to target page via client-side link + await page.locator('#navigate-link').click(); + await expect(page.locator('#sri-target-heading')).toBeVisible(); + + // Verify client-side interactivity on the target page + const targetButton = page.locator('#target-button'); + await expect(targetButton).toContainText('Click me'); + await targetButton.click(); + await expect(targetButton).toContainText('Clicked!'); + + // Navigate back + await page.locator('#back-link').click(); + await expect(page.locator('#sri-test-heading')).toBeVisible(); + + expect(consoleErrors.filter(e => e.includes('integrity'))).toHaveLength(0); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts index f392a63d4086..939347da2a09 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts @@ -16,7 +16,7 @@ test.skip('Should send a transaction with a http span', async ({ request }) => { data: expect.objectContaining({ 'http.method': 'GET', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }), description: 'GET https://example.com/', }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts index c65ba88c39c3..65a6820a83da 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts @@ -16,7 +16,7 @@ test.skip('Should send a transaction with a http span', async ({ request }) => { data: expect.objectContaining({ 'http.method': 'GET', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }), description: 'GET https://example.com/', }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json index 9667f17865f1..70d7802b28db 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "typescript": "~5.0.0" diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index ab92769115d1..99fd417c2a54 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -19,7 +19,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/core": "latest || *", - "nitro": "^3.0.260415-beta", + "nitro": "^3.0.260429-beta", "rolldown": "latest", "vite": "latest" }, diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-cache.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-cache.ts new file mode 100644 index 000000000000..e1dd1d2bb4e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-cache.ts @@ -0,0 +1,82 @@ +import { defineCachedFunction, defineCachedHandler } from 'nitro/cache'; +import { defineHandler, getQuery } from 'nitro/h3'; + +const getCachedUser = defineCachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +const getCachedData = defineCachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +const cachedHandler = defineCachedHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? '123'); + const dataKey = String(getQuery(event).data ?? 'test-key'); + + // cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // cachedEventHandler + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage-aliases.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage-aliases.ts new file mode 100644 index 000000000000..404e97985875 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage-aliases.ts @@ -0,0 +1,45 @@ +import { defineHandler } from 'nitro/h3'; +import { useStorage } from 'nitro/storage'; + +export default defineHandler(async () => { + const storage = useStorage('cache'); + + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage.ts new file mode 100644 index 000000000000..00891134af1f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-storage.ts @@ -0,0 +1,53 @@ +import { defineHandler } from 'nitro/h3'; +import { useStorage } from 'nitro/storage'; + +export default defineHandler(async () => { + const storage = useStorage('cache'); + + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/cache.test.ts new file mode 100644 index 000000000000..feda6b3ea3fe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/cache.test.ts @@ -0,0 +1,142 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nitro'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/test-cache') ?? false; + }); + + const response = await request.get('/api/test-cache'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nitro', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + expect(cacheMissSpan).toBeDefined(); + expect(cacheMissSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + }); + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + expect(cacheHitSpan).toBeDefined(); + expect(cacheHitSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + }); + + // setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + expect(cacheSetSpan).toBeDefined(); + expect(cacheSetSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + 'db.operation.name': 'setItem', + }); + + // Spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Spans for cachedHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/test-cache') ?? false; + }); + + await request.get(`/api/test-cache?user=${uniqueUser}&data=${uniqueData}`); + const transaction = await transactionPromise; + + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nitro', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + const allGetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get_item', + ); + const allSetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.set_item', + ); + + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // At least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // At least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..173e6c0b82d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage-aliases.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nitro'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `cache:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/test-storage-aliases') ?? false; + }); + + const response = await request.get('/api/test-storage-aliases'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.system.name': expect.any(String), + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.system.name': expect.any(String), + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.system.name': expect.any(String), + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.system.name': expect.any(String), + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.system.name': expect.any(String), + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nitro', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage.test.ts new file mode 100644 index 000000000000..e4a959219c10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/storage.test.ts @@ -0,0 +1,143 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nitro'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `cache:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/test-storage') ?? false; + }); + + const response = await request.get('/api/test-storage'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.system.name': expect.any(String), + }); + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.system.name': expect.any(String), + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.system.name': expect.any(String), + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.system.name': expect.any(String), + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.system.name': expect.any(String), + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + 'db.operation.name': 'getKeys', + 'db.system.name': expect.any(String), + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.system.name': expect.any(String), + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nitro', + 'db.operation.name': 'clear', + 'db.system.name': expect.any(String), + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nitro', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts index 937f2b7acc27..7e1b95e9e53f 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts @@ -133,7 +133,7 @@ test('Should record spans from http instrumentation', async ({ request }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), data: expect.objectContaining({ 'http.flavor': '1.1', - 'http.host': 'example.com:80', + 'http.host': 'example.com', 'http.method': 'GET', 'http.response.status_code': 200, 'http.status_code': 200, @@ -146,7 +146,7 @@ test('Should record spans from http instrumentation', async ({ request }) => { 'net.transport': 'ip_tcp', 'otel.kind': 'CLIENT', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', @@ -155,6 +155,6 @@ test('Should record spans from http instrumentation', async ({ request }) => { timestamp: expect.any(Number), status: 'ok', op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', }); }); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-streaming/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json b/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json new file mode 100644 index 000000000000..77124040ff6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json @@ -0,0 +1,35 @@ +{ + "name": "node-express-streaming-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@trpc/server": "10.45.4", + "@trpc/client": "10.45.4", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "express": "^4.21.2", + "typescript": "~5.0.0", + "zod": "~3.25.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz" + }, + "resolutions": { + "@types/qs": "6.9.17" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-streaming/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts new file mode 100644 index 000000000000..5a0d1afa4141 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts @@ -0,0 +1,148 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + enableLogs: true, + traceLifecycle: 'stream', + integrations: [ + Sentry.spanStreamingIntegration(), + Sentry.nativeNodeFetchIntegration({ + headersToSpanAttributes: { + responseHeaders: ['content-length'], + }, + }), + ], +}); + +import { TRPCError, initTRPC } from '@trpc/server'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import express from 'express'; +import { z } from 'zod'; +import { mcpRouter } from './mcp'; + +const app = express(); +const port = 3030; + +app.use(express.json()); + +app.use(mcpRouter); + +app.get('/crash-in-with-monitor/:id', async (req, res) => { + try { + await Sentry.withMonitor('express-crash', async () => { + throw new Error(`This is an exception withMonitor: ${req.params.id}`); + }); + res.sendStatus(200); + } catch (error: any) { + res.status(500); + res.send({ message: error.message, pid: process.pid }); + } +}); + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-log', function (req, res) { + Sentry.logger.debug('Accessed /test-log route'); + res.send({ message: 'Log sent' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-transaction', function (_req, res) { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + + res.send({ status: 'ok' }); +}); + +app.get('/test-outgoing-fetch', async function (_req, res) { + const response = await fetch('http://localhost:3030/test-success'); + const data = await response.json(); + res.send(data); +}); +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.get('/test-local-variables-uncaught', function (req, res) { + const randomVariableToRecord = Math.random(); + throw new Error(`Uncaught Local Variable Error - ${JSON.stringify({ randomVariableToRecord })}`); +}); + +app.get('/test-local-variables-caught', function (req, res) { + const randomVariableToRecord = Math.random(); + + let exceptionId: string; + try { + throw new Error('Local Variable Error'); + } catch (e) { + exceptionId = Sentry.captureException(e); + } + + res.send({ exceptionId, randomVariableToRecord }); +}); + +Sentry.setupExpressErrorHandler(app); + +// @ts-ignore +app.use(function onError(err, req, res, next) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); + +export const t = initTRPC.context().create(); + +const procedure = t.procedure.use(Sentry.trpcMiddleware({ attachRpcInput: true })); + +export const appRouter = t.router({ + getSomething: procedure.input(z.string()).query(opts => { + return { id: opts.input, name: 'Bilbo' }; + }), + createSomething: procedure.mutation(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + return { success: true }; + }), + crashSomething: procedure + .input(z.object({ nested: z.object({ nested: z.object({ nested: z.string() }) }) })) + .mutation(() => { + throw new Error('I crashed in a trpc handler'); + }), + badRequest: procedure.mutation(() => { + throw new TRPCError({ code: 'BAD_REQUEST', cause: new Error('Bad Request') }); + }), +}); + +export type AppRouter = typeof appRouter; + +const createContext = () => ({ someStaticValue: 'asdf' }); +type Context = Awaited>; + +app.use( + '/trpc', + trpcExpress.createExpressMiddleware({ + router: appRouter, + createContext, + }), +); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/mcp.ts new file mode 100644 index 000000000000..72c4535a3d6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/mcp.ts @@ -0,0 +1,221 @@ +import { randomUUID } from 'node:crypto'; +import express from 'express'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { z } from 'zod'; +import { wrapMcpServerWithSentry } from '@sentry/node'; + +// Helper to check if request is an initialize request (compatible with all MCP SDK versions) +function isInitializeRequest(body: unknown): boolean { + return typeof body === 'object' && body !== null && (body as { method?: string }).method === 'initialize'; +} + +const mcpRouter = express.Router(); + +const server = wrapMcpServerWithSentry( + new McpServer({ + name: 'Echo', + version: '1.0.0', + }), +); + +server.resource('echo', new ResourceTemplate('echo://{message}', { list: undefined }), async (uri, { message }) => ({ + contents: [ + { + uri: uri.href, + text: `Resource echo: ${message}`, + }, + ], +})); + +server.tool('echo', { message: z.string() }, async ({ message }, rest) => { + return { + content: [{ type: 'text', text: `Tool echo: ${message}` }], + }; +}); + +server.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + +server.prompt('echo', { message: z.string() }, ({ message }, extra) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}`, + }, + }, + ], +})); + +server.tool('always-error', {}, async () => { + throw new Error('intentional error for span status testing'); +}); + +const transports: Record = {}; + +mcpRouter.get('/sse', async (_, res) => { + const transport = new SSEServerTransport('/messages', res); + transports[transport.sessionId] = transport; + res.on('close', () => { + delete transports[transport.sessionId]; + }); + await server.connect(transport); +}); + +mcpRouter.post('/messages', async (req, res) => { + const sessionId = req.query.sessionId; + const transport = transports[sessionId as string]; + if (transport) { + await transport.handlePostMessage(req, res, req.body); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); + +// ============================================================================= +// Streamable HTTP Transport Endpoints +// This uses StreamableHTTPServerTransport which wraps WebStandardStreamableHTTPServerTransport +// and exercises the wrapper transport pattern that was fixed in the sessionId-based correlation +// See: https://github.com/getsentry/sentry-mcp/issues/767 +// ============================================================================= + +// Create a separate wrapped server for streamable HTTP (to test independent of SSE) +const streamableServer = wrapMcpServerWithSentry( + new McpServer({ + name: 'Echo-Streamable', + version: '1.0.0', + }), +); + +// Register the same handlers on the streamable server +streamableServer.resource( + 'echo', + new ResourceTemplate('echo://{message}', { list: undefined }), + async (uri, { message }) => ({ + contents: [ + { + uri: uri.href, + text: `Resource echo: ${message}`, + }, + ], + }), +); + +streamableServer.tool('echo', { message: z.string() }, async ({ message }) => { + return { + content: [{ type: 'text', text: `Tool echo: ${message}` }], + }; +}); + +streamableServer.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + +streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}`, + }, + }, + ], +})); + +// Map to store streamable transports by session ID +const streamableTransports: Record = {}; + +// POST endpoint for streamable HTTP (handles both initialization and subsequent requests) +mcpRouter.post('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && streamableTransports[sessionId]) { + // Reuse existing transport for session + transport = streamableTransports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request - create new transport + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: sid => { + // Store transport when session is initialized + streamableTransports[sid] = transport; + }, + }); + + // Clean up on close + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && streamableTransports[sid]) { + delete streamableTransports[sid]; + } + }; + + // Connect to server before handling request + await streamableServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + // Invalid request + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, + id: null, + }); + return; + } + + // Handle request with existing transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling streamable HTTP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null, + }); + } + } +}); + +// GET endpoint for SSE streams (server-initiated messages) +mcpRouter.get('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !streamableTransports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const transport = streamableTransports[sessionId]; + await transport.handleRequest(req, res); +}); + +// DELETE endpoint for session termination +mcpRouter.delete('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !streamableTransports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const transport = streamableTransports[sessionId]; + await transport.handleRequest(req, res); +}); + +export { mcpRouter }; diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..4ae5a5eab608 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-streaming', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts new file mode 100644 index 000000000000..628a48c56456 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-streaming', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is an exception with id 123'); + expect(exception?.mechanism).toEqual({ + type: 'auto.middleware.express', + handled: false, + }); + + expect(errorEvent.request).toMatchObject({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); + +test('Should record caught exceptions with local variable', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-streaming', event => { + return event.transaction === 'GET /test-local-variables-caught'; + }); + + await fetch(`${baseURL}/test-local-variables-caught`); + + const errorEvent = await errorEventPromise; + + const frames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + expect(frames?.[frames.length - 1]?.vars?.randomVariableToRecord).toBeDefined(); +}); + +test('To not crash app from withMonitor', async ({ baseURL }) => { + const doRequest = async (id: number) => { + const response = await fetch(`${baseURL}/crash-in-with-monitor/${id}`); + return response.json(); + }; + const [response1, response2] = await Promise.all([doRequest(1), doRequest(2)]); + expect(response1.message).toBe('This is an exception withMonitor: 1'); + expect(response2.message).toBe('This is an exception withMonitor: 2'); + expect(response1.pid).toBe(response2.pid); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/logs.test.ts new file mode 100644 index 000000000000..fddd80692dd0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/logs.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; +import type { SerializedLogContainer } from '@sentry/core'; + +test('should send logs', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('node-express-streaming', envelope => { + return envelope[0].type === 'log' && (envelope[1] as SerializedLogContainer).items[0]?.level === 'debug'; + }); + + await fetch(`${baseURL}/test-log`); + + const logEnvelope = await logEnvelopePromise; + const log = (logEnvelope[1] as SerializedLogContainer).items[0]; + expect(log?.level).toBe('debug'); + expect(log?.body).toBe('Accessed /test-log route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/mcp.test.ts new file mode 100644 index 000000000000..ec82de5af455 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/mcp.test.ts @@ -0,0 +1,302 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +// TODO: MCP handler spans (tools/call, resources/read, etc.) are not emitted as streamed spans +// with SSE transport — only the POST /messages HTTP server span arrives in the envelope. +// Re-enable once the MCP instrumentation supports span streaming over SSE. +test.skip('Should record streamed spans for mcp handlers', async ({ baseURL }) => { + const transport = new SSEClientTransport(new URL(`${baseURL}/sse`)); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const initializeSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'initialize' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + await client.connect(transport); + + await test.step('initialize handshake', async () => { + const initializeSpan = await initializeSpanPromise; + expect(initializeSpan).toBeDefined(); + expect(getSpanOp(initializeSpan)).toBe('mcp.server'); + expect(initializeSpan.attributes?.['mcp.method.name']?.value).toBe('initialize'); + expect(initializeSpan.attributes?.['mcp.client.name']?.value).toBe('test-client'); + expect(initializeSpan.attributes?.['mcp.server.name']?.value).toBe('Echo'); + }); + + await test.step('tool handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'tools/call echo' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const toolResult = await client.callTool({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'Tool echo: foobar', + type: 'text', + }, + ], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.attributes?.['mcp.method.name']?.value).toBe('tools/call'); + }); + + await test.step('registerTool handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'tools/call echo-register' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const toolResult = await client.callTool({ + name: 'echo-register', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'registerTool echo: foobar', + type: 'text', + }, + ], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.attributes?.['mcp.method.name']?.value).toBe('tools/call'); + expect(toolSpan.attributes?.['mcp.tool.name']?.value).toBe('echo-register'); + }); + + await test.step('resource handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const resourceSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'resources/read echo://foobar' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const resourceResult = await client.readResource({ + uri: 'echo://foobar', + }); + + expect(resourceResult).toMatchObject({ + contents: [{ text: 'Resource echo: foobar', uri: 'echo://foobar' }], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const resourceSpan = await resourceSpanPromise; + expect(resourceSpan).toBeDefined(); + expect(getSpanOp(resourceSpan)).toBe('mcp.server'); + expect(resourceSpan.attributes?.['mcp.method.name']?.value).toBe('resources/read'); + }); + + await test.step('prompt handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const promptSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'prompts/get echo' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const promptResult = await client.getPrompt({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(promptResult).toMatchObject({ + messages: [ + { + content: { + text: 'Please process this message: foobar', + type: 'text', + }, + role: 'user', + }, + ], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const promptSpan = await promptSpanPromise; + expect(promptSpan).toBeDefined(); + expect(getSpanOp(promptSpan)).toBe('mcp.server'); + expect(promptSpan.attributes?.['mcp.method.name']?.value).toBe('prompts/get'); + }); + + await test.step('error tool sets span status to error', async () => { + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'tools/call always-error' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + try { + await client.callTool({ name: 'always-error', arguments: {} }); + } catch { + // Expected: MCP SDK throws when the tool returns a JSON-RPC error + } + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.status).toBe('error'); + }); +}); + +test('Should record streamed spans for streamable HTTP transport (wrapper transport pattern)', async ({ baseURL }) => { + const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`)); + + const client = new Client({ + name: 'test-client-streamable', + version: '1.0.0', + }); + + const initializeSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'initialize' && + getSpanOp(span) === 'mcp.server' && + span.attributes?.['mcp.server.name']?.value === 'Echo-Streamable' + ); + }); + + await client.connect(transport); + + await test.step('initialize handshake', async () => { + const initializeSpan = await initializeSpanPromise; + expect(initializeSpan).toBeDefined(); + expect(getSpanOp(initializeSpan)).toBe('mcp.server'); + expect(initializeSpan.attributes?.['mcp.method.name']?.value).toBe('initialize'); + expect(initializeSpan.attributes?.['mcp.client.name']?.value).toBe('test-client-streamable'); + expect(initializeSpan.attributes?.['mcp.server.name']?.value).toBe('Echo-Streamable'); + expect(String(initializeSpan.attributes?.['mcp.transport']?.value)).toMatch(/StreamableHTTPServerTransport/); + }); + + await test.step('tool handler (tests wrapper transport correlation)', async () => { + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'tools/call echo' && + getSpanOp(span) === 'mcp.server' && + String(span.attributes?.['mcp.transport']?.value).includes('StreamableHTTPServerTransport') + ); + }); + + const toolResult = await client.callTool({ + name: 'echo', + arguments: { + message: 'wrapper-transport-test', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'Tool echo: wrapper-transport-test', + type: 'text', + }, + ], + }); + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.attributes?.['mcp.method.name']?.value).toBe('tools/call'); + expect(toolSpan.attributes?.['mcp.tool.name']?.value).toBe('echo'); + expect(toolSpan.attributes?.['mcp.tool.result.content_count']?.value).toBe(1); + }); + + await test.step('resource handler', async () => { + const resourceSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'resources/read echo://streamable-test' && + getSpanOp(span) === 'mcp.server' && + String(span.attributes?.['mcp.transport']?.value).includes('StreamableHTTPServerTransport') + ); + }); + + const resourceResult = await client.readResource({ + uri: 'echo://streamable-test', + }); + + expect(resourceResult).toMatchObject({ + contents: [{ text: 'Resource echo: streamable-test', uri: 'echo://streamable-test' }], + }); + + const resourceSpan = await resourceSpanPromise; + expect(resourceSpan).toBeDefined(); + expect(getSpanOp(resourceSpan)).toBe('mcp.server'); + expect(resourceSpan.attributes?.['mcp.method.name']?.value).toBe('resources/read'); + }); + + await test.step('prompt handler', async () => { + const promptSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'prompts/get echo' && + getSpanOp(span) === 'mcp.server' && + String(span.attributes?.['mcp.transport']?.value).includes('StreamableHTTPServerTransport') + ); + }); + + const promptResult = await client.getPrompt({ + name: 'echo', + arguments: { + message: 'streamable-prompt', + }, + }); + + expect(promptResult).toMatchObject({ + messages: [ + { + content: { + text: 'Please process this message: streamable-prompt', + type: 'text', + }, + role: 'user', + }, + ], + }); + + const promptSpan = await promptSpanPromise; + expect(promptSpan).toBeDefined(); + expect(getSpanOp(promptSpan)).toBe('mcp.server'); + expect(promptSpan.attributes?.['mcp.method.name']?.value).toBe('prompts/get'); + }); + + await client.close(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/misc.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/misc.test.ts new file mode 100644 index 000000000000..b6bc94e19232 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/misc.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; +import { waitForRequest } from '@sentry-internal/test-utils'; +import { SDK_VERSION } from '@sentry/node'; + +test('sends user-agent header with SDK name and version in envelope requests', async ({ baseURL }) => { + const requestPromise = waitForRequest('node-express-streaming', () => true); + + await fetch(`${baseURL}/test-exception/123`); + + const request = await requestPromise; + + expect(request.rawProxyRequestHeaders).toMatchObject({ + 'user-agent': `sentry.javascript.node/${SDK_VERSION}`, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts new file mode 100644 index 000000000000..38cb1402c623 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; + +test('Sends streamed spans for an API route', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('node-express-streaming', spans => { + return spans.some( + span => span.name === 'GET /test-transaction' && getSpanOp(span) === 'http.server' && span.is_segment, + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const spans = await spansPromise; + + const rootSpan = spans.find(span => span.is_segment); + expect(rootSpan).toBeDefined(); + expect(rootSpan!.name).toBe('GET /test-transaction'); + expect(getSpanOp(rootSpan!)).toBe('http.server'); + expect(rootSpan!.status).toBe('ok'); + expect(rootSpan!.trace_id).toMatch(/[a-f0-9]{32}/); + expect(rootSpan!.attributes?.['sentry.source']?.value).toBe('route'); + expect(rootSpan!.attributes?.['sentry.origin']?.value).toBe('auto.http.otel.http'); + expect(rootSpan!.attributes?.['http.response.status_code']?.value).toBe(200); + + const childSpans = spans.filter(span => !span.is_segment); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: 'test-span', + is_segment: false, + status: 'ok', + }), + ); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: 'query', + is_segment: false, + status: 'ok', + }), + ); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: 'expressInit', + is_segment: false, + status: 'ok', + }), + ); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: '/test-transaction', + is_segment: false, + status: 'ok', + }), + ); + + // All spans share the same trace_id + for (const span of spans) { + expect(span.trace_id).toBe(rootSpan!.trace_id); + } +}); + +test('Sends streamed spans for an errored route', async ({ baseURL }) => { + const rootSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'GET /test-exception/:id' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + await fetch(`${baseURL}/test-exception/777`); + + const rootSpan = await rootSpanPromise; + + expect(rootSpan.name).toBe('GET /test-exception/:id'); + expect(getSpanOp(rootSpan)).toBe('http.server'); + expect(rootSpan.status).toBe('error'); + expect(rootSpan.attributes?.['http.status_code']?.value).toBe(500); +}); + +test('Outgoing fetch spans are streamed', async ({ baseURL }) => { + const fetchSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return getSpanOp(span) === 'http.client' && !span.is_segment && span.name.includes('localhost:3030/test-success'); + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const fetchSpan = await fetchSpanPromise; + + expect(fetchSpan).toBeDefined(); + expect(fetchSpan.status).toBe('ok'); +}); + +// TODO: headersToSpanAttributes has a pre-existing type error in packed tarballs (also affects the +// non-streaming node-express app). Re-enable once the NodeFetchOptions type is fixed upstream. +test.skip('Outgoing fetch spans include response headers when headersToSpanAttributes is configured', async ({ + baseURL, +}) => { + const fetchSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return getSpanOp(span) === 'http.client' && !span.is_segment && span.name.includes('localhost:3030/test-success'); + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const fetchSpan = await fetchSpanPromise; + + expect(fetchSpan).toBeDefined(); + expect(fetchSpan.attributes?.['http.response.header.content-length']).toBeDefined(); +}); + +test('Extracts HTTP request headers as streamed span attributes', async ({ baseURL }) => { + const rootSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'GET /test-transaction' && + getSpanOp(span) === 'http.server' && + span.is_segment && + span.attributes?.['http.request.header.user_agent']?.value === 'Custom-Agent/1.0 (Test)' + ); + }); + + await fetch(`${baseURL}/test-transaction`, { + headers: { + 'User-Agent': 'Custom-Agent/1.0 (Test)', + 'Content-Type': 'application/json', + 'X-Custom-Header': 'test-value', + Accept: 'application/json, text/plain', + 'X-Request-ID': 'req-123', + }, + }); + + const rootSpan = await rootSpanPromise; + + expect(rootSpan.attributes?.['http.request.header.user_agent']?.value).toBe('Custom-Agent/1.0 (Test)'); + expect(rootSpan.attributes?.['http.request.header.content_type']?.value).toBe('application/json'); + expect(rootSpan.attributes?.['http.request.header.x_custom_header']?.value).toBe('test-value'); + expect(rootSpan.attributes?.['http.request.header.accept']?.value).toBe('application/json, text/plain'); + expect(rootSpan.attributes?.['http.request.header.x_request_id']?.value).toBe('req-123'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/trpc.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/trpc.test.ts new file mode 100644 index 000000000000..bc0b982adbe9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/trpc.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import type { AppRouter } from '../src/app'; + +test('Should record streamed span for trpc query', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/getSomething' && getSpanOp(span) === 'rpc.server'; + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.getSomething.query('foobar'); + + const trpcSpan = await trpcSpanPromise; + expect(trpcSpan).toBeDefined(); + expect(trpcSpan.name).toBe('trpc/getSomething'); + expect(getSpanOp(trpcSpan)).toBe('rpc.server'); + expect(trpcSpan.attributes?.['sentry.origin']?.value).toBe('auto.rpc.trpc'); +}); + +test('Should record streamed span for trpc mutation', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/createSomething' && getSpanOp(span) === 'rpc.server'; + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.createSomething.mutate(); + + const trpcSpan = await trpcSpanPromise; + expect(trpcSpan).toBeDefined(); + expect(trpcSpan.name).toBe('trpc/createSomething'); + expect(getSpanOp(trpcSpan)).toBe('rpc.server'); + expect(trpcSpan.attributes?.['sentry.origin']?.value).toBe('auto.rpc.trpc'); +}); + +test('Should record streamed span and error for a crashing trpc handler', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/crashSomething' && getSpanOp(span) === 'rpc.server'; + }); + + const errorEventPromise = waitForError('node-express-streaming', errorEvent => { + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('I crashed in a trpc handler')); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await expect(trpcClient.crashSomething.mutate({ nested: { nested: { nested: 'foobar' } } })).rejects.toBeDefined(); + + await expect(trpcSpanPromise).resolves.toBeDefined(); + await expect(errorEventPromise).resolves.toBeDefined(); + + expect((await errorEventPromise).contexts?.trpc?.['procedure_type']).toBe('mutation'); + expect((await errorEventPromise).contexts?.trpc?.['procedure_path']).toBe('crashSomething'); + + expect((await errorEventPromise).contexts?.trpc?.['input']).toEqual({ + nested: { + nested: { + nested: 'foobar', + }, + }, + }); +}); + +test('Should record streamed span and error for a trpc handler that returns a status code', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/badRequest' && getSpanOp(span) === 'rpc.server'; + }); + + const errorEventPromise = waitForError('node-express-streaming', errorEvent => { + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('Bad Request')); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await expect(trpcClient.badRequest.mutate()).rejects.toBeDefined(); + + await expect(trpcSpanPromise).resolves.toBeDefined(); + await expect(errorEventPromise).resolves.toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-streaming/tsconfig.json new file mode 100644 index 000000000000..0060abd94682 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx index 9c9ccd812edd..877edfe0c2ce 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx @@ -26,8 +26,7 @@ startTransition(() => { hydrateRoot( document, - {/* unstable_instrumentations is React Router 7.x's prop name (will become `instrumentations` in v8) */} - + , ); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx index 1cbc6b6166fe..178a8ed4e377 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx @@ -17,6 +17,4 @@ export default handleRequest; export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true }); -// Use Sentry's instrumentation API for server-side tracing -// `unstable_instrumentations` is React Router 7.x's export name (will become `instrumentations` in v8) -export const unstable_instrumentations = [Sentry.createSentryServerInstrumentation()]; +export const instrumentations = [Sentry.createSentryServerInstrumentation()]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/package.json index 56c4b7d052d7..2c78f5adb154 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/package.json @@ -35,7 +35,7 @@ "@types/react-dom": "^19.1.2", "tailwindcss": "^4.1.4", "typescript": "^5.8.3", - "vite": "^6.3.3", + "vite": "^6.4.2", "vite-tsconfig-paths": "^5.1.4" }, "browserslist": { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts index d32fd24c75a6..1f41355a8ebd 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts @@ -9,6 +9,7 @@ test.describe('client - pageload performance', () => { }); await page.goto(`/performance`); + await page.getByRole('heading', { name: 'Performance Page' }).waitFor(); const transaction = await txPromise; @@ -62,6 +63,7 @@ test.describe('client - pageload performance', () => { }); await page.goto(`/performance/with/sentry`); + await page.getByRole('heading', { name: 'Dynamic Parameter Page' }).waitFor(); const transaction = await txPromise; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json index 28f189bcd1f3..80535ff38302 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json @@ -35,7 +35,7 @@ "@types/react-dom": "^19.1.2", "tailwindcss": "^4.1.4", "typescript": "^5.8.3", - "vite": "^6.3.3", + "vite": "^6.4.2", "vite-tsconfig-paths": "^5.1.4" }, "browserslist": { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts index d32fd24c75a6..1f41355a8ebd 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts @@ -9,6 +9,7 @@ test.describe('client - pageload performance', () => { }); await page.goto(`/performance`); + await page.getByRole('heading', { name: 'Performance Page' }).waitFor(); const transaction = await txPromise; @@ -62,6 +63,7 @@ test.describe('client - pageload performance', () => { }); await page.goto(`/performance/with/sentry`); + await page.getByRole('heading', { name: 'Dynamic Parameter Page' }).waitFor(); const transaction = await txPromise; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.gitignore @@ -0,0 +1,29 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/index.html b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/index.html new file mode 100644 index 000000000000..e4b78eae1230 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json new file mode 100644 index 000000000000..4fddbfa60945 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json @@ -0,0 +1,65 @@ +{ + "name": "react-router-7-spa-streaming", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "file:../../packed/sentry-react-packed.tgz", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router": "^7.13.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "vite": "^6.4.2", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.0.0" + }, + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-ts3.8": "pnpm install && pnpm add typescript@3.8 && pnpm build", + "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && pnpm build", + "test:assert": "pnpm test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-ts3.8", + "label": "react-router-7-spa-streaming (TS 3.8)" + } + ] + }, + "pnpm": { + "overrides": { + "esbuild": "0.24.0" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs new file mode 100644 index 000000000000..7fda76df18ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm preview --port 3030`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/main.tsx new file mode 100644 index 000000000000..9f69427b101d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/main.tsx @@ -0,0 +1,57 @@ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { + BrowserRouter, + Route, + Routes, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router'; +import Index from './pages/Index'; +import SSE from './pages/SSE'; +import User from './pages/User'; + +const replay = Sentry.replayIntegration(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + integrations: [ + Sentry.reactRouterV7BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + trackFetchStreamPerformance: true, + }), + Sentry.spanStreamingIntegration(), + replay, + ], + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + release: 'e2e-test', + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + tunnel: 'http://localhost:3031', + sendDefaultPii: true, +}); + +const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + + + } /> + } /> + } /> + + , +); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/Index.tsx new file mode 100644 index 000000000000..688cba53fb70 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/Index.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Link } from 'react-router'; + +const Index = () => { + return ( + <> + { + throw new Error('I am an error!'); + }} + /> + + navigate + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/SSE.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/SSE.tsx new file mode 100644 index 000000000000..4c0ae97036ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/SSE.tsx @@ -0,0 +1,58 @@ +import * as Sentry from '@sentry/react'; +import * as React from 'react'; + +const fetchSSE = async ({ timeout, abort = false }: { timeout: boolean; abort?: boolean }) => { + Sentry.startSpanManual({ name: 'sse stream using fetch' }, async span => { + const controller = new AbortController(); + + const res = await Sentry.startSpan({ name: 'sse fetch call' }, async () => { + const endpoint = `http://localhost:8080/${timeout ? 'sse-timeout' : 'sse'}`; + + const signal = controller.signal; + return await fetch(endpoint, { signal }); + }); + + const stream = res.body; + const reader = stream?.getReader(); + + const readChunk = async () => { + if (abort) { + controller.abort(); + } + const readRes = await reader?.read(); + if (readRes?.done) { + return; + } + + new TextDecoder().decode(readRes?.value); + + await readChunk(); + }; + + try { + await readChunk(); + } catch (error) { + console.error('Could not fetch sse', error); + } + + span.end(); + }); +}; + +const SSE = () => { + return ( + <> + + + + + ); +}; + +export default SSE; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/User.tsx new file mode 100644 index 000000000000..671455a92fff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/User.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const User = () => { + return

I am a blank page :)

; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..202974be53c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-spa-streaming', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/errors.test.ts new file mode 100644 index 000000000000..bd60e80c0246 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page, baseURL }) => { + const errorEventPromise = waitForError('react-router-7-spa-streaming', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const errorEventPromise = waitForError('react-router-7-spa-streaming', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + // Only capture error once pageload span was sent + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id || ''), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts new file mode 100644 index 000000000000..0080a584463a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/`); + + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.status).toBe('ok'); + expect(span.attributes?.['sentry.origin']?.value).toBe('auto.pageload.react.reactrouter_v7'); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.trace_id).toMatch(/[a-f0-9]{32}/); + expect(navigationSpan.status).toBe('ok'); + expect(navigationSpan.attributes?.['sentry.origin']?.value).toBe('auto.navigation.react.reactrouter_v7'); + expect(navigationSpan.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('sends an INP span', async ({ page }) => { + const inpSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'ui.interaction.click'; + }); + + await page.goto(`/`); + + await page.click('#exception-button'); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + const inpSpan = await inpSpanPromise; + + expect(inpSpan.name).toBe('body > div#root > input#exception-button[type="button"]'); + expect(inpSpan.trace_id).toMatch(/[a-f0-9]{32}/); + expect(inpSpan.span_id).toMatch(/[a-f0-9]{16}/); + expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp); + expect(inpSpan.attributes?.['sentry.op']?.value).toBe('ui.interaction.click'); + expect(inpSpan.attributes?.['sentry.origin']?.value).toBe('auto.http.browser.inp'); + expect(inpSpan.attributes?.['sentry.exclusive_time']?.value).toEqual(expect.any(Number)); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json new file mode 100644 index 000000000000..7af258198f12 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "types": ["vite/client"] + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/vite.config.ts new file mode 100644 index 000000000000..63c2c4317df7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/vite.config.ts @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + envPrefix: 'PUBLIC_', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json index c792359c5a3f..eee79f453d56 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json @@ -13,7 +13,7 @@ "devDependencies": { "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", - "vite": "^6.0.1", + "vite": "^6.4.2", "@vitejs/plugin-react": "^4.3.4", "typescript": "~5.0.0" }, diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json index 04a2fd2adeec..0a8fbefff393 100644 --- a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json @@ -25,7 +25,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "typescript": "^5.7.2", - "vite": "^7.1.7", + "vite": "^7.3.2", "vite-plugin-solid": "^2.11.2" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json index 162c148d3a86..12f39178da15 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/package.json @@ -28,7 +28,7 @@ "svelte-check": "^4.3.1", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^7.1.3" + "vite": "^7.3.2" }, "type": "module", "volta": { diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json index 8081e11fe19f..b95f2348ba3b 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json @@ -25,10 +25,11 @@ "svelte": "^5.20.2", "svelte-check": "^4.1.4", "typescript": "^5.0.0", - "vite": "^6.1.1", - "wrangler": "4.61.0" + "vite": "^6.4.2", + "wrangler": "^4.61.0" }, "volta": { + "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/node-core-integration-tests/suites/anr/forked.js b/dev-packages/node-core-integration-tests/suites/anr/forked.js index 90148e549ce1..2fd7a0678e8d 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/forked.js +++ b/dev-packages/node-core-integration-tests/suites/anr/forked.js @@ -9,7 +9,7 @@ setTimeout(() => { }, 10000); const client = Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', debug: true, integrations: [Sentry.anrIntegration({ captureStackTrace: true, anrThreshold: 100 })], diff --git a/dev-packages/node-core-integration-tests/suites/anr/test.ts b/dev-packages/node-core-integration-tests/suites/anr/test.ts index 406830c9b299..b1aabd2eb001 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-core-integration-tests/suites/anr/test.ts @@ -221,7 +221,11 @@ describe('should report ANR when event loop blocked', { timeout: 90_000 }, () => }); test('from forked process', async () => { - await createRunner(__dirname, 'forker.js').expect({ event: ANR_EVENT_WITH_SCOPE }).start().completed(); + await createRunner(__dirname, 'forker.js') + .withMockSentryServer() + .expect({ event: ANR_EVENT_WITH_SCOPE }) + .start() + .completed(); }); test('worker can be stopped and restarted', async () => { diff --git a/dev-packages/node-core-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-core-integration-tests/suites/cron/cron/scenario.ts index 4ddd9f115189..3ef46faa9e90 100644 --- a/dev-packages/node-core-integration-tests/suites/cron/cron/scenario.ts +++ b/dev-packages/node-core-integration-tests/suites/cron/cron/scenario.ts @@ -1,12 +1,10 @@ import * as Sentry from '@sentry/node-core'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import { CronJob } from 'cron'; import { setupOtel } from '../../../utils/setupOtel'; const client = Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', - transport: loggingTransport, }); setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/cron/cron/test.ts b/dev-packages/node-core-integration-tests/suites/cron/cron/test.ts index 60edd2812b4b..8209e2332042 100644 --- a/dev-packages/node-core-integration-tests/suites/cron/cron/test.ts +++ b/dev-packages/node-core-integration-tests/suites/cron/cron/test.ts @@ -7,6 +7,7 @@ afterAll(() => { test('cron instrumentation', async () => { await createRunner(__dirname, 'scenario.ts') + .withMockSentryServer() .expect({ check_in: { check_in_id: expect.any(String), diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts index 25096f1be7e5..858e80e0718d 100644 --- a/dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts +++ b/dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts @@ -11,6 +11,7 @@ describe('light mode logs', () => { .expect({ log: logsContainer => { expect(logsContainer).toEqual({ + version: 2, items: [ { attributes: { diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts index c0c9d291de78..d2a67f8df890 100644 --- a/dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts +++ b/dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts @@ -11,6 +11,7 @@ describe('light mode metrics', () => { .unignore('trace_metric') .expect({ trace_metric: { + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-core-integration-tests/suites/public-api/logs/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/logs/test.ts index 53c80a6194c5..8afc4402475d 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/logs/test.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/logs/test.ts @@ -11,6 +11,7 @@ describe('logger public API', () => { .expect({ log: logsContainer => { expect(logsContainer).toEqual({ + version: 2, items: [ { attributes: { diff --git a/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts index 9494ce2a99ca..303eb22f3285 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts @@ -11,6 +11,7 @@ describe('metrics', () => { .unignore('trace_metric') .expect({ trace_metric: { + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index 3184aae69d64..ee018e45e53b 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -123,16 +123,37 @@ test('sends a streamed span envelope with correct spans for a manually started s status: 'ok', }); + const expectedAttributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + 'process.runtime.engine.name': { type: 'string', value: 'v8' }, + 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, + 'app.start_time': { type: 'string', value: expect.any(String) }, + 'app.memory': { type: 'integer', value: expect.any(Number) }, + 'device.boot_time': { type: 'string', value: expect.any(String) }, + 'device.memory_size': { type: 'integer', value: expect.any(Number) }, + 'device.free_memory': { type: 'integer', value: expect.any(Number) }, + 'device.processor_count': { type: 'integer', value: expect.any(Number) }, + 'device.cpu_description': { type: 'string', value: expect.any(String) }, + 'device.processor_frequency': { type: 'integer', value: expect.any(Number) }, + 'culture.locale': { type: 'string', value: expect.any(String) }, + 'culture.timezone': { type: 'string', value: expect.any(String) }, + // TODO: device.archs is an array and currently dropped during serialization + // 'device.archs': { type: 'array', value: [expect.any(String)] }, + }; + + // process.availableMemory is only available in Node 22+ + if (typeof (process as any).availableMemory === 'function') { + expectedAttributes['app.free_memory'] = { type: 'integer', value: expect.any(Number) }; + } + expect(segmentSpan).toEqual({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - }, + attributes: expectedAttributes, name: 'test-span', is_segment: true, trace_id: traceId, diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts index 17393f21a8a4..a1a9ce5d51dc 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -1,202 +1,101 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; -import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; describe('outgoing http requests with tracing & spans disabled', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - conditionalTest({ min: 22 })('node >=22', () => { - test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { - expect.assertions(11); + test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v1', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], - }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', - }, + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + type: 'Error', + value: 'foo', }, ], }, - }) - .start() - .completed(); - - closeTestServer(); - }); - }); - - // On older node versions, outgoing requests do not get trace-headers injected, sadly - // This is because the necessary diagnostics channel hook is not available yet - conditionalTest({ max: 21 })('node <22', () => { - test('outgoing http requests generate breadcrumbs correctly with tracing & spans disabled', async () => { - expect.assertions(9); - - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v1', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); - - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', }, - ], - }, - }) - .start() - .completed(); + timestamp: expect.any(Number), + type: 'http', + }, + ], + }, + }) + .start() + .completed(); - closeTestServer(); - }); + closeTestServer(); }); }); }); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index c079c807fa3e..13cffc27fcde 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -25,11 +25,11 @@ "dependencies": { "@anthropic-ai/sdk": "0.63.0", "@apollo/server": "^5.5.0", - "@aws-sdk/client-s3": "^3.993.0", + "@aws-sdk/client-s3": "^3.1041.0", "@google/genai": "^1.20.0", "@growthbook/growthbook": "^1.6.1", "@hapi/hapi": "^21.3.10", - "@hono/node-server": "^1.19.10", + "@hono/node-server": "^1.19.13", "@langchain/anthropic": "^0.3.10", "@langchain/core": "^0.3.80", "@langchain/openai": "^0.5.0", @@ -58,7 +58,7 @@ "generic-pool": "^3.9.0", "graphql": "^16.11.0", "graphql-tag": "^2.12.6", - "hono": "^4.12.12", + "hono": "^4.12.14", "http-terminator": "^3.2.0", "ioredis": "^5.4.1", "kafkajs": "2.2.4", @@ -80,6 +80,7 @@ "prisma": "6.15.0", "proxy": "^2.1.1", "redis-4": "npm:redis@^4.6.14", + "redis-5": "npm:redis@^5.12.0", "reflect-metadata": "0.2.1", "rxjs": "^7.8.2", "tedious": "^19.2.1", diff --git a/dev-packages/node-integration-tests/suites/anr/forked.js b/dev-packages/node-integration-tests/suites/anr/forked.js index 18720a7258af..e0e120f41256 100644 --- a/dev-packages/node-integration-tests/suites/anr/forked.js +++ b/dev-packages/node-integration-tests/suites/anr/forked.js @@ -8,7 +8,7 @@ setTimeout(() => { }, 10000); Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', debug: true, integrations: [Sentry.anrIntegration({ captureStackTrace: true, anrThreshold: 100 })], diff --git a/dev-packages/node-integration-tests/suites/anr/test.ts b/dev-packages/node-integration-tests/suites/anr/test.ts index c9a81ccb5db0..653483b64237 100644 --- a/dev-packages/node-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-integration-tests/suites/anr/test.ts @@ -210,7 +210,11 @@ describe('should report ANR when event loop blocked', { timeout: 90_000 }, () => }); test('from forked process', async () => { - await createRunner(__dirname, 'forker.js').expect({ event: ANR_EVENT_WITH_SCOPE }).start().completed(); + await createRunner(__dirname, 'forker.js') + .withMockSentryServer() + .expect({ event: ANR_EVENT_WITH_SCOPE }) + .start() + .completed(); }); test('worker can be stopped and restarted', async () => { diff --git a/dev-packages/node-integration-tests/suites/context-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/context-streamed/scenario.ts new file mode 100644 index 000000000000..7f1b5ddd053f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/context-streamed/scenario.ts @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +Sentry.startSpan({ name: 'test-span' }, () => { + // noop +}); + +void Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/context-streamed/test.ts b/dev-packages/node-integration-tests/suites/context-streamed/test.ts new file mode 100644 index 000000000000..9d1a6ca5099a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/context-streamed/test.ts @@ -0,0 +1,43 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('nodeContextIntegration sets context attributes on segment spans', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + span: container => { + const segmentSpan = container.items.find(s => !!s.is_segment); + expect(segmentSpan).toBeDefined(); + + const attrs = segmentSpan!.attributes!; + + // Static attributes + expect(attrs['app.start_time']).toEqual({ type: 'string', value: expect.any(String) }); + // TODO: device.archs is an array and currently dropped during serialization + // expect(attrs['device.archs']).toEqual({ type: 'array', value: [expect.any(String)] }); + expect(attrs['device.boot_time']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['device.processor_count']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['device.cpu_description']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['device.processor_frequency']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['device.memory_size']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['culture.locale']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['culture.timezone']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['process.runtime.engine.name']).toEqual({ type: 'string', value: 'v8' }); + expect(attrs['process.runtime.engine.version']).toEqual({ type: 'string', value: expect.any(String) }); + + // Dynamic attributes + expect(attrs['app.memory']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['device.free_memory']).toEqual({ type: 'integer', value: expect.any(Number) }); + + // process.availableMemory is only available in Node 22+ + if (typeof (process as any).availableMemory === 'function') { + expect(attrs['app.free_memory']).toEqual({ type: 'integer', value: expect.any(Number) }); + } + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts index 6fe6838844de..a51eab1d8d6f 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts @@ -1,11 +1,9 @@ import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import { CronJob } from 'cron'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', - transport: loggingTransport, }); const CronJobWithCheckIn = Sentry.cron.instrumentCron(CronJob, 'my-cron-job'); diff --git a/dev-packages/node-integration-tests/suites/cron/cron/test.ts b/dev-packages/node-integration-tests/suites/cron/cron/test.ts index 078cc0997221..3606b4d02808 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/test.ts @@ -7,6 +7,7 @@ afterAll(() => { test('cron instrumentation', { timeout: 30_000 }, async () => { await createRunner(__dirname, 'scenario.ts') + .withMockSentryServer() .expect({ check_in: { check_in_id: expect.any(String), diff --git a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts index 6b9f43e738d2..e992d70c4de3 100644 --- a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts @@ -39,6 +39,7 @@ describe('logs', () => { .expect({ log: logsContainer => { expect(logsContainer).toEqual({ + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts index 825d94f41624..dfb3094f1bb9 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts @@ -10,6 +10,7 @@ describe('metrics server.address', () => { const runner = createRunner(__dirname, 'scenario.ts') .expect({ trace_metric: { + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts index 048513da3c19..86eb295e0c5d 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -10,6 +10,7 @@ describe('metrics server.address', () => { const runner = createRunner(__dirname, 'scenario.ts') .expect({ trace_metric: { + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts index ff67b73e9ad3..9b266552b052 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts @@ -10,6 +10,7 @@ describe('metrics', () => { const runner = createRunner(__dirname, 'scenario.ts') .expect({ trace_metric: { + version: 2, items: [ { timestamp: expect.any(Number), diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index b31ca320df53..88e3f3686622 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -123,16 +123,37 @@ test('sends a streamed span envelope with correct spans for a manually started s status: 'ok', }); + const expectedAttributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + 'process.runtime.engine.name': { type: 'string', value: 'v8' }, + 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, + 'app.start_time': { type: 'string', value: expect.any(String) }, + 'app.memory': { type: 'integer', value: expect.any(Number) }, + // TODO: device.archs is an array and currently dropped during serialization + // 'device.archs': { type: 'array', value: [expect.any(String)] }, + 'device.boot_time': { type: 'string', value: expect.any(String) }, + 'device.memory_size': { type: 'integer', value: expect.any(Number) }, + 'device.free_memory': { type: 'integer', value: expect.any(Number) }, + 'device.processor_count': { type: 'integer', value: expect.any(Number) }, + 'device.cpu_description': { type: 'string', value: expect.any(String) }, + 'device.processor_frequency': { type: 'integer', value: expect.any(Number) }, + 'culture.locale': { type: 'string', value: expect.any(String) }, + 'culture.timezone': { type: 'string', value: expect.any(String) }, + }; + + // process.availableMemory is only available in Node 22+ + if (typeof (process as any).availableMemory === 'function') { + expectedAttributes['app.free_memory'] = { type: 'integer', value: expect.any(Number) }; + } + expect(segmentSpan).toEqual({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - }, + attributes: expectedAttributes, name: 'test-span', is_segment: true, trace_id: traceId, diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/instrument.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/instrument.mjs rename to dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/instrument.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/scenario-fetch.mjs b/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/scenario-fetch.mjs new file mode 100644 index 000000000000..6a1cc2c77ba3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/scenario-fetch.mjs @@ -0,0 +1,4 @@ +import * as Sentry from '@sentry/node'; +fetch('http://localhost:9999/external').catch(async () => { + await Sentry.flush(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/test.ts new file mode 100644 index 000000000000..58eb063ed345 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/test.ts @@ -0,0 +1,29 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('http.client span with streaming enabled', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario-fetch.mjs', 'instrument.mjs', (createRunner, test) => { + test('sends http.client span without a local parent when span streaming is enabled', async () => { + const runner = createRunner() + .expect({ + span: span => { + const httpClientSpan = span.items.find(item => + item.attributes?.['sentry.op'] + ? item.attributes['sentry.op'].type === 'string' && item.attributes['sentry.op'].value === 'http.client' + : false, + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan?.name).toMatch(/^GET .*\/external$/); + }, + }) + .start(); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts index 0549d7e914c0..1fea661d33e5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts @@ -29,13 +29,13 @@ test('captures spans for outgoing http requests', async () => { expect.objectContaining({ description: expect.stringMatching(/GET .*\/api\/v0/), op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', status: 'ok', }), expect.objectContaining({ description: expect.stringMatching(/GET .*\/api\/v1/), op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', status: 'not_found', data: expect.objectContaining({ 'http.response.status_code': 404, diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario-mitigation.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario-mitigation.ts new file mode 100644 index 000000000000..decdf15a413e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario-mitigation.ts @@ -0,0 +1,40 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [ + // Disable Sentry's span creation so that OTel HttpInstrumentation + // is the only source of http.client spans. Breadcrumbs and + // trace-propagation headers are still injected; only span creation + // is suppressed. + Sentry.httpIntegration({ spans: false }), + ], +}); + +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; + +registerInstrumentations({ + instrumentations: [new HttpInstrumentation()], +}); + +import * as http from 'http'; + +void Sentry.startSpan({ name: 'test_transaction' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); +}); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => {}); + httpRes.on('end', resolve); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario.ts new file mode 100644 index 000000000000..69edce226157 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/scenario.ts @@ -0,0 +1,36 @@ +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, + debug: true, +}); + +// Simulate a user who independently sets up OTel HttpInstrumentation +// alongside the Sentry SDK, as when adopting Sentry into existing OTel app +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; + +registerInstrumentations({ + instrumentations: [new HttpInstrumentation()], +}); + +import * as http from 'http'; + +void Sentry.startSpan({ name: 'test_transaction' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); +}); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => {}); + httpRes.on('end', resolve); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/test.ts new file mode 100644 index 000000000000..495b6a8a17f6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/test.ts @@ -0,0 +1,92 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('registers double spans when OTel HttpInstrumentation is also active — documents known issue', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', () => {}) + .start(); + + const runner = createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: txn => { + expect(txn.transaction).toBe('test_transaction'); + + const httpClientSpans = (txn.spans ?? []).filter(s => s.op === 'http.client'); + + // PROBLEM: two http.client spans are produced for a single + // outgoing request when @opentelemetry/instrumentation-http runs + // alongside Sentry. + // + // - OTel's HttpInstrumentation monkey-patches http.request and + // creates an OTel span; Sentry's SentrySpanProcessor converts that + // to a Sentry span. + // - On Node >=22.12 Sentry's SentryHttpInstrumentation additionally + // subscribes to the http.client.request.created diagnostic channel. + // The channel fires inside OTel's already-patched http.request, + // triggering Sentry to create a *second* http.client span as a + // child of the first. + // - On Node <22.12 both instrumentations monkey-patch http.request, + // so both wrappers fire and each creates its own span. + // + // MITIGATION: pass `spans: false` to httpIntegration() so Sentry + // defers all outgoing span creation to OTel's HttpInstrumentation + // (whose spans Sentry already captures via SentrySpanProcessor). + // + // See the 'mitigation' scenario alongside this test. + expect(httpClientSpans).toHaveLength(2); + + // The outer span comes from OTel HttpInstrumentation (no Sentry + // origin). The inner span is the one Sentry's own handler creates; + // it is a *child* of the outer span with origin 'auto.http.client'. + const sentrySpan = httpClientSpans.find(s => s.data?.['sentry.origin'] === 'auto.http.client'); + const otelSpan = httpClientSpans.find(s => s.data?.['sentry.origin'] !== 'auto.http.client'); + + expect(sentrySpan).toBeDefined(); + expect(otelSpan).toBeDefined(); + + // the sentry-created span is nested inside the otel-created span. + expect(sentrySpan!.parent_span_id).toBe(otelSpan!.span_id); + }, + }) + .start(); + + await runner.completed(); + + // The double-wrap warning should have been logged to stderr (via debug.warn) + // since scenario.ts initialises Sentry with debug: true. + const logs = runner.getLogs(); + expect(logs.some(l => l.includes('Double-wrapped http.client detected'))).toBe(true); + + closeTestServer(); +}); + +test('mitigation: spans: false on httpIntegration prevents double-instrumentation', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', () => {}) + .start(); + + await createRunner(__dirname, 'scenario-mitigation.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: txn => { + expect(txn.transaction).toBe('test_transaction'); + + const httpClientSpans = (txn.spans ?? []).filter(s => s.op === 'http.client'); + // With spans: false in httpIntegration(), Sentry does not create its + // own span. OTel's HttpInstrumentation still creates one, which + // flows through SentrySpanProcessor, so there is exactly one + // http.client span. + expect(httpClientSpans).toHaveLength(1); + expect(httpClientSpans[0]).toMatchObject({ + description: expect.stringMatching(/GET .*\/api\/v0/), + status: 'ok', + }); + }, + }) + .start() + .completed(); + + closeTestServer(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts index 94ccd6c9702a..60add149deab 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts @@ -37,11 +37,11 @@ test('strips and handles query params in spans of outgoing http requests', async 'net.transport': 'ip_tcp', 'otel.kind': 'CLIENT', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }, description: `GET ${SERVER_URL}/api/v0/users`, op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', status: 'ok', parent_span_id: txn.contexts?.trace?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts index 8c16e8b36133..13fb7a12fa85 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts @@ -1,5 +1,7 @@ +import type { TransactionEvent } from '@sentry/core'; import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { assertSentryTransaction } from '../../../utils/assertions'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; describe('MongoDB experimental Test', () => { @@ -17,160 +19,125 @@ describe('MongoDB experimental Test', () => { cleanupChildProcesses(); }); - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction', - spans: [ - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': '$cmd', - 'db.operation': 'isMaster', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': - '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', - 'otel.kind': 'CLIENT', - }, - description: - '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': '$cmd', - 'db.operation': 'isMaster', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': - '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', - 'otel.kind': 'CLIENT', - }, - description: - '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': 'movies', - 'db.operation': 'insert', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"title":"?","_id":{"_bsontype":"?","id":"?"}}', - 'otel.kind': 'CLIENT', - }, - description: '{"title":"?","_id":{"_bsontype":"?","id":"?"}}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': 'movies', - 'db.operation': 'find', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"title":"?"}', - 'otel.kind': 'CLIENT', - }, - description: '{"title":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': 'movies', - 'db.operation': 'update', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"title":"?"}', - 'otel.kind': 'CLIENT', - }, - description: '{"title":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': 'movies', - 'db.operation': 'find', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"title":"?"}', - 'otel.kind': 'CLIENT', - }, - description: '{"title":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': 'movies', - 'db.operation': 'find', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"title":"?"}', - 'otel.kind': 'CLIENT', - }, - description: '{"title":"?"}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.db.otel.mongo', - 'sentry.op': 'db', - 'db.system': 'mongodb', - 'db.name': 'admin', - 'db.mongodb.collection': '$cmd', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': expect.any(Number), - 'db.statement': '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":"?"}}]}', - 'otel.kind': 'CLIENT', - }, - description: '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":"?"}}]}', - op: 'db', - origin: 'auto.db.otel.mongo', - }), - ], - }; + const SPAN_FIND_MATCHER = expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': 'find', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"title":"?"}', + 'otel.kind': 'CLIENT', + }, + description: '{"title":"?"}', + op: 'db', + origin: 'auto.db.otel.mongo', + }); + + const SPAN_INSERT_MATCHER = expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': 'insert', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"title":"?","_id":{"_bsontype":"?","id":"?"}}', + 'otel.kind': 'CLIENT', + }, + description: '{"title":"?","_id":{"_bsontype":"?","id":"?"}}', + op: 'db', + origin: 'auto.db.otel.mongo', + }); + + const SPAN_ISMASTER_MATCHER = expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': '$cmd', + 'db.operation': 'isMaster', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': + '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', + 'otel.kind': 'CLIENT', + }, + description: + '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', + op: 'db', + origin: 'auto.db.otel.mongo', + }); + + const SPAN_UPDATE_MATCHER = expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': 'update', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"title":"?"}', + 'otel.kind': 'CLIENT', + }, + description: '{"title":"?"}', + op: 'db', + origin: 'auto.db.otel.mongo', + }); + + const SPAN_ENDSESSIONS_MATCHER = expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': '$cmd', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":"?"}}]}', + 'otel.kind': 'CLIENT', + }, + description: '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":"?"}}]}', + op: 'db', + origin: 'auto.db.otel.mongo', + }); test('CJS - should auto-instrument `mongodb` package.', async () => { - await createRunner(__dirname, 'scenario.js').expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + await createRunner(__dirname, 'scenario.js') + .expect({ + transaction: (txn: TransactionEvent) => { + assertSentryTransaction(txn, { transaction: 'Test Transaction' }); + const spans = txn.spans || []; + expect(spans).toHaveLength(8); + + expect(spans).toContainEqual(SPAN_FIND_MATCHER); + expect(spans).toContainEqual(SPAN_INSERT_MATCHER); + expect(spans).toContainEqual(SPAN_ISMASTER_MATCHER); + expect(spans).toContainEqual(SPAN_UPDATE_MATCHER); + expect(spans).toContainEqual(SPAN_ENDSESSIONS_MATCHER); + + // Ensure duplicate spans are correctly there + const findSpans = spans.filter(span => span.data['db.operation'] === 'find'); + expect(findSpans).toHaveLength(3); + + const isMasterSpans = spans.filter(span => span.data['db.operation'] === 'isMaster'); + expect(isMasterSpans).toHaveLength(2); + }, + }) + .start() + .completed(); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs deleted file mode 100644 index 18afc6db5113..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs +++ /dev/null @@ -1,2 +0,0 @@ -import http from 'http'; -http.get('http://localhost:9999/external', () => {}).on('error', () => {}); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts deleted file mode 100644 index 2b987f92d755..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { afterAll, describe, expect } from 'vitest'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; - -describe('no_parent_span client report (streaming)', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('records no_parent_span outcome for http.client span without a local parent', async () => { - const runner = createRunner() - .unignore('client_report') - .expect({ - client_report: report => { - expect(report.discarded_events).toEqual([ - { - category: 'span', - quantity: 1, - reason: 'no_parent_span', - }, - ]); - }, - }) - .start(); - - await runner.completed(); - }); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/redis-dc/docker-compose.yml new file mode 100644 index 000000000000..9cad2efa4eff --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.9' + +services: + db: + image: redis:latest + restart: always + container_name: integration-tests-redis-dc + ports: + - '6379:6379' + healthcheck: + test: ['CMD-SHELL', 'redis-cli ping | grep -q PONG'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js new file mode 100644 index 000000000000..5cf455203d48 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js @@ -0,0 +1,47 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.redisIntegration({ cachePrefixes: ['dc-cache:'] })], +}); + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +async function run() { + // Yield a microtick so the DC subscriber (deferred via Promise.resolve().then) + // is registered before node-redis eagerly creates its native TracingChannels on require(). + await Promise.resolve(); + + const { createClient } = require('redis-5'); + const redisClient = await createClient({ socket: { host: '127.0.0.1', port: 6379 } }).connect(); + + await Sentry.startSpan( + { + name: 'Test Span Redis 5 DC', + op: 'test-span-redis-5-dc', + }, + async () => { + try { + await redisClient.set('dc-test-key', 'test-value'); + await redisClient.set('dc-cache:test-key', 'test-value'); + + await redisClient.set('dc-cache:test-key-ex', 'test-value', { EX: 10 }); + + await redisClient.get('dc-test-key'); + await redisClient.get('dc-cache:test-key'); + await redisClient.get('dc-cache:unavailable-data'); + + await redisClient.mGet(['dc-test-key', 'dc-cache:test-key', 'dc-cache:unavailable-data']); + } finally { + await redisClient.disconnect(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts new file mode 100644 index 000000000000..7b7c111f4d29 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts @@ -0,0 +1,110 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('redis v5 diagnostics_channel auto instrumentation', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should create spans for redis v5 commands via diagnostics_channel', { timeout: 60_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Span Redis 5 DC', + spans: expect.arrayContaining([ + expect.objectContaining({ + op: 'db.redis', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.op': 'db.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.system': 'redis', + 'db.statement': 'SET dc-test-key [1 other arguments]', + }), + }), + // cache SET: span name updated to key by cacheResponseHook + expect.objectContaining({ + description: 'dc-cache:test-key', + op: 'cache.put', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.statement': 'SET dc-cache:test-key [1 other arguments]', + 'cache.key': ['dc-cache:test-key'], + 'cache.item_size': 2, + }), + }), + // cache SET with EX option: redis v5 sends SET key value EX 10 as the command + expect.objectContaining({ + description: 'dc-cache:test-key-ex', + op: 'cache.put', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.statement': 'SET dc-cache:test-key-ex [3 other arguments]', + 'cache.key': ['dc-cache:test-key-ex'], + 'cache.item_size': 2, + }), + }), + expect.objectContaining({ + op: 'db.redis', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.op': 'db.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.system': 'redis', + 'db.statement': 'GET dc-test-key', + }), + }), + // cache GET (hit) + expect.objectContaining({ + description: 'dc-cache:test-key', + op: 'cache.get', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.statement': 'GET dc-cache:test-key', + 'cache.hit': true, + 'cache.key': ['dc-cache:test-key'], + 'cache.item_size': 10, + }), + }), + // cache GET (miss) + expect.objectContaining({ + description: 'dc-cache:unavailable-data', + op: 'cache.get', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.statement': 'GET dc-cache:unavailable-data', + 'cache.hit': false, + 'cache.key': ['dc-cache:unavailable-data'], + }), + }), + // MGET: node-redis sanitizes args for diagnostics_channel (keys become '?'), + // so cache detection cannot match prefixes — remains a plain db.redis span. + expect.objectContaining({ + op: 'db.redis', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.op': 'db.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', + 'db.system': 'redis', + 'db.statement': 'MGET [3 other arguments]', + }), + }), + ]), + }; + + // node-redis emits a node-redis:connect DC event for the initial connection. + // That fires before startSpan so it arrives as the first envelope. + const EXPECTED_CONNECT = { + transaction: 'redis-connect', + }; + + await createRunner(__dirname, 'scenario-redis-5.js') + .withDockerCompose({ workingDirectory: [__dirname] }) + .expect({ transaction: EXPECTED_CONNECT }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument-without-request-data.mjs b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument-without-request-data.mjs new file mode 100644 index 000000000000..04492fbce291 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument-without-request-data.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + sendDefaultPii: true, + integrations: defaults => defaults.filter(i => i.name !== 'RequestData'), +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs rename to dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument.mjs index 48a860c510c5..761e5f9c474d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument.mjs @@ -5,7 +5,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0, - sendDefaultPii: true, transport: loggingTransport, traceLifecycle: 'stream', + sendDefaultPii: true, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/server.mjs b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/server.mjs new file mode 100644 index 000000000000..07398392cb75 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/server.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +app.get('/test', (_req, res) => { + res.send({ response: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/test.ts new file mode 100644 index 000000000000..c5657cef6c3a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/test.ts @@ -0,0 +1,74 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('requestData-streamed', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('applies request data attributes to the segment span', async () => { + const runner = createRunner() + .expect({ + span: container => { + const serverSpan = container.items.find(item => item.is_segment); + + expect(serverSpan).toBeDefined(); + + expect(serverSpan?.attributes?.['url.full']).toEqual({ + type: 'string', + value: expect.stringContaining('/test?foo=bar'), + }); + + expect(serverSpan?.attributes?.['http.request.method']).toEqual({ + type: 'string', + value: 'GET', + }); + + expect(serverSpan?.attributes?.['url.query']).toEqual({ + type: 'string', + value: 'foo=bar', + }); + + expect(serverSpan?.attributes?.['http.request.header.host']).toEqual({ + type: 'string', + value: expect.any(String), + }); + + expect(serverSpan?.attributes?.['user.ip_address']).toEqual({ + type: 'string', + value: expect.any(String), + }); + }, + }) + .start(); + + await runner.makeRequest('get', '/test?foo=bar'); + + await runner.completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-without-request-data.mjs', (createRunner, test) => { + test('does not apply request data attributes when requestDataIntegration is removed', async () => { + const runner = createRunner() + .expect({ + span: container => { + const serverSpan = container.items.find(item => item.is_segment); + + expect(serverSpan).toBeDefined(); + + // url.query and user.ip_address are only set by applyScopeToSegmentSpan + // (not by OTel instrumentation), so they should be absent when the integration is removed + expect(serverSpan?.attributes?.['url.query']).toBeUndefined(); + expect(serverSpan?.attributes?.['user.ip_address']).toBeUndefined(); + }, + }) + .start(); + + await runner.makeRequest('get', '/test?foo=bar'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts index 17393f21a8a4..a1a9ce5d51dc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -1,202 +1,101 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; -import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; describe('outgoing http requests with tracing & spans disabled', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - conditionalTest({ min: 22 })('node >=22', () => { - test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { - expect.assertions(11); + test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v1', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], - }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', - }, + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + type: 'Error', + value: 'foo', }, ], }, - }) - .start() - .completed(); - - closeTestServer(); - }); - }); - - // On older node versions, outgoing requests do not get trace-headers injected, sadly - // This is because the necessary diagnostics channel hook is not available yet - conditionalTest({ max: 21 })('node <22', () => { - test('outgoing http requests generate breadcrumbs correctly with tracing & spans disabled', async () => { - expect.assertions(9); - - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v1', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); - - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', }, - ], - }, - }) - .start() - .completed(); + timestamp: expect.any(Number), + type: 'http', + }, + ], + }, + }) + .start() + .completed(); - closeTestServer(); - }); + closeTestServer(); }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs index 4185d972da4d..501375fecd80 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs @@ -35,6 +35,8 @@ async function run() { prompt: 'What is the weather in San Francisco?', }); }); + + await Sentry.flush(2000); } run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs index b6abe6fdf673..ef8cb19c3646 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs @@ -75,6 +75,8 @@ async function run() { prompt: 'Where is the third span?', }); }); + + await Sentry.flush(2000); } run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-pii.mjs new file mode 100644 index 000000000000..0cc510d5c9e6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-pii.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-truncation.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming-with-truncation.mjs rename to dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-truncation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument.mjs new file mode 100644 index 000000000000..cf42383e5c6e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-error-in-tool.mjs new file mode 100644 index 000000000000..a305d0bd2dd8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-error-in-tool.mjs @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + Sentry.setTag('test-tag', 'test-value'); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + try { + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async () => { + throw new Error('Error in tool'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + } catch { + // Expected error - we want the spans to still be flushed + } + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-truncation.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-span-streaming.mjs rename to dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-truncation.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario.mjs new file mode 100644 index 000000000000..ef8cb19c3646 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario.mjs @@ -0,0 +1,82 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'First span here!', + }), + }), + prompt: 'Where is the first span?', + }); + + // This span should have input and output prompts attached because telemetry is explicitly enabled. + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Second span here!', + }), + }), + prompt: 'Where is the second span?', + }); + + // This span should include tool calls and tool results + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + description: 'Get the current weather for a location', + parameters: z.object({ location: z.string() }), + execute: async args => { + return `Weather in ${args.location}: Sunny, 72°F`; + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + + // This span should not be captured because we've disabled telemetry + await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Third span here!', + }), + }), + prompt: 'Where is the third span?', + }); + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts new file mode 100644 index 000000000000..66a96cad2317 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts @@ -0,0 +1,347 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_TOOL_CALL_ID_ATTRIBUTE, + GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE, + GEN_AI_TOOL_INPUT_ATTRIBUTE, + GEN_AI_TOOL_NAME_ATTRIBUTE, + GEN_AI_TOOL_OUTPUT_ATTRIBUTE, + GEN_AI_TOOL_TYPE_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +/** + * Helper to match a typed attribute value in a SerializedStreamedSpan. + * Streamed span attributes are `{ value: X, type: Y }` objects, unlike transaction + * span `data` which stores values directly. + */ +function attr(value: unknown) { + return expect.objectContaining({ value }); +} + +describe('Vercel AI integration (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_SPANS_DEFAULT_PII_FALSE = { + items: expect.arrayContaining([ + // First span - invoke_agent for simple generateText + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText'), + 'vercel.ai.streaming': attr(false), + }), + }), + // Second span - generate_content for simple generateText + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText.doGenerate'), + 'vercel.ai.streaming': attr(false), + }), + }), + // Third span - invoke_agent for explicit telemetry generateText + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fourth span - tool call invoke_agent + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fifth span - tool call generate_content + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Sixth span - execute_tool + // Note: gen_ai.tool.description is NOT present when sendDefaultPii: false because ai.prompt.tools is not recorded + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + const EXPECTED_SPANS_DEFAULT_PII_TRUE = { + items: expect.arrayContaining([ + // First span - invoke_agent with input/output messages (PII enabled) + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: attr(1), + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr('[{"role":"user","content":"Where is the first span?"}]'), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText'), + 'vercel.ai.streaming': attr(false), + }), + }), + // Second span - generate_content with input/output messages + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Third span - explicit telemetry invoke_agent with messages + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr('[{"role":"user","content":"Where is the second span?"}]'), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"Second span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fourth span - tool call invoke_agent with messages + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"user","content":"What is the weather in San Francisco?"}]', + ), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"Tool call completed!"},{"type":"tool_call","id":"call-1","name":"getWeather","arguments":"{ \\"location\\": \\"San Francisco\\" }"}],"finish_reason":"tool_call"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fifth span - tool call generate_content with available_tools + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE]: expect.objectContaining({ + value: expect.stringContaining('getWeather'), + }), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Sixth span - execute_tool with description and input/output + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE]: attr('Get the current weather for a location'), + [GEN_AI_TOOL_INPUT_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + const EXPECTED_SPANS_ERROR_IN_TOOL = { + items: expect.arrayContaining([ + expect.objectContaining({ + name: 'invoke_agent', + status: 'error', + attributes: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'error', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates ai related spans in streaming mode with sendDefaultPii: false', async () => { + await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_FALSE }).start().completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates ai related spans in streaming mode with sendDefaultPii: true', async () => { + await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_TRUE }).start().completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-error-in-tool.mjs', 'instrument.mjs', (createRunner, test) => { + test('normalizes error status in streaming mode', async () => { + await createRunner().ignore('event').expect({ span: EXPECTED_SPANS_ERROR_IN_TOOL }).start().completed(); + }); + }); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-truncation.mjs', 'instrument.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-truncation.mjs', 'instrument-with-truncation.mjs', (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, content should be truncated despite streaming. + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('[{"role":"user","content":"AAAA'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongContent.length, + ); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument-with-pii.mjs new file mode 100644 index 000000000000..0cc510d5c9e6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument-with-pii.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument.mjs new file mode 100644 index 000000000000..cf42383e5c6e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-error-in-tool.mjs new file mode 100644 index 000000000000..51bdae176158 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-error-in-tool.mjs @@ -0,0 +1,47 @@ +import * as Sentry from '@sentry/node'; +import { generateText, tool } from 'ai'; +import { MockLanguageModelV3 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + try { + await generateText({ + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 15, noCache: 15, cached: 0 }, + outputTokens: { total: 25, noCache: 25, cached: 0 }, + totalTokens: { total: 40, noCache: 40, cached: 0 }, + }, + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'getWeather', + input: JSON.stringify({ location: 'San Francisco' }), + }, + ], + warnings: [], + }), + }), + tools: { + getWeather: tool({ + inputSchema: z.object({ location: z.string() }), + execute: async () => { + throw new Error('Error in tool'); + }, + }), + }, + prompt: 'What is the weather in San Francisco?', + }); + } catch { + // Expected error - we want the spans to still be flushed + } + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario.mjs new file mode 100644 index 000000000000..bf5e43a32e65 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario.mjs @@ -0,0 +1,95 @@ +import * as Sentry from '@sentry/node'; +import { generateText, tool } from 'ai'; +import { MockLanguageModelV3 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 10, noCache: 10, cached: 0 }, + outputTokens: { total: 20, noCache: 20, cached: 0 }, + totalTokens: { total: 30, noCache: 30, cached: 0 }, + }, + content: [{ type: 'text', text: 'First span here!' }], + warnings: [], + }), + }), + prompt: 'Where is the first span?', + }); + + // This span should have input and output prompts attached because telemetry is explicitly enabled. + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 10, noCache: 10, cached: 0 }, + outputTokens: { total: 20, noCache: 20, cached: 0 }, + totalTokens: { total: 30, noCache: 30, cached: 0 }, + }, + content: [{ type: 'text', text: 'Second span here!' }], + warnings: [], + }), + }), + prompt: 'Where is the second span?', + }); + + // This span should include tool calls and tool results + await generateText({ + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 15, noCache: 15, cached: 0 }, + outputTokens: { total: 25, noCache: 25, cached: 0 }, + totalTokens: { total: 40, noCache: 40, cached: 0 }, + }, + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'getWeather', + input: JSON.stringify({ location: 'San Francisco' }), + }, + ], + warnings: [], + }), + }), + tools: { + getWeather: tool({ + description: 'Get the current weather for a location', + inputSchema: z.object({ location: z.string() }), + execute: async ({ location }) => `Weather in ${location}: Sunny, 72°F`, + }), + }, + prompt: 'What is the weather in San Francisco?', + }); + + // This span should not be captured because we've disabled telemetry + await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 10, noCache: 10, cached: 0 }, + outputTokens: { total: 20, noCache: 20, cached: 0 }, + totalTokens: { total: 30, noCache: 30, cached: 0 }, + }, + content: [{ type: 'text', text: 'Third span here!' }], + warnings: [], + }), + }), + prompt: 'Where is the third span?', + }); + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts new file mode 100644 index 000000000000..05226300e160 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts @@ -0,0 +1,335 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_TOOL_CALL_ID_ATTRIBUTE, + GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE, + GEN_AI_TOOL_INPUT_ATTRIBUTE, + GEN_AI_TOOL_NAME_ATTRIBUTE, + GEN_AI_TOOL_OUTPUT_ATTRIBUTE, + GEN_AI_TOOL_TYPE_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +/** + * Helper to match a typed attribute value in a SerializedStreamedSpan. + * Streamed span attributes are `{ value: X, type: Y }` objects, unlike transaction + * span `data` which stores values directly. + */ +function attr(value: unknown) { + return expect.objectContaining({ value }); +} + +describe('Vercel AI integration (streaming, V6)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_SPANS_DEFAULT_PII_FALSE = { + items: expect.arrayContaining([ + // First span - invoke_agent for simple generateText + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText'), + 'vercel.ai.streaming': attr(false), + 'vercel.ai.request.headers.user-agent': expect.objectContaining({ value: expect.any(String) }), + }), + }), + // Second span - generate_content for simple generateText + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText.doGenerate'), + 'vercel.ai.streaming': attr(false), + }), + }), + // Third span - invoke_agent for explicit telemetry generateText + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fourth span - tool call invoke_agent + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fifth span - tool call generate_content + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Sixth span - execute_tool + // Note: gen_ai.tool.description is NOT present when sendDefaultPii: false because ai.prompt.tools is not recorded + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + const EXPECTED_SPANS_DEFAULT_PII_TRUE = { + items: expect.arrayContaining([ + // First span - invoke_agent with input/output messages (PII enabled) + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: attr(1), + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr('[{"role":"user","content":"Where is the first span?"}]'), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + 'vercel.ai.pipeline.name': attr('generateText'), + 'vercel.ai.streaming': attr(false), + }), + }), + // Second span - generate_content with input/output messages + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Third span - explicit telemetry invoke_agent with messages + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr('[{"role":"user","content":"Where is the second span?"}]'), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"text","content":"Second span here!"}],"finish_reason":"stop"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(10), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(20), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(30), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fourth span - tool call invoke_agent with messages (V6: no text part, only tool_call) + expect.objectContaining({ + name: 'invoke_agent', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"user","content":"What is the weather in San Francisco?"}]', + ), + [GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]: attr( + '[{"role":"assistant","parts":[{"type":"tool_call","id":"call-1","name":"getWeather","arguments":"{\\"location\\":\\"San Francisco\\"}"}],"finish_reason":"tool_call"}]', + ), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Fifth span - tool call generate_content with available_tools + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE]: expect.objectContaining({ + value: expect.stringContaining('getWeather'), + }), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + // Sixth span - execute_tool with description and input/output + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE]: attr('Get the current weather for a location'), + [GEN_AI_TOOL_INPUT_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: expect.objectContaining({ value: expect.any(String) }), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + const EXPECTED_SPANS_ERROR_IN_TOOL = { + items: expect.arrayContaining([ + expect.objectContaining({ + name: 'invoke_agent', + attributes: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.invoke_agent'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + expect.objectContaining({ + name: 'generate_content mock-model-id', + status: 'ok', + attributes: expect.objectContaining({ + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: attr('mock-model-id'), + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: attr(15), + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: attr(25), + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: attr(40), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.generate_content'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + expect.objectContaining({ + name: 'execute_tool getWeather', + status: 'error', + attributes: expect.objectContaining({ + [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), + [GEN_AI_TOOL_NAME_ATTRIBUTE]: attr('getWeather'), + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: attr('function'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: attr('execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: attr('gen_ai.execute_tool'), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: attr('auto.vercelai.otel'), + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates ai related spans in streaming mode with sendDefaultPii: false', async () => { + await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_FALSE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('creates ai related spans in streaming mode with sendDefaultPii: true', async () => { + await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_TRUE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-error-in-tool.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('normalizes error status in streaming mode', async () => { + await createRunner().ignore('event').expect({ span: EXPECTED_SPANS_ERROR_IN_TOOL }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index 5aa1dc8342a5..d75a1faf8ea0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -983,51 +983,4 @@ describe('Vercel AI integration', () => { }); }, ); - - const streamingLongContent = 'A'.repeat(50_000); - - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('automatically disables truncation when span streaming is enabled', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - const chatSpan = spans.find(s => - s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), - ); - expect(chatSpan).toBeDefined(); - }, - }) - .start() - .completed(); - }); - }); - - createEsmAndCjsTests( - __dirname, - 'scenario-span-streaming.mjs', - 'instrument-streaming-with-truncation.mjs', - (createRunner, test) => { - test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - // With explicit enableTruncation: true, content should be truncated despite streaming. - const chatSpan = spans.find(s => - s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('[{"role":"user","content":"AAAA'), - ); - expect(chatSpan).toBeDefined(); - expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( - streamingLongContent.length, - ); - }, - }) - .start() - .completed(); - }); - }, - ); }); diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json index 7f18d7acac0e..1499b59fbc6d 100644 --- a/dev-packages/size-limit-gh-action/package.json +++ b/dev-packages/size-limit-gh-action/package.json @@ -19,7 +19,7 @@ "@actions/exec": "1.1.1", "@actions/github": "^5.0.0", "@actions/glob": "0.6.1", - "@actions/io": "1.1.3", + "@actions/io": "3.0.2", "bytes-iec": "3.1.1", "markdown-table": "3.0.3" }, diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index e66c45403dd8..c304082d87f7 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -44,11 +44,13 @@ "@playwright/test": "~1.56.0" }, "dependencies": { - "express": "^4.21.2" + "express": "^4.21.2", + "ws": "^8.20.0" }, "devDependencies": { "@playwright/test": "~1.56.0", "@sentry/core": "10.51.0", + "@types/ws": "^8.18.1", "eslint-plugin-regexp": "^1.15.0" }, "volta": { diff --git a/dev-packages/test-utils/src/cdp-client.ts b/dev-packages/test-utils/src/cdp-client.ts new file mode 100644 index 000000000000..491d7bfa77d9 --- /dev/null +++ b/dev-packages/test-utils/src/cdp-client.ts @@ -0,0 +1,310 @@ +import { WebSocket } from 'ws'; + +/** + * Configuration options for the Chrome Developer Protocol (CDP) client. + */ +export interface CDPClientOptions { + /** + * WebSocket URL to connect to (e.g., 'ws://127.0.0.1:9229/ws'). + * Can also use the format 'ws://host:port' without path for standard V8 inspector. + */ + url: string; + + /** + * Number of connection retry attempts before giving up. + * @default 5 + */ + retries?: number; + + /** + * Delay in milliseconds between retry attempts. + * @default 1000 + */ + retryDelayMs?: number; + + /** + * Connection timeout in milliseconds. + * @default 10000 + */ + connectionTimeoutMs?: number; + + /** + * Default timeout for CDP method calls in milliseconds. + * @default 30000 + */ + defaultTimeoutMs?: number; + + /** + * Whether to log debug messages. + * @default false + */ + debug?: boolean; +} + +interface CDPResponse { + id?: number; + method?: string; + params?: unknown; + error?: { message: string }; + result?: unknown; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +} + +type EventHandler = (params: unknown) => void; + +/** + * Low-level CDP client for connecting to V8 inspector endpoints. + * + * For memory profiling, prefer using `MemoryProfiler` which provides a higher-level API. + * + * @example + * ```typescript + * const cdp = new CDPClient({ url: 'ws://127.0.0.1:9229/ws' }); + * await cdp.connect(); + * await cdp.send('Runtime.enable'); + * await cdp.close(); + * ``` + */ +export class CDPClient { + #ws: WebSocket | null; + #messageId: number; + #pendingRequests: Map; + #eventHandlers: Map>; + #connected: boolean; + readonly #options: Required; + + public constructor(options: CDPClientOptions) { + this.#ws = null; + this.#messageId = 0; + this.#pendingRequests = new Map(); + this.#eventHandlers = new Map(); + this.#connected = false; + this.#options = { + retries: 5, + retryDelayMs: 1000, + connectionTimeoutMs: 10000, + defaultTimeoutMs: 30000, + debug: false, + ...options, + }; + } + + /** + * Connect to the V8 inspector WebSocket endpoint. + * Will retry according to the configured retry settings. + */ + public async connect(): Promise { + const { retries, retryDelayMs } = this.#options; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + await this.#tryConnect(); + return; + } catch (err) { + this.#log(`Connection attempt ${attempt}/${retries} failed:`, (err as Error).message); + if (attempt < retries) { + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + } else { + throw err; + } + } + } + } + + /** + * Send a CDP method call and wait for the response. + * + * @param method - The CDP method name (e.g., 'HeapProfiler.enable') + * @param params - Optional parameters for the method + * @param timeoutMs - Timeout in milliseconds (defaults to configured defaultTimeoutMs) + * @returns The result from the CDP method + */ + public async send(method: string, params?: Record, timeoutMs?: number): Promise { + if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket not connected'); + } + + const timeout = timeoutMs ?? this.#options.defaultTimeoutMs; + const id = ++this.#messageId; + const message = JSON.stringify({ id, method, params }); + + this.#log('Sending:', method, params || ''); + + return new Promise((resolve, reject) => { + this.#pendingRequests.set(id, { + resolve: value => resolve(value as T), + reject, + }); + this.#ws!.send(message); + + setTimeout(() => { + if (this.#pendingRequests.has(id)) { + this.#pendingRequests.delete(id); + reject(new Error(`CDP request ${method} timed out after ${timeout}ms`)); + } + }, timeout); + }); + } + + /** + * Send a CDP method call without waiting for a response. + * Useful for commands that may not return responses in certain V8 environments. + * + * @param method - The CDP method name + * @param params - Optional parameters for the method + * @param settleDelayMs - Time to wait after sending (default: 100ms) + */ + public async sendFireAndForget(method: string, params?: Record, settleDelayMs = 100): Promise { + if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket not connected'); + } + + const id = ++this.#messageId; + const message = JSON.stringify({ id, method, params }); + + this.#log('Sending (fire-and-forget):', method, params || ''); + + this.#ws.send(message); + + // Give the command time to execute + await new Promise(resolve => setTimeout(resolve, settleDelayMs)); + } + + /** + * Register a handler for a CDP event method (e.g., 'HeapProfiler.addHeapSnapshotChunk'). + * Returns a function that, when called, removes the handler. + */ + public on(method: string, handler: EventHandler): () => void { + let handlers = this.#eventHandlers.get(method); + if (!handlers) { + handlers = new Set(); + this.#eventHandlers.set(method, handlers); + } + handlers.add(handler); + + return () => { + handlers.delete(handler); + if (handlers.size === 0) { + this.#eventHandlers.delete(method); + } + }; + } + + /** + * Check if the client is currently connected. + */ + public isConnected(): boolean { + return this.#connected && this.#ws?.readyState === WebSocket.OPEN; + } + + /** + * Close the WebSocket connection. + */ + public async close(): Promise { + if (this.#ws) { + this.#ws.close(); + this.#ws = null; + this.#connected = false; + } + } + + #log(...args: unknown[]): void { + if (this.#options.debug) { + // eslint-disable-next-line no-console + console.log('[CDPClient]', ...args); + } + } + + async #tryConnect(): Promise { + const { url, connectionTimeoutMs } = this.#options; + + return new Promise((resolve, reject) => { + this.#ws = new WebSocket(url); + + const timeoutId = setTimeout(() => { + // Close the WebSocket to prevent state corruption from orphaned sockets on retry + this.#ws?.close(); + reject(new Error(`Connection to ${url} timed out after ${connectionTimeoutMs}ms`)); + }, connectionTimeoutMs); + + this.#ws.on('open', () => { + clearTimeout(timeoutId); + this.#connected = true; + this.#log('WebSocket connected to', url); + resolve(); + }); + + this.#ws.on('error', (err: Error) => { + clearTimeout(timeoutId); + this.#ws?.close(); + reject(new Error(`Failed to connect to inspector at ${url}: ${err.message}`)); + }); + + this.#ws.on('close', () => { + this.#connected = false; + }); + + this.#setupMessageHandler(); + }); + } + + #setupMessageHandler(): void { + this.#ws?.on('message', (data: Buffer) => { + try { + const rawMessage = data.toString(); + this.#log('Received raw message:', rawMessage.slice(0, 500)); + + const message = JSON.parse(rawMessage) as CDPResponse; + + if (message.method) { + this.#handleCdpEvent(message); + return; + } + + if (message.id !== undefined) { + this.#handleCdpResponse(message); + } + } catch (e) { + this.#log('Failed to parse CDP message:', e); + } + }); + } + + #handleCdpEvent(message: CDPResponse): void { + this.#log('CDP event:', message.method); + + const handlers = this.#eventHandlers.get(message.method!); + + if (handlers) { + for (const handler of handlers) { + try { + handler(message.params); + } catch (err) { + this.#log('Event handler threw:', err); + } + } + } + } + + #handleCdpResponse(message: CDPResponse): void { + this.#log('CDP response for id:', message.id, 'error:', message.error, 'has result:', message.result !== undefined); + + const pending = this.#pendingRequests.get(message.id!); + + if (pending) { + this.#pendingRequests.delete(message.id!); + + if (message.error) { + pending.reject(new Error(`CDP error: ${message.error.message}`)); + } else { + pending.resolve(message.result); + } + } else { + this.#log('No pending request found for id:', message.id); + } + } +} diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 54e5d11749b4..c47f46fcde5e 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -20,3 +20,9 @@ export { createBasicSentryServer, createTestServer } from './server'; export { startMockSentryServer } from './mock-sentry-server'; export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server'; export * from './sourcemap-upload-utils'; + +export { CDPClient } from './cdp-client'; +export type { CDPClientOptions } from './cdp-client'; + +export { MemoryProfiler } from './memory-profiler'; +export type { MemoryProfilerOptions, SnapshotStats, SnapshotComparisonResult } from './memory-profiler'; diff --git a/dev-packages/test-utils/src/memory-profiler.ts b/dev-packages/test-utils/src/memory-profiler.ts new file mode 100644 index 000000000000..f6ad1d67227e --- /dev/null +++ b/dev-packages/test-utils/src/memory-profiler.ts @@ -0,0 +1,317 @@ +import { mkdir, writeFile } from 'fs/promises'; +import { dirname } from 'path'; +import { CDPClient } from './cdp-client'; + +/** + * Options for creating a MemoryProfiler. + */ +export interface MemoryProfilerOptions { + /** + * Inspector port number. + * @default 9229 + */ + port?: number; + + /** + * WebSocket path (e.g., '/ws' for wrangler, '' for Node.js inspector). + * @default '/ws' + */ + path?: string; + + /** + * Host address. + * @default '127.0.0.1' + */ + host?: string; + + /** + * Number of connection retry attempts. + * @default 10 + */ + retries?: number; + + /** + * Delay between retry attempts in milliseconds. + * @default 2000 + */ + retryDelayMs?: number; + + /** + * Delay after garbage collection in milliseconds. + * This gives V8 time to complete GC before measuring. + * @default 2000 + */ + gcSettleDelayMs?: number; + + /** + * Enable debug logging. + * @default false + */ + debug?: boolean; +} + +/** + * V8 heap snapshot format (partial). + */ +interface V8HeapSnapshot { + snapshot: { + meta: { + node_fields: string[]; + edge_fields: string[]; + }; + }; + nodes: number[]; + edges: number[]; +} + +/** + * Parsed snapshot statistics. + */ +export interface SnapshotStats { + nodeCount: number; + edgeCount: number; + totalSize: number; +} + +/** + * Result from comparing two heap snapshots. + */ +export interface SnapshotComparisonResult { + baseline: SnapshotStats; + final: SnapshotStats; + nodeGrowth: number; + nodeGrowthPercent: number; + edgeGrowth: number; + edgeGrowthPercent: number; + sizeGrowth: number; + sizeGrowthPercent: number; +} + +/** + * High-level memory profiler for V8 inspector endpoints. + * + * Provides a simple API for memory testing via CDP (Chrome DevTools Protocol). + * Works with any V8 inspector endpoint including: + * - Wrangler dev server (Cloudflare Workers) + * - Node.js inspector (--inspect flag) + * + * @example + * ```typescript + * const profiler = new MemoryProfiler({ port: 9229 }); + * await profiler.connect(); + * + * // ... make initial requests to let the runtime settle ... + * + * const baseline = await profiler.takeHeapSnapshot(); + * + * // ... run some operations that might leak memory ... + * + * const final = await profiler.takeHeapSnapshot(); + * + * const result = profiler.compareSnapshots(baseline, final); + * console.log(`Node growth: ${result.nodeGrowthPercent.toFixed(2)}%`); + * + * await profiler.close(); + * ``` + */ +export class MemoryProfiler { + readonly #cdp: CDPClient; + readonly #gcSettleDelayMs: number; + readonly #debug: boolean; + + #initialized: boolean; + + public constructor(options: MemoryProfilerOptions = {}) { + const { + port = 9229, + path = '/ws', + host = '127.0.0.1', + retries = 10, + retryDelayMs = 2000, + gcSettleDelayMs = 3000, + debug = false, + } = options; + + this.#debug = debug; + + this.#cdp = new CDPClient({ + url: `ws://${host}:${port}${path}`, + retries, + retryDelayMs, + debug, + }); + this.#gcSettleDelayMs = gcSettleDelayMs; + this.#initialized = false; + } + + /** + * Connect to the V8 inspector and enable required CDP domains. + */ + public async connect(): Promise { + await this.#cdp.connect(); + await this.#cdp.send('HeapProfiler.enable'); + await this.#cdp.send('Runtime.enable'); + this.#initialized = true; + } + + /** + * Check if the profiler is connected to the inspector. + */ + public isConnected(): boolean { + return this.#cdp.isConnected() && this.#initialized; + } + + /** + * Capture a V8 heap snapshot. If `outputPath` is provided, the snapshot is written there + * as a `.heapsnapshot` file that can be loaded into Chrome DevTools (Memory tab → Load). + * + * Some V8 inspectors (e.g., wrangler) stream chunks via `HeapProfiler.addHeapSnapshotChunk` + * but never send a response to the `takeHeapSnapshot` request. We work around that by + * resolving once chunk events go idle for `chunkIdleMs` (default 2s). + * + * @param outputPath - Optional file path to save the snapshot + * @param chunkIdleMs - How long to wait after the last chunk before considering the snapshot complete + * @param overallTimeoutMs - Maximum time to wait for any chunks before throwing (prevents infinite hang) + * @returns The full snapshot string. + */ + public async takeHeapSnapshot(outputPath?: string, chunkIdleMs = 2000, overallTimeoutMs = 5000): Promise { + this.#ensureConnected(); + await this.#collectGarbage(); + + const chunks: string[] = []; + const startedAt = Date.now(); + let lastChunkAt = Date.now(); + let receivedAny = false; + + const unsubscribe = this.#cdp.on('HeapProfiler.addHeapSnapshotChunk', params => { + const chunk = (params as { chunk?: string }).chunk; + if (typeof chunk === 'string') { + chunks.push(chunk); + lastChunkAt = Date.now(); + receivedAny = true; + } + }); + + try { + await this.#cdp.sendFireAndForget('HeapProfiler.takeHeapSnapshot', { + reportProgress: false, + captureNumericValue: false, + }); + + // Poll until chunks stop arriving for `chunkIdleMs`, or we hit the overall timeout + const pollInterval = 200; + + while (!receivedAny || Date.now() - lastChunkAt < chunkIdleMs) { + if (!receivedAny && Date.now() - startedAt > overallTimeoutMs) { + throw new Error(`Heap snapshot timed out after ${overallTimeoutMs}ms: no chunks received from V8 inspector`); + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } finally { + unsubscribe(); + } + + const snapshot = chunks.join(''); + + if (outputPath) { + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, snapshot, 'utf8'); + } + + return snapshot; + } + + /** + * Compare two heap snapshots and return growth metrics. + * This is more reliable than `Runtime.getHeapUsage` for leak detection + * as it measures actual retained objects rather than V8 internal metrics. + */ + public compareSnapshots(baselineSnapshot: string, finalSnapshot: string): SnapshotComparisonResult { + const baseline = this.#parseSnapshotStats(baselineSnapshot); + const final = this.#parseSnapshotStats(finalSnapshot); + + const nodeGrowth = final.nodeCount - baseline.nodeCount; + const edgeGrowth = final.edgeCount - baseline.edgeCount; + const sizeGrowth = final.totalSize - baseline.totalSize; + + const result: SnapshotComparisonResult = { + baseline, + final, + nodeGrowth, + nodeGrowthPercent: baseline.nodeCount > 0 ? (nodeGrowth / baseline.nodeCount) * 100 : 0, + edgeGrowth, + edgeGrowthPercent: baseline.edgeCount > 0 ? (edgeGrowth / baseline.edgeCount) * 100 : 0, + sizeGrowth, + sizeGrowthPercent: baseline.totalSize > 0 ? (sizeGrowth / baseline.totalSize) * 100 : 0, + }; + + if (this.#debug) { + // eslint-disable-next-line no-console + console.log('Snapshot comparison:', { + baselineNodes: baseline.nodeCount, + finalNodes: final.nodeCount, + nodeGrowth, + nodeGrowthPercent: `${result.nodeGrowthPercent.toFixed(2)}%`, + sizeGrowthKB: (sizeGrowth / 1024).toFixed(2), + }); + } + + return result; + } + + /** + * Parse a heap snapshot string and extract statistics. + */ + #parseSnapshotStats(snapshotJson: string): SnapshotStats { + const snapshot = JSON.parse(snapshotJson) as V8HeapSnapshot; + const meta = snapshot.snapshot?.meta; + + if (!meta?.node_fields) { + throw new Error('Invalid heap snapshot format: missing meta.node_fields'); + } + + if (!meta?.edge_fields) { + throw new Error('Invalid heap snapshot format: missing meta.edge_fields'); + } + + const nodeFieldCount = meta.node_fields.length; + const nodeCount = snapshot.nodes.length / nodeFieldCount; + const edgeCount = snapshot.edges.length / meta.edge_fields.length; + + const selfSizeIdx = meta.node_fields.indexOf('self_size'); + let totalSize = 0; + + if (selfSizeIdx !== -1) { + for (let i = 0; i < snapshot.nodes.length; i += nodeFieldCount) { + totalSize += snapshot.nodes[i + selfSizeIdx] ?? 0; + } + } + + return { nodeCount, edgeCount, totalSize }; + } + + /** + * Close the connection to the inspector. + */ + public async close(): Promise { + await this.#cdp.close(); + this.#initialized = false; + } + + #ensureConnected(): void { + if (!this.#initialized) { + throw new Error('MemoryProfiler not connected. Call connect() first.'); + } + } + + async #collectGarbage(): Promise { + // V8 uses generational GC (young/old generations) and incremental marking. + // A single GC call may only collect young generation objects. Multiple passes + // ensure objects are promoted to old generation and fully collected, giving + // more stable heap measurements for leak detection. + for (let i = 0; i < 3; i++) { + await this.#cdp.sendFireAndForget('HeapProfiler.collectGarbage', undefined, 500); + } + await new Promise(resolve => setTimeout(resolve, this.#gcSettleDelayMs)); + } +} diff --git a/package.json b/package.json index 396d361ade40..e71f94772bd0 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,6 @@ "packages/vue", "packages/wasm", "dev-packages/browser-integration-tests", - "dev-packages/bundle-analyzer-scenarios", "dev-packages/e2e-tests", "dev-packages/node-integration-tests", "dev-packages/bun-integration-tests", @@ -122,9 +121,9 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@rollup/pluginutils": "^5.1.0", + "@size-limit/esbuild": "~12.1.0", "@size-limit/file": "~12.1.0", "@size-limit/webpack": "~12.1.0", - "@size-limit/esbuild": "~12.1.0", "@types/jsdom": "^21.1.6", "@types/node": "^18.19.1", "@vitest/coverage-v8": "^3.2.4", @@ -142,7 +141,7 @@ "rimraf": "^5.0.10", "rollup": "^4.59.0", "rollup-plugin-cleanup": "^3.2.1", - "rollup-plugin-license": "^3.3.1", + "rollup-plugin-license": "^3.7.1", "size-limit": "~12.1.0", "sucrase": "^3.35.0", "ts-node": "10.9.2", diff --git a/packages/browser/src/integrations/spotlight.ts b/packages/browser/src/integrations/spotlight.ts index bea72e029a97..4c04b16ed63b 100644 --- a/packages/browser/src/integrations/spotlight.ts +++ b/packages/browser/src/integrations/spotlight.ts @@ -1,4 +1,4 @@ -import type { Client, Envelope, Event, IntegrationFn } from '@sentry/core'; +import type { Client, Envelope, IntegrationFn } from '@sentry/core'; import { debug, defineIntegration, serializeEnvelope } from '@sentry/core'; import { getNativeImplementation } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; @@ -14,6 +14,8 @@ export type SpotlightConnectionOptions = { export const INTEGRATION_NAME = 'SpotlightBrowser'; +export const SPOTLIGHT_IGNORE_SPANS = [{ op: 'ui.interaction.click', name: '#sentry-spotlight' }]; + const _spotlightIntegration = ((options: Partial = {}) => { const sidecarUrl = options.sidecarUrl || 'http://localhost:8969/stream'; @@ -22,10 +24,10 @@ const _spotlightIntegration = ((options: Partial = { setup: () => { DEBUG_BUILD && debug.log('Using Sidecar URL', sidecarUrl); }, - // We don't want to send interaction transactions/root spans created from - // clicks within Spotlight to Sentry. Neither do we want them to be sent to - // spotlight. - processEvent: event => (isSpotlightInteraction(event) ? null : event), + beforeSetup(client: Client) { + const opts = client.getOptions(); + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...SPOTLIGHT_IGNORE_SPANS]; + }, afterAllSetup: (client: Client) => { setupSidecarForwarding(client, sidecarUrl); }, @@ -73,16 +75,3 @@ function setupSidecarForwarding(client: Client, sidecarUrl: string): void { * Learn more about spotlight at https://spotlightjs.com */ export const spotlightBrowserIntegration = defineIntegration(_spotlightIntegration); - -/** - * Flags if the event is a transaction created from an interaction with the spotlight UI. - */ -export function isSpotlightInteraction(event: Event): boolean { - return Boolean( - event.type === 'transaction' && - event.spans && - event.contexts?.trace && - event.contexts.trace.op === 'ui.action.click' && - event.spans.some(({ description }) => description?.includes('#sentry-spotlight')), - ); -} diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index b393f0585b5b..9cbf45563f0b 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -406,9 +406,11 @@ function xhrCallback( const client = getClient(); const hasParent = !!getActiveSpan(); + // With span streaming, we always emit http.client spans, even without a parent span + const shouldEmitSpan = hasParent || (!!client && hasSpanStreamingEnabled(client)); const span = - shouldCreateSpanResult && hasParent + shouldCreateSpanResult && shouldEmitSpan ? startInactiveSpan({ name: `${method} ${urlForSpanName}`, attributes: { @@ -425,7 +427,7 @@ function xhrCallback( }) : new SentryNonRecordingSpan(); - if (shouldCreateSpanResult && !hasParent) { + if (shouldCreateSpanResult && !shouldEmitSpan) { client?.recordDroppedEvent('no_parent_span', 'span'); } @@ -438,7 +440,7 @@ function xhrCallback( // If performance is disabled (TWP) or there's no active root span (pageload/navigation/interaction), // we do not want to use the span as base for the trace headers, // which means that the headers will be generated from the scope and the sampling decision is deferred - hasSpansEnabled() && hasParent ? span : undefined, + hasSpansEnabled() && shouldEmitSpan ? span : undefined, propagateTraceparent, ); } diff --git a/packages/browser/test/integrations/spotlight.test.ts b/packages/browser/test/integrations/spotlight.test.ts new file mode 100644 index 000000000000..c49a971e3c28 --- /dev/null +++ b/packages/browser/test/integrations/spotlight.test.ts @@ -0,0 +1,51 @@ +import type { Client, ClientOptions } from '@sentry/core'; +import { shouldIgnoreSpan } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { SPOTLIGHT_IGNORE_SPANS, spotlightBrowserIntegration } from '../../src/integrations/spotlight'; + +function makeMockClient(initial: Partial = {}): Client { + const options = { ...initial } as ClientOptions; + return { getOptions: () => options } as Client; +} + +function setupIntegrationAndGetIgnoreSpans(initial: Partial = {}) { + const integration = spotlightBrowserIntegration(); + const client = makeMockClient(initial); + integration.beforeSetup!(client); + return client.getOptions().ignoreSpans!; +} + +describe('spotlightBrowserIntegration', () => { + it('appends spotlight interaction filters to ignoreSpans', () => { + expect(setupIntegrationAndGetIgnoreSpans()).toEqual(SPOTLIGHT_IGNORE_SPANS); + }); + + it('preserves user-provided ignoreSpans entries', () => { + expect(setupIntegrationAndGetIgnoreSpans({ ignoreSpans: [/keep-me/] })).toEqual([ + /keep-me/, + ...SPOTLIGHT_IGNORE_SPANS, + ]); + }); + + describe('drops spotlight interaction spans', () => { + it.each([ + ['click on spotlight overlay', 'body > div#sentry-spotlight > div.overlay'], + ['click on spotlight button', 'body > div > div#sentry-spotlight > button.close'], + ['click on nested spotlight element', 'html > body > aside#sentry-spotlight'], + ])('%s', (_label, name) => { + const ignoreSpans = setupIntegrationAndGetIgnoreSpans(); + expect(shouldIgnoreSpan({ description: name, op: 'ui.interaction.click' }, ignoreSpans)).toBe(true); + }); + }); + + describe('keeps non-spotlight interaction spans', () => { + it.each([ + ['regular click', 'body > div.main > button.submit', 'ui.interaction.click'], + ['regular ui action', '/dashboard', 'ui.action.click'], + ['non-interaction span', 'GET /api/data', 'http.client'], + ])('%s', (_label, name, op) => { + const ignoreSpans = setupIntegrationAndGetIgnoreSpans(); + expect(shouldIgnoreSpan({ description: name, op }, ignoreSpans)).toBe(false); + }); + }); +}); diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index b64ee35fc50e..456c5c222b22 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -590,7 +590,6 @@ describe('Browser Profiling v2 trace lifecycle', () => { Sentry.init({ ...getBaseOptionsForTraceLifecycle(send), - debug: true, }); Sentry.uiProfiler.startProfiler(); @@ -691,7 +690,6 @@ describe('Browser Profiling v2 manual lifecycle', () => { Sentry.init({ ...getBaseOptionsForManualLifecycle(send), - debug: true, }); Sentry.uiProfiler.startProfiler(); diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index f9d97230701c..a08db412ccec 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -69,7 +69,6 @@ describe('BrowserProfilingIntegration', () => { }); it("warns when profileLifecycle is 'trace' but tracing is disabled", async () => { - debug.enable(); const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); // @ts-expect-error mock constructor diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 58294ea31fa2..83f00a09092a 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -17,6 +17,8 @@ import { spanToJSON, startInactiveSpan, TRACING_DEFAULTS, + browserPerformanceTimeOrigin, + getSpanDescendants, } from '@sentry/core'; import { JSDOM } from 'jsdom'; import { TextDecoder, TextEncoder } from 'util'; @@ -58,6 +60,8 @@ const originalGlobalHistory = WINDOW.history; describe('browserTracingIntegration', () => { beforeEach(() => { vi.useFakeTimers(); + // Ensure start time aligns with cached origin time, which is used as pageload start time + vi.setSystemTime(browserPerformanceTimeOrigin()!); getCurrentScope().clear(); getIsolationScope().clear(); getCurrentScope().setClient(undefined); @@ -228,11 +232,11 @@ describe('browserTracingIntegration', () => { setCurrentClient(client); client.init(); - const span = getActiveSpan(); + const span = getActiveSpan()!; expect(span).toBeDefined(); - expect(spanIsSampled(span!)).toBe(true); - expect(span!.isRecording()).toBe(true); - expect(spanToJSON(span!)).toEqual({ + expect(spanIsSampled(span)).toBe(true); + expect(span.isRecording()).toBe(true); + expect(spanToJSON(span)).toEqual({ description: '/', op: 'pageload', origin: 'auto.pageload.browser', @@ -254,13 +258,13 @@ describe('browserTracingIntegration', () => { vi.advanceTimersByTime(1600); WINDOW.history.pushState({}, '', '/test'); - expect(span!.isRecording()).toBe(false); + expect(span.isRecording()).toBe(false); - const span2 = getActiveSpan(); + const span2 = getActiveSpan()!; expect(span2).toBeDefined(); - expect(spanIsSampled(span2!)).toBe(true); - expect(span2!.isRecording()).toBe(true); - expect(spanToJSON(span2!)).toEqual({ + expect(spanIsSampled(span2)).toBe(true); + expect(span2.isRecording()).toBe(true); + expect(spanToJSON(span2)).toEqual({ description: '/test', op: 'navigation', origin: 'auto.navigation.browser', @@ -290,15 +294,16 @@ describe('browserTracingIntegration', () => { const dom2 = new JSDOM(undefined, { url: 'https://example.com/test2' }); Object.defineProperty(global, 'location', { value: dom2.window.document.location, writable: true }); + vi.advanceTimersByTime(1600); WINDOW.history.pushState({}, '', '/test2'); - expect(span2!.isRecording()).toBe(false); + expect(span2.isRecording()).toBe(false); - const span3 = getActiveSpan(); + const span3 = getActiveSpan()!; expect(span3).toBeDefined(); - expect(spanIsSampled(span3!)).toBe(true); - expect(span3!.isRecording()).toBe(true); - expect(spanToJSON(span3!)).toEqual({ + expect(spanIsSampled(span3)).toBe(true); + expect(span3.isRecording()).toBe(true); + expect(spanToJSON(span3)).toEqual({ description: '/test2', op: 'navigation', origin: 'auto.navigation.browser', @@ -325,6 +330,66 @@ describe('browserTracingIntegration', () => { }); }); + it('starts redirect when URL changes after < 1.5s', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration()], + }), + ); + setCurrentClient(client); + client.init(); + + const span = getActiveSpan()!; + expect(span).toBeDefined(); + expect(spanIsSampled(span)).toBe(true); + expect(span.isRecording()).toBe(true); + expect(spanToJSON(span)).toEqual({ + description: '/', + op: 'pageload', + origin: 'auto.pageload.browser', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // this is what is used to get the span name - JSDOM does not update this on it's own! + const dom = new JSDOM(undefined, { url: 'https://example.com/test' }); + Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true }); + + vi.advanceTimersByTime(100); + WINDOW.history.pushState({}, '', '/test'); + + expect(span.isRecording()).toBe(true); + + const span2 = getActiveSpan()!; + expect(span2).toBeDefined(); + + // span is still active now + expect(getActiveSpan()).toBe(span); + + // span has connected redirect span + expect(getSpanDescendants(span).map(span => spanToJSON(span))).toContainEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation.redirect', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + description: '/test', + op: 'navigation.redirect', + origin: 'auto.navigation.browser', + parent_span_id: span.spanContext().spanId, + }), + ); + }); + describe('startBrowserTracingPageLoadSpan', () => { it('works without integration setup', () => { const client = new BrowserClient( @@ -1301,7 +1366,6 @@ describe('browserTracingIntegration', () => { describe('idleTimeout', () => { it('is created by default', () => { - vi.useFakeTimers(); const client = new BrowserClient( getDefaultBrowserClientOptions({ tracesSampleRate: 1, @@ -1336,8 +1400,6 @@ describe('browserTracingIntegration', () => { }); it('can be a custom value', () => { - vi.useFakeTimers(); - const client = new BrowserClient( getDefaultBrowserClientOptions({ tracesSampleRate: 1, @@ -1440,36 +1502,4 @@ describe('browserTracingIntegration', () => { expect(spanJson2.links).toBeUndefined(); }); }); - - // TODO(lforst): I cannot manage to get this test to pass. - /* - it('heartbeatInterval can be a custom value', () => { - vi.useFakeTimers(); - - const interval = 200; - - const client = new BrowserClient( - getDefaultBrowserClientOptions({ - tracesSampleRate: 1, - integrations: [browserTracingIntegration({ heartbeatInterval: interval })], - }), - ); - - setCurrentClient(client); - client.init(); - - const mockFinish = vi.fn(); - // eslint-disable-next-line deprecation/deprecation - const transaction = getActiveTransaction() as IdleTransaction; - transaction.sendAutoFinishSignal(); - transaction.end = mockFinish; - - const span = startInactiveSpan({ name: 'child-span' }); // activities = 1 - span!.end(); // activities = 0 - - expect(mockFinish).toHaveBeenCalledTimes(0); - vi.advanceTimersByTime(interval * 3); - expect(mockFinish).toHaveBeenCalledTimes(1); - }); - */ }); diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index eaa9b3ddb032..fb4a9bf84882 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -122,6 +122,7 @@ export { wrapRequestHandler } from './request'; export { CloudflareClient } from './client'; export { getDefaultIntegrations } from './sdk'; +export { httpServerIntegration } from './integrations/httpServer'; export { fetchIntegration } from './integrations/fetch'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { honoIntegration } from './integrations/hono'; diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index 6a9daf83ec1c..e8b2466da821 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -7,6 +7,7 @@ import { instrumentWorkerEntrypointScheduled } from './worker/instrumentSchedule import { instrumentWorkerEntrypointTail } from './worker/instrumentTail'; import { getFinalOptions } from '../options'; import { instrumentContext } from '../utils/instrumentContext'; +import { instrumentEnv } from './worker/instrumentEnv'; export type WorkerEntrypointConstructor = new ( ctx: ExecutionContext, @@ -63,7 +64,8 @@ export function instrumentWorkerEntrypoint(); * * Currently detects: * - DurableObjectNamespace (via `idFromName` duck-typing) - * - Service bindings / JSRPC proxies (wraps `fetch` for trace propagation) + * - Service bindings / JSRPC proxies + * - Queue producers (via `send` + `sendBatch` duck-typing) * - * Extensible for future binding types (KV, D1, Queue, etc.). + * Extensible for future binding types (KV, D1, etc.). * * @param env - The Cloudflare env object to instrument * @param options - Optional CloudflareOptions to control RPC trace propagation @@ -31,12 +33,6 @@ export function instrumentEnv>(env: Env, opt const rpcPropagation = options ? getEffectiveRpcPropagation(options) : false; - // As of now only trace propagation is used for the instrumentEnv - // so this is an optimization to avoid wrapping the env in a proxy if trace propagation is disabled - if (!rpcPropagation) { - return env; - } - return new Proxy(env, { get(target, prop, receiver) { const item = Reflect.get(target, prop, receiver); @@ -51,6 +47,17 @@ export function instrumentEnv>(env: Env, opt return cached; } + if (isQueue(item)) { + const bindingName = typeof prop === 'string' ? prop : String(prop); + const instrumented = instrumentQueueProducer(item, bindingName); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + + if (!rpcPropagation) { + return item; + } + if (isDurableObjectNamespace(item)) { const instrumented = instrumentDurableObjectNamespace(item); instrumentedBindings.set(item, instrumented); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts index c57b7abd8aaa..a99b81c6c341 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -42,6 +42,8 @@ function wrapQueueHandler( 'faas.trigger': 'pubsub', 'messaging.destination.name': batch.queue, 'messaging.system': 'cloudflare', + 'messaging.operation.type': 'process', + 'messaging.operation.name': 'process', 'messaging.batch.message_count': batch.messages.length, 'messaging.message.retry.count': batch.messages.reduce((acc, message) => acc + message.attempts - 1, 0), [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueueProducer.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueueProducer.ts new file mode 100644 index 000000000000..e52135704bb4 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueueProducer.ts @@ -0,0 +1,104 @@ +import type { MessageSendRequest, Queue, QueueSendBatchOptions, QueueSendOptions } from '@cloudflare/workers-types'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; + +const ORIGIN = 'auto.faas.cloudflare.queue'; + +function startPublishSpan( + options: { + bindingName: string; + bodySize: number | undefined; + messageCount?: number; + }, + callback: () => T, +): T { + const { bindingName, bodySize, messageCount } = options; + + return startSpan( + { + op: 'queue.publish', + name: `send ${bindingName}`, + attributes: { + 'messaging.system': 'cloudflare', + 'messaging.destination.name': bindingName, + 'messaging.operation.type': 'send', + 'messaging.operation.name': 'send', + ...(messageCount !== undefined && { 'messaging.batch.message_count': messageCount }), + 'messaging.message.body.size': bodySize, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.publish', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }, + }, + callback, + ); +} + +function getBodySize(body: unknown): number | undefined { + if (body == null) { + return undefined; + } + + if (typeof body === 'string') { + return new TextEncoder().encode(body).byteLength; + } + + if (body instanceof ArrayBuffer) { + return body.byteLength; + } + + if (ArrayBuffer.isView(body)) { + return body.byteLength; + } + + try { + return new TextEncoder().encode(JSON.stringify(body)).byteLength; + } catch { + return undefined; + } +} + +/** + * Wraps a Queue producer binding to create `queue.publish` spans on + * `send` and `sendBatch` calls. + * + * The queue's own name is not available on the binding object, so we use + * the env binding key (e.g. `MY_QUEUE`) as `messaging.destination.name`. + */ +export function instrumentQueueProducer(queue: T, bindingName: string): T { + return new Proxy(queue, { + get(target, prop, receiver) { + if (prop === 'send') { + const original = Reflect.get(target, prop, receiver) as Queue['send']; + + return function (this: unknown, message: unknown, options?: QueueSendOptions): Promise { + return startPublishSpan({ bindingName, bodySize: getBodySize(message) }, () => + Reflect.apply(original, target, [message, options]), + ); + }; + } + + if (prop === 'sendBatch') { + const original = Reflect.get(target, prop, receiver) as Queue['sendBatch']; + return function ( + this: unknown, + messages: Iterable, + options?: QueueSendBatchOptions, + ): Promise { + const messageArray = Array.from(messages); + const totalBodySize = messageArray.reduce((acc, m) => { + const size = getBodySize(m.body); + if (size === undefined) { + return acc; + } + return (acc ?? 0) + size; + }, undefined); + + return startPublishSpan({ bindingName, bodySize: totalBodySize, messageCount: messageArray.length }, () => + Reflect.apply(original, target, [messageArray, options]), + ); + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); +} diff --git a/packages/cloudflare/src/integrations/httpServer.ts b/packages/cloudflare/src/integrations/httpServer.ts new file mode 100644 index 000000000000..31747e11a4d1 --- /dev/null +++ b/packages/cloudflare/src/integrations/httpServer.ts @@ -0,0 +1,106 @@ +import type { Client, IntegrationFn, MaxRequestBodySize } from '@sentry/core'; +import { captureBodyFromWinterCGRequest, defineIntegration, getIsolationScope } from '@sentry/core'; + +const INTEGRATION_NAME = 'HttpServer'; + +export interface HttpServerIntegrationOptions { + /** + * Controls the maximum size of incoming request bodies attached to events. + * + * Only applies to requests with textual content types (text/*, application/json, + * application/x-www-form-urlencoded, application/xml, application/graphql). + * Binary data is not captured. + * + * Available options: + * - `'none'`: No request bodies will be attached + * - `'small'`: Request bodies up to 1,000 bytes will be attached + * - `'medium'`: Request bodies up to 10,000 bytes will be attached (default) + * - `'always'`: Request bodies will always be attached (up to 1MB limit) + * + * @default 'medium' + */ + maxRequestBodySize?: MaxRequestBodySize; + + /** + * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * This can be useful for long running requests where the body is not needed, health check endpoints, + * or requests containing sensitive data that should not be captured. + * + * @param url The full URL of the incoming request, including query string, protocol, host, etc. + * @param request The incoming Request object. + * @returns `true` to skip body capture for this request, `false` to capture normally. + * + * @example + * ```ts + * Sentry.httpServerIntegration({ + * ignoreRequestBody: (url) => url.includes('/health') || url.includes('/upload'), + * }) + * ``` + */ + ignoreRequestBody?: (url: string, request: Request) => boolean; +} + +interface HttpServerIntegrationInstance { + name: string; + maxRequestBodySize: MaxRequestBodySize; + ignoreRequestBody?: (url: string, request: Request) => boolean; +} + +const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): HttpServerIntegrationInstance => { + return { + name: INTEGRATION_NAME, + maxRequestBodySize: options.maxRequestBodySize ?? 'medium', + ignoreRequestBody: options.ignoreRequestBody, + }; +}) satisfies IntegrationFn; + +/** + * Configures incoming HTTP request handling for Cloudflare Workers. + * + * This integration controls how incoming HTTP request data is captured, + * matching the API of `httpServerIntegration` in Node.js. + * + * @example + * ```ts + * Sentry.init({ + * integrations: [ + * Sentry.httpServerIntegration({ + * maxRequestBodySize: 'medium', + * ignoreRequestBody: (url) => url.includes('/health'), + * }), + * ], + * }); + * ``` + */ +export const httpServerIntegration = defineIntegration(_httpServerIntegration); + +/** + * Capture the request body based on the HttpServer integration config. + * Called internally by `wrapRequestHandler`. + */ +export async function captureIncomingRequestBody(client: Client, request: Request): Promise { + const integration = client.getIntegrationByName(INTEGRATION_NAME); + + if (!integration) { + return; + } + + const maxRequestBodySize = integration.maxRequestBodySize; + + if (maxRequestBodySize === 'none') { + return; + } + + // Skip GET and HEAD requests - they don't have bodies + // Also skip OPTIONS, even if they may have a body, they might not give a lot of extra value + if (request.method === 'GET' || request.method === 'HEAD' || request.method === 'OPTIONS') { + return; + } + + if (integration.ignoreRequestBody?.(request.url, request)) { + return; + } + + const isolationScope = getIsolationScope(); + await captureBodyFromWinterCGRequest(request, isolationScope, maxRequestBodySize); +} diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index 4fbdd9cf7fb4..f89e93924e1b 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -12,6 +12,7 @@ import { winterCGHeadersToDict, withIsolationScope, } from '@sentry/core'; +import { captureIncomingRequestBody } from './integrations/httpServer'; import type { CloudflareOptions } from './client'; import { flushAndDispose } from './flush'; import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils'; @@ -98,6 +99,10 @@ export function wrapRequestHandler( } } + if (client) { + await captureIncomingRequestBody(client, request); + } + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index a5eb7f4edcda..b957fabe1e70 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -15,6 +15,7 @@ import { import type { CloudflareClientOptions, CloudflareOptions } from './client'; import { CloudflareClient } from './client'; import { makeFlushLock } from './flush'; +import { httpServerIntegration } from './integrations/httpServer'; import { fetchIntegration } from './integrations/fetch'; import { honoIntegration } from './integrations/hono'; import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; @@ -36,6 +37,7 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[ linkedErrorsIntegration(), fetchIntegration(), honoIntegration(), + httpServerIntegration(), // TODO(v11): the `include` object should be defined directly in the integration based on `sendDefaultPii` requestDataIntegration(sendDefaultPii ? undefined : { include: { cookies: false } }), consoleIntegration(), diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index 26801578dde2..5ced12c78389 100644 --- a/packages/cloudflare/src/utils/isBinding.ts +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -31,7 +31,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { DurableObjectNamespace } from '@cloudflare/workers-types'; +import type { DurableObjectNamespace, Queue } from '@cloudflare/workers-types'; /** * Checks if a value is a JSRPC proxy (service binding). @@ -59,3 +59,11 @@ const isNotJSRPC = (item: unknown): item is Record => !isJSRPC( export function isDurableObjectNamespace(item: unknown): item is DurableObjectNamespace { return item != null && isNotJSRPC(item) && typeof item.idFromName === 'function'; } + +/** + * Duck-type check for Queue producer bindings. + * Queue has `send` and `sendBatch` async methods. + */ +export function isQueue(item: unknown): item is Queue { + return item != null && isNotJSRPC(item) && typeof item.send === 'function' && typeof item.sendBatch === 'function'; +} diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index ab115317b7b0..a324cb1e3678 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -34,7 +34,7 @@ describe('instrumentEnv', () => { expect(instrumented.UNKNOWN).toBe(unknownBinding); }); - it('returns env as-is when enableRpcTracePropagation is disabled', () => { + it('does not instrument DurableObjectNamespace when enableRpcTracePropagation is disabled', () => { const doNamespace = { idFromName: vi.fn(), idFromString: vi.fn(), @@ -44,8 +44,7 @@ describe('instrumentEnv', () => { const env = { COUNTER: doNamespace }; const instrumented = instrumentEnv(env); - // When trace propagation is disabled, env is returned as-is - expect(instrumented).toBe(env); + // DO bindings pass through untouched when RPC propagation is disabled expect(instrumented.COUNTER).toBe(doNamespace); expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); }); @@ -176,6 +175,47 @@ describe('instrumentEnv', () => { expect(instrumented.UNDEF_VAL).toBeUndefined(); }); + it('wraps Queue bindings in a proxy', async () => { + const send = vi.fn().mockResolvedValue(undefined); + const sendBatch = vi.fn().mockResolvedValue(undefined); + const queue = { send, sendBatch }; + const env = { MY_QUEUE: queue }; + const instrumented = instrumentEnv(env); + + const wrapped = instrumented.MY_QUEUE as typeof queue; + // Wrapped binding is a Proxy, not the original reference + expect(wrapped).not.toBe(queue); + // Calls are forwarded to the underlying queue + await wrapped.send('hello'); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[0]).toBe('hello'); + }); + + it('caches the wrapped Queue binding across repeated access', () => { + const queue = { send: vi.fn(), sendBatch: vi.fn() }; + const env = { MY_QUEUE: queue }; + const instrumented = instrumentEnv(env); + + expect(instrumented.MY_QUEUE).toBe(instrumented.MY_QUEUE); + }); + + it('wraps Queue bindings independently from DO bindings', () => { + const queue = { send: vi.fn(), sendBatch: vi.fn() }; + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const env = { MY_QUEUE: queue, COUNTER: doNamespace }; + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + + // Access both — DO instrumentation only fires on property access + expect(instrumented.MY_QUEUE).not.toBe(queue); + instrumented.COUNTER; + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); + }); + describe('JSRPC RPC method instrumentation', () => { it('does not inject Sentry RPC meta by default (enableRpcTracePropagation not set)', () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index a6a3e6a64cfd..54069e14c251 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -1,6 +1,6 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; import * as SentryCore from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getInstrumented } from '../../src/instrument'; import { instrumentWorkerEntrypoint, @@ -281,4 +281,268 @@ describe('instrumentWorkerEntrypoint', () => { expect(obj.methodTwo()).toBe('two'); }); }); + + describe('env instrumentation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('passes instrumented env to the constructor when enableRpcTracePropagation is enabled', () => { + const mockContext = createMockExecutionContext(); + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace, SENTRY_DSN: 'dsn' }; + + let constructorEnv: unknown; + const TestClass = class extends WorkerEntrypoint { + constructor(ctx: ExecutionContext, env: typeof mockEnv) { + super(); + constructorEnv = env; + } + fetch() { + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + Reflect.construct(instrumented, [mockContext, mockEnv]); + + expect(constructorEnv).not.toBe(mockEnv); + }); + + it('exposes instrumented DurableObjectNamespace via this.env when enableRpcTracePropagation is enabled', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const mockStub = { + id: { toString: () => 'stub-id' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }; + const doNamespace = { + idFromName: vi.fn().mockReturnValue({ toString: () => 'id-1' }), + idFromString: vi.fn(), + get: vi.fn().mockReturnValue(mockStub), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + const stub = this.env.COUNTER.get(this.env.COUNTER.idFromName('test')); + (stub as any).myRpcMethod('arg1'); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + await obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('returns original DurableObjectNamespace via this.env when enableRpcTracePropagation is disabled', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const mockStub = { + id: { toString: () => 'stub-id' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }; + const doNamespace = { + idFromName: vi.fn().mockReturnValue({ toString: () => 'id-1' }), + idFromString: vi.fn(), + get: vi.fn().mockReturnValue(mockStub), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + const stub = this.env.COUNTER.get(this.env.COUNTER.idFromName('test')); + (stub as any).myRpcMethod('arg1'); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: false }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + await obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1'); + }); + + it('injects Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is enabled', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const mockEnv = { SERVICE: jsrpcProxy }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + (this.env.SERVICE as any).myRpcMethod('arg1', 42); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + await obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('does not inject Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is disabled', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const mockEnv = { SERVICE: jsrpcProxy }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + (this.env.SERVICE as any).myRpcMethod('arg1', 42); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: false }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + await obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42); + }); + + it('caches instrumented bindings across multiple accesses via this.env', async () => { + const mockContext = createMockExecutionContext(); + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace }; + + let firstAccess: unknown; + let secondAccess: unknown; + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + firstAccess = this.env.COUNTER; + secondAccess = this.env.COUNTER; + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + await obj.fetch(new Request('https://example.com')); + + expect(firstAccess).toBe(secondAccess); + }); + + it('primitive env values are returned unchanged', async () => { + const mockContext = createMockExecutionContext(); + const mockEnv = { SENTRY_DSN: 'https://key@sentry.io/123', PORT: 8080, DEBUG: true }; + + let capturedDsn: unknown; + let capturedPort: unknown; + let capturedDebug: unknown; + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + capturedDsn = this.env.SENTRY_DSN; + capturedPort = this.env.PORT; + capturedDebug = this.env.DEBUG; + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + await obj.fetch(new Request('https://example.com')); + + expect(capturedDsn).toBe('https://key@sentry.io/123'); + expect(capturedPort).toBe(8080); + expect(capturedDebug).toBe(true); + }); + }); }); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts index 6930ff7180df..999073012b92 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts @@ -278,6 +278,8 @@ describe('instrumentQueue', () => { 'faas.trigger': 'pubsub', 'messaging.destination.name': batch.queue, 'messaging.system': 'cloudflare', + 'messaging.operation.type': 'process', + 'messaging.operation.name': 'process', 'messaging.batch.message_count': batch.messages.length, 'messaging.message.retry.count': batch.messages.reduce((acc, message) => acc + message.attempts - 1, 0), 'sentry.sample_rate': 1, diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentQueueProducer.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentQueueProducer.test.ts new file mode 100644 index 000000000000..b094641984ce --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentQueueProducer.test.ts @@ -0,0 +1,183 @@ +import type { Queue } from '@cloudflare/workers-types'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { instrumentQueueProducer } from '../../../src/instrumentations/worker/instrumentQueueProducer'; + +function createMockQueue(): Queue { + return { + send: vi.fn().mockResolvedValue(undefined), + sendBatch: vi.fn().mockResolvedValue(undefined), + } as unknown as Queue; +} + +describe('instrumentQueueProducer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('send', () => { + test('forwards the call to the underlying queue', async () => { + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + await wrapped.send({ hello: 'world' }, { contentType: 'json' }); + + expect(queue.send).toHaveBeenCalledTimes(1); + expect(queue.send).toHaveBeenLastCalledWith({ hello: 'world' }, { contentType: 'json' }); + }); + + test('starts a queue.publish span with messaging attributes', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + await wrapped.send('hello'); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + const [spanCtx] = startSpanSpy.mock.calls[0]!; + expect(spanCtx).toMatchObject({ + op: 'queue.publish', + name: 'send MY_QUEUE', + attributes: { + 'messaging.system': 'cloudflare', + 'messaging.destination.name': 'MY_QUEUE', + 'messaging.operation.type': 'send', + 'messaging.operation.name': 'send', + 'messaging.message.body.size': 5, + 'sentry.op': 'queue.publish', + 'sentry.origin': 'auto.faas.cloudflare.queue', + }, + }); + }); + + test('computes body size for object payloads via JSON.stringify', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + await wrapped.send({ a: 1 }); + + const attrs = startSpanSpy.mock.calls[0]![0].attributes!; + expect(attrs['messaging.message.body.size']).toBe(JSON.stringify({ a: 1 }).length); + }); + + test('computes body size for ArrayBuffer payloads', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + const buf = new ArrayBuffer(42); + await wrapped.send(buf); + + const attrs = startSpanSpy.mock.calls[0]![0].attributes!; + expect(attrs['messaging.message.body.size']).toBe(42); + }); + + test('omits body size when payload cannot be serialized', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + // Circular reference - JSON.stringify throws + const circular: Record = {}; + circular.self = circular; + await wrapped.send(circular); + + const attrs = startSpanSpy.mock.calls[0]![0].attributes!; + expect(attrs['messaging.message.body.size']).toBeUndefined(); + }); + }); + + describe('sendBatch', () => { + test('forwards the call to the underlying queue', async () => { + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + await wrapped.sendBatch([{ body: 'a' }, { body: 'b' }]); + + expect(queue.sendBatch).toHaveBeenCalledTimes(1); + }); + + test('starts a queue.publish span with batch attributes', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + await wrapped.sendBatch([{ body: 'aa' }, { body: 'bbb' }]); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + const [spanCtx] = startSpanSpy.mock.calls[0]!; + expect(spanCtx).toMatchObject({ + op: 'queue.publish', + name: 'send MY_QUEUE', + attributes: { + 'messaging.system': 'cloudflare', + 'messaging.destination.name': 'MY_QUEUE', + 'messaging.operation.type': 'send', + 'messaging.operation.name': 'send', + 'messaging.batch.message_count': 2, + 'messaging.message.body.size': 5, + 'sentry.op': 'queue.publish', + 'sentry.origin': 'auto.faas.cloudflare.queue', + }, + }); + }); + + test('handles iterables (not just arrays)', async () => { + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + function* gen() { + yield { body: 'a' }; + yield { body: 'b' }; + } + + await wrapped.sendBatch(gen()); + + expect(queue.sendBatch).toHaveBeenCalledTimes(1); + const passed = (queue.sendBatch as unknown as ReturnType).mock.calls[0]![0]; + expect(Array.isArray(passed)).toBe(true); + expect(passed).toHaveLength(2); + }); + + test('omits body size when all payloads cannot be serialized', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + const circular1: Record = {}; + circular1.self = circular1; + const circular2: Record = {}; + circular2.self = circular2; + + await wrapped.sendBatch([{ body: circular1 }, { body: circular2 }]); + + const attrs = startSpanSpy.mock.calls[0]![0].attributes!; + expect(attrs['messaging.message.body.size']).toBeUndefined(); + }); + + test('sums only sizable bodies when batch contains mixed payloads', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const queue = createMockQueue(); + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE'); + + const circular: Record = {}; + circular.self = circular; + + await wrapped.sendBatch([{ body: 'aa' }, { body: circular }, { body: 'bbb' }]); + + const attrs = startSpanSpy.mock.calls[0]![0].attributes!; + expect(attrs['messaging.message.body.size']).toBe(5); + }); + }); + + test('forwards unknown property accesses transparently', () => { + const queue = Object.assign(createMockQueue(), { + customMethod: vi.fn().mockReturnValue('hi'), + }) as unknown as Queue & { + customMethod: () => string; + }; + const wrapped = instrumentQueueProducer(queue, 'MY_QUEUE') as Queue & { customMethod: () => string }; + expect(wrapped.customMethod()).toBe('hi'); + }); +}); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 28733ccfe651..2164989833b3 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -204,6 +204,142 @@ describe('withSentry', () => { expect(sentryEvent.contexts?.culture).toEqual({ timezone: 'UTC' }); }); + + test('captures request body with default integration (medium size)', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + // Default integrations include httpServerIntegration with 'medium' default + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'test', data: 'value' }), + }), + context, + }, + () => { + SentryCore.captureMessage('request body'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toEqual( + JSON.stringify({ username: 'test', data: 'value' }), + ); + }); + + test('does not capture request body for GET requests', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com'), + context, + }, + () => { + SentryCore.captureMessage('get request'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); + + test('does not capture request body for HEAD requests', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com', { method: 'HEAD' }), + context, + }, + () => { + SentryCore.captureMessage('head request'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); + + test('does not capture request body for OPTIONS requests', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com', { method: 'OPTIONS' }), + context, + }, + () => { + SentryCore.captureMessage('options request'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); + + test('does not capture request body for binary content types', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com', { + method: 'POST', + headers: { 'content-type': 'image/png' }, + body: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + }), + context, + }, + () => { + SentryCore.captureMessage('binary'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); }); describe('error instrumentation', () => { diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts index 2c6599ed2e42..95db6e1ff3e9 100644 --- a/packages/cloudflare/test/utils/isBinding.test.ts +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isDurableObjectNamespace, isJSRPC } from '../../src/utils/isBinding'; +import { isDurableObjectNamespace, isJSRPC, isQueue } from '../../src/utils/isBinding'; describe('isJSRPC', () => { it('returns false for a plain object', () => { @@ -120,3 +120,52 @@ describe('isDurableObjectNamespace', () => { expect(isDurableObjectNamespace({ idFromName: 'not-a-function' })).toBe(false); }); }); + +describe('isQueue', () => { + it('returns true for an object with send and sendBatch methods', () => { + const queue = { + send: async () => {}, + sendBatch: async () => {}, + }; + expect(isQueue(queue)).toBe(true); + }); + + it('returns false when send is missing', () => { + expect(isQueue({ sendBatch: async () => {} })).toBe(false); + }); + + it('returns false when sendBatch is missing', () => { + expect(isQueue({ send: async () => {} })).toBe(false); + }); + + it('returns false when send is not a function', () => { + expect(isQueue({ send: 'nope', sendBatch: async () => {} })).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(isQueue(null)).toBe(false); + expect(isQueue(undefined)).toBe(false); + }); + + it('returns false for a JSRPC proxy even though it returns functions for send/sendBatch', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isQueue(jsrpcProxy)).toBe(false); + }); + + it('returns false for a DurableObjectNamespace-like object', () => { + const doNamespace = { + idFromName: () => ({}), + idFromString: () => ({}), + get: () => ({}), + newUniqueId: () => ({}), + }; + expect(isQueue(doNamespace)).toBe(false); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 2cf7c1afb171..766a0a4ecfdc 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -425,12 +425,16 @@ export abstract class Client { // @ts-expect-error - PromiseLike is a subset of Promise public async flush(timeout?: number): PromiseLike { const transport = this._transport; + + // Emit `flush` unconditionally so weight-based log/metric flushers drain + // their buffers and clear their idle timers, even when no transport is + // configured (e.g. no DSN). + this.emit('flush'); + if (!transport) { return true; } - this.emit('flush'); - const clientFinished = await this._isClientDoneProcessing(timeout); const transportFlushed = await transport.flush(timeout); diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index c65f147613dc..a64a98255fa9 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -2,6 +2,7 @@ import { getClient } from './currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes'; import { setHttpStatus, SPAN_STATUS_ERROR, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; +import { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled'; import type { FetchBreadcrumbHint } from './types-hoist/breadcrumb'; import type { HandlerDataFetch } from './types-hoist/instrument'; import type { ResponseHookInfo } from './types-hoist/request'; @@ -110,13 +111,15 @@ export function instrumentFetchRequest( const client = getClient(); const hasParent = !!getActiveSpan(); + // With span streaming, we always emit http.client spans, even without a parent span + const shouldEmitSpan = hasParent || (!!client && hasSpanStreamingEnabled(client)); const span = - shouldCreateSpanResult && hasParent + shouldCreateSpanResult && shouldEmitSpan ? startInactiveSpan(getSpanStartOptions(url, method, spanOrigin)) : new SentryNonRecordingSpan(); - if (shouldCreateSpanResult && !hasParent) { + if (shouldCreateSpanResult && !shouldEmitSpan) { client?.recordDroppedEvent('no_parent_span', 'span'); } @@ -136,7 +139,7 @@ export function instrumentFetchRequest( // If performance is disabled (TWP) or there's no active root span (pageload/navigation/interaction), // we do not want to use the span as base for the trace headers, // which means that the headers will be generated from the scope and the sampling decision is deferred - hasSpansEnabled() && hasParent ? span : undefined, + hasSpansEnabled() && shouldEmitSpan ? span : undefined, propagateTraceparent, ); if (headers) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4d80ea02ed33..1751192d13dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,11 +112,15 @@ export { shouldIgnoreSpan } from './utils/should-ignore-span'; export { winterCGHeadersToDict, winterCGRequestToRequestData, + captureBodyFromWinterCGRequest, httpRequestToRequestData, extractQueryParamsFromUrl, headersToDict, httpHeadersToSpanAttributes, + getMaxBodyByteLength, + MAX_BODY_BYTE_LENGTH, } from './utils/request'; +export type { MaxRequestBodySize } from './utils/request'; export { DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; export { functionToStringIntegration } from './integrations/functiontostring'; @@ -142,9 +146,27 @@ export { instrumentPostgresJsSql } from './integrations/postgresjs'; export { zodErrorsIntegration } from './integrations/zoderrors'; export { thirdPartyErrorFilterIntegration } from './integrations/third-party-errors-filter'; export { consoleIntegration } from './integrations/console'; -export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integrations/featureFlags'; +export type { FeatureFlagsIntegration } from './integrations/featureFlags'; +export { featureFlagsIntegration } from './integrations/featureFlags'; export { growthbookIntegration } from './integrations/featureFlags'; export { conversationIdIntegration } from './integrations/conversationId'; +export { patchHttpModuleClient } from './integrations/http/client-patch'; +export { getHttpClientSubscriptions } from './integrations/http/client-subscriptions'; +export { addOutgoingRequestBreadcrumb } from './integrations/http/add-outgoing-request-breadcrumb'; +export { + getRequestUrl, + getRequestUrlObject, + getRequestUrlFromClientRequest, + getRequestOptions, +} from './integrations/http/get-request-url'; +export { HTTP_ON_CLIENT_REQUEST, HTTP_ON_SERVER_REQUEST } from './integrations/http/constants'; +export type { + HttpInstrumentationOptions, + HttpClientRequest, + HttpIncomingMessage, + HttpServerResponse, + HttpModuleExport, +} from './integrations/http/types'; export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) @@ -339,6 +361,7 @@ export { dynamicSamplingContextToSentryBaggageHeader, parseBaggageHeader, objectToBaggageHeader, + mergeBaggageHeaders, } from './utils/baggage'; export { getSanitizedUrlString, @@ -571,9 +594,9 @@ export type { UnstableRollupPluginOptions, UnstableWebpackPluginOptions, } from './build-time-plugins/buildTimeOptionsBase'; +export type { RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner } from './utils/randomSafeContext'; export { withRandomSafeContext as _INTERNAL_withRandomSafeContext, - type RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, safeMathRandom as _INTERNAL_safeMathRandom, safeDateNow as _INTERNAL_safeDateNow, } from './utils/randomSafeContext'; diff --git a/packages/core/src/integrations/express/index.ts b/packages/core/src/integrations/express/index.ts index bbb1f8fe8a28..df616e7b7f32 100644 --- a/packages/core/src/integrations/express/index.ts +++ b/packages/core/src/integrations/express/index.ts @@ -33,7 +33,6 @@ import { DEBUG_BUILD } from '../../debug-build'; import type { ExpressApplication, ExpressErrorMiddleware, - ExpressExport, ExpressHandlerOptions, ExpressIntegrationOptions, ExpressLayer, @@ -49,16 +48,13 @@ import type { import { defaultShouldHandleError, getLayerPath, - hasDefaultProp, isExpressWithoutRouterPrototype, isExpressWithRouterPrototype, } from './utils'; import { wrapMethod } from '../../utils/object'; import { patchLayer } from './patch-layer'; import { setSDKProcessingMetadata } from './set-sdk-processing-metadata'; - -const getExpressExport = (express: ExpressModuleExport): ExpressExport => - hasDefaultProp(express) ? express.default : (express as ExpressExport); +import { getDefaultExport } from '../../utils/get-default-export'; function isLegacyOptions( options: ExpressModuleExport | (ExpressIntegrationOptions & { express: ExpressModuleExport }), @@ -119,7 +115,7 @@ export function patchExpressModule( } // pass in the require() or import() result of express - const express = getExpressExport(moduleExports); + const express = getDefaultExport(moduleExports); const routerProto: ExpressRouterv4 | ExpressRouterv5 | undefined = isExpressWithRouterPrototype(express) ? express.Router.prototype // Express v5 : isExpressWithoutRouterPrototype(express) diff --git a/packages/core/src/integrations/express/utils.ts b/packages/core/src/integrations/express/utils.ts index c3473bbab18a..af22a6ea1d97 100644 --- a/packages/core/src/integrations/express/utils.ts +++ b/packages/core/src/integrations/express/utils.ts @@ -30,7 +30,6 @@ import type { SpanAttributes } from '../../types-hoist/span'; import { getStoredLayers } from './request-layer-store'; import type { - ExpressExport, ExpressIntegrationOptions, ExpressLayer, ExpressLayerType, @@ -254,14 +253,6 @@ const isExpressRouterPrototype = (routerProto?: unknown): routerProto is Express export const isExpressWithoutRouterPrototype = (express: unknown): express is ExpressExportv4 => isExpressRouterPrototype((express as ExpressExportv4).Router) && !isExpressWithRouterPrototype(express); -// dynamic puts the default on .default, require or normal import are fine -export const hasDefaultProp = ( - express: unknown, -): express is { - [k: string]: unknown; - default: ExpressExport; -} => !!express && typeof express === 'object' && 'default' in express && typeof express.default === 'function'; - function getStatusCodeFromResponse(error: MiddlewareError): number { const statusCode = error.status || error.statusCode || error.status_code || error.output?.statusCode; return statusCode ? parseInt(statusCode as string, 10) : 500; diff --git a/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts b/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts new file mode 100644 index 000000000000..251dbfc540a6 --- /dev/null +++ b/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts @@ -0,0 +1,39 @@ +import { addBreadcrumb } from '../../breadcrumbs'; +import { getBreadcrumbLogLevelFromHttpStatusCode } from '../../utils/breadcrumb-log-level'; +import { getSanitizedUrlString, parseUrl } from '../../utils/url'; +import { getRequestUrlFromClientRequest } from './get-request-url'; +import type { HttpClientRequest, HttpIncomingMessage } from './types'; + +/** + * Create a breadcrumb for a finished outgoing HTTP request. + */ +export function addOutgoingRequestBreadcrumb( + request: HttpClientRequest, + response: HttpIncomingMessage | undefined, +): void { + const url = getRequestUrlFromClientRequest(request); + const parsedUrl = parseUrl(url); + + const statusCode = response?.statusCode; + const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); + + addBreadcrumb( + { + category: 'http', + data: { + status_code: statusCode, + url: getSanitizedUrlString(parsedUrl), + 'http.method': request.method || 'GET', + ...(parsedUrl.search ? { 'http.query': parsedUrl.search } : {}), + ...(parsedUrl.hash ? { 'http.fragment': parsedUrl.hash } : {}), + }, + type: 'http', + level, + }, + { + event: 'response', + request, + response, + }, + ); +} diff --git a/packages/core/src/integrations/http/client-patch.ts b/packages/core/src/integrations/http/client-patch.ts new file mode 100644 index 000000000000..fba60fd9c198 --- /dev/null +++ b/packages/core/src/integrations/http/client-patch.ts @@ -0,0 +1,108 @@ +/** + * Platform-portable HTTP(S) outgoing-request patching integration + * + * Patches the `http` and `https` Node.js built-in module exports to create + * Sentry spans for outgoing requests and optionally inject distributed trace + * propagation headers. + * + * @module + * + * This Sentry integration is a derivative work based on the OpenTelemetry + * HTTP instrumentation. + * + * + * + * Extended under the terms of the Apache 2.0 license linked below: + * + * ---- + * + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getDefaultExport } from '../../utils/get-default-export'; +import { HTTP_ON_CLIENT_REQUEST } from './constants'; +import type { HttpExport, HttpModuleExport, HttpInstrumentationOptions, HttpClientRequest } from './types'; +import { getOriginalFunction, wrapMethod } from '../../utils/object'; +import { getHttpClientSubscriptions } from './client-subscriptions'; + +function patchHttpRequest(httpModule: HttpExport, options: HttpInstrumentationOptions): void { + // avoid double-wrap + if (!getOriginalFunction(httpModule.request)) { + const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = getHttpClientSubscriptions({ + ...options, + http: httpModule, + }); + + const originalRequest = httpModule.request; + wrapMethod(httpModule, 'request', function patchedRequest(this: HttpExport, ...args: unknown[]) { + const request = originalRequest.apply(this, args) as HttpClientRequest; + onHttpClientRequestCreated({ request }, HTTP_ON_CLIENT_REQUEST); + return request; + }); + } +} + +// This simply ensures that http.get calls http.request, which we patched. +// Call it from the object each time, to ensure that any subsequent patches +// or other mutations are also respected. +function patchHttpGet(httpModule: HttpExport) { + if (!getOriginalFunction(httpModule.get)) { + // match node's normalization to exactly 3 arguments. + wrapMethod(httpModule, 'get', function patchedGet(this: HttpExport, input: unknown, options: unknown, cb: unknown) { + // http.get is like http.request but automatically calls .end() + const request = httpModule.request.call(this, input, options, cb) as HttpClientRequest; + request.end(); + return request; + }); + } +} + +function patchModule(httpModuleExport: HttpModuleExport, options: HttpInstrumentationOptions = {}): HttpModuleExport { + const httpDefault = getDefaultExport(httpModuleExport); + const httpModule = httpModuleExport as HttpExport; + // if we have a default, patch that, and copy to the import container + if (httpDefault !== httpModuleExport) { + patchModule(httpDefault, options); + // copy with defineProperty because these might be configured oddly + for (const method of ['get', 'request']) { + const desc = Object.getOwnPropertyDescriptor(httpDefault, method); + /* v8 ignore start - will always be set at this point */ + if (desc) { + Object.defineProperty(httpModule, method, desc); + } + /* v8 ignore stop */ + } + return httpModule; + } + patchHttpRequest(httpModule, options); + patchHttpGet(httpModule); + return httpModuleExport; +} + +/** + * Patch an `node:http` or `node:https` module-shaped export so that every + * outgoing request is tracked by Sentry. + * + * @example + * ```javascript + * import http from 'http'; + * import { patchHttpModule } from '@sentry/core'; + * patchHttpModule(http, { propagateTrace: true }); + * ``` + */ +export const patchHttpModuleClient = ( + httpModuleExport: HttpModuleExport, + options: HttpInstrumentationOptions = {}, +): HttpModuleExport => patchModule(httpModuleExport, options); diff --git a/packages/core/src/integrations/http/client-subscriptions.ts b/packages/core/src/integrations/http/client-subscriptions.ts new file mode 100644 index 000000000000..0bece51e441f --- /dev/null +++ b/packages/core/src/integrations/http/client-subscriptions.ts @@ -0,0 +1,189 @@ +/** + * Define the channels and subscription methods to subscribe to in order to + * instrument the `node:http` module. Note that this does *not* actually + * register the subscriptions, it simply returns a data object with the + * channel names and the subscription handlers. Attach these to diagnostic + * channels on Node versions where they are supported (ie, >=22.12.0). + * + * If any other platforms that do support diagnostic channels eventually add + * channel coverage for the `node:http` client, then these methods can be + * used on those platforms as well. + * + * This implementation is used in the client-patch strategy, by simply + * calling the handlers with the relevant data at the appropriate time. + */ + +import type { SpanStatus } from '../../types-hoist/spanStatus'; +import { addOutgoingRequestBreadcrumb } from './add-outgoing-request-breadcrumb'; +import { + getSpanStatusFromHttpCode, + SPAN_STATUS_ERROR, + SPAN_STATUS_UNSET, + startInactiveSpan, + SUPPRESS_TRACING_KEY, + withActiveSpan, +} from '../../tracing'; +import { debug } from '../../utils/debug-logger'; +import { LRUMap } from '../../utils/lru'; +import { getOutgoingRequestSpanData, setIncomingResponseSpanData } from './get-outgoing-span-data'; +import { getRequestUrlFromClientRequest } from './get-request-url'; +import { injectTracePropagationHeaders } from './inject-trace-propagation-headers'; +import type { HttpInstrumentationOptions, HttpClientRequest, HttpIncomingMessage } from './types'; +import { DEBUG_BUILD } from '../../debug-build'; +import { LOG_PREFIX, HTTP_ON_CLIENT_REQUEST } from './constants'; +import type { ClientSubscriptionName } from './constants'; +import { getClient, getCurrentScope } from '../../currentScopes'; +import { hasSpansEnabled } from '../../utils/hasSpansEnabled'; +import { doubleWrapWarning } from './double-wrap-warning'; + +type ChannelListener = (message: unknown, name: string | symbol) => void; + +export type HttpClientSubscriptions = Record; + +export function getHttpClientSubscriptions(options: HttpInstrumentationOptions): HttpClientSubscriptions { + const propagationDecisionMap = new LRUMap(100); + const getConfig = () => getClient()?.getOptions(); + + const onHttpClientRequestCreated: ChannelListener = (data: unknown): void => { + // Skip all instrumentation if tracing is suppressed + // (e.g., Sentry's own transport uses this to avoid self-instrumentation) + if (getCurrentScope().getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY] === true) { + return; + } + + const clientOptions = getConfig(); + const { + errorMonitor = 'error', + spans: createSpans = clientOptions ? hasSpansEnabled(clientOptions) : true, + propagateTrace = false, + breadcrumbs = true, + http, + https, + suppressOtelWarning = false, + } = options; + + const { request } = data as { request: HttpClientRequest }; + + // check if request is ignored. if so, we do nothing at all. + if (options.ignoreOutgoingRequests?.(getRequestUrlFromClientRequest(request), request)) { + return; + } + + // guard against adding breadcrumbs multiple times, or when not enabled + let addedBreadcrumbs = false; + function addBreadcrumbs(request: HttpClientRequest, response: HttpIncomingMessage | undefined) { + if (!addedBreadcrumbs) { + addedBreadcrumbs = true; + addOutgoingRequestBreadcrumb(request, response); + } + } + + // called if spans and/or trace propagation are disabled + function breadcrumbsOnly(request: HttpClientRequest) { + request.on(errorMonitor, () => addBreadcrumbs(request, undefined)); + request.prependListener('response', response => { + if (request.listenerCount('response') <= 1) { + response.resume(); + } + response.on('end', () => addBreadcrumbs(request, response)); + response.on(errorMonitor, () => addBreadcrumbs(request, response)); + }); + } + + if (!createSpans) { + // no spans, but maybe tracing and/or breadcrumbs + if (breadcrumbs) { + breadcrumbsOnly(request); + } + if (propagateTrace) { + injectTracePropagationHeaders(request, propagationDecisionMap); + } + return; + } + + // guard against OTel wrapping the same module and emitting double-spans + // this doesn't prevent it, just prints a debug warning for the user. + if (!suppressOtelWarning) { + if (http) doubleWrapWarning(http); + if (https) doubleWrapWarning(https); + } + + // spans are enabled + const span = startInactiveSpan(getOutgoingRequestSpanData(request)); + options.outgoingRequestHook?.(span, request); + + // Inject trace headers after span creation so sentry-trace contains the + // outgoing span's ID (not the parent's), enabling downstream services to + // link to this span. + if (propagateTrace) { + if (span.isRecording()) { + withActiveSpan(span, () => { + injectTracePropagationHeaders(request, propagationDecisionMap); + }); + } else { + injectTracePropagationHeaders(request, propagationDecisionMap); + } + } + + let spanEnded = false; + function endSpan(status: SpanStatus): void { + if (!spanEnded) { + spanEnded = true; + span.setStatus(status); + span.end(); + } + } + + // Fallback: end span if the connection closes before any response. + // This is removed if we do get a response, because in that case + // we want to only end the span when the response is finished. + const requestOnClose = () => endSpan({ code: SPAN_STATUS_UNSET }); + request.on('close', requestOnClose); + + request.on(errorMonitor, error => { + DEBUG_BUILD && debug.log(LOG_PREFIX, 'outgoingRequest on request error()', error); + if (breadcrumbs) { + addBreadcrumbs(request, undefined); + } + endSpan({ code: SPAN_STATUS_ERROR }); + }); + + request.prependListener('response', response => { + // no longer need this, listen on response now. + // do not end the span until the response finishes + request.removeListener('close', requestOnClose); + if (request.listenerCount('response') <= 1) { + response.resume(); + } + setIncomingResponseSpanData(response, span); + options.outgoingResponseHook?.(span, response); + + let finished = false; + function finishWithResponse(error?: unknown): void { + if (!finished) { + finished = true; + if (error) { + DEBUG_BUILD && debug.log(LOG_PREFIX, 'outgoingRequest on response error()', error); + } + if (breadcrumbs) { + addBreadcrumbs(request, response); + } + const aborted = response.aborted && !response.complete; + const status: SpanStatus = + error || typeof response.statusCode !== 'number' || aborted + ? { code: SPAN_STATUS_ERROR } + : getSpanStatusFromHttpCode(response.statusCode); + options.applyCustomAttributesOnSpan?.(span, request, response); + endSpan(status); + } + } + + response.on('end', () => finishWithResponse()); + response.on(errorMonitor, finishWithResponse); + }); + }; + + return { + [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated, + }; +} diff --git a/packages/core/src/integrations/http/constants.ts b/packages/core/src/integrations/http/constants.ts new file mode 100644 index 000000000000..f2af12b00b62 --- /dev/null +++ b/packages/core/src/integrations/http/constants.ts @@ -0,0 +1,5 @@ +export const LOG_PREFIX = '@sentry/instrumentation-http'; +export const HTTP_ON_CLIENT_REQUEST = 'http.client.request.created'; +export const HTTP_ON_SERVER_REQUEST = 'http.server.request.start'; +export type ClientSubscriptionName = typeof HTTP_ON_CLIENT_REQUEST; +export type ServerSubscriptionName = typeof HTTP_ON_SERVER_REQUEST; diff --git a/packages/core/src/integrations/http/double-wrap-warning.ts b/packages/core/src/integrations/http/double-wrap-warning.ts new file mode 100644 index 000000000000..27dea32dd9b3 --- /dev/null +++ b/packages/core/src/integrations/http/double-wrap-warning.ts @@ -0,0 +1,24 @@ +import { DEBUG_BUILD } from '../../debug-build'; +import { debug } from '../../utils/debug-logger'; +import type { HttpModuleExport } from './types'; + +const isOtelWrapped = (fn: Function & { __unwrap?: Function }): fn is Function & { __unwrap: Function } => + typeof fn.__unwrap === 'function'; + +// exported for tess +export const warning = + 'Double-wrapped http.client detected. Either disable spans in Sentry.httpIntegration, or disable the OpenTelemetry HTTP instrumentation.'; + +let didDoubleWrapWarning = false; +// no-op in non-debug builds +export const doubleWrapWarning = DEBUG_BUILD + ? (http: HttpModuleExport) => { + if (!didDoubleWrapWarning) { + if (isOtelWrapped(http.request) || isOtelWrapped(http.get)) { + // TODO: add link to documentation + didDoubleWrapWarning = true; + debug.warn(warning); + } + } + } + : () => {}; diff --git a/packages/core/src/integrations/http/get-outgoing-span-data.ts b/packages/core/src/integrations/http/get-outgoing-span-data.ts new file mode 100644 index 000000000000..2d1d5bae37c2 --- /dev/null +++ b/packages/core/src/integrations/http/get-outgoing-span-data.ts @@ -0,0 +1,85 @@ +import type { Span, SpanAttributes } from '../../types-hoist/span'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../../semanticAttributes'; +import { getHttpSpanDetailsFromUrlObject, parseStringToURLObject } from '../../utils/url'; +import type { HttpClientRequest, HttpIncomingMessage } from './types'; +import { getRequestUrlFromClientRequest } from './get-request-url'; +import type { StartSpanOptions } from '../../types-hoist/startSpanOptions'; + +/** + * Build the initial span name and attributes for an outgoing HTTP request. + * This is called before the span is created, to get the initial details. + */ +export function getOutgoingRequestSpanData(request: HttpClientRequest): StartSpanOptions { + const url = getRequestUrlFromClientRequest(request); + const [name, attributes] = getHttpSpanDetailsFromUrlObject( + parseStringToURLObject(url), + 'client', + 'auto.http.client', + request, + ); + + const userAgent = request.getHeader('user-agent'); + + return { + name, + attributes: { + // TODO(v11): Update these to the Sentry semantic attributes for urls. + // https://getsentry.github.io/sentry-conventions/attributes/ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + 'otel.kind': 'CLIENT', + 'http.url': url, + 'http.method': request.method, + 'http.target': request.path || '/', + 'net.peer.name': request.host, + 'http.host': request.getHeader('host') as string | undefined, + ...(userAgent ? { 'user_agent.original': userAgent as string } : {}), + ...attributes, + }, + onlyIfParent: true, + }; +} + +/** + * Add span attributes once the response is received. + */ +export function setIncomingResponseSpanData(response: HttpIncomingMessage, span: Span): void { + const { statusCode, statusMessage, httpVersion, socket } = response; + const transport = httpVersion?.toUpperCase() !== 'QUIC' ? 'ip_tcp' : 'ip_udp'; + + span.setAttributes({ + 'http.response.status_code': statusCode, + 'network.protocol.version': httpVersion, + // TODO(v11): Update these to the Sentry semantic attributes for urls. + // https://getsentry.github.io/sentry-conventions/attributes/ + 'http.flavor': httpVersion, + 'network.transport': transport, + 'net.transport': transport, + 'http.status_text': statusMessage?.toUpperCase(), + 'http.status_code': statusCode, + ...getResponseContentLengthAttributes(response), + ...getSocketAttrs(socket), + }); +} + +function getSocketAttrs(socket: HttpIncomingMessage['socket']): SpanAttributes { + if (!socket) return {}; + const { remoteAddress, remotePort } = socket; + return { + 'network.peer.address': remoteAddress, + 'network.peer.port': remotePort, + 'net.peer.ip': remoteAddress, + 'net.peer.port': remotePort, + }; +} + +function getResponseContentLengthAttributes(response: HttpIncomingMessage): SpanAttributes { + const { headers } = response; + const contentLengthHeader = headers['content-length']; + const length = contentLengthHeader ? parseInt(String(contentLengthHeader), 10) : -1; + const encoding = headers['content-encoding']; + return length >= 0 + ? encoding && encoding !== 'identity' + ? { 'http.response_content_length': length } + : { 'http.response_content_length_uncompressed': length } + : {}; +} diff --git a/packages/core/src/integrations/http/get-request-url.ts b/packages/core/src/integrations/http/get-request-url.ts new file mode 100644 index 000000000000..024ac704acd1 --- /dev/null +++ b/packages/core/src/integrations/http/get-request-url.ts @@ -0,0 +1,48 @@ +import type { HttpClientRequest, HttpRequestOptions } from './types'; + +/** Convert an outgoing request to request options. */ +export function getRequestOptions(request: HttpClientRequest): HttpRequestOptions { + // request.host may be 'hostname:port' when the caller passed + // { host: 'hostname:port' } to http.request(). Split it so that + // `hostname` is always port-free (matching the http.RequestOptions contract) + // and the port is not lost when request.port is undefined. + const hostWithPort = request.host || ''; + const portInHost = /^(.*):(\d+)$/.exec(hostWithPort); + const hostname = portInHost ? portInHost[1] : hostWithPort; + const port = request.port ?? (portInHost ? Number(portInHost[2]) : undefined); + + return { + method: request.method, + port, + protocol: request.protocol, + host: request.host, + hostname, + path: request.path, + headers: request.getHeaders(), + }; +} + +export function getRequestUrl(requestOptions: HttpRequestOptions): string { + return String(getRequestUrlObject(requestOptions)); +} + +export function getRequestUrlObject(requestOptions: HttpRequestOptions): URL { + const protocol = requestOptions.protocol || 'http:'; + const hostHeader = requestOptions.headers?.host && String(requestOptions.headers?.host); + const hostname = hostHeader || requestOptions.hostname || requestOptions.host || ''; + // Don't log standard :80 (http) and :443 (https) ports to reduce the noise + // Also don't add port if the hostname already includes a port + const port = + !requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 || /^(.*):(\d+)$/.test(hostname) + ? '' + : `:${requestOptions.port}`; + const path = requestOptions.path ? requestOptions.path : '/'; + return new URL(path, `${protocol}//${hostname}${port}`); +} + +/** + * Build the full URL string from a Node.js ClientRequest. + */ +export function getRequestUrlFromClientRequest(request: HttpClientRequest): string { + return String(getRequestUrl(getRequestOptions(request))); +} diff --git a/packages/core/src/integrations/http/index.ts b/packages/core/src/integrations/http/index.ts new file mode 100644 index 000000000000..bbcaf3400c07 --- /dev/null +++ b/packages/core/src/integrations/http/index.ts @@ -0,0 +1,3 @@ +export type { HttpInstrumentationOptions } from './types'; +export * from './client-patch'; +export * from './client-subscriptions'; diff --git a/packages/core/src/integrations/http/inject-trace-propagation-headers.ts b/packages/core/src/integrations/http/inject-trace-propagation-headers.ts new file mode 100644 index 000000000000..0324cabb81b2 --- /dev/null +++ b/packages/core/src/integrations/http/inject-trace-propagation-headers.ts @@ -0,0 +1,78 @@ +import type { LRUMap } from '../../utils/lru'; +import { getClient } from '../../currentScopes'; +import { DEBUG_BUILD } from '../../debug-build'; +import { debug } from '../../utils/debug-logger'; +import { isError } from '../../utils/is'; +import { getTraceData } from '../../utils/traceData'; +import { shouldPropagateTraceForUrl } from '../../utils/tracePropagationTargets'; +import { LOG_PREFIX } from './constants'; +import { getRequestUrlFromClientRequest } from './get-request-url'; +import type { HttpClientRequest } from './types'; +import { mergeBaggageHeaders } from '../../utils/baggage'; + +/** + * Inject Sentry trace-propagation headers into an outgoing request if the + * target URL matches the configured `tracePropagationTargets`. + * + * Note: this must be called *before* calling `request.end()` (or firing the + * `http.client.request.start` diagnostics channel), because at that point, + * the headers have already been sent, and cannot be modified. + */ +export function injectTracePropagationHeaders( + request: HttpClientRequest, + propagationDecisionMap: LRUMap, +): void { + const url = getRequestUrlFromClientRequest(request); + const clientOptions = getClient()?.getOptions(); + const { tracePropagationTargets, propagateTraceparent } = clientOptions ?? {}; + + if (!shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap)) { + return; + } + + const hasExistingSentryTraceHeader = !!request.getHeader('sentry-trace'); + + if (hasExistingSentryTraceHeader) { + // add nothing if there's already a sentry-trace header, + // or else baggage can be sent twice. + return; + } + + const traceData = getTraceData({ propagateTraceparent }); + if (!traceData) return; + + const { 'sentry-trace': sentryTrace, baggage, traceparent } = traceData; + + if (sentryTrace) { + try { + request.setHeader('sentry-trace', sentryTrace); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added sentry-trace header'); + } catch (e) { + DEBUG_BUILD && + debug.error(LOG_PREFIX, 'Failed to set sentry-trace header:', isError(e) ? e.message : 'Unknown error'); + } + } + + if (traceparent && !request.getHeader('traceparent')) { + try { + request.setHeader('traceparent', traceparent); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added traceparent header'); + } catch (e) { + DEBUG_BUILD && + debug.error(LOG_PREFIX, 'Failed to set traceparent header:', isError(e) ? e.message : 'Unknown error'); + } + } + + if (baggage) { + const merged = mergeBaggageHeaders(request.getHeader('baggage'), baggage); + if (merged) { + try { + request.setHeader('baggage', merged); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added baggage header'); + } catch (e) { + DEBUG_BUILD && + debug.error(LOG_PREFIX, 'Failed to set baggage header:', isError(e) ? e.message : 'Unknown error'); + } + } + } +} diff --git a/packages/core/src/integrations/http/types.ts b/packages/core/src/integrations/http/types.ts new file mode 100644 index 000000000000..fb9a132a1e3b --- /dev/null +++ b/packages/core/src/integrations/http/types.ts @@ -0,0 +1,281 @@ +/** + * Platform-portable HTTP(S) outgoing-request integration – type definitions. + * + * @module + * + * This Sentry integration is a derivative work based on the OpenTelemetry + * HTTP instrumentation. + * + * + * + * Extended under the terms of the Apache 2.0 license linked below: + * + * ---- + * + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { RequestEventData } from '../../types-hoist/request'; +import type { Span } from '../../types-hoist/span'; + +/** Minimal interface for a Node.js http.ClientRequest */ +export interface HttpClientRequest { + method?: string; + path?: string; + host?: string; + protocol?: string; + port?: number; + end(): void; + getHeader(name: string): string | string[] | number | undefined; + getHeaders(): Record; + setHeader(name: string, value: string | string[] | number): void; + removeHeader(name: string): void; + prependListener(event: 'response', listener: (res: HttpIncomingMessage) => void): this; + prependListener(event: string | symbol, listener: (...args: unknown[]) => void): this; + on(event: string | symbol, listener: (...args: unknown[]) => void): this; + once(event: string | symbol, listener: (...args: unknown[]) => void): this; + listenerCount(event: string | symbol): number; + removeListener(event: string | symbol, listener: (...args: unknown[]) => void): this; +} + +/** Minimal interface for http client RequestOptions */ +export interface HttpRequestOptions { + method?: string; + protocol?: string | null; + hostname?: string | null; + host?: string | null; + port?: string | number | null; + path?: string | null; + headers?: Record; +} + +/** Minimal interface for a Node.js http.ServerResponse */ +export interface HttpServerResponse { + statusCode: number; + statusMessage?: string; + headers: Record; + once(ev: string, ...data: unknown[]): this; + once(ev: 'close'): this; + on(ev: string | symbol, handler: (...data: unknown[]) => void): this; +} + +export interface HttpServer { + emit(ev: string, ...data: unknown[]): this; + emit(ev: 'request', request: HttpIncomingMessage, response: HttpServerResponse): this; +} + +export interface HttpSocket { + remoteAddress?: string; + remotePort?: number; + localAddress?: string; + localPort?: number; +} + +/** Minimal interface for a Node.js http.IncomingMessage */ +export interface HttpIncomingMessage { + statusCode?: number; + statusMessage?: string; + httpVersion?: string; + url?: string; + method?: string; + headers: Record; + socket?: HttpSocket; + aborted?: boolean; + complete?: boolean; + resume(): void; + on(event: 'end', listener: () => void): this; + on(event: string | symbol, listener: (...args: unknown[]) => void): this; + addListener(event: 'end', listener: () => void): this; + addListener(event: string | symbol, listener: (...args: unknown[]) => void): this; + off(event: string | symbol, listener: (...args: unknown[]) => void): this; + removeListener(event: string | symbol, listener: (...args: unknown[]) => void): this; +} + +/** Minimal interface for a Node.js http / https module export */ +export interface HttpExport { + //oxlint-disable typescript/no-explicit-any + request: (...args: any[]) => HttpClientRequest; + //oxlint-disable typescript/no-explicit-any + get: (...args: any[]) => HttpClientRequest; + [key: string]: unknown; +} + +export type HttpModuleExport = HttpExport | (HttpExport & { default: HttpExport }); + +export interface HttpInstrumentationOptions { + /** + * Whether to create spans for outgoing HTTP requests. + * @default true + */ + spans?: boolean; + + /** + * Whether to inject distributed trace propagation headers + * (`sentry-trace`, `baggage`, `traceparent`) into outgoing requests. + * @default false + */ + propagateTrace?: boolean; + + /** + * Skip span / breadcrumb creation for requests to matching URLs. + * Receives the full URL string and the outgoing request object. + */ + ignoreOutgoingRequests?: (url: string, request: HttpClientRequest) => boolean; + + /** + * Whether breadcrumbs should be recorded for outgoing requests. + * @default true + */ + breadcrumbs?: boolean; + + /** + * Called after the outgoing-request span is created by the client. + * Use this to add custom attributes to the span. + */ + outgoingRequestHook?: (span: Span, request: HttpClientRequest) => void; + + /** + * Called when the response is received by the client. + */ + outgoingResponseHook?: (span: Span, response: HttpIncomingMessage) => void; + + /** + * Called when both the request and response are available (after the + * response ends). Useful for adding attributes based on both objects. + */ + applyCustomAttributesOnSpan?: (span: Span, request: HttpClientRequest, response: HttpIncomingMessage) => void; + + /** + * Symbol to use for observing errors on EventEmitters without consuming + * them. Pass `EventEmitter.errorMonitor` from Node.js `events` module. + * Falls back to the plain `'error'` event string when not provided. + * + * Using the real `errorMonitor` symbol ensures that Sentry does not + * swallow errors before they reach user-supplied `'error'` handlers. + */ + errorMonitor?: symbol | string; + + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * Available options: + * - 'none': No request bodies will be attached + * - 'small': Request bodies up to 1,000 bytes will be attached + * - 'medium': Request bodies up to 10,000 bytes will be attached (default) + * - 'always': Request bodies will always be attached + * + * Note that even with 'always' setting, bodies exceeding 1MB will never be attached + * for performance and security reasons. + * + * @default 'medium' + */ + maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + + /** + * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * This can be useful for long running requests where the body is not needed and we want to avoid capturing it. + * + * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the incoming request. + * @param request Contains the {@type RequestOptions} object used to make the incoming request. + */ + ignoreRequestBody?: (url: string, request: HttpIncomingMessage) => boolean; + + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry. + * Read more about Release Health: https://docs.sentry.io/product/releases/health/ + * + * Defaults to `true`. + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions tracked with `trackIncomingRequestsAsSessions` will be flushed as a session aggregate. + * + * Defaults to `60000` (60s). + */ + sessionFlushingDelayMS?: number; + + /** + * Optional callback that can be used by integrations to emit the 'request' + * event within a given Sentry or OTEL context, possibly after creating a + * span, as in the HttpServerSpansIntegration. + */ + wrapServerEmitRequest?: ( + request: HttpIncomingMessage, + response: HttpServerResponse, + normalizedRequest: RequestEventData, + next: () => void, + ) => void; + + /** + * Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`. + * Spans will be non recording if tracing is disabled. + * + * The `urlPath` param consists of the URL path and query string (if any) of the incoming request. + * For example: `'/users/details?id=123'` + * + * The `request` param contains the original {@type IncomingMessage} object of the incoming request. + * You can use it to filter on additional properties like method, headers, etc. + */ + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; + + /** + * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. + * This helps reduce noise in your transactions. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Do not capture spans for incoming HTTP requests with the given status codes. + * By default, spans with some 3xx and 4xx status codes are ignored (see @default). + * Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes. + * + * @default `[[401, 404], [301, 303], [305, 399]]` + */ + ignoreStatusCodes?: (number | [number, number])[]; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, but before it is recorded. + */ + onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; + + /** + * A hook that can be used to mutate the span one last time when the + * response is finished, eg to update the transaction name based on + * the RPC metadata. + */ + onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; + + /** + * Optional: pass in the `http` and `https` modules in order to detect + * whether a standalone OTel instrumentation is attempting to wrap them + * as well. This is fine, as long as `spans` option is disabled, but will + * result in double-emitting spans otherwise. + * + * Since this cannot be fully prevented due to module load timing, and isn't + * necessarily harmful per se (just noisy/annoying), and there are a number + * of reasonable approaches to fix it (disable the OTel instrumentation, + * disable this instrumentation, or keep both and disable spans in one or the + * other), we simply print a warning so the user can hopefully make an + * informed decision about how to address it (if at all). + */ + http?: HttpModuleExport; + https?: HttpModuleExport; + + /** suppress the warning about double-wrapping with OTel */ + suppressOtelWarning?: boolean; +} diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 9ff6033ed7a2..7c462954e075 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -1,9 +1,14 @@ +import { getIsolationScope } from '../currentScopes'; import { defineIntegration } from '../integration'; +import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS } from '../semanticAttributes'; import type { Event } from '../types-hoist/event'; import type { IntegrationFn } from '../types-hoist/integration'; -import type { RequestEventData } from '../types-hoist/request'; +import type { QueryParams, RequestEventData } from '../types-hoist/request'; +import type { StreamedSpanJSON } from '../types-hoist/span'; import { parseCookie } from '../utils/cookie'; +import { httpHeadersToSpanAttributes } from '../utils/request'; import { getClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress'; +import { safeSetSpanJSONAttributes } from '../tracing/spans/captureSpan'; interface RequestDataIncludeOptions { cookies?: boolean; @@ -55,6 +60,22 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = return event; }, + processSegmentSpan(span, client) { + const { sdkProcessingMetadata = {} } = getIsolationScope().getScopeData(); + const { normalizedRequest, ipAddress } = sdkProcessingMetadata; + + if (!normalizedRequest) { + return; + } + + const { sendDefaultPii } = client.getOptions(); + const includeWithDefaultPiiApplied: RequestDataIncludeOptions = { + ...include, + ip: include.ip ?? sendDefaultPii, + }; + + addNormalizedRequestDataToSpan(span, normalizedRequest, ipAddress, includeWithDefaultPiiApplied, sendDefaultPii); + }, }; }) satisfies IntegrationFn; @@ -91,6 +112,60 @@ function addNormalizedRequestDataToEvent( } } +function addNormalizedRequestDataToSpan( + span: StreamedSpanJSON, + normalizedRequest: RequestEventData, + ipAddress: string | undefined, + include: RequestDataIncludeOptions, + sendDefaultPii: boolean | undefined, +): void { + const requestData = extractNormalizedRequestData(normalizedRequest, include); + const attributes: Record = {}; + + if (requestData.url) { + attributes['url.full'] = requestData.url; + } + + if (requestData.method) { + attributes['http.request.method'] = requestData.method; + } + + if (requestData.query_string) { + attributes['url.query'] = normalizeQueryString(requestData.query_string); + } + + safeSetSpanJSONAttributes(span, attributes); + + // Process cookies before headers so normalizedRequest.cookies takes precedence + // over the raw cookie header (matching the processEvent path). + if (requestData.cookies && Object.keys(requestData.cookies).length > 0) { + const cookieString = Object.entries(requestData.cookies) + .map(([name, value]) => `${name}=${value}`) + .join('; '); + const cookieAttributes = httpHeadersToSpanAttributes({ cookie: cookieString }, sendDefaultPii ?? false, 'request'); + safeSetSpanJSONAttributes(span, cookieAttributes); + } + + if (requestData.headers) { + const headerAttributes = httpHeadersToSpanAttributes(requestData.headers, sendDefaultPii ?? false, 'request'); + safeSetSpanJSONAttributes(span, headerAttributes); + } + + if (requestData.data != null) { + const serialized = typeof requestData.data === 'string' ? requestData.data : JSON.stringify(requestData.data); + if (serialized) { + safeSetSpanJSONAttributes(span, { 'http.request.body.data': serialized }); + } + } + + if (include.ip) { + const ip = (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress || undefined; + if (ip) { + safeSetSpanJSONAttributes(span, { [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: ip }); + } + } +} + function extractNormalizedRequestData( normalizedRequest: RequestEventData, include: RequestDataIncludeOptions, @@ -101,13 +176,10 @@ function extractNormalizedRequestData( if (include.headers) { requestData.headers = headers; - // Remove the Cookie header in case cookie data should not be included in the event if (!include.cookies) { delete (headers as { cookie?: string }).cookie; } - // Remove IP headers in case IP data should not be included in the event. - // Match case-insensitively — same as getClientIPAddress — so lowercase keys are stripped too. if (!include.ip) { const ipHeaderNamesLower = new Set(ipHeaderNames.map(name => name.toLowerCase())); for (const key of Object.keys(headers)) { @@ -140,3 +212,14 @@ function extractNormalizedRequestData( return requestData; } + +function normalizeQueryString(queryString: QueryParams): string | undefined { + if (typeof queryString === 'string') { + return queryString || undefined; + } + + const pairs = Array.isArray(queryString) ? queryString : Object.entries(queryString); + const result = pairs.map(([key, value]) => `${key}=${value}`).join('&'); + + return result || undefined; +} diff --git a/packages/core/src/logs/envelope.ts b/packages/core/src/logs/envelope.ts index c1d5b23e1575..3e30a5680316 100644 --- a/packages/core/src/logs/envelope.ts +++ b/packages/core/src/logs/envelope.ts @@ -4,14 +4,18 @@ import type { SerializedLog } from '../types-hoist/log'; import type { SdkMetadata } from '../types-hoist/sdkmetadata'; import { dsnToString } from '../utils/dsn'; import { createEnvelope } from '../utils/envelope'; +import { isBrowser } from '../utils/isBrowser'; /** * Creates a log container envelope item for a list of logs. * * @param items - The logs to include in the envelope. + * @param inferUserData - If true, tells Relay to infer the end-user IP and User-Agent from the incoming request. + * Only emitted as `ingest_settings` in browser environments. * @returns The created log container envelope item. */ -export function createLogContainerEnvelopeItem(items: Array): LogContainerItem { +export function createLogContainerEnvelopeItem(items: Array, inferUserData?: boolean): LogContainerItem { + const inferSetting = inferUserData ? 'auto' : 'never'; return [ { type: 'log', @@ -19,6 +23,10 @@ export function createLogContainerEnvelopeItem(items: Array): Log content_type: 'application/vnd.sentry.items.log+json', }, { + version: 2, + ...(isBrowser() && { + ingest_settings: { infer_ip: inferSetting, infer_user_agent: inferSetting }, + }), items, }, ]; @@ -33,6 +41,7 @@ export function createLogContainerEnvelopeItem(items: Array): Log * @param metadata - The metadata to include in the envelope. * @param tunnel - The tunnel to include in the envelope. * @param dsn - The DSN to include in the envelope. + * @param inferUserData - If true, tells Relay to infer the end-user IP and User-Agent from the incoming request. * @returns The created envelope. */ export function createLogEnvelope( @@ -40,6 +49,7 @@ export function createLogEnvelope( metadata?: SdkMetadata, tunnel?: string, dsn?: DsnComponents, + inferUserData?: boolean, ): LogEnvelope { const headers: LogEnvelope[0] = {}; @@ -54,5 +64,5 @@ export function createLogEnvelope( headers.dsn = dsnToString(dsn); } - return createEnvelope(headers, [createLogContainerEnvelopeItem(logs)]); + return createEnvelope(headers, [createLogContainerEnvelopeItem(logs, inferUserData)]); } diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 097ffbb6906e..c1eff9f50fcf 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -192,7 +192,13 @@ export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array } const clientOptions = client.getOptions(); - const envelope = createLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn()); + const envelope = createLogEnvelope( + logBuffer, + clientOptions._metadata, + clientOptions.tunnel, + client.getDsn(), + clientOptions.sendDefaultPii, + ); // Clear the log buffer after envelopes have been constructed. _getBufferMap().set(client, []); diff --git a/packages/core/src/metrics/envelope.ts b/packages/core/src/metrics/envelope.ts index 71ef0832667b..565b957c4f6d 100644 --- a/packages/core/src/metrics/envelope.ts +++ b/packages/core/src/metrics/envelope.ts @@ -4,14 +4,21 @@ import type { SerializedMetric } from '../types-hoist/metric'; import type { SdkMetadata } from '../types-hoist/sdkmetadata'; import { dsnToString } from '../utils/dsn'; import { createEnvelope } from '../utils/envelope'; +import { isBrowser } from '../utils/isBrowser'; /** * Creates a metric container envelope item for a list of metrics. * * @param items - The metrics to include in the envelope. + * @param inferUserData - If true, tells Relay to infer the end-user IP and User-Agent from the incoming request. + * Only emitted as `ingest_settings` in browser environments. * @returns The created metric container envelope item. */ -export function createMetricContainerEnvelopeItem(items: Array): MetricContainerItem { +export function createMetricContainerEnvelopeItem( + items: Array, + inferUserData?: boolean, +): MetricContainerItem { + const inferSetting = inferUserData ? 'auto' : 'never'; return [ { type: 'trace_metric', @@ -19,6 +26,10 @@ export function createMetricContainerEnvelopeItem(items: Array content_type: 'application/vnd.sentry.items.trace-metric+json', } as MetricContainerItem[0], { + version: 2, + ...(isBrowser() && { + ingest_settings: { infer_ip: inferSetting, infer_user_agent: inferSetting }, + }), items, }, ]; @@ -33,6 +44,7 @@ export function createMetricContainerEnvelopeItem(items: Array * @param metadata - The metadata to include in the envelope. * @param tunnel - The tunnel to include in the envelope. * @param dsn - The DSN to include in the envelope. + * @param inferUserData - If true, tells Relay to infer the end-user IP and User-Agent from the incoming request. * @returns The created envelope. */ export function createMetricEnvelope( @@ -40,6 +52,7 @@ export function createMetricEnvelope( metadata?: SdkMetadata, tunnel?: string, dsn?: DsnComponents, + inferUserData?: boolean, ): MetricEnvelope { const headers: MetricEnvelope[0] = {}; @@ -54,5 +67,5 @@ export function createMetricEnvelope( headers.dsn = dsnToString(dsn); } - return createEnvelope(headers, [createMetricContainerEnvelopeItem(metrics)]); + return createEnvelope(headers, [createMetricContainerEnvelopeItem(metrics, inferUserData)]); } diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 0545414654ef..26cc11fc0422 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -225,7 +225,13 @@ export function _INTERNAL_flushMetricsBuffer(client: Client, maybeMetricBuffer?: } const clientOptions = client.getOptions(); - const envelope = createMetricEnvelope(metricBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn()); + const envelope = createMetricEnvelope( + metricBuffer, + clientOptions._metadata, + clientOptions.tunnel, + client.getDsn(), + clientOptions.sendDefaultPii, + ); // Clear the metric buffer after envelopes have been constructed. _getBufferMap().set(client, []); diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 3d3736876015..9b56045b37f3 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -13,6 +13,7 @@ export { withActiveSpan, suppressTracing, startNewTrace, + SUPPRESS_TRACING_KEY, } from './trace'; export { getDynamicSamplingContextFromClient, diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index e41a9cfdf484..bed3f1790740 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -18,6 +18,7 @@ import { } from '../../semanticAttributes'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; import { getCombinedScopeData } from '../../utils/scopeData'; +import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '../../utils/url'; import { INTERNAL_getSegmentSpan, showSpanDropWarning, @@ -96,10 +97,27 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW } function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void { - // TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span + // TODO: Apply contexts data from auto instrumentation to segment span // This will follow in a separate PR } +/** + * Safely set attributes on a span JSON. + * If an attribute already exists, it will not be overwritten. + */ +export function safeSetSpanJSONAttributes( + spanJSON: StreamedSpanJSON, + newAttributes: RawAttributes>, +): void { + const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); + + Object.entries(newAttributes).forEach(([key, value]) => { + if (value != null && !(key in originalAttributes)) { + originalAttributes[key] = value; + } + }); +} + function applyCommonSpanAttributes( spanJSON: StreamedSpanJSON, serializedSegmentSpan: StreamedSpanJSON, @@ -144,23 +162,6 @@ export function applyBeforeSendSpanCallback( return modifedSpan; } -/** - * Safely set attributes on a span JSON. - * If an attribute already exists, it will not be overwritten. - */ -export function safeSetSpanJSONAttributes( - spanJSON: StreamedSpanJSON, - newAttributes: RawAttributes>, -): void { - const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); - - Object.entries(newAttributes).forEach(([key, value]) => { - if (value != null && !(key in originalAttributes)) { - originalAttributes[key] = value; - } - }); -} - // OTel SpanKind values (numeric to avoid importing from @opentelemetry/api) const SPAN_KIND_SERVER = 1; const SPAN_KIND_CLIENT = 2; @@ -241,21 +242,55 @@ function inferHttpSpanData( return; } - // Only overwrite the span name when we have an explicit http.route — it's more specific than - // what OTel instrumentation sets as the span name. For all other cases (url.full, http.target), - // the OTel-set name is already good enough and we'd risk producing a worse name (e.g. full URL). const httpRoute = attributes['http.route']; if (typeof httpRoute === 'string') { spanJSON.name = `${httpMethod} ${httpRoute}`; safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route' }); } else { - // Fallback: set source to 'url' for HTTP spans without a route. - // The spec requires sentry.span.source on segment spans, and the non-streamed exporter - // always sets this — so we need to ensure it's present for streamed spans too. + // Infer span name from URL attributes, matching the non-streamed exporter's behavior. + // Only overwrite the name for OTel spans (known spanKind) + if (spanKind === SPAN_KIND_CLIENT || spanKind === SPAN_KIND_SERVER) { + const urlPath = getUrlPath(attributes, spanKind); + if (urlPath) { + spanJSON.name = `${httpMethod} ${urlPath}`; + } + } safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }); } } +/** + * Extract a URL path from span attributes for use in the span name. + * Mirrors the logic in the non-streamed exporter's `getSanitizedUrl`. + */ +function getUrlPath( + attributes: RawAttributes>, + spanKind: number | undefined, +): string | undefined { + const httpUrl = attributes['http.url'] || attributes['url.full']; + const httpTarget = attributes['http.target']; + + const parsedUrl = typeof httpUrl === 'string' ? parseUrl(httpUrl) : undefined; + const sanitizedUrl = parsedUrl ? getSanitizedUrlString(parsedUrl) : undefined; + + // For server spans, prefer the relative target path + if (spanKind === SPAN_KIND_SERVER && typeof httpTarget === 'string') { + return stripUrlQueryAndFragment(httpTarget); + } + + // For client spans (and others), use the full sanitized URL + if (sanitizedUrl) { + return sanitizedUrl; + } + + // Fall back to target if no full URL is available + if (typeof httpTarget === 'string') { + return stripUrlQueryAndFragment(httpTarget); + } + + return undefined; +} + function inferDbSpanData(spanJSON: StreamedSpanJSON, attributes: RawAttributes>): void { safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }); diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 45379866d56e..297687d40dcc 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -36,7 +36,7 @@ import { SPAN_STATUS_ERROR } from './spanstatus'; import { setCapturedScopesOnSpan } from './utils'; import type { Client } from '../client'; -const SUPPRESS_TRACING_KEY = '__SENTRY_SUPPRESS_TRACING__'; +export const SUPPRESS_TRACING_KEY = '__SENTRY_SUPPRESS_TRACING__'; /** * Wraps a function with a transaction/span and finishes the span after the function is done. diff --git a/packages/core/src/tracing/vercel-ai/constants.ts b/packages/core/src/tracing/vercel-ai/constants.ts index f1d9d3168e84..27c2b901554d 100644 --- a/packages/core/src/tracing/vercel-ai/constants.ts +++ b/packages/core/src/tracing/vercel-ai/constants.ts @@ -5,6 +5,13 @@ import type { ToolCallSpanContext } from './types'; // without keeping full Span objects (and their potentially large attributes) alive. export const toolCallSpanContextMap = new Map(); +// Used to make tool descriptions available to execute_tool spans in the span streaming path. +// Streamed spans are processed individually, so execute_tool spans cannot look up descriptions +// from their sibling doGenerate span on span end (as we do for transactions). +// Instead we store descriptions at spanStart and apply them in the processSpan hook. +// Stores parent_span_id -> Map +export const toolDescriptionMap = new Map>(); + /** Maps Vercel AI span names to standardized OpenTelemetry operation names. */ export const SPAN_TO_OPERATION_NAME = new Map([ ['ai.generateText', 'invoke_agent'], diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 55b53c362612..c6ff4c784dde 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -4,7 +4,7 @@ import { getClient } from '../../currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { shouldEnableTruncation } from '../ai/utils'; import type { Event } from '../../types-hoist/event'; -import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON } from '../../types-hoist/span'; +import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON, StreamedSpanJSON } from '../../types-hoist/span'; import { spanToJSON } from '../../utils/spanUtils'; import { GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE, @@ -14,6 +14,7 @@ import { GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, GEN_AI_TOOL_CALL_ID_ATTRIBUTE, + GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE, GEN_AI_TOOL_INPUT_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE, GEN_AI_TOOL_OUTPUT_ATTRIBUTE, @@ -24,8 +25,9 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { SPAN_TO_OPERATION_NAME, toolCallSpanContextMap } from './constants'; +import { SPAN_TO_OPERATION_NAME, toolCallSpanContextMap, toolDescriptionMap } from './constants'; import type { TokenSummary } from './types'; +import { hasSpanStreamingEnabled } from '../spans/hasSpanStreamingEnabled'; import { accumulateTokensForParent, applyAccumulatedTokens, @@ -233,19 +235,12 @@ function buildOutputMessages(attributes: Record): void { /** * Post-process spans emitted by the Vercel AI SDK. */ -function processEndedVercelAiSpan(span: SpanJSON): void { - const { data: attributes, origin } = span; - - if (origin !== 'auto.vercelai.otel') { - return; - } - - // The Vercel AI SDK sets span status to raw error message strings. - // Any such value should be normalized to a SpanStatusType value. We pick internal_error as it is the most generic. - if (span.status && span.status !== 'ok') { - span.status = 'internal_error'; - } - +/** + * Rename and normalize Vercel AI SDK attributes to OpenTelemetry semantic conventions. + * This is the shared attribute processing logic used by both the legacy event processor + * path (SpanJSON) and the streamed span path (StreamedSpanJSON). + */ +export function processVercelAiSpanAttributes(attributes: Record): void { renameAttributeKey(attributes, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE); renameAttributeKey(attributes, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE); renameAttributeKey(attributes, AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE); @@ -338,6 +333,49 @@ function processEndedVercelAiSpan(span: SpanJSON): void { } } +function processEndedVercelAiSpan(span: SpanJSON): void { + const { data: attributes, origin } = span; + + if (origin !== 'auto.vercelai.otel') { + return; + } + + // The Vercel AI SDK sets span status to raw error message strings. + // Any such value should be normalized to a SpanStatusType value. We pick internal_error as it is the most generic. + if (span.status && span.status !== 'ok') { + span.status = 'internal_error'; + } + + processVercelAiSpanAttributes(attributes); +} + +function processVercelAiStreamedSpan(span: StreamedSpanJSON): void { + const attributes = span.attributes; + if (attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] !== 'auto.vercelai.otel') { + return; + } + + processVercelAiSpanAttributes(attributes); + + // Look up tool description from the toolDescriptionMap for execute_tool spans + if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'gen_ai.execute_tool' && span.parent_span_id) { + const descriptions = toolDescriptionMap.get(span.parent_span_id); + + if (descriptions) { + const toolName = attributes[GEN_AI_TOOL_NAME_ATTRIBUTE]; + if (typeof toolName === 'string') { + const desc = descriptions.get(toolName); + if (desc) { + attributes[GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE] = desc; + } + } + } + } + + // Clean up tool descriptions when the parent span ends + toolDescriptionMap.delete(span.span_id); +} + /** * Renames an attribute key in the provided attributes object if the old key exists. * This function safely handles null and undefined values. @@ -418,6 +456,41 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute if (modelId && operationName) { span.updateName(`${operationName} ${modelId}`); } + + // Store tool descriptions in the toolDescriptionMap so processSpan can apply them to execute_tool spans. + // This is only needed for span streaming (transaction path handles this separately) + const client = getClient(); + if ( + client && + hasSpanStreamingEnabled(client) && + attributes[AI_PROMPT_TOOLS_ATTRIBUTE] && + Array.isArray(attributes[AI_PROMPT_TOOLS_ATTRIBUTE]) + ) { + const descriptions = new Map(); + + // parse tool names and descriptions from tool string array + for (const toolStr of attributes[AI_PROMPT_TOOLS_ATTRIBUTE] as unknown[]) { + try { + const parsed = (typeof toolStr === 'string' ? JSON.parse(toolStr) : toolStr) as { + name?: string; + description?: string; + }; + if (parsed?.name && parsed?.description) { + descriptions.set(parsed.name, parsed.description); + } + } catch { + // ignore parse errors + } + } + if (descriptions.size > 0) { + // Tool call spans are siblings of doGenerate (both children of invoke_agent), + // so we key by the parent span ID (the invoke_agent span). + const parentSpanId = spanToJSON(span).parent_span_id; + if (parentSpanId) { + toolDescriptionMap.set(parentSpanId, descriptions); + } + } + } } /** @@ -427,9 +500,12 @@ export function addVercelAiProcessors(client: Client): void { client.on('spanStart', onVercelAiSpanStart); // Note: We cannot do this on `spanEnd`, because the span cannot be mutated anymore at this point client.addEventProcessor(Object.assign(vercelAiEventProcessor, { id: 'VercelAiEventProcessor' })); + client.on('processSpan', span => { + processVercelAiStreamedSpan(span); + }); } -function addProviderMetadataToAttributes(attributes: SpanAttributes): void { +function addProviderMetadataToAttributes(attributes: Record): void { const providerMetadata = attributes[AI_RESPONSE_PROVIDER_METADATA_ATTRIBUTE] as string | undefined; if (providerMetadata) { try { @@ -506,7 +582,11 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { /** * Sets an attribute only if the value is not null or undefined. */ -function setAttributeIfDefined(attributes: SpanAttributes, key: string, value: SpanAttributeValue | undefined): void { +function setAttributeIfDefined( + attributes: Record, + key: string, + value: SpanAttributeValue | undefined, +): void { if (value != null) { attributes[key] = value; } diff --git a/packages/core/src/types-hoist/log.ts b/packages/core/src/types-hoist/log.ts index 7c704d3caf77..0f84ebbcbdda 100644 --- a/packages/core/src/types-hoist/log.ts +++ b/packages/core/src/types-hoist/log.ts @@ -64,5 +64,10 @@ export interface SerializedLog { } export type SerializedLogContainer = { + version?: number; + ingest_settings?: { + infer_ip?: 'auto' | 'never'; + infer_user_agent?: 'auto' | 'never'; + }; items: Array; }; diff --git a/packages/core/src/types-hoist/metric.ts b/packages/core/src/types-hoist/metric.ts index 976fc9fe863f..1b6380cbc471 100644 --- a/packages/core/src/types-hoist/metric.ts +++ b/packages/core/src/types-hoist/metric.ts @@ -77,5 +77,10 @@ export interface SerializedMetric { } export type SerializedMetricContainer = { + version?: number; + ingest_settings?: { + infer_ip?: 'auto' | 'never'; + infer_user_agent?: 'auto' | 'never'; + }; items: Array; }; diff --git a/packages/core/src/types-hoist/webfetchapi.ts b/packages/core/src/types-hoist/webfetchapi.ts index 78b7d464ea71..a944356182b8 100644 --- a/packages/core/src/types-hoist/webfetchapi.ts +++ b/packages/core/src/types-hoist/webfetchapi.ts @@ -13,5 +13,7 @@ export interface WebFetchRequest { readonly headers: WebFetchHeaders; readonly method: string; readonly url: string; + readonly body?: unknown; clone(): WebFetchRequest; + text(): Promise; } diff --git a/packages/core/src/utils/baggage.ts b/packages/core/src/utils/baggage.ts index 9f4f85313951..20b3aa65a81c 100644 --- a/packages/core/src/utils/baggage.ts +++ b/packages/core/src/utils/baggage.ts @@ -166,3 +166,75 @@ export function objectToBaggageHeader(object: Record): string | } }, ''); } + +/** + * Merge two baggage headers into one. + * - Sentry-specific entries (keys starting with "sentry-") from the new + * baggage take precedence + * - Non-Sentry entries from existing baggage take precedence + * + * The order of the existing baggage will be preserved, and new entries will + * be added to the end. + * + * This matches the behavior of OTEL's propagation.inject() which uses + * `baggage.setEntry()` to overwrite existing entries with the same key. + */ +export function mergeBaggageHeaders( + existing: Existing, + incoming: string, +): string | undefined | Existing { + if (!existing) { + return incoming; + } + + const existingEntries = parseBaggageHeader(existing); + const incomingEntries = parseBaggageHeader(incoming); + + if (!incomingEntries) { + return existing; + } + + // 1. All non-sentry entries from existing are kept + // 2. All sentry- entries from the new baggage are retained + // 3. If sentry- entries present in new, ignore from old, else keep from old. + // 4. Non-sentry entries from new are only kept if not in existing. + + const merged: Record = {}; + + // partition incoming entries into sentry and non-sentry prefixed + let hasNewSentryEntries = false; + const newSentryEntries: Record = {}; + const newNonSentryEntries: Record = {}; + for (const [key, value] of Object.entries(incomingEntries)) { + if (key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { + newSentryEntries[key] = value; + hasNewSentryEntries = true; + } else { + newNonSentryEntries[key] = value; + } + } + + // If new baggage contains at least one sentry- value, we remove all old + // sentry- values otherwise, we keep old sentry- values. If we don't remove + // old sentry- values, we end up with an inconsistent dynamic sampling + // context propagation. + if (existingEntries) { + for (const [key, value] of Object.entries(existingEntries)) { + if (!hasNewSentryEntries || !key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { + merged[key] = value; + } + } + } + + // Assign new sentry fields. + if (hasNewSentryEntries) { + Object.assign(merged, newSentryEntries); + } + + // assign new non-sentry fields not found on existing object. + for (const [key, value] of Object.entries(newNonSentryEntries)) { + merged[key] ??= value; + } + + return objectToBaggageHeader(merged); +} diff --git a/packages/core/src/utils/chain-and-copy-promiselike.ts b/packages/core/src/utils/chain-and-copy-promiselike.ts index 4d8db088d22e..ea04d77e015e 100644 --- a/packages/core/src/utils/chain-and-copy-promiselike.ts +++ b/packages/core/src/utils/chain-and-copy-promiselike.ts @@ -32,6 +32,7 @@ export const chainAndCopyPromiseLike = >( // eslint-disable-next-line @typescript-eslint/no-explicit-any const copyProps = >(original: T, chained: T): T => { + if (!chained) return original; let mutated = false; //oxlint-disable-next-line guard-for-in for (const key in original) { diff --git a/packages/core/src/utils/get-default-export.ts b/packages/core/src/utils/get-default-export.ts new file mode 100644 index 000000000000..4e406219562f --- /dev/null +++ b/packages/core/src/utils/get-default-export.ts @@ -0,0 +1,27 @@ +/** + * Often we patch a module's default export, but we want to be able to do + * something like this: + * + * ```ts + * patchTheThing(await import('the-thing')); + * ``` + * + * Or like this: + * + * ```ts + * import theThing from 'the-thing'; + * patchTheThing(theThing); + * ``` + * + * Note: this does not support modules with a falsey default export. However, + * presumably in those cases, there's no default export to patch anyway. + */ +export function getDefaultExport(moduleExport: T | { default: T }): T { + return ( + (!!moduleExport && + typeof moduleExport === 'object' && + 'default' in moduleExport && + (moduleExport as { default: T }).default) || + (moduleExport as T) + ); +} diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 3f7477e6459b..700f0272f121 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -1,6 +1,42 @@ +/* eslint-disable max-lines-per-function */ +import { DEBUG_BUILD } from '../debug-build'; +import type { Scope } from '../scope'; import type { PolymorphicRequest } from '../types-hoist/polymorphics'; import type { RequestEventData } from '../types-hoist/request'; import type { WebFetchHeaders, WebFetchRequest } from '../types-hoist/webfetchapi'; +import { debug } from './debug-logger'; +import { safeUnref } from './timer'; + +/** + * Maximum size of incoming HTTP request bodies attached to events. + * + * - `'none'`: No request bodies will be attached + * - `'small'`: Request bodies up to 1,000 bytes will be attached + * - `'medium'`: Request bodies up to 10,000 bytes will be attached + * - `'always'`: Request bodies will always be attached (up to 1MB hard cap) + */ +export type MaxRequestBodySize = 'none' | 'small' | 'medium' | 'always'; + +/** Hard cap on captured body size, even when `maxRequestBodySize` is `'always'`. */ +export const MAX_BODY_BYTE_LENGTH = 1_024 * 1_024; + +/** Content types that are safe to capture as text. */ +const TEXT_CONTENT_TYPES = [ + 'text/', + 'application/json', + 'application/x-www-form-urlencoded', + 'application/xml', + 'application/graphql', +]; + +/** + * Convert a `maxRequestBodySize` setting to a maximum byte length. + */ +export function getMaxBodyByteLength(maxRequestBodySize: Exclude): number { + if (maxRequestBodySize === 'small') return 1_000; + if (maxRequestBodySize === 'medium') return 10_000; + return MAX_BODY_BYTE_LENGTH; +} /** * Transforms a `Headers` object that implements the `Web Fetch API` (https://developer.mozilla.org/en-US/docs/Web/API/Headers) into a simple key-value dict. @@ -56,6 +92,96 @@ export function winterCGRequestToRequestData(req: WebFetchRequest): RequestEvent }; } +/** + * Checks if the content type is textual and safe to capture. + */ +function isTextualContentType(contentType: string | null): boolean { + if (!contentType) { + return false; + } + const lowerContentType = contentType.toLowerCase(); + return TEXT_CONTENT_TYPES.some(type => lowerContentType.includes(type)); +} + +/** + * Captures the body from a Web Fetch API Request and adds it to the isolation scope. + * + * This function clones the request to read the body without affecting the original. + * Only textual content types are captured - binary data is skipped. + * + * This is used by WinterCG-compatible runtimes (Cloudflare Workers, Deno, Bun, Vercel Edge, etc.) + * that use the Web Fetch API Request object. + * + * @param request - The incoming Web Fetch API Request + * @param isolationScope - The isolation scope to add the body to + * @param maxRequestBodySize - The maximum size of the request body to capture ('small' = 1KB, 'medium' = 10KB, 'always' = 1MB) + */ +export async function captureBodyFromWinterCGRequest( + request: WebFetchRequest, + isolationScope: Scope, + maxRequestBodySize: Exclude, +): Promise { + try { + const contentType = request.headers.get('content-type'); + + if (!isTextualContentType(contentType)) { + DEBUG_BUILD && debug.log('Skipping body capture for non-textual content type:', contentType); + return; + } + + if (!request.body) { + return; + } + + const contentLength = request.headers.get('content-length'); + const maxBodySize = getMaxBodyByteLength(maxRequestBodySize); + + if (contentLength) { + const length = parseInt(contentLength, 10); + if (!isNaN(length) && length > MAX_BODY_BYTE_LENGTH) { + DEBUG_BUILD && debug.log('Skipping body capture: body too large', length); + return; + } + } + + const clonedRequest = request.clone(); + const bodyPromise = clonedRequest.text(); + const timeoutPromise = new Promise(resolve => { + safeUnref(setTimeout(() => resolve(null), 2000)); + }); + + const body = await Promise.race([bodyPromise, timeoutPromise]); + + if (body === null) { + DEBUG_BUILD && debug.log('Timeout reading request body'); + return; + } + + if (!body) { + return; + } + + // Using TextEncoder to get byte length for UTF-8 strings + const encoder = new TextEncoder(); + const bytes = encoder.encode(body); + const bodyByteLength = bytes.length; + + let truncatedBody: string; + if (bodyByteLength > maxBodySize) { + const decoder = new TextDecoder(); + truncatedBody = `${decoder.decode(bytes.slice(0, maxBodySize - 3))}...`; + } else { + truncatedBody = body; + } + + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } }); + + DEBUG_BUILD && debug.log('Captured request body:', bodyByteLength, 'bytes'); + } catch (error) { + DEBUG_BUILD && debug.error('Error capturing request body:', error); + } +} + /** * Convert a HTTP request object to RequestEventData to be passed as normalizedRequest. * Instead of allowing `PolymorphicRequest` to be passed, diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 248f0d89dfba..88a6bceb6a3e 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -328,7 +328,7 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef } /** - * Convert the various statuses to the simple onces expected by Sentry for steamed spans ('ok' is default). + * Convert the various statuses to the simple ones expected by Sentry for streamed spans ('ok' is default). */ export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | 'error' { return !status || diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 1548a4aecce4..a8971498cef8 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -3115,6 +3115,29 @@ describe('Client', () => { safeUnrefSpy.mockRestore(); }); + + it('flush() drains the log buffer when client has no transport', async () => { + // Client without DSN — _transport is undefined + const options = getDefaultTestClientOptions({ + enableLogs: true, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const flushLogsHandler = vi.fn(); + client.on('flushLogs', flushLogsHandler); + + // Capture a log which starts the weight-based flush timer + _INTERNAL_captureLog({ message: 'test log', level: 'info' }, scope); + + expect(flushLogsHandler).not.toHaveBeenCalled(); + + // flush() should drain the buffer (and clear the timer) even without a transport + await client.flush(); + + expect(flushLogsHandler).toHaveBeenCalledTimes(1); + }); }); describe('metric weight-based flushing', () => { @@ -3201,6 +3224,27 @@ describe('Client', () => { safeUnrefSpy.mockRestore(); }); + + it('flush() drains the metric buffer when client has no transport', async () => { + // Client without DSN — _transport is undefined + const options = getDefaultTestClientOptions({}); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const flushMetricsHandler = vi.fn(); + client.on('flushMetrics', flushMetricsHandler); + + // Capture a metric which starts the weight-based flush timer + _INTERNAL_captureMetric({ name: 'test_metric', value: 42, type: 'counter', attributes: {} }, { scope }); + + expect(flushMetricsHandler).not.toHaveBeenCalled(); + + // flush() should drain the buffer (and clear the timer) even without a transport + await client.flush(); + + expect(flushMetricsHandler).toHaveBeenCalledTimes(1); + }); }); describe('promise buffer usage', () => { diff --git a/packages/core/test/lib/integrations/express/patch-layer.test.ts b/packages/core/test/lib/integrations/express/patch-layer.test.ts index 8953955ee373..5b9ba443aa84 100644 --- a/packages/core/test/lib/integrations/express/patch-layer.test.ts +++ b/packages/core/test/lib/integrations/express/patch-layer.test.ts @@ -11,11 +11,12 @@ import { type Span } from '../../../../src/types-hoist/span'; import { EventEmitter } from 'node:events'; import { getOriginalFunction, markFunctionWrapped } from '../../../../src'; -let DEBUG_BUILD = false; +// must be var to hoist above vi.mock +var DEBUG_BUILD = true; beforeEach(() => (DEBUG_BUILD = true)); vi.mock('../../../../src/debug-build', () => ({ get DEBUG_BUILD() { - return DEBUG_BUILD; + return DEBUG_BUILD ?? true; }, })); diff --git a/packages/core/test/lib/integrations/express/utils.test.ts b/packages/core/test/lib/integrations/express/utils.test.ts index b41d89076900..a7ec32d96e8d 100644 --- a/packages/core/test/lib/integrations/express/utils.test.ts +++ b/packages/core/test/lib/integrations/express/utils.test.ts @@ -15,7 +15,6 @@ import { getLayerMetadata, getLayerPath, getRouterPath, - hasDefaultProp, isExpressWithoutRouterPrototype, isExpressWithRouterPrototype, isLayerIgnored, @@ -368,14 +367,6 @@ describe('getConstructedRoute', () => { }); }); -describe('hasDefaultProp', () => { - it('returns detects the presence of a default function prop', () => { - expect(hasDefaultProp({ default: function express() {} })).toBe(true); - expect(hasDefaultProp({ default: 'other thing' })).toBe(false); - expect(hasDefaultProp({})).toBe(false); - }); -}); - describe('isExpressWith(out)RouterPrototype', () => { it('detects what kind of express this is', () => { expect(isExpressWithoutRouterPrototype({})).toBe(false); diff --git a/packages/core/test/lib/integrations/http/add-outgoing-request-breadcrumb.test.ts b/packages/core/test/lib/integrations/http/add-outgoing-request-breadcrumb.test.ts new file mode 100644 index 000000000000..8ed6a1f3d660 --- /dev/null +++ b/packages/core/test/lib/integrations/http/add-outgoing-request-breadcrumb.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as breadcrumbsModule from '../../../../src/breadcrumbs'; +import { addOutgoingRequestBreadcrumb } from '../../../../src/integrations/http/add-outgoing-request-breadcrumb'; +import type { HttpClientRequest, HttpIncomingMessage } from '../../../../src/integrations/http/types'; + +function makeMockRequest(overrides: Partial> = {}): HttpClientRequest { + return { + method: 'GET', + path: '/api/test', + host: 'example.com', + protocol: 'http:', + port: 80, + getHeader: vi.fn(() => undefined), + getHeaders: vi.fn(() => ({})), + setHeader: vi.fn(), + removeHeader: vi.fn(), + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + prependListener: vi.fn(), + listenerCount: vi.fn(() => 0), + removeListener: vi.fn(), + ...overrides, + } as unknown as HttpClientRequest; +} + +function makeMockResponse(overrides: Partial = {}): HttpIncomingMessage { + return { + statusCode: 200, + statusMessage: 'OK', + httpVersion: '1.1', + headers: {}, + resume: vi.fn(), + on: vi.fn(), + addListener: vi.fn(), + off: vi.fn(), + removeListener: vi.fn(), + ...overrides, + } as unknown as HttpIncomingMessage; +} + +describe('addOutgoingRequestBreadcrumb', () => { + beforeEach(() => { + vi.spyOn(breadcrumbsModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('adds a breadcrumb with category "http" and type "http"', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse()); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledOnce(); + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ category: 'http', type: 'http' }), + expect.anything(), + ); + }); + + it('includes sanitized URL, method, and status_code in data', () => { + const request = makeMockRequest({ method: 'POST' }); + const response = makeMockResponse({ statusCode: 201 }); + + addOutgoingRequestBreadcrumb(request, response); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + url: 'http://example.com/api/test', + 'http.method': 'POST', + status_code: 201, + }), + }), + expect.anything(), + ); + }); + + it('includes http.query when the URL has a query string', () => { + addOutgoingRequestBreadcrumb(makeMockRequest({ path: '/api/test?foo=bar' }), makeMockResponse()); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ 'http.query': '?foo=bar' }), + }), + expect.anything(), + ); + // The main URL in data.url should not contain the query string + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.data?.url).not.toContain('foo=bar'); + }); + + it('does not include http.query when the URL has no query string', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse()); + + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.data).not.toHaveProperty('http.query'); + }); + + it('does not include http.fragment by default', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse()); + + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.data).not.toHaveProperty('http.fragment'); + }); + + it('sets level to "warning" for 4xx status codes', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse({ statusCode: 404 })); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ level: 'warning' }), + expect.anything(), + ); + }); + + it('sets level to "error" for 5xx status codes', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse({ statusCode: 500 })); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ level: 'error' }), + expect.anything(), + ); + }); + + it('does not set level for 2xx status codes', () => { + addOutgoingRequestBreadcrumb(makeMockRequest(), makeMockResponse({ statusCode: 200 })); + + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.level).toBeUndefined(); + }); + + it('passes hint with event, request, and response', () => { + const request = makeMockRequest(); + const response = makeMockResponse(); + + addOutgoingRequestBreadcrumb(request, response); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith(expect.anything(), { + event: 'response', + request, + response, + }); + }); + + it('handles undefined response (network error)', () => { + const request = makeMockRequest(); + + addOutgoingRequestBreadcrumb(request, undefined); + + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledOnce(); + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.data?.status_code).toBeUndefined(); + expect(callArg.level).toBeUndefined(); + expect(breadcrumbsModule.addBreadcrumb).toHaveBeenCalledWith(expect.anything(), { + event: 'response', + request, + response: undefined, + }); + }); + + it('defaults method to "GET" when request.method is undefined', () => { + addOutgoingRequestBreadcrumb(makeMockRequest({ method: undefined }), makeMockResponse()); + + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; + expect(callArg.data?.['http.method']).toBe('GET'); + }); +}); diff --git a/packages/core/test/lib/integrations/http/client-patch.test.ts b/packages/core/test/lib/integrations/http/client-patch.test.ts new file mode 100644 index 000000000000..4c50c46b61c2 --- /dev/null +++ b/packages/core/test/lib/integrations/http/client-patch.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HTTP_ON_CLIENT_REQUEST } from '../../../../src/integrations/http/constants'; +import { patchHttpModuleClient } from '../../../../src/integrations/http/client-patch'; +import type { HttpClientRequest, HttpExport } from '../../../../src/integrations/http/types'; +import { getOriginalFunction } from '../../../../src/utils/object'; + +const mockClientRequestHandler = vi.fn(); + +vi.mock('../../../../src/integrations/http/client-subscriptions', () => ({ + getHttpClientSubscriptions: vi.fn(() => ({ + [HTTP_ON_CLIENT_REQUEST]: mockClientRequestHandler, + })), +})); + +function makeMockClientRequest(): HttpClientRequest { + return { + method: 'GET', + path: '/api/test', + host: 'example.com', + protocol: 'http:', + port: 80, + end: vi.fn(), + getHeader: vi.fn(() => undefined), + getHeaders: vi.fn(() => ({})), + setHeader: vi.fn(), + removeHeader: vi.fn(), + on: vi.fn(), + once: vi.fn(), + prependListener: vi.fn(), + listenerCount: vi.fn(() => 0), + removeListener: vi.fn(), + } as unknown as HttpClientRequest; +} + +function makeMockHttpModule(): HttpExport & { + request: ReturnType; + get: ReturnType; +} { + const mockClientReq = makeMockClientRequest(); + const request = vi.fn(() => mockClientReq); + const get = vi.fn(() => mockClientReq); + return { request, get }; +} + +describe('patchHttpModuleClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('replaces request with a wrapped version', () => { + const httpModule = makeMockHttpModule(); + const originalRequest = httpModule.request; + + patchHttpModuleClient(httpModule); + + expect(httpModule.request).not.toBe(originalRequest); + }); + + it('preserves the original function via __sentry_original__', () => { + const httpModule = makeMockHttpModule(); + const originalRequest = httpModule.request; + + patchHttpModuleClient(httpModule); + + expect(getOriginalFunction(httpModule.request)).toBe(originalRequest); + }); + + it('still calls the original request when the patched one is invoked', () => { + const httpModule = makeMockHttpModule(); + const originalRequest = httpModule.request; + + patchHttpModuleClient(httpModule); + httpModule.request('http://example.com/'); + + expect(originalRequest).toHaveBeenCalledOnce(); + }); + + it('returns the result of the original request', () => { + const httpModule = makeMockHttpModule(); + + patchHttpModuleClient(httpModule); + const result = httpModule.request('http://example.com/'); + + expect(result).toBeDefined(); + }); + + it('invokes the subscription handler after each request', () => { + const httpModule = makeMockHttpModule(); + + patchHttpModuleClient(httpModule); + httpModule.request('http://example.com/'); + + expect(mockClientRequestHandler).toHaveBeenCalledOnce(); + expect(mockClientRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ request: expect.any(Object) }), + HTTP_ON_CLIENT_REQUEST, + ); + }); + + it('wraps get to call .end() on the returned request automatically', () => { + const httpModule = makeMockHttpModule(); + const mockReq = makeMockClientRequest(); + httpModule.request = vi.fn(() => mockReq); + + patchHttpModuleClient(httpModule); + httpModule.get('http://example.com/'); + + expect(mockReq.end).toHaveBeenCalledOnce(); + }); + + it('is idempotent — patching a second time does not re-wrap', () => { + const httpModule = makeMockHttpModule(); + + patchHttpModuleClient(httpModule); + const wrappedRequest = httpModule.request; + + patchHttpModuleClient(httpModule); + + expect(httpModule.request).toBe(wrappedRequest); + }); + + it('handles CJS default export — patches the default and copies back to the container', () => { + const httpDefault = makeMockHttpModule(); + const httpModule: HttpExport & { default: HttpExport } = { + ...httpDefault, + default: httpDefault, + }; + const originalRequest = httpDefault.request; + + patchHttpModuleClient(httpModule); + + // The default export's request is now wrapped + expect(getOriginalFunction(httpDefault.request)).toBe(originalRequest); + // The module container's request descriptor was copied from the default + expect(httpModule.request).toBe(httpDefault.request); + }); +}); diff --git a/packages/core/test/lib/integrations/http/client-subscriptions.test.ts b/packages/core/test/lib/integrations/http/client-subscriptions.test.ts new file mode 100644 index 000000000000..46b486368cdc --- /dev/null +++ b/packages/core/test/lib/integrations/http/client-subscriptions.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as breadcrumbModule from '../../../../src/integrations/http/add-outgoing-request-breadcrumb'; +import { HTTP_ON_CLIENT_REQUEST } from '../../../../src/integrations/http/constants'; +import { getHttpClientSubscriptions } from '../../../../src/integrations/http/client-subscriptions'; +import type { HttpClientRequest, HttpIncomingMessage } from '../../../../src/integrations/http/types'; +import { SUPPRESS_TRACING_KEY } from '../../../../src/tracing'; +import { getCurrentScope, withScope } from '../../../../src/currentScopes'; + +function makeMockRequest(): HttpClientRequest & { + _responseListeners: ((res: HttpIncomingMessage) => void)[]; +} { + const responseListeners: ((res: HttpIncomingMessage) => void)[] = []; + return { + method: 'GET', + path: '/test', + host: 'example.com', + protocol: 'http:', + port: 80, + getHeader: () => undefined, + getHeaders: () => ({}), + setHeader: vi.fn(), + removeHeader: vi.fn(), + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + prependListener: vi.fn((_event: string, fn: (...args: unknown[]) => void) => { + responseListeners.push(fn as (res: HttpIncomingMessage) => void); + }), + listenerCount: () => 0, + removeListener: vi.fn(), + _responseListeners: responseListeners, + } as unknown as HttpClientRequest & { _responseListeners: ((res: HttpIncomingMessage) => void)[] }; +} + +function makeMockResponse(): HttpIncomingMessage & { _endListeners: (() => void)[] } { + const endListeners: (() => void)[] = []; + return { + statusCode: 200, + statusMessage: 'OK', + httpVersion: '1.1', + headers: {}, + resume: vi.fn(), + on: vi.fn((_event: string, fn: (...args: unknown[]) => void) => { + if (_event === 'end') endListeners.push(fn as () => void); + }), + addListener: vi.fn(), + off: vi.fn(), + removeListener: vi.fn(), + _endListeners: endListeners, + } as unknown as HttpIncomingMessage & { _endListeners: (() => void)[] }; +} + +describe('getHttpClientSubscriptions', () => { + afterEach(() => { + vi.restoreAllMocks(); + getCurrentScope().setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: undefined }); + }); + + describe('suppressTracing', () => { + it('does not add breadcrumbs when suppressTracing is active', () => { + const spy = vi.spyOn(breadcrumbModule, 'addOutgoingRequestBreadcrumb'); + const subscriptions = getHttpClientSubscriptions({ breadcrumbs: true, spans: false }); + const handler = subscriptions[HTTP_ON_CLIENT_REQUEST]; + + withScope(scope => { + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); + + const request = makeMockRequest(); + handler({ request }, HTTP_ON_CLIENT_REQUEST); + + // no response listeners should have been registered + expect(request._responseListeners).toHaveLength(0); + + // simulate a response completing anyway — breadcrumb must still not fire + const response = makeMockResponse(); + request._responseListeners.forEach(fn => fn(response)); + response._endListeners.forEach(fn => fn()); + }); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('does not propagate trace headers when suppressTracing is active', () => { + const subscriptions = getHttpClientSubscriptions({ breadcrumbs: false, spans: false, propagateTrace: true }); + const handler = subscriptions[HTTP_ON_CLIENT_REQUEST]; + + withScope(scope => { + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); + + const request = makeMockRequest(); + handler({ request }, HTTP_ON_CLIENT_REQUEST); + + expect(request.setHeader).not.toHaveBeenCalled(); + }); + }); + + it('still adds breadcrumbs when suppressTracing is NOT active', () => { + const spy = vi.spyOn(breadcrumbModule, 'addOutgoingRequestBreadcrumb'); + const subscriptions = getHttpClientSubscriptions({ breadcrumbs: true, spans: false }); + const handler = subscriptions[HTTP_ON_CLIENT_REQUEST]; + + const request = makeMockRequest(); + handler({ request }, HTTP_ON_CLIENT_REQUEST); + + const response = makeMockResponse(); + request._responseListeners.forEach(fn => fn(response)); + response._endListeners.forEach(fn => fn()); + + expect(spy).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/http/constants.test.ts b/packages/core/test/lib/integrations/http/constants.test.ts new file mode 100644 index 000000000000..6a63e74ba5c6 --- /dev/null +++ b/packages/core/test/lib/integrations/http/constants.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { + HTTP_ON_CLIENT_REQUEST, + HTTP_ON_SERVER_REQUEST, + LOG_PREFIX, +} from '../../../../src/integrations/http/constants'; + +describe('http constants', () => { + it('LOG_PREFIX is the expected string', () => { + expect(LOG_PREFIX).toBe('@sentry/instrumentation-http'); + }); + + it('HTTP_ON_CLIENT_REQUEST is the diagnostics-channel name', () => { + expect(HTTP_ON_CLIENT_REQUEST).toBe('http.client.request.created'); + }); + + it('HTTP_ON_SERVER_REQUEST is the diagnostics-channel name', () => { + expect(HTTP_ON_SERVER_REQUEST).toBe('http.server.request.start'); + }); +}); diff --git a/packages/core/test/lib/integrations/http/double-wrap-warning.test.ts b/packages/core/test/lib/integrations/http/double-wrap-warning.test.ts new file mode 100644 index 000000000000..34108bbe5111 --- /dev/null +++ b/packages/core/test/lib/integrations/http/double-wrap-warning.test.ts @@ -0,0 +1,59 @@ +import { it, expect, describe, vi } from 'vitest'; +import { doubleWrapWarning, warning } from '../../../../src/integrations/http/double-wrap-warning'; +import type { HttpModuleExport } from '../../../../src/integrations/http/types'; + +const DEBUG_WARNS: string[] = []; +vi.mock('../../../../src/utils/debug-logger', () => ({ + debug: { + warn: (msg: string) => { + DEBUG_WARNS.push(msg); + }, + }, +})); + +// must be var, because vi.mock hoists +var debugBuild: boolean = true; +vi.mock(import('../../../../src/debug-build'), () => ({ + get DEBUG_BUILD() { + return debugBuild ?? true; + }, +})); + +describe('doubleWrapWarning', () => { + it('prints no warning if http.request/get not wrapped', () => { + doubleWrapWarning({ + request() {}, + get() {}, + } as unknown as HttpModuleExport); + expect(DEBUG_WARNS).toStrictEqual([]); + }); + + it('prints exactly one warning if http.request/get are wrapped', () => { + doubleWrapWarning({ + request: Object.assign(() => {}, { __unwrap() {} }), + get: Object.assign(() => {}, { __unwrap() {} }), + } as unknown as HttpModuleExport); + doubleWrapWarning({ + request: Object.assign(() => {}, { __unwrap() {} }), + get: Object.assign(() => {}, { __unwrap() {} }), + } as unknown as HttpModuleExport); + doubleWrapWarning({ + request: Object.assign(() => {}, { __unwrap() {} }), + get: Object.assign(() => {}, { __unwrap() {} }), + } as unknown as HttpModuleExport); + expect(DEBUG_WARNS).toStrictEqual([warning]); + DEBUG_WARNS.length = 0; + }); + + it('is a no-op if not in debug mode', async () => { + vi.resetModules(); + debugBuild = false; + const { doubleWrapWarning } = await import('../../../../src/integrations/http/double-wrap-warning'); + doubleWrapWarning({ + request: Object.assign(() => {}, { __unwrap() {} }), + get: Object.assign(() => {}, { __unwrap() {} }), + } as unknown as HttpModuleExport); + expect(DEBUG_WARNS).toStrictEqual([]); + DEBUG_WARNS.length = 0; + }); +}); diff --git a/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts b/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts new file mode 100644 index 000000000000..fa5eddc88f65 --- /dev/null +++ b/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + getOutgoingRequestSpanData, + setIncomingResponseSpanData, +} from '../../../../src/integrations/http/get-outgoing-span-data'; +import type { HttpClientRequest, HttpIncomingMessage } from '../../../../src/integrations/http/types'; +import type { Span } from '../../../../src/types-hoist/span'; + +function makeMockRequest(overrides: Partial> = {}): HttpClientRequest { + return { + method: 'GET', + path: '/api/test', + host: 'example.com', + protocol: 'http:', + port: 80, + getHeader: vi.fn(() => undefined), + getHeaders: vi.fn(() => ({})), + setHeader: vi.fn(), + removeHeader: vi.fn(), + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + prependListener: vi.fn(), + listenerCount: vi.fn(() => 0), + removeListener: vi.fn(), + ...overrides, + } as unknown as HttpClientRequest; +} + +function makeMockResponse(overrides: Partial = {}): HttpIncomingMessage { + return { + statusCode: 200, + statusMessage: 'OK', + httpVersion: '1.1', + headers: {}, + socket: undefined, + resume: vi.fn(), + on: vi.fn(), + addListener: vi.fn(), + off: vi.fn(), + removeListener: vi.fn(), + ...overrides, + } as unknown as HttpIncomingMessage; +} + +describe('getOutgoingRequestSpanData', () => { + it('returns onlyIfParent: true', () => { + const result = getOutgoingRequestSpanData(makeMockRequest()); + expect(result.onlyIfParent).toBe(true); + }); + + it('sets sentry.op to "http.client"', () => { + const result = getOutgoingRequestSpanData(makeMockRequest()); + expect(result.attributes!['sentry.op']).toBe('http.client'); + }); + + it('sets otel.kind to "CLIENT"', () => { + const result = getOutgoingRequestSpanData(makeMockRequest()); + expect(result.attributes!['otel.kind']).toBe('CLIENT'); + }); + + it('builds the span name from method and URL', () => { + const result = getOutgoingRequestSpanData(makeMockRequest({ method: 'POST' })); + expect(result.name).toMatch(/^POST /); + }); + + it('includes http.url, http.method, http.target, net.peer.name', () => { + const result = getOutgoingRequestSpanData(makeMockRequest()); + expect(result.attributes).toMatchObject({ + 'http.url': 'http://example.com/api/test', + 'http.method': 'GET', + 'http.target': '/api/test', + 'net.peer.name': 'example.com', + }); + }); + + it('falls back to "/" for http.target when path is not set', () => { + const result = getOutgoingRequestSpanData(makeMockRequest({ path: undefined })); + expect(result.attributes!['http.target']).toBe('/'); + }); + + it('includes user_agent.original when user-agent header is set', () => { + const request = makeMockRequest({ + getHeader: (name: string) => (name === 'user-agent' ? 'Mozilla/5.0' : undefined), + }); + const result = getOutgoingRequestSpanData(request); + expect(result.attributes!['user_agent.original']).toBe('Mozilla/5.0'); + }); + + it('omits user_agent.original when user-agent header is absent', () => { + const result = getOutgoingRequestSpanData(makeMockRequest()); + expect(result.attributes).not.toHaveProperty('user_agent.original'); + }); + + it('includes non-standard port in the URL', () => { + const result = getOutgoingRequestSpanData(makeMockRequest({ port: 3000 })); + expect(result.attributes!['http.url']).toContain(':3000'); + }); +}); + +describe('setIncomingResponseSpanData', () => { + function makeMockSpan(): Span & { setAttributes: ReturnType } { + return { setAttributes: vi.fn() } as unknown as Span & { setAttributes: ReturnType }; + } + + it('sets http.response.status_code from statusCode', () => { + const span = makeMockSpan(); + setIncomingResponseSpanData(makeMockResponse({ statusCode: 201 }), span); + expect(span.setAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'http.response.status_code': 201 })); + }); + + it('sets network.protocol.version and http.flavor from httpVersion', () => { + const span = makeMockSpan(); + setIncomingResponseSpanData(makeMockResponse({ httpVersion: '2.0' }), span); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ 'network.protocol.version': '2.0', 'http.flavor': '2.0' }), + ); + }); + + it('sets http.status_text from statusMessage', () => { + const span = makeMockSpan(); + setIncomingResponseSpanData(makeMockResponse({ statusMessage: 'Created' }), span); + expect(span.setAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'http.status_text': 'CREATED' })); + }); + + it('uses ip_tcp transport for non-QUIC connections', () => { + const span = makeMockSpan(); + setIncomingResponseSpanData(makeMockResponse({ httpVersion: '1.1' }), span); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ 'network.transport': 'ip_tcp', 'net.transport': 'ip_tcp' }), + ); + }); + + it('uses ip_udp transport for QUIC connections', () => { + const span = makeMockSpan(); + setIncomingResponseSpanData(makeMockResponse({ httpVersion: 'QUIC' }), span); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ 'network.transport': 'ip_udp', 'net.transport': 'ip_udp' }), + ); + }); + + it('includes socket address and port attributes when socket is present', () => { + const span = makeMockSpan(); + const response = makeMockResponse({ + socket: { remoteAddress: '1.2.3.4', remotePort: 12345 }, + }); + setIncomingResponseSpanData(response, span); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'network.peer.address': '1.2.3.4', + 'network.peer.port': 12345, + 'net.peer.ip': '1.2.3.4', + 'net.peer.port': 12345, + }), + ); + }); + + it('includes uncompressed content-length when content-encoding is identity', () => { + const span = makeMockSpan(); + const response = makeMockResponse({ + headers: { 'content-length': '42', 'content-encoding': 'identity' }, + }); + setIncomingResponseSpanData(response, span); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ 'http.response_content_length_uncompressed': 42 }), + ); + }); + + it('includes compressed content-length when content-encoding is gzip', () => { + const span = makeMockSpan(); + const response = makeMockResponse({ + headers: { 'content-length': '100', 'content-encoding': 'gzip' }, + }); + setIncomingResponseSpanData(response, span); + expect(span.setAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'http.response_content_length': 100 })); + }); +}); diff --git a/packages/core/test/lib/integrations/http/get-request-url.test.ts b/packages/core/test/lib/integrations/http/get-request-url.test.ts new file mode 100644 index 000000000000..55bc1ba90f3a --- /dev/null +++ b/packages/core/test/lib/integrations/http/get-request-url.test.ts @@ -0,0 +1,58 @@ +import { it, describe, expect } from 'vitest'; +import { getRequestUrlFromClientRequest, getRequestUrl } from '../../../../src/integrations/http/get-request-url'; +import type { HttpClientRequest, HttpRequestOptions } from '../../../../src/integrations/http/types'; + +describe('getRequestUrl', () => { + it.each([ + [{ protocol: 'http:', hostname: 'localhost', port: 80 }, 'http://localhost/'], + [{ protocol: 'http:', hostname: 'localhost', host: 'localhost:80', port: 80 }, 'http://localhost/'], + [{ protocol: 'http:', hostname: 'localhost', port: 3000 }, 'http://localhost:3000/'], + [{ protocol: 'http:', host: 'localhost:3000', port: 3000 }, 'http://localhost:3000/'], + [{ protocol: 'https:', hostname: 'localhost', port: 443 }, 'https://localhost/'], + [{ protocol: 'https:', hostname: 'localhost', port: 443, path: '/my-path' }, 'https://localhost/my-path'], + [ + { protocol: 'https:', hostname: 'www.example.com', port: 443, path: '/my-path' }, + 'https://www.example.com/my-path', + ], + [{ protocol: 'https:', host: 'www.example.com:443', path: '/my-path' }, 'https://www.example.com/my-path'], + [ + { + protocol: 'https:', + headers: { + host: 'proxy.local', + }, + path: 'http://www.example.com:80/', + }, + 'http://www.example.com/', + ], + [ + { + host: 'proxy.local', + port: 3128, + method: 'GET', + path: 'http://target.example/foo', + }, + 'http://target.example/foo', + ], + [ + { + protocol: 'data:', + host: null, + method: 'GET', + path: 'data:text/plain;hello, world!', + }, + 'data:text/plain;hello, world!', + ], + ])('works with %s', (input: HttpRequestOptions, expected: string | undefined) => { + // pretend to be a client request that option-ifies to this value + const clientRequest = { + ...input, + hostname: undefined, + host: input.hostname ?? input.host, + headers: undefined, + getHeaders: () => input.headers ?? {}, + } as unknown as HttpClientRequest; + expect(String(getRequestUrl(input))).toBe(expected); + expect(getRequestUrlFromClientRequest(clientRequest)).toBe(expected); + }); +}); diff --git a/packages/core/test/lib/integrations/http/inject-trace-propagation-headers.test.ts b/packages/core/test/lib/integrations/http/inject-trace-propagation-headers.test.ts new file mode 100644 index 000000000000..3338d066328f --- /dev/null +++ b/packages/core/test/lib/integrations/http/inject-trace-propagation-headers.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { injectTracePropagationHeaders } from '../../../../src/integrations/http/inject-trace-propagation-headers'; +import type { HttpClientRequest } from '../../../../src/integrations/http/types'; +import { LRUMap } from '../../../../src/utils/lru'; + +const DEFAULT_SENTRY_TRACE = 'aabbccdd-aabbccdd-1'; +const DEFAULT_TRACEPARENT = '00-aabbccdd-aabbccdd-01'; +const DEFAULT_BAGGAGE = 'sentry-trace_id=aabbccdd,sentry-sampled=true'; + +vi.mock('../../../../src/utils/traceData', () => ({ + getTraceData: vi.fn(() => ({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + traceparent: DEFAULT_TRACEPARENT, + baggage: DEFAULT_BAGGAGE, + })), +})); + +vi.mock('../../../../src/currentScopes', () => ({ + getClient: vi.fn(() => ({ + getOptions: vi.fn(() => ({ + tracePropagationTargets: undefined, + })), + })), +})); + +function makeMockRequest(existingHeaders: Record = {}): HttpClientRequest & { + setHeader: ReturnType; +} { + return { + method: 'GET', + path: '/api/test', + host: 'example.com', + protocol: 'http:', + port: 80, + getHeader: vi.fn((name: string) => existingHeaders[name]), + getHeaders: vi.fn(() => existingHeaders), + setHeader: vi.fn((key: string, value: string) => { + existingHeaders[key] = value; + }), + removeHeader: vi.fn(), + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + prependListener: vi.fn(), + listenerCount: vi.fn(() => 0), + removeListener: vi.fn(), + } as unknown as HttpClientRequest & { setHeader: ReturnType }; +} + +describe('injectTracePropagationHeaders', () => { + let propagationDecisionMap: LRUMap; + + beforeEach(() => { + propagationDecisionMap = new LRUMap(100); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('injects sentry-trace, traceparent, and baggage headers', () => { + const request = makeMockRequest(); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).toHaveBeenCalledWith('sentry-trace', DEFAULT_SENTRY_TRACE); + expect(request.setHeader).toHaveBeenCalledWith('traceparent', DEFAULT_TRACEPARENT); + expect(request.setHeader).toHaveBeenCalledWith('baggage', DEFAULT_BAGGAGE); + }); + + it('does not overwrite an existing sentry-trace header', () => { + const request = makeMockRequest({ 'sentry-trace': 'existing-value' }); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).not.toHaveBeenCalledWith('sentry-trace', expect.anything()); + }); + + it('does not overwrite an existing traceparent header', () => { + const request = makeMockRequest({ traceparent: 'existing-parent' }); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).not.toHaveBeenCalledWith('traceparent', expect.anything()); + }); + + it('merges baggage with existing baggage header', () => { + const request = makeMockRequest({ baggage: 'custom=value' }); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + const baggageCall = vi.mocked(request.setHeader).mock.calls.find(c => c[0] === 'baggage'); + expect(baggageCall).toBeDefined(); + const merged = baggageCall![1] as string; + expect(merged).toContain('custom=value'); + expect(merged).toContain(DEFAULT_BAGGAGE); + expect(request.getHeaders()).toStrictEqual({ + baggage: `custom=value,${DEFAULT_BAGGAGE}`, + 'sentry-trace': DEFAULT_SENTRY_TRACE, + traceparent: DEFAULT_TRACEPARENT, + }); + }); + + it('does not inject trace propagation headers when sentry-trace is already present', () => { + const request = makeMockRequest({ + baggage: 'original=value', + 'sentry-trace': 'yyyyyyyy-xxxxxxxx-1', + }); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + const baggageCall = vi.mocked(request.setHeader).mock.calls.find(c => c[0] === 'baggage'); + expect(baggageCall).toBe(undefined); + expect(request.getHeaders()).toStrictEqual({ + baggage: 'original=value', + 'sentry-trace': 'yyyyyyyy-xxxxxxxx-1', + }); + }); + + it('does not inject headers when URL does not match tracePropagationTargets', async () => { + const { getClient } = await import('../../../../src/currentScopes'); + vi.mocked(getClient).mockReturnValue({ + getOptions: vi.fn(() => ({ + tracePropagationTargets: [/^https:\/\/api\.example\.com(?:\/|$)/], + })), + } as any); + + const request = makeMockRequest(); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).not.toHaveBeenCalled(); + }); + + it('does not inject headers when getTraceData returns null', async () => { + const { getTraceData } = await import('../../../../src/utils/traceData'); + vi.mocked(getTraceData).mockReturnValueOnce(null as any); + + const request = makeMockRequest(); + + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).not.toHaveBeenCalled(); + }); + + it('does not inject headers when getClient returns undefined', async () => { + const { getClient } = await import('../../../../src/currentScopes'); + vi.mocked(getClient).mockReturnValueOnce(undefined); + + const request = makeMockRequest(); + + // tracePropagationTargets is undefined → propagation is allowed + // but there is no client, so clientOptions is undefined + // In this case, tracePropagationTargets is undefined → shouldPropagateTraceForUrl returns true + // So headers should still be injected + injectTracePropagationHeaders(request, propagationDecisionMap); + + expect(request.setHeader).toHaveBeenCalledWith('sentry-trace', DEFAULT_SENTRY_TRACE); + }); + + it('caches propagation decisions in the decision map', async () => { + // tracePropagationTargets must be defined for the decision to be cached + const { getClient } = await import('../../../../src/currentScopes'); + vi.mocked(getClient).mockReturnValue({ + getOptions: vi.fn(() => ({ + tracePropagationTargets: ['example.com'], + })), + } as any); + + const request1 = makeMockRequest(); + const request2 = makeMockRequest(); + + injectTracePropagationHeaders(request1, propagationDecisionMap); + injectTracePropagationHeaders(request2, propagationDecisionMap); + + // Both requests should have had headers injected (URL matches the target) + expect(request1.setHeader).toHaveBeenCalledWith('sentry-trace', DEFAULT_SENTRY_TRACE); + expect(request2.setHeader).toHaveBeenCalledWith('sentry-trace', DEFAULT_SENTRY_TRACE); + + // The decision map should contain a cached entry for the URL + expect(propagationDecisionMap.size).toBe(1); + }); + + it('handles setHeader exceptions gracefully', () => { + const request = makeMockRequest(); + vi.mocked(request.setHeader).mockImplementation(() => { + throw new Error('Headers already sent'); + }); + + // Should not throw + expect(() => injectTracePropagationHeaders(request, propagationDecisionMap)).not.toThrow(); + }); +}); diff --git a/packages/core/test/lib/integrations/requestdata.test.ts b/packages/core/test/lib/integrations/requestdata.test.ts index df8e8d4d8766..7b2dca819ea3 100644 --- a/packages/core/test/lib/integrations/requestdata.test.ts +++ b/packages/core/test/lib/integrations/requestdata.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Client } from '../../../src/client'; +import * as currentScopes from '../../../src/currentScopes'; import { requestDataIntegration } from '../../../src/integrations/requestdata'; import type { Event } from '../../../src/types-hoist/event'; +import type { StreamedSpanJSON } from '../../../src/types-hoist/span'; import { ipHeaderNames } from '../../../src/vendor/getIpAddress'; function mockClient(sendDefaultPii: boolean | undefined): Client { @@ -602,3 +604,268 @@ describe('requestDataIntegration', () => { expect(event.request?.headers?.['X-Forwarded-For']).toBeUndefined(); }); }); + +describe('requestDataIntegration processSegmentSpan', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function makeSpan(overrides: Partial = {}): StreamedSpanJSON { + return { + name: 'GET /test', + span_id: 'abc123', + trace_id: 'def456', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: true, + attributes: {}, + ...overrides, + }; + } + + function mockIsolationScope(normalizedRequest: Record, ipAddress?: string): void { + vi.spyOn(currentScopes, 'getIsolationScope').mockReturnValue({ + getScopeData: () => ({ + sdkProcessingMetadata: { normalizedRequest, ipAddress }, + }), + } as ReturnType); + } + + it('applies request data attributes to the segment span', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com/api/users', + method: 'GET', + query_string: 'page=1&limit=10', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'url.full': 'https://example.com/api/users', + 'http.request.method': 'GET', + 'url.query': 'page=1&limit=10', + 'http.request.header.content_type': 'application/json', + 'http.request.header.accept': 'application/json', + }); + }); + + it('does not apply attributes when normalizedRequest is missing', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({}); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toEqual({}); + }); + + it('sets user.ip_address from headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com', + headers: { 'x-forwarded-for': '203.0.113.50' }, + }); + + integration.processSegmentSpan!(span, mockClient(true)); + + expect(span.attributes).toMatchObject({ + 'user.ip_address': '203.0.113.50', + }); + }); + + it('falls back to ipAddress from sdkProcessingMetadata', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', headers: {} }, '192.168.1.1'); + + integration.processSegmentSpan!(span, mockClient(true)); + + expect(span.attributes).toMatchObject({ + 'user.ip_address': '192.168.1.1', + }); + }); + + it('does not set user.ip_address when sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com', + headers: { 'x-forwarded-for': '203.0.113.50' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('user.ip_address'); + }); + + it('applies cookies from normalizedRequest.cookies', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + cookies: { theme: 'dark', locale: 'en' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.cookie.theme': 'dark', + 'http.request.header.cookie.locale': 'en', + }); + }); + + it('falls back to cookie header when normalizedRequest.cookies is not set', () => { + const integration = requestDataIntegration({ include: { headers: false } }); + const span = makeSpan(); + + mockIsolationScope({ + headers: { cookie: 'theme=dark; locale=en' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.cookie.theme': 'dark', + 'http.request.header.cookie.locale': 'en', + }); + }); + + it('filters sensitive cookies', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + cookies: { theme: 'dark', 'connect.sid': 'secret', session_token: 'secret' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.cookie.theme': 'dark', + 'http.request.header.cookie.connect.sid': '[Filtered]', + 'http.request.header.cookie.session_token': '[Filtered]', + }); + }); + + it('applies request body data', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ data: { key: 'value' } }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.body.data': '{"key":"value"}', + }); + }); + + it('handles query_string in object format', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ query_string: { page: '1', limit: '10' } }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'url.query': 'page=1&limit=10', + }); + }); + + describe('respects include options', () => { + it('excludes url when include.url is false', () => { + const integration = requestDataIntegration({ include: { url: false } }); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', method: 'GET' }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('url.full'); + expect(span.attributes).toMatchObject({ 'http.request.method': 'GET' }); + }); + + it('excludes headers when include.headers is false', () => { + const integration = requestDataIntegration({ include: { headers: false } }); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com', + headers: { 'content-type': 'application/json' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('http.request.header.content_type'); + }); + + it('strips cookie header when include.cookies is false', () => { + const integration = requestDataIntegration({ include: { cookies: false } }); + const span = makeSpan(); + + mockIsolationScope({ + headers: { 'content-type': 'application/json', cookie: 'theme=dark' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.content_type': 'application/json', + }); + expect(span.attributes).not.toHaveProperty('http.request.header.cookie.theme'); + }); + + it('strips IP headers when include.ip is false', () => { + const integration = requestDataIntegration({ include: { ip: false } }); + const span = makeSpan(); + + mockIsolationScope({ + headers: { 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.50' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.content_type': 'application/json', + }); + expect(span.attributes).not.toHaveProperty('http.request.header.x_forwarded_for'); + expect(span.attributes).not.toHaveProperty('user.ip_address'); + }); + + it('excludes data when include.data is false', () => { + const integration = requestDataIntegration({ include: { data: false } }); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', data: { key: 'value' } }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('http.request.body.data'); + }); + + it('excludes query_string when include.query_string is false', () => { + const integration = requestDataIntegration({ include: { query_string: false } }); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', query_string: 'page=1' }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('url.query'); + }); + }); +}); diff --git a/packages/core/test/lib/logs/envelope.test.ts b/packages/core/test/lib/logs/envelope.test.ts index 7fbe1a439910..86626364f506 100644 --- a/packages/core/test/lib/logs/envelope.test.ts +++ b/packages/core/test/lib/logs/envelope.test.ts @@ -5,6 +5,7 @@ import type { SerializedLog } from '../../../src/types-hoist/log'; import type { SdkMetadata } from '../../../src/types-hoist/sdkmetadata'; import * as utilsDsn from '../../../src/utils/dsn'; import * as utilsEnvelope from '../../../src/utils/envelope'; +import { isBrowser } from '../../../src/utils/isBrowser'; // Mock utils functions vi.mock('../../../src/utils/dsn', () => ({ @@ -13,20 +14,65 @@ vi.mock('../../../src/utils/dsn', () => ({ vi.mock('../../../src/utils/envelope', () => ({ createEnvelope: vi.fn((_headers, items) => [_headers, items]), })); +vi.mock('../../../src/utils/isBrowser', () => ({ + isBrowser: vi.fn(() => false), +})); + +afterEach(() => { + vi.mocked(isBrowser).mockReturnValue(false); +}); describe('createLogContainerEnvelopeItem', () => { - it('creates an envelope item with correct structure', () => { + it('emits version: 2 without ingest_settings when not in browser', () => { + const mockLog: SerializedLog = { + timestamp: 1713859200, + level: 'info', + body: 'Test log message', + }; + + const result = createLogContainerEnvelopeItem([mockLog], true); + + expect(result[0]).toEqual({ type: 'log', item_count: 1, content_type: 'application/vnd.sentry.items.log+json' }); + expect(result[1]).toEqual({ + version: 2, + items: [mockLog], + }); + }); + + it("includes ingest_settings with 'auto' values when in browser and inferUserData is true", () => { + vi.mocked(isBrowser).mockReturnValue(true); + const mockLog: SerializedLog = { timestamp: 1713859200, - level: 'error', - body: 'Test error message', + level: 'info', + body: 'Test log message', }; - const result = createLogContainerEnvelopeItem([mockLog, mockLog]); + const result = createLogContainerEnvelopeItem([mockLog], true); + + expect(result[1]).toEqual({ + version: 2, + ingest_settings: { infer_ip: 'auto', infer_user_agent: 'auto' }, + items: [mockLog], + }); + }); + + it("includes ingest_settings with 'never' values when in browser and inferUserData is false", () => { + vi.mocked(isBrowser).mockReturnValue(true); - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ type: 'log', item_count: 2, content_type: 'application/vnd.sentry.items.log+json' }); - expect(result[1]).toEqual({ items: [mockLog, mockLog] }); + const mockLog: SerializedLog = { + timestamp: 1713859200, + level: 'info', + body: 'Test log message', + }; + + const result = createLogContainerEnvelopeItem([mockLog], false); + + expect(result[1]).toEqual({ + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, + items: [mockLog], + }); }); }); @@ -133,7 +179,7 @@ describe('createLogEnvelope', () => { expect.arrayContaining([ expect.arrayContaining([ { type: 'log', item_count: 2, content_type: 'application/vnd.sentry.items.log+json' }, - { items: mockLogs }, + { version: 2, items: mockLogs }, ]), ]), ); diff --git a/packages/core/test/lib/metrics/envelope.test.ts b/packages/core/test/lib/metrics/envelope.test.ts index 87132e4bcaa0..25bf5e61923b 100644 --- a/packages/core/test/lib/metrics/envelope.test.ts +++ b/packages/core/test/lib/metrics/envelope.test.ts @@ -5,6 +5,7 @@ import type { SerializedMetric } from '../../../src/types-hoist/metric'; import type { SdkMetadata } from '../../../src/types-hoist/sdkmetadata'; import * as utilsDsn from '../../../src/utils/dsn'; import * as utilsEnvelope from '../../../src/utils/envelope'; +import { isBrowser } from '../../../src/utils/isBrowser'; vi.mock('../../../src/utils/dsn', () => ({ dsnToString: vi.fn(dsn => `https://${dsn.publicKey}@${dsn.host}/`), @@ -12,9 +13,16 @@ vi.mock('../../../src/utils/dsn', () => ({ vi.mock('../../../src/utils/envelope', () => ({ createEnvelope: vi.fn((_headers, items) => [_headers, items]), })); +vi.mock('../../../src/utils/isBrowser', () => ({ + isBrowser: vi.fn(() => false), +})); + +afterEach(() => { + vi.mocked(isBrowser).mockReturnValue(false); +}); describe('createMetricContainerEnvelopeItem', () => { - it('creates an envelope item with correct structure', () => { + it('emits version: 2 without ingest_settings when not in browser', () => { const mockMetric: SerializedMetric = { timestamp: 1713859200, trace_id: '3d9355f71e9c444b81161599adac6e29', @@ -26,15 +34,63 @@ describe('createMetricContainerEnvelopeItem', () => { attributes: {}, }; - const result = createMetricContainerEnvelopeItem([mockMetric, mockMetric]); + const result = createMetricContainerEnvelopeItem([mockMetric], true); - expect(result).toHaveLength(2); expect(result[0]).toEqual({ type: 'trace_metric', - item_count: 2, + item_count: 1, content_type: 'application/vnd.sentry.items.trace-metric+json', }); - expect(result[1]).toEqual({ items: [mockMetric, mockMetric] }); + expect(result[1]).toEqual({ + version: 2, + items: [mockMetric], + }); + }); + + it("includes ingest_settings with 'auto' values when in browser and inferUserData is true", () => { + vi.mocked(isBrowser).mockReturnValue(true); + + const mockMetric: SerializedMetric = { + timestamp: 1713859200, + trace_id: '3d9355f71e9c444b81161599adac6e29', + span_id: '8b5f5e5e5e5e5e5e', + name: 'test.metric', + type: 'counter', + value: 1, + unit: 'count', + attributes: {}, + }; + + const result = createMetricContainerEnvelopeItem([mockMetric], true); + + expect(result[1]).toEqual({ + version: 2, + ingest_settings: { infer_ip: 'auto', infer_user_agent: 'auto' }, + items: [mockMetric], + }); + }); + + it("includes ingest_settings with 'never' values when in browser and inferUserData is false", () => { + vi.mocked(isBrowser).mockReturnValue(true); + + const mockMetric: SerializedMetric = { + timestamp: 1713859200, + trace_id: '3d9355f71e9c444b81161599adac6e29', + span_id: '8b5f5e5e5e5e5e5e', + name: 'test.metric', + type: 'counter', + value: 1, + unit: 'count', + attributes: {}, + }; + + const result = createMetricContainerEnvelopeItem([mockMetric], false); + + expect(result[1]).toEqual({ + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, + items: [mockMetric], + }); }); }); @@ -165,7 +221,7 @@ describe('createMetricEnvelope', () => { expect.arrayContaining([ expect.arrayContaining([ { type: 'trace_metric', item_count: 2, content_type: 'application/vnd.sentry.items.trace-metric+json' }, - { items: mockMetrics }, + { version: 2, items: mockMetrics }, ]), ]), ); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 56b039d56b67..186f7f23a536 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -530,10 +530,10 @@ describe('inferSpanDataFromOtelAttributes', () => { expect(spanJSON.attributes?.['sentry.source']).toBe('route'); }); - it('does not overwrite name when no http.route but sets source to url', () => { + it('infers name from url.full when no http.route and sets source to url', () => { const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'url.full': 'http://example.com/api' }); inferSpanDataFromOtelAttributes(spanJSON, 2); - expect(spanJSON.name).toBe('GET'); + expect(spanJSON.name).toBe('GET http://example.com/api'); expect(spanJSON.attributes?.['sentry.source']).toBe('url'); }); diff --git a/packages/core/test/lib/utils/baggage.test.ts b/packages/core/test/lib/utils/baggage.test.ts index f3717a524bf8..dcc94fa1a839 100644 --- a/packages/core/test/lib/utils/baggage.test.ts +++ b/packages/core/test/lib/utils/baggage.test.ts @@ -1,10 +1,23 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, it, vi } from 'vitest'; import { baggageHeaderToDynamicSamplingContext, dynamicSamplingContextToSentryBaggageHeader, + mergeBaggageHeaders, parseBaggageHeader, + objectToBaggageHeader, } from '../../../src/utils/baggage'; +const DEBUG_WARNS: string[] = []; + +vi.mock(import('../../../src/debug-build'), () => ({ DEBUG_BUILD: true })); +vi.mock('../../../src/utils/debug-logger', () => ({ + debug: { + warn(msg: string) { + DEBUG_WARNS.push(msg); + }, + }, +})); + test.each([ ['', undefined], [' ', undefined], @@ -84,3 +97,184 @@ describe('parseBaggageHeader', () => { }); }); }); + +describe('objectToBaggageHeader', () => { + it('does not create a baggage header that is too big', () => { + const obj: Record = {}; + // it takes this many to exceed the limit + for (let i = 0; i < 765; i++) { + obj[`foo${i}`] = String(i); + } + const header = objectToBaggageHeader(obj); + expect(header?.endsWith(',foo764=764')).toBe(false); + expect(Number(header?.length) < 8192).toBe(true); + expect(DEBUG_WARNS).toStrictEqual([ + 'Not adding key: foo764 with val: 764 to baggage header due to exceeding baggage size limits.', + ]); + }); +}); + +describe('mergeBaggageHeaders', () => { + it('returns new baggage when existing is undefined', () => { + const result = mergeBaggageHeaders(undefined, 'foo=bar'); + expect(result).toBe('foo=bar'); + }); + + it('returns existing baggage when new baggage is empty', () => { + const result = mergeBaggageHeaders('foo=bar', ''); + expect(result).toBe('foo=bar'); + }); + + it('returns existing baggage when new baggage is invalid', () => { + const result = mergeBaggageHeaders('foo=bar', 'invalid'); + expect(result).toBe('foo=bar'); + }); + + it('handles empty existing baggage', () => { + const result = mergeBaggageHeaders('', 'foo=bar,sentry-release=1.0.0'); + expect(result).toBe('foo=bar,sentry-release=1.0.0'); + }); + + it('preserves existing non-Sentry entries', () => { + const result = mergeBaggageHeaders('foo=bar,other=vendor', 'foo=newvalue,third=party'); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('third=party'); + expect(entries).not.toContain('foo=newvalue'); + }); + + it('overwrites existing Sentry entries with new ones', () => { + const result = mergeBaggageHeaders( + 'sentry-release=1.0.0,sentry-environment=prod', + 'sentry-release=2.0.0,sentry-environment=staging', + ); + + const entries = result?.split(','); + expect(entries).toContain('sentry-release=2.0.0'); + expect(entries).toContain('sentry-environment=staging'); + expect(entries).not.toContain('sentry-release=1.0.0'); + expect(entries).not.toContain('sentry-environment=prod'); + }); + + it('merges Sentry and non-Sentry entries correctly', () => { + const result = mergeBaggageHeaders('foo=bar,sentry-release=1.0.0,other=vendor', 'sentry-release=2.0.0,third=party'); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('third=party'); + expect(entries).toContain('sentry-release=2.0.0'); + expect(entries).not.toContain('sentry-release=1.0.0'); + }); + + it('handles third-party baggage with Sentry entries', () => { + const result = mergeBaggageHeaders( + 'other=vendor,foo=bar,third=party,sentry-release=9.9.9,sentry-environment=staging,sentry-sample_rate=0.54,last=item', + 'sentry-release=2.1.0,sentry-environment=myEnv', + ); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('last=item'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('third=party'); + expect(entries).toContain('sentry-environment=myEnv'); + expect(entries).toContain('sentry-release=2.1.0'); + expect(entries).not.toContain('sentry-environment=staging'); + expect(entries).not.toContain('sentry-release=9.9.9'); + }); + + it('adds new Sentry entries when they do not exist', () => { + const result = mergeBaggageHeaders('foo=bar,other=vendor', 'sentry-release=1.0.0,sentry-environment=prod'); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('sentry-release=1.0.0'); + expect(entries).toContain('sentry-environment=prod'); + }); + + it('handles array-type existing baggage', () => { + const result = mergeBaggageHeaders(['foo=bar', 'other=vendor'], 'sentry-release=1.0.0'); + + const entries = (result as string)?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('sentry-release=1.0.0'); + }); + + it('preserves order of existing entries', () => { + const result = mergeBaggageHeaders('first=1,second=2,third=3', 'fourth=4'); + expect(result).toBe('first=1,second=2,third=3,fourth=4'); + }); + + it('handles complex scenario with multiple Sentry keys', () => { + const result = mergeBaggageHeaders( + 'foo=bar,sentry-release=old,sentry-environment=old,other=vendor', + 'sentry-release=new,sentry-environment=new,sentry-transaction=test,new=entry', + ); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('sentry-release=new'); + expect(entries).toContain('sentry-environment=new'); + expect(entries).toContain('sentry-transaction=test'); + expect(entries).toContain('new=entry'); + expect(entries).not.toContain('sentry-release=old'); + expect(entries).not.toContain('sentry-environment=old'); + }); + + it('overwrites existing Sentry entries with new SDK values', () => { + const result = mergeBaggageHeaders( + 'sentry-trace_id=abc123,sentry-sampled=false,non-sentry=keep', + 'sentry-trace_id=xyz789,sentry-sampled=true', + ); + + const entries = result?.split(','); + expect(entries).toContain('sentry-trace_id=xyz789'); + expect(entries).toContain('sentry-sampled=true'); + expect(entries).toContain('non-sentry=keep'); + expect(entries).not.toContain('sentry-trace_id=abc123'); + expect(entries).not.toContain('sentry-sampled=false'); + }); + + it('merges non-conflicting baggage entries', () => { + const existing = 'custom-key=value'; + const newBaggage = 'sentry-environment=production'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('custom-key=value,sentry-environment=production'); + }); + + it('overwrites existing Sentry entries when keys conflict', () => { + const existing = 'sentry-environment=staging'; + const newBaggage = 'sentry-environment=production'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('sentry-environment=production'); + }); + + it('handles multiple entries with Sentry conflicts', () => { + const existing = 'custom-key=value1,sentry-environment=staging'; + const newBaggage = 'sentry-environment=production,sentry-trace_id=123'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toContain('custom-key=value1'); + expect(result).toContain('sentry-environment=production'); + expect(result).toContain('sentry-trace_id=123'); + expect(result).not.toContain('sentry-environment=staging'); + }); + + it('removes all sentry- values from old baggage and only adds new ones (if at least one new sentry- value is present)', () => { + const existing = 'sentry-trace_id=old,sentry-sampled=false,non-sentry=keep'; + const newBaggage = 'sentry-trace_id=new,sentry-environment=new'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('non-sentry=keep,sentry-trace_id=new,sentry-environment=new'); + }); + + it('preserves existing sentry entries when new baggage has no sentry entries', () => { + const result = mergeBaggageHeaders('sentry-release=1.0.0,foo=bar', 'baz=qux'); + + expect(result).toBe('sentry-release=1.0.0,foo=bar,baz=qux'); + }); +}); diff --git a/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts b/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts index 2f4415940dc8..d357ffd9bb34 100644 --- a/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts +++ b/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts @@ -53,4 +53,25 @@ describe('chain and copy promiselike objects', () => { expect(success).toBe(true); expect(error).toBe(false); }); + + it('returns original when .then() returns undefined', () => { + const original = { + value: 42, + then() { + return undefined; + }, + customMethod() { + return 'hello'; + }, + } as unknown as PromiseLike & { customMethod: () => string }; + + const q = chainAndCopyPromiseLike( + original, + () => {}, + () => {}, + ); + + expect(q).toBe(original); + expect((q as typeof original).customMethod()).toBe('hello'); + }); }); diff --git a/packages/core/test/lib/utils/get-default-export.test.ts b/packages/core/test/lib/utils/get-default-export.test.ts new file mode 100644 index 000000000000..2a12e8d1d1a9 --- /dev/null +++ b/packages/core/test/lib/utils/get-default-export.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { getDefaultExport } from '../../../src/utils/get-default-export'; + +describe('getDefaultExport', () => { + it('returns the default export if there is one', () => { + const mod = { + default: () => {}, + }; + expect(getDefaultExport(mod)).toBe(mod.default); + }); + it('returns the module export if no default', () => { + const mod = {}; + expect(getDefaultExport(mod)).toBe(mod); + }); + it('returns the module if a function and not plain object', () => { + const mod = Object.assign(function () {}, { + default: () => {}, + }); + expect(getDefaultExport(mod)).toBe(mod); + }); + it('returns the module if a default is falsey', () => { + const mod = Object.assign(function () {}, { + default: false, + }); + expect(getDefaultExport(mod)).toBe(mod); + }); +}); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 250fcf8443c8..d6add13abc1b 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { + captureBodyFromWinterCGRequest, extractQueryParamsFromUrl, headersToDict, httpHeadersToSpanAttributes, @@ -7,6 +8,7 @@ import { winterCGHeadersToDict, winterCGRequestToRequestData, } from '../../../src/utils/request'; +import type { Scope } from '../../../src/scope'; describe('request utils', () => { describe('winterCGHeadersToDict', () => { @@ -851,4 +853,292 @@ describe('request utils', () => { }); }); }); + + describe('captureBodyFromWinterCGRequest', () => { + function createMockRequest(options: { + body?: string | null; + contentType?: string; + contentLength?: string; + }): Request { + const headers = new Headers(); + if (options.contentType) { + headers.set('content-type', options.contentType); + } + if (options.contentLength) { + headers.set('content-length', options.contentLength); + } + + return new Request('https://example.com/test', { + method: 'POST', + headers, + body: options.body ?? undefined, + }); + } + + function createMockScope(): Scope & { capturedData: unknown } { + const scope = { + capturedData: undefined as unknown, + setSDKProcessingMetadata(metadata: { normalizedRequest?: { data?: unknown } }) { + scope.capturedData = metadata.normalizedRequest?.data; + }, + }; + return scope as Scope & { capturedData: unknown }; + } + + it('captures JSON body', async () => { + const jsonBody = JSON.stringify({ userId: 42, email: 'user@example.com', action: 'update_profile' }); + const request = createMockRequest({ + body: jsonBody, + contentType: 'application/json', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe(jsonBody); + }); + + it('captures form-urlencoded body', async () => { + const request = createMockRequest({ + body: 'username=test&password=secret', + contentType: 'application/x-www-form-urlencoded', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('username=test&password=secret'); + }); + + it('captures text/plain body', async () => { + const request = createMockRequest({ + body: 'Hello, World!', + contentType: 'text/plain', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('Hello, World!'); + }); + + it('captures text/html body', async () => { + const request = createMockRequest({ + body: 'Test', + contentType: 'text/html', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('Test'); + }); + + it('captures application/xml body', async () => { + const request = createMockRequest({ + body: 'value', + contentType: 'application/xml', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('value'); + }); + + it('captures application/graphql body', async () => { + const request = createMockRequest({ + body: 'query { user { name } }', + contentType: 'application/graphql', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('query { user { name } }'); + }); + + it('skips non-textual content types', async () => { + const request = createMockRequest({ + body: 'binary data', + contentType: 'application/octet-stream', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('skips image content types', async () => { + const request = createMockRequest({ + body: 'image data', + contentType: 'image/png', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('skips when content-type is explicitly non-textual', async () => { + const request = { + headers: { + get: (name: string) => (name === 'content-type' ? null : null), + }, + body: {}, + clone: () => ({ + text: () => Promise.resolve('some data'), + }), + } as unknown as Request; + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('skips when body is null', async () => { + const request = createMockRequest({ + body: null, + contentType: 'application/json', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('skips when body is empty', async () => { + const request = createMockRequest({ + body: '', + contentType: 'application/json', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('truncates body when it exceeds small size limit (1000 bytes)', async () => { + const largeBody = 'x'.repeat(2000); + const request = createMockRequest({ + body: largeBody, + contentType: 'text/plain', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'small'); + + expect(scope.capturedData).toHaveLength(1000); + expect((scope.capturedData as string).endsWith('...')).toBe(true); + }); + + it('truncates body when it exceeds medium size limit (10000 bytes)', async () => { + const largeBody = 'x'.repeat(20000); + const request = createMockRequest({ + body: largeBody, + contentType: 'text/plain', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toHaveLength(10000); + expect((scope.capturedData as string).endsWith('...')).toBe(true); + }); + + it('does not truncate body within small size limit', async () => { + const smallBody = 'x'.repeat(500); + const request = createMockRequest({ + body: smallBody, + contentType: 'text/plain', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'small'); + + expect(scope.capturedData).toBe(smallBody); + }); + + it('skips when content-length exceeds 1MB limit', async () => { + const request = createMockRequest({ + body: 'small body', + contentType: 'application/json', + contentLength: '2000000', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'always'); + + expect(scope.capturedData).toBeUndefined(); + }); + + it('captures body with always size limit', async () => { + const largeBody = 'x'.repeat(50000); + const request = createMockRequest({ + body: largeBody, + contentType: 'text/plain', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'always'); + + expect(scope.capturedData).toBe(largeBody); + }); + + it('handles content-type with charset', async () => { + const request = createMockRequest({ + body: '{"test":"value"}', + contentType: 'application/json; charset=utf-8', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('{"test":"value"}'); + }); + + it('does not throw on errors', async () => { + const request = { + headers: { + get: () => { + throw new Error('Test error'); + }, + }, + body: 'test', + clone: () => request, + } as unknown as Request; + const scope = createMockScope(); + + await expect(captureBodyFromWinterCGRequest(request, scope, 'medium')).resolves.not.toThrow(); + expect(scope.capturedData).toBeUndefined(); + }); + + it('handles timeout gracefully', async () => { + vi.useFakeTimers(); + + const neverResolve = new Promise(() => {}); + const request = { + headers: new Headers({ 'content-type': 'application/json' }), + body: {}, + clone: () => ({ + text: () => neverResolve, + }), + } as unknown as Request; + const scope = createMockScope(); + + const promise = captureBodyFromWinterCGRequest(request, scope, 'medium'); + + await vi.advanceTimersByTimeAsync(2500); + await promise; + + expect(scope.capturedData).toBeUndefined(); + + vi.useRealTimers(); + }); + }); }); diff --git a/packages/deno/src/integrations/context.ts b/packages/deno/src/integrations/context.ts index 979ffff7d0e8..62ea66807171 100644 --- a/packages/deno/src/integrations/context.ts +++ b/packages/deno/src/integrations/context.ts @@ -1,5 +1,5 @@ -import type { Event, IntegrationFn } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core'; const INTEGRATION_NAME = 'DenoContext'; @@ -22,41 +22,54 @@ async function getOSRelease(): Promise { : undefined; } -async function addDenoRuntimeContext(event: Event): Promise { - event.contexts = { - ...{ - app: { - app_start_time: new Date(Date.now() - performance.now()).toISOString(), - }, - device: { - arch: Deno.build.arch, - // eslint-disable-next-line no-restricted-globals - processor_count: navigator.hardwareConcurrency, - }, - os: { - name: getOSName(), - version: await getOSRelease(), - }, - v8: { - name: 'v8', - version: Deno.version.v8, - }, - typescript: { - name: 'TypeScript', - version: Deno.version.typescript, - }, - }, - ...event.contexts, +const _denoContextIntegration = (() => { + const appStartTime = new Date(Date.now() - performance.now()).toISOString(); + const osName = getOSName(); + const arch = Deno.build.arch; + // eslint-disable-next-line no-restricted-globals + const processorCount = navigator.hardwareConcurrency; + const v8Version = Deno.version.v8; + const tsVersion = Deno.version.typescript; + + const cachedContext = { + app: { app_start_time: appStartTime }, + device: { arch, processor_count: processorCount }, + os: { name: osName } as { name: string; version?: string }, + v8: { name: 'v8', version: v8Version }, + typescript: { name: 'TypeScript', version: tsVersion }, }; - return event; -} + const cachedSpanAttributes: Record = { + 'app.start_time': appStartTime, + // Convention uses 'device.archs' (string[]), but array attributes are not yet serialized. + // Once array serialization lands, this will start appearing on spans automatically. + 'device.archs': [arch], + 'device.processor_count': processorCount, + 'os.name': osName, + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': v8Version, + }; + + getOSRelease() + .then(release => { + cachedContext.os.version = release; + cachedSpanAttributes['os.version'] = release; + }) + .catch(() => { + // Ignore - os.version will be undefined + }); -const _denoContextIntegration = (() => { return { name: INTEGRATION_NAME, processEvent(event) { - return addDenoRuntimeContext(event); + event.contexts = { + ...cachedContext, + ...event.contexts, + }; + return event; + }, + processSegmentSpan(span) { + safeSetSpanJSONAttributes(span, cachedSpanAttributes); }, }; }) satisfies IntegrationFn; diff --git a/packages/hono/README.md b/packages/hono/README.md index c0d791030134..bf1ce203d8c5 100644 --- a/packages/hono/README.md +++ b/packages/hono/README.md @@ -4,20 +4,18 @@

-# Official Sentry SDK for Hono (ALPHA) +# Official Sentry SDK for Hono (BETA) [![npm version](https://img.shields.io/npm/v/@sentry/hono.svg)](https://www.npmjs.com/package/@sentry/hono) [![npm dm](https://img.shields.io/npm/dm/@sentry/hono.svg)](https://www.npmjs.com/package/@sentry/hono) [![npm dt](https://img.shields.io/npm/dt/@sentry/hono.svg)](https://www.npmjs.com/package/@sentry/hono) -This SDK is compatible with Hono 4+ and is currently in ALPHA. Alpha features are still in progress, may have bugs and might include breaking changes. +This SDK is compatible with Hono 4+ and is currently in BETA. Beta features are still in progress and may have bugs. Please reach out on [GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have any feedback or concerns. ## Links -- [General SDK Docs](https://docs.sentry.io/quickstart/) - Official Docs for this Hono SDK are coming soon! - -The current [Hono SDK Docs](https://docs.sentry.io/platforms/javascript/guides/hono/) explain using Sentry in Hono by using other Sentry SDKs (e.g. `@sentry/node` or `@sentry/cloudflare`) +- [Official SDK Docs](https://docs.sentry.io/platforms/javascript/guides/hono/) ## Install diff --git a/packages/hono/src/shared/isExpectedError.ts b/packages/hono/src/shared/isExpectedError.ts new file mode 100644 index 000000000000..f3014be0fb5e --- /dev/null +++ b/packages/hono/src/shared/isExpectedError.ts @@ -0,0 +1,17 @@ +/** + * 3xx and 4xx errors are expected (redirects, auth failures, not found, bad + * request) and should not be captured as Sentry error events. + * + * Checks any error-like value that carries a numeric `status` property — this + * covers Hono's `HTTPException`, third-party middleware errors, and custom + * error subclasses. + */ +export function isExpectedError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + + const status = (error as { status?: unknown }).status; + + return typeof status === 'number' && status >= 300 && status < 500; +} diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/hono/src/shared/middlewareHandlers.ts index a470733b47a8..41902d90f84f 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/hono/src/shared/middlewareHandlers.ts @@ -11,6 +11,7 @@ import { import type { Context } from 'hono'; import { routePath } from 'hono/route'; import { hasFetchEvent } from '../utils/hono-context'; +import { isExpectedError } from './isExpectedError'; /** * Request handler for Hono framework @@ -42,7 +43,7 @@ export function responseHandler(context: Context): void { getIsolationScope().setTransactionName(`${context.req.method} ${routePath(context)}`); - if (context.error) { + if (context.error && !isExpectedError(context.error)) { getClient()?.captureException(context.error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); diff --git a/packages/hono/src/shared/wrapMiddlewareSpan.ts b/packages/hono/src/shared/wrapMiddlewareSpan.ts index b93e5de0bded..13668e129c66 100644 --- a/packages/hono/src/shared/wrapMiddlewareSpan.ts +++ b/packages/hono/src/shared/wrapMiddlewareSpan.ts @@ -1,17 +1,17 @@ import { captureException, getActiveSpan, - getRootSpan, getOriginalFunction, + getRootSpan, markFunctionWrapped, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, - SPAN_STATUS_OK, startInactiveSpan, type WrappedFunction, } from '@sentry/core'; import { type MiddlewareHandler } from 'hono'; +import { isExpectedError } from './isExpectedError'; const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; @@ -41,14 +41,15 @@ export function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHa }); try { - const result = await handler(context, next); - span.setStatus({ code: SPAN_STATUS_OK }); - return result; + return await handler(context, next); } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { handled: false, type: MIDDLEWARE_ORIGIN }, - }); + if (!isExpectedError(error)) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { handled: false, type: MIDDLEWARE_ORIGIN }, + }); + } + throw error; } finally { span.end(); diff --git a/packages/hono/test/shared/isExpectedError.test.ts b/packages/hono/test/shared/isExpectedError.test.ts new file mode 100644 index 000000000000..235db79e1357 --- /dev/null +++ b/packages/hono/test/shared/isExpectedError.test.ts @@ -0,0 +1,78 @@ +import { HTTPException } from 'hono/http-exception'; +import { describe, expect, it } from 'vitest'; +import { isExpectedError } from '../../src/shared/isExpectedError'; + +describe('isExpectedError', () => { + describe('HTTPException', () => { + it('returns true for 4xx HTTPException', () => { + expect(isExpectedError(new HTTPException(400, { message: 'Bad Request' }))).toBe(true); + expect(isExpectedError(new HTTPException(401, { message: 'Unauthorized' }))).toBe(true); + expect(isExpectedError(new HTTPException(403, { message: 'Forbidden' }))).toBe(true); + expect(isExpectedError(new HTTPException(404, { message: 'Not Found' }))).toBe(true); + expect(isExpectedError(new HTTPException(422, { message: 'Unprocessable Entity' }))).toBe(true); + expect(isExpectedError(new HTTPException(499))).toBe(true); + }); + + it('returns false for 5xx HTTPException', () => { + expect(isExpectedError(new HTTPException(500, { message: 'Internal Server Error' }))).toBe(false); + expect(isExpectedError(new HTTPException(502, { message: 'Bad Gateway' }))).toBe(false); + expect(isExpectedError(new HTTPException(503, { message: 'Service Unavailable' }))).toBe(false); + }); + }); + + describe('custom error classes with status property', () => { + it('returns true for custom Error subclass with 4xx status', () => { + class AuthError extends Error { + status = 401; + } + expect(isExpectedError(new AuthError('unauthorized'))).toBe(true); + }); + + it('returns false for custom Error subclass with 5xx status', () => { + class DbError extends Error { + status = 500; + } + expect(isExpectedError(new DbError('connection lost'))).toBe(false); + }); + + it('returns true for plain object with 4xx status', () => { + expect(isExpectedError({ status: 404, message: 'Not Found' })).toBe(true); + expect(isExpectedError({ status: 400 })).toBe(true); + }); + + it('returns false for plain object with 5xx status', () => { + expect(isExpectedError({ status: 500, message: 'Internal Server Error' })).toBe(false); + }); + }); + + describe('non-expected errors', () => { + it('returns false for plain Error without status', () => { + expect(isExpectedError(new Error('something broke'))).toBe(false); + }); + + it('returns false for non-object values', () => { + expect(isExpectedError('string error')).toBe(false); + expect(isExpectedError(42)).toBe(false); + expect(isExpectedError(null)).toBe(false); + expect(isExpectedError(undefined)).toBe(false); + expect(isExpectedError(true)).toBe(false); + }); + + it('returns false when status is not a number', () => { + expect(isExpectedError({ status: '404' })).toBe(false); + expect(isExpectedError({ status: null })).toBe(false); + expect(isExpectedError({ status: undefined })).toBe(false); + }); + + it('returns true for 3xx status', () => { + expect(isExpectedError({ status: 301 })).toBe(true); + expect(isExpectedError({ status: 302 })).toBe(true); + expect(isExpectedError({ status: 399 })).toBe(true); + }); + + it('returns false for 2xx status', () => { + expect(isExpectedError({ status: 200 })).toBe(false); + expect(isExpectedError({ status: 299 })).toBe(false); + }); + }); +}); diff --git a/packages/hono/test/shared/middlewareHandlers.test.ts b/packages/hono/test/shared/middlewareHandlers.test.ts index 83099370320c..b8e4cdef1062 100644 --- a/packages/hono/test/shared/middlewareHandlers.test.ts +++ b/packages/hono/test/shared/middlewareHandlers.test.ts @@ -55,7 +55,7 @@ describe('responseHandler', () => { }); }); - it('captures error regardless of status code', () => { + it('captures 5xx HTTPException', () => { const mockCaptureException = vi.fn(); getClientMock.mockReturnValue({ captureException: mockCaptureException, @@ -101,7 +101,6 @@ describe('responseHandler', () => { // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(500, error) as any); - // captureException is called — it handles deduplication internally via checkOrSetAlreadyCaught expect(mockCaptureException).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts index f6e5a3b21617..25e4d1a6a485 100644 --- a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -15,7 +15,14 @@ export async function handleRunAfterProductionCompile( distDir, buildTool, usesNativeDebugIds, - }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack'; usesNativeDebugIds?: boolean }, + sriEnabled, + }: { + releaseName?: string; + distDir: string; + buildTool: 'webpack' | 'turbopack'; + usesNativeDebugIds?: boolean; + sriEnabled?: boolean; + }, sentryBuildOptions: SentryBuildOptions, ): Promise { if (sentryBuildOptions.debug) { @@ -68,10 +75,19 @@ export async function handleRunAfterProductionCompile( // the deleted .map files, and in Next.js 16 (turbopack) those requests fall through // to the app router instead of returning 404, which can break middleware-dependent // features like Clerk auth. + // When SRI is enabled, we must skip this step because Next.js computes integrity + // hashes during the build — modifying files afterward invalidates those hashes. const deleteSourcemapsAfterUpload = sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload ?? false; - if (deleteSourcemapsAfterUpload && buildTool === 'turbopack') { + if (deleteSourcemapsAfterUpload && buildTool === 'turbopack' && !sriEnabled) { await stripSourceMappingURLComments(path.join(distDir, 'static'), sentryBuildOptions.debug); } + + if (deleteSourcemapsAfterUpload && buildTool === 'turbopack' && sriEnabled && sentryBuildOptions.debug) { + // eslint-disable-next-line no-console + console.debug( + '[@sentry/nextjs] Skipping sourceMappingURL comment stripping because Subresource Integrity (SRI) is enabled.', + ); + } } const SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\/[#@] sourceMappingURL=[^\n]+$/; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 9aa31f79e535..86068841e773 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -46,6 +46,7 @@ export type NextConfigObject = { instrumentationHook?: boolean; clientTraceMetadata?: string[]; serverComponentsExternalPackages?: string[]; // next < v15.0.0 + sri?: { algorithm?: string }; }; productionBrowserSourceMaps?: boolean; // https://nextjs.org/docs/pages/api-reference/next-config-js/env diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts index 76187ca319f9..edd62b8ba8c3 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts @@ -140,6 +140,7 @@ export function maybeSetUpRunAfterProductionCompileHook({ distDir, buildTool: bundlerInfo.isTurbopack ? 'turbopack' : 'webpack', usesNativeDebugIds: bundlerInfo.isTurbopack ? turboPackConfig?.debugIds : undefined, + sriEnabled: !!incomingUserNextConfigObject.experimental?.sri, }, userSentryOptions, ); @@ -160,6 +161,7 @@ export function maybeSetUpRunAfterProductionCompileHook({ distDir, buildTool: bundlerInfo.isTurbopack ? 'turbopack' : 'webpack', usesNativeDebugIds: bundlerInfo.isTurbopack ? turboPackConfig?.debugIds : undefined, + sriEnabled: !!incomingUserNextConfigObject.experimental?.sri, }, userSentryOptions, ); diff --git a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts index 3d551dfb6c40..7c21c26d2ef6 100644 --- a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts +++ b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts @@ -388,6 +388,43 @@ describe('handleRunAfterProductionCompile', () => { expect(readdirSpy).not.toHaveBeenCalled(); }); + + it('does NOT strip sourceMappingURL comments when SRI is enabled', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + sriEnabled: true, + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(readdirSpy).not.toHaveBeenCalled(); + }); + + it('strips sourceMappingURL comments when SRI is not enabled', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + sriEnabled: false, + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(readdirSpy).toHaveBeenCalledWith( + path.join('/path/to/.next', 'static'), + expect.objectContaining({ recursive: true }), + ); + }); }); describe('path handling', () => { diff --git a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts new file mode 100644 index 000000000000..054fe6873ce3 --- /dev/null +++ b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts @@ -0,0 +1,169 @@ +import { + captureException, + flushIfServerless, + GLOBAL_OBJ, + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpanManual, +} from '@sentry/core'; +import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry/tracing-channel'; +import type { TraceContext } from 'unstorage/tracing'; + +const ORIGIN = 'auto.cache.nitro'; + +const globalWithStorageChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + __SENTRY_NITRO_STORAGE_CHANNELS_INSTRUMENTED__: boolean; +}; + +const TRACED_OPERATIONS = [ + 'hasItem', + 'getItem', + 'getItemRaw', + 'getItems', + 'setItem', + 'setItemRaw', + 'setItems', + 'removeItem', + 'getKeys', + 'clear', +] as const; + +type TracedOperation = (typeof TRACED_OPERATIONS)[number]; + +const CACHE_HIT_OPERATIONS = new Set(['hasItem', 'getItem', 'getItemRaw']); + +const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; + +/** + * Subscribes to unstorage tracing channels and creates Sentry spans for storage operations. + */ +export function captureStorageEvents(): void { + if (globalWithStorageChannels.__SENTRY_NITRO_STORAGE_CHANNELS_INSTRUMENTED__) { + return; + } + + for (const operation of TRACED_OPERATIONS) { + setupStorageTracingChannel(operation); + } + + globalWithStorageChannels.__SENTRY_NITRO_STORAGE_CHANNELS_INSTRUMENTED__ = true; +} + +function setupStorageTracingChannel(operation: TracedOperation): void { + const keys = (data: TraceContext): string[] => data.keys ?? []; + const mountBase = (data: TraceContext): string => (data.base ?? '').replace(/:$/, ''); + + const channel = tracingChannel(`unstorage.${operation}`, data => { + const cacheKeys = keys(data); + + return startSpanManual( + { + name: cacheKeys.join(', ') || operation, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(operation)}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: cacheKeys.length > 1 ? cacheKeys : cacheKeys[0], + 'db.operation.name': operation, + 'db.collection.name': mountBase(data), + 'db.system.name': data.driver?.name ?? 'unknown', + }, + }, + span => span, + ); + }); + + channel.subscribe({ + asyncEnd(data: TracingChannelContextWithSpan) { + if (data._sentrySpan && CACHE_HIT_OPERATIONS.has(operation)) { + const hit = operation === 'hasItem' ? Boolean(data.result) : isCacheHit(data.keys?.[0], data.result); + data._sentrySpan.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, hit); + } + + data._sentrySpan?.setStatus({ code: SPAN_STATUS_OK }); + data._sentrySpan?.end(); + + void flushIfServerless(); + }, + error(data: TracingChannelContextWithSpan) { + captureException(data.error, { + mechanism: { handled: false, type: ORIGIN }, + }); + + data._sentrySpan?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + data._sentrySpan?.end(); + + void flushIfServerless(); + }, + }); +} + +function normalizeMethodName(methodName: string): string { + return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +function isEmptyValue(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +interface CacheEntry { + value?: T; + expires?: number; +} + +interface ResponseCacheEntry { + status?: number; + body?: unknown; + headers?: Record; +} + +function isCacheHit(key: unknown, value: unknown): boolean { + try { + const isEmpty = isEmptyValue(value); + if (isEmpty || typeof key !== 'string' || !CACHED_FN_HANDLERS_RE.test(key)) { + return !isEmpty; + } + + const entry = typeof value === 'string' ? (JSON.parse(value) as CacheEntry) : (value as CacheEntry); + + return validateCacheEntry(key, entry); + } catch { + return false; + } +} + +function validateCacheEntry( + key: string, + entry: CacheEntry | CacheEntry, +): boolean { + if (isEmptyValue(entry.value)) { + return false; + } + + if (Date.now() > (entry.expires || 0)) { + return false; + } + + if (isResponseCacheEntry(key, entry)) { + if ((entry.value.status ?? 0) >= 400) { + return false; + } + + if (entry.value.body === undefined) { + return false; + } + + if (entry.value.headers?.etag === 'undefined' || entry.value.headers?.['last-modified'] === 'undefined') { + return false; + } + } + + return true; +} + +function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { + return key.startsWith('nitro:handlers:'); +} diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index bf70536b7800..f99c0e9e1dbd 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -41,11 +41,6 @@ export function captureTracingEvents(): void { globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__ = true; } -/** - * No-op function to satisfy the tracing channel subscribe callbacks - */ -const NOOP = (): void => {}; - /** * Extracts the HTTP status code from a tracing channel result. * The result is the return value of the traced handler, which is a Response for srvx @@ -126,8 +121,6 @@ function setupH3TracingChannels(): void { start: (data: H3TracingRequestEvent) => { setServerTimingHeaders(data.event); }, - asyncStart: NOOP, - end: NOOP, asyncEnd: (data: TracingChannelContextWithSpan) => { onTraceEnd(data); @@ -192,9 +185,6 @@ function setupSrvxTracingChannels(): void { // Subscribe to events (span already created in bindStore) fetchChannel.subscribe({ - start: () => {}, - asyncStart: () => {}, - end: () => {}, asyncEnd: data => { onTraceEnd(data); @@ -239,9 +229,6 @@ function setupSrvxTracingChannels(): void { // Subscribe to events (span already created in bindStore) middlewareChannel.subscribe({ - start: () => {}, - asyncStart: () => {}, - end: () => {}, asyncEnd: onTraceEnd, error: onTraceError, }); diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 2feee84bcc55..9fd3f93a6f40 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,9 +1,11 @@ import { definePlugin } from 'nitro'; import { captureErrorHook } from '../hooks/captureErrorHook'; +import { captureStorageEvents } from '../hooks/captureStorageEvents'; import { captureTracingEvents } from '../hooks/captureTracingEvents'; export default definePlugin(nitroApp => { nitroApp.hooks.hook('error', captureErrorHook); captureTracingEvents(); + captureStorageEvents(); }); diff --git a/packages/node-core/src/common-exports.ts b/packages/node-core/src/common-exports.ts index b2f1deee7f4a..ddedd5c171eb 100644 --- a/packages/node-core/src/common-exports.ts +++ b/packages/node-core/src/common-exports.ts @@ -33,7 +33,6 @@ export { consoleIntegration } from './integrations/console'; export { getSentryRelease, defaultStackParser } from './sdk/api'; export { createGetModuleFromFilename } from './utils/module'; export { addOriginToSpan } from './utils/addOriginToSpan'; -export { getRequestUrl } from './utils/getRequestUrl'; export { initializeEsmLoader } from './sdk/esmLoader'; export { isCjs } from './utils/detection'; export { createMissingInstrumentationContext } from './utils/createMissingInstrumentationContext'; @@ -124,6 +123,7 @@ export { withStreamedSpan, metrics, envToBool, + getRequestUrl, } from '@sentry/core'; export type { diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index a9633b94c25d..8ce1cade960d 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -3,6 +3,9 @@ export { httpIntegration } from './integrations/http'; export { httpServerSpansIntegration } from './integrations/http/httpServerSpansIntegration'; export { httpServerIntegration } from './integrations/http/httpServerIntegration'; +export type { HttpServerIntegrationOptions } from './integrations/http/httpServerIntegration'; +export type { HttpServerSpansIntegrationOptions } from './integrations/http/httpServerSpansIntegration'; + export { SentryHttpInstrumentation, type SentryHttpInstrumentationOptions, diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index a39f75bfa2a9..3c3904582315 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -15,7 +15,7 @@ import type { IntegrationFn, OsContext, } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core'; export const readFileAsync = promisify(readFile); export const readDirAsync = promisify(readdir); @@ -42,8 +42,6 @@ interface ContextOptions { } const _nodeContextIntegration = ((options: ContextOptions = {}) => { - let cachedContext: Promise | undefined; - const _options = { app: true, os: true, @@ -53,13 +51,56 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { ...options, }; - /** Add contexts to the event. Caches the context so we only look it up once. */ - async function addContext(event: Event): Promise { - if (cachedContext === undefined) { - cachedContext = _getContexts(); + // Compute contexts eagerly (shared between tx and span paths) + const appContext = _options.app ? getAppContext() : undefined; + const deviceContext = _options.device ? getDeviceContext(_options.device) : undefined; + const cultureContext = _options.culture ? getCultureContext() : undefined; + const cloudResourceContext = _options.cloudResource ? getCloudResourceContext() : undefined; + const osContextPromise = _options.os ? getOsContext() : undefined; + + // Map static context data to span attributes + const cachedSpanAttributes: Record = { + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + ...contextsToSpanAttributes({ + app: appContext, + device: deviceContext, + culture: cultureContext, + cloud_resource: cloudResourceContext, + }), + }; + + if (osContextPromise) { + osContextPromise + .then(osCtx => Object.assign(cachedSpanAttributes, contextsToSpanAttributes({ os: osCtx }))) + .catch(() => { + // Ignore - os attributes will be undefined + }); + } + + // Build contexts for event processing (reuses same data, awaits async OS context) + const contextsPromise: Promise = (async () => { + const contexts: Contexts = {}; + if (osContextPromise) { + contexts.os = await osContextPromise; + } + if (appContext) { + contexts.app = appContext; } + if (deviceContext) { + contexts.device = deviceContext; + } + if (cultureContext) { + contexts.culture = cultureContext; + } + if (cloudResourceContext) { + contexts.cloud_resource = cloudResourceContext; + } + return contexts; + })(); - const updatedContext = _updateContext(await cachedContext); + async function addContext(event: Event): Promise { + const updatedContext = _updateContext(await contextsPromise); // TODO(v11): conditional with `sendDefaultPii` here? event.contexts = { @@ -74,42 +115,15 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { return event; } - /** Get the contexts from node. */ - async function _getContexts(): Promise { - const contexts: Contexts = {}; - - if (_options.os) { - contexts.os = await getOsContext(); - } - - if (_options.app) { - contexts.app = getAppContext(); - } - - if (_options.device) { - contexts.device = getDeviceContext(_options.device); - } - - if (_options.culture) { - const culture = getCultureContext(); - - if (culture) { - contexts.culture = culture; - } - } - - if (_options.cloudResource) { - contexts.cloud_resource = getCloudResourceContext(); - } - - return contexts; - } - return { name: INTEGRATION_NAME, processEvent(event) { return addContext(event); }, + processSegmentSpan(span) { + safeSetSpanJSONAttributes(span, cachedSpanAttributes); + safeSetSpanJSONAttributes(span, getDynamicSpanAttributes(appContext, deviceContext)); + }, }; }) satisfies IntegrationFn; @@ -142,6 +156,98 @@ function _updateContext(contexts: Contexts): Contexts { return contexts; } +export function contextsToSpanAttributes(contexts: Contexts): Record { + const attrs: Record = {}; + + const { app, device, os: osCtx, culture, cloud_resource } = contexts; + + if (app) { + if (app.app_start_time) { + attrs['app.start_time'] = app.app_start_time; + } + } + + if (device) { + if (device.arch) { + attrs['device.archs'] = [device.arch]; + } + if (device.boot_time) { + attrs['device.boot_time'] = device.boot_time; + } + if (device.memory_size != null) { + attrs['device.memory_size'] = device.memory_size; + } + if (device.processor_count != null) { + attrs['device.processor_count'] = device.processor_count; + } + if (device.cpu_description) { + attrs['device.cpu_description'] = device.cpu_description; + } + if (device.processor_frequency != null) { + attrs['device.processor_frequency'] = device.processor_frequency; + } + } + + if (osCtx) { + if (osCtx.name) { + attrs['os.name'] = osCtx.name; + } + if (osCtx.version) { + attrs['os.version'] = osCtx.version; + } + if (osCtx.kernel_version) { + attrs['os.kernel_version'] = osCtx.kernel_version; + } + if (osCtx.build) { + attrs['os.build'] = osCtx.build; + } + } + + if (culture) { + if (culture.locale) { + attrs['culture.locale'] = culture.locale; + } + if (culture.timezone) { + attrs['culture.timezone'] = culture.timezone; + } + } + + // CloudResourceContext already uses dot-notation keys matching span attribute conventions + if (cloud_resource) { + for (const [key, value] of Object.entries(cloud_resource)) { + if (value != null) { + attrs[key] = value; + } + } + } + + return attrs; +} + +export function getDynamicSpanAttributes( + appContext: AppContext | undefined, + deviceContext: DeviceContext | undefined, +): Record { + const attrs: Record = {}; + + if (appContext) { + attrs['app.memory'] = process.memoryUsage().rss; + if (typeof (process as ProcessWithCurrentValues).availableMemory === 'function') { + const freeMemory = (process as ProcessWithCurrentValues).availableMemory?.(); + if (freeMemory != null) { + attrs['app.free_memory'] = freeMemory; + } + } + } + + // Only include if memory tracking was initially enabled (indicated by free_memory being set) + if (deviceContext?.free_memory != null) { + attrs['device.free_memory'] = os.freemem(); + } + + return attrs; +} + /** * Returns the operating system context. * diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index 60cf7bbae9aa..9a53f4ed926e 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,48 +1,28 @@ -/* eslint-disable max-lines */ -import type { ChannelListener } from 'node:diagnostics_channel'; -import { subscribe, unsubscribe } from 'node:diagnostics_channel'; -import { errorMonitor } from 'node:events'; -import type * as http from 'node:http'; -import type * as https from 'node:https'; -import { context, SpanStatusCode, trace } from '@opentelemetry/api'; +import { subscribe } from 'node:diagnostics_channel'; +import { context, trace } from '@opentelemetry/api'; import { isTracingSuppressed } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import { - ATTR_HTTP_RESPONSE_STATUS_CODE, - ATTR_NETWORK_PEER_ADDRESS, - ATTR_NETWORK_PEER_PORT, - ATTR_NETWORK_PROTOCOL_VERSION, - ATTR_NETWORK_TRANSPORT, - ATTR_URL_FULL, - ATTR_USER_AGENT_ORIGINAL, - SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH, - SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED, -} from '@opentelemetry/semantic-conventions'; -import type { Span, SpanAttributes, SpanStatus } from '@sentry/core'; -import { - debug, - getHttpSpanDetailsFromUrlObject, - getSpanStatusFromHttpCode, - LRUMap, - parseStringToURLObject, - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - startInactiveSpan, +import type { ClientRequest, IncomingMessage, ServerResponse } from 'node:http'; +import type { + HttpClientRequest, + HttpIncomingMessage, + HttpInstrumentationOptions, + HttpModuleExport, + Span, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; +import { getHttpClientSubscriptions, patchHttpModuleClient, SDK_VERSION, getRequestOptions } from '@sentry/core'; import { INSTRUMENTATION_NAME } from './constants'; -import { - addRequestBreadcrumb, - addTracePropagationHeadersToOutgoingRequest, - getClientRequestUrl, - getRequestOptions, -} from './outgoing-requests'; +import { HTTP_ON_CLIENT_REQUEST } from '@sentry/core'; +import { NODE_VERSION } from '../../nodeVersion'; +import { errorMonitor } from 'node:events'; +import * as http from 'node:http'; +import * as https from 'node:https'; -type Http = typeof http; -type Https = typeof https; -type IncomingHttpHeaders = http.IncomingHttpHeaders; -type OutgoingHttpHeaders = http.OutgoingHttpHeaders; +const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = + (NODE_VERSION.major === 22 && NODE_VERSION.minor >= 12) || + (NODE_VERSION.major === 23 && NODE_VERSION.minor >= 2) || + NODE_VERSION.major >= 24; export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** @@ -96,19 +76,19 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * Hooks for outgoing request spans, called when `createSpansForOutgoingRequests` is enabled. * These mirror the OTEL HttpInstrumentation hooks for backwards compatibility. */ - outgoingRequestHook?: (span: Span, request: http.ClientRequest) => void; - outgoingResponseHook?: (span: Span, response: http.IncomingMessage) => void; + outgoingRequestHook?: (span: Span, request: ClientRequest | HttpClientRequest) => void; + outgoingResponseHook?: (span: Span, response: IncomingMessage | HttpIncomingMessage) => void; outgoingRequestApplyCustomAttributes?: ( span: Span, - request: http.ClientRequest, - response: http.IncomingMessage, + request: HttpClientRequest, + response: HttpIncomingMessage, ) => void; // All options below do not do anything anymore in this instrumentation, and will be removed in the future. // They are only kept here for backwards compatibility - the respective functionality is now handled by the httpServerIntegration/httpServerSpansIntegration. /** - * @depreacted This no longer does anything. + * @deprecated This no longer does anything. */ extractIncomingTraceFromHeader?: boolean; @@ -125,7 +105,7 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** * @deprecated This no longer does anything. */ - ignoreSpansForIncomingRequests?: (urlPath: string, request: http.IncomingMessage) => boolean; + ignoreSpansForIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; /** * @deprecated This no longer does anything. @@ -146,12 +126,12 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * @deprecated This no longer does anything. */ instrumentation?: { - requestHook?: (span: Span, req: http.ClientRequest | http.IncomingMessage) => void; - responseHook?: (span: Span, response: http.IncomingMessage | http.ServerResponse) => void; + requestHook?: (span: Span, req: ClientRequest | IncomingMessage) => void; + responseHook?: (span: Span, response: IncomingMessage | ServerResponse) => void; applyCustomAttributesOnSpan?: ( span: Span, - request: http.ClientRequest | http.IncomingMessage, - response: http.IncomingMessage | http.ServerResponse, + request: ClientRequest | IncomingMessage, + response: IncomingMessage | ServerResponse, ) => void; }; @@ -178,64 +158,80 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts */ export class SentryHttpInstrumentation extends InstrumentationBase { - private _propagationDecisionMap: LRUMap; - private _ignoreOutgoingRequestsMap: WeakMap; - public constructor(config: SentryHttpInstrumentationOptions = {}) { super(INSTRUMENTATION_NAME, SDK_VERSION, config); - - this._propagationDecisionMap = new LRUMap(100); - this._ignoreOutgoingRequestsMap = new WeakMap(); } /** @inheritdoc */ public init(): [InstrumentationNodeModuleDefinition, InstrumentationNodeModuleDefinition] { - // We register handlers when either http or https is instrumented - // but we only want to register them once, whichever is loaded first - let hasRegisteredHandlers = false; - - const onHttpClientResponseFinish = ((_data: unknown) => { - const data = _data as { request: http.ClientRequest; response: http.IncomingMessage }; - this._onOutgoingRequestFinish(data.request, data.response); - }) satisfies ChannelListener; - - const onHttpClientRequestError = ((_data: unknown) => { - const data = _data as { request: http.ClientRequest }; - this._onOutgoingRequestFinish(data.request, undefined); - }) satisfies ChannelListener; - - const onHttpClientRequestCreated = ((_data: unknown) => { - const data = _data as { request: http.ClientRequest }; - this._onOutgoingRequestCreated(data.request); - }) satisfies ChannelListener; - - const wrap = (moduleExports: T): T => { - if (hasRegisteredHandlers) { - return moduleExports; - } + const { outgoingRequestApplyCustomAttributes: applyCustomAttributesOnSpan, ...options } = this.getConfig(); + const patchOptions: HttpInstrumentationOptions = { + propagateTrace: options.propagateTraceInOutgoingRequests, + applyCustomAttributesOnSpan, + ...options, + spans: options.createSpansForOutgoingRequests && (options.spans ?? true), + ignoreOutgoingRequests(url, request) { + return ( + isTracingSuppressed(context.active()) || + !!options.ignoreOutgoingRequests?.(url, getRequestOptions(request as ClientRequest)) + ); + }, + outgoingRequestHook(span, request) { + options.outgoingRequestHook?.(span, request); + // We monkey-patch `req.once('response'), which is used to trigger + // the callback of the request, so that it runs in the active context + // eslint-disable-next-line @typescript-eslint/unbound-method, deprecation/deprecation + const originalOnce = request.once; + + const newOnce = new Proxy(originalOnce, { + apply(target, thisArg, args: Parameters) { + const [event] = args; + if (event !== 'response') { + return target.apply(thisArg, args); + } + + const parentContext = context.active(); + const requestContext = trace.setSpan(parentContext, span); + + return context.with(requestContext, () => { + return target.apply(thisArg, args); + }); + }, + }); - hasRegisteredHandlers = true; + // eslint-disable-next-line deprecation/deprecation + request.once = newOnce; + }, + outgoingResponseHook(span, response) { + options.outgoingResponseHook?.(span, response); + context.bind(context.active(), response); + }, + errorMonitor, + // Pass these in to detect OTel double-wrapping if we're enabling spans + http, + https, + }; - subscribe('http.client.response.finish', onHttpClientResponseFinish); + // only generate the subscriber function if we'll actually use it + const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL + ? getHttpClientSubscriptions(patchOptions) + : {}; - // When an error happens, we still want to have a breadcrumb - // In this case, `http.client.response.finish` is not triggered - subscribe('http.client.request.error', onHttpClientRequestError); + // guard because we cover both http and https with the same subscribers + let hasRegisteredHandlers = false; + const sub = onHttpClientRequestCreated + ? (moduleExports: T): T => { + if (!hasRegisteredHandlers && onHttpClientRequestCreated) { + hasRegisteredHandlers = true; + subscribe(HTTP_ON_CLIENT_REQUEST, onHttpClientRequestCreated); + } + return moduleExports; + } + : undefined; - // NOTE: This channel only exists since Node 22.12+ - // Before that, outgoing requests are not patched - // and trace headers are not propagated, sadly. - if (this.getConfig().propagateTraceInOutgoingRequests || this.getConfig().createSpansForOutgoingRequests) { - subscribe('http.client.request.created', onHttpClientRequestCreated); - } - return moduleExports; - }; + const wrapHttp = sub ?? ((moduleExports: HttpModuleExport) => patchHttpModuleClient(moduleExports, patchOptions)); - const unwrap = (): void => { - unsubscribe('http.client.response.finish', onHttpClientResponseFinish); - unsubscribe('http.client.request.error', onHttpClientRequestError); - unsubscribe('http.client.request.created', onHttpClientRequestCreated); - }; + const wrapHttps = sub ?? ((moduleExports: HttpModuleExport) => patchHttpModuleClient(moduleExports, patchOptions)); /** * You may be wondering why we register these diagnostics-channel listeners @@ -246,284 +242,8 @@ export class SentryHttpInstrumentation extends InstrumentationBase) { - const [event] = args; - if (event !== 'response') { - return target.apply(thisArg, args); - } - - const parentContext = context.active(); - const requestContext = trace.setSpan(parentContext, span); - - return context.with(requestContext, () => { - return target.apply(thisArg, args); - }); - }, - }); - - // eslint-disable-next-line deprecation/deprecation - request.once = newOnce; - - /** - * Determines if the request has errored or the response has ended/errored. - */ - let responseFinished = false; - - const endSpan = (status: SpanStatus): void => { - if (responseFinished) { - return; - } - responseFinished = true; - - span.setStatus(status); - span.end(); - }; - - request.prependListener('response', response => { - if (request.listenerCount('response') <= 1) { - response.resume(); - } - - context.bind(context.active(), response); - - const additionalAttributes = _getOutgoingRequestEndedSpanData(response); - span.setAttributes(additionalAttributes); - - this.getConfig().outgoingResponseHook?.(span, response); - this.getConfig().outgoingRequestApplyCustomAttributes?.(span, request, response); - - const endHandler = (forceError: boolean = false): void => { - this._diag.debug('outgoingRequest on end()'); - - const status = - // eslint-disable-next-line deprecation/deprecation - forceError || typeof response.statusCode !== 'number' || (response.aborted && !response.complete) - ? { code: SpanStatusCode.ERROR } - : getSpanStatusFromHttpCode(response.statusCode); - - endSpan(status); - }; - - response.on('end', () => { - endHandler(); - }); - response.on(errorMonitor, error => { - this._diag.debug('outgoingRequest on response error()', error); - endHandler(true); - }); - }); - - // Fallback if proper response end handling above fails - request.on('close', () => { - endSpan({ code: SpanStatusCode.UNSET }); - }); - request.on(errorMonitor, error => { - this._diag.debug('outgoingRequest on request error()', error); - endSpan({ code: SpanStatusCode.ERROR }); - }); - - return span; - } - - /** - * This is triggered when an outgoing request finishes. - * It has access to the final request and response objects. - */ - private _onOutgoingRequestFinish(request: http.ClientRequest, response?: http.IncomingMessage): void { - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Handling finished outgoing request'); - - const _breadcrumbs = this.getConfig().breadcrumbs; - const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; - - // Note: We cannot rely on the map being set by `_onOutgoingRequestCreated`, because that is not run in Node <22 - const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request) ?? this._shouldIgnoreOutgoingRequest(request); - this._ignoreOutgoingRequestsMap.set(request, shouldIgnore); - - if (breadCrumbsEnabled && !shouldIgnore) { - addRequestBreadcrumb(request, response); - } - } - - /** - * This is triggered when an outgoing request is created. - * It creates a span (if enabled) and propagates trace headers within the span's context, - * so downstream services link to the outgoing HTTP span rather than its parent. - */ - private _onOutgoingRequestCreated(request: http.ClientRequest): void { - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Handling outgoing request created'); - - const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request) ?? this._shouldIgnoreOutgoingRequest(request); - this._ignoreOutgoingRequestsMap.set(request, shouldIgnore); - - if (shouldIgnore) { - return; - } - - const shouldCreateSpan = this.getConfig().createSpansForOutgoingRequests && (this.getConfig().spans ?? true); - const shouldPropagate = this.getConfig().propagateTraceInOutgoingRequests; - - if (shouldCreateSpan) { - const span = this._startSpanForOutgoingRequest(request); - - // Propagate headers within the span's context so the sentry-trace header - // contains the outgoing span's ID, not the parent span's ID. - // Only do this if the span is recording (has a parent) - otherwise the non-recording - // span would produce all-zero trace IDs instead of using the scope's propagation context. - if (shouldPropagate && span.isRecording()) { - const requestContext = trace.setSpan(context.active(), span); - context.with(requestContext, () => { - addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); - }); - } else if (shouldPropagate) { - addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); - } - } else if (shouldPropagate) { - addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); - } - } - - /** - * Check if the given outgoing request should be ignored. - */ - private _shouldIgnoreOutgoingRequest(request: http.ClientRequest): boolean { - if (isTracingSuppressed(context.active())) { - return true; - } - - const ignoreOutgoingRequests = this.getConfig().ignoreOutgoingRequests; - - if (!ignoreOutgoingRequests) { - return false; - } - - const options = getRequestOptions(request); - const url = getClientRequestUrl(request); - return ignoreOutgoingRequests(url, options); - } -} - -function _getOutgoingRequestSpanData(request: http.ClientRequest): [string, SpanAttributes] { - const url = getClientRequestUrl(request); - - const [name, attributes] = getHttpSpanDetailsFromUrlObject( - parseStringToURLObject(url), - 'client', - 'auto.http.otel.http', - request, - ); - - const userAgent = request.getHeader('user-agent'); - - return [ - name, - { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', - 'otel.kind': 'CLIENT', - [ATTR_USER_AGENT_ORIGINAL]: userAgent, - [ATTR_URL_FULL]: url, - 'http.url': url, - 'http.method': request.method, - 'http.target': request.path || '/', - 'net.peer.name': request.host, - 'http.host': request.getHeader('host'), - ...attributes, - }, - ]; -} - -/** - * Exported for testing purposes. - */ -export function _getOutgoingRequestEndedSpanData(response: http.IncomingMessage): SpanAttributes { - const { statusCode, statusMessage, httpVersion, socket } = response; - - // httpVersion can be undefined in some cases and we seem to have encountered this before: - // https://github.com/getsentry/sentry-javascript/blob/ec8c8c64cde6001123db0199a8ca017b8863eac8/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts#L158 - // see: #20415 - const transport = httpVersion?.toUpperCase() !== 'QUIC' ? 'ip_tcp' : 'ip_udp'; - - const additionalAttributes: SpanAttributes = { - [ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode, - [ATTR_NETWORK_PROTOCOL_VERSION]: httpVersion, - 'http.flavor': httpVersion, - [ATTR_NETWORK_TRANSPORT]: transport, - 'net.transport': transport, - ['http.status_text']: statusMessage?.toUpperCase(), - 'http.status_code': statusCode, - ...getResponseContentLengthAttributes(response), - }; - - if (socket) { - const { remoteAddress, remotePort } = socket; - - additionalAttributes[ATTR_NETWORK_PEER_ADDRESS] = remoteAddress; - additionalAttributes[ATTR_NETWORK_PEER_PORT] = remotePort; - additionalAttributes['net.peer.ip'] = remoteAddress; - additionalAttributes['net.peer.port'] = remotePort; - } - - return additionalAttributes; -} - -function getResponseContentLengthAttributes(response: http.IncomingMessage): SpanAttributes { - const length = getContentLength(response.headers); - if (length == null) { - return {}; - } - - if (isCompressed(response.headers)) { - // eslint-disable-next-line deprecation/deprecation - return { [SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH]: length }; - } else { - // eslint-disable-next-line deprecation/deprecation - return { [SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED]: length }; - } -} - -function getContentLength(headers: http.OutgoingHttpHeaders | http.IncomingHttpHeaders): number | undefined { - const contentLengthHeader = headers['content-length']; - if (typeof contentLengthHeader === 'number') { - return contentLengthHeader; - } - if (typeof contentLengthHeader !== 'string') { - return undefined; - } - - const contentLength = parseInt(contentLengthHeader, 10); - if (isNaN(contentLength)) { - return undefined; - } - - return contentLength; -} - -function isCompressed(headers: OutgoingHttpHeaders | IncomingHttpHeaders): boolean { - const encoding = headers['content-encoding']; - - return !!encoding && encoding !== 'identity'; } diff --git a/packages/node-core/src/integrations/http/constants.ts b/packages/node-core/src/integrations/http/constants.ts index 6ad7b4319758..35073ae0491d 100644 --- a/packages/node-core/src/integrations/http/constants.ts +++ b/packages/node-core/src/integrations/http/constants.ts @@ -1,4 +1 @@ export const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; - -/** We only want to capture request bodies up to 1mb. */ -export const MAX_BODY_BYTE_LENGTH = 1024 * 1024; diff --git a/packages/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts index 986be8d4c8ff..1c08e9f4f16e 100644 --- a/packages/node-core/src/integrations/http/httpServerIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -4,7 +4,7 @@ import type { EventEmitter } from 'node:events'; import type { IncomingMessage, RequestOptions, Server, ServerResponse } from 'node:http'; import type { Socket } from 'node:net'; import { context, createContextKey, propagation } from '@opentelemetry/api'; -import type { AggregationCounts, Client, Integration, IntegrationFn, Scope } from '@sentry/core'; +import type { AggregationCounts, Client, HttpIncomingMessage, Integration, IntegrationFn, Scope } from '@sentry/core'; import { _INTERNAL_safeMathRandom, addNonEnumerableProperty, @@ -30,7 +30,7 @@ interface WeakRefImpl { } type StartSpanCallback = (next: () => boolean) => boolean; -type RequestWithOptionalStartSpanCallback = IncomingMessage & { +type RequestWithOptionalStartSpanCallback = HttpIncomingMessage & { _startSpanCallback?: WeakRefImpl; }; diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index 7909482a5923..3d70387df415 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -1,5 +1,5 @@ import { errorMonitor } from 'node:events'; -import type { ClientRequest, IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'; +import type { IncomingHttpHeaders } from 'node:http'; import { context, SpanKind, trace } from '@opentelemetry/api'; import type { RPCMetadata } from '@opentelemetry/core'; import { getRPCMetadata, isTracingSuppressed, RPCType, setRPCMetadata } from '@opentelemetry/core'; @@ -11,7 +11,17 @@ import { SEMATTRS_NET_HOST_PORT, SEMATTRS_NET_PEER_IP, } from '@opentelemetry/semantic-conventions'; -import type { Event, Integration, IntegrationFn, Span, SpanAttributes, SpanStatus } from '@sentry/core'; +import type { + Event, + HttpClientRequest, + HttpIncomingMessage, + HttpServerResponse, + Integration, + IntegrationFn, + Span, + SpanAttributes, + SpanStatus, +} from '@sentry/core'; import { debug, getIsolationScope, @@ -43,7 +53,7 @@ export interface HttpServerSpansIntegrationOptions { * The `request` param contains the original {@type IncomingMessage} object of the incoming request. * You can use it to filter on additional properties like method, headers, etc. */ - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; /** * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. @@ -66,12 +76,12 @@ export interface HttpServerSpansIntegrationOptions { * @deprecated This is deprecated in favor of `incomingRequestSpanHook`. */ instrumentation?: { - requestHook?: (span: Span, req: ClientRequest | IncomingMessage) => void; - responseHook?: (span: Span, response: IncomingMessage | ServerResponse) => void; + requestHook?: (span: Span, req: HttpClientRequest | HttpIncomingMessage) => void; + responseHook?: (span: Span, response: HttpIncomingMessage | HttpServerResponse) => void; applyCustomAttributesOnSpan?: ( span: Span, - request: ClientRequest | IncomingMessage, - response: IncomingMessage | ServerResponse, + request: HttpClientRequest | HttpIncomingMessage, + response: HttpIncomingMessage | HttpServerResponse, ) => void; }; @@ -79,7 +89,7 @@ export interface HttpServerSpansIntegrationOptions { * A hook that can be used to mutate the span for incoming requests. * This is triggered after the span is created, but before it is recorded. */ - onSpanCreated?: (span: Span, request: IncomingMessage, response: ServerResponse) => void; + onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions = {}) => { @@ -106,8 +116,8 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions client.on('httpServerRequest', (_request, _response, normalizedRequest) => { // Type-casting this here because we do not want to put the node types into core - const request = _request as IncomingMessage; - const response = _response as ServerResponse; + const request = _request as HttpIncomingMessage; + const response = _response as HttpServerResponse; const startSpan = (next: () => boolean): boolean => { if ( @@ -127,7 +137,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions const userAgent = headers['user-agent']; const ips = headers['x-forwarded-for']; const httpVersion = request.httpVersion; - const host = headers.host; + const host = headers.host as string | undefined; const hostname = host?.replace(/^(.*)(:[0-9]{1,5})/, '$1') || 'localhost'; const tracer = client.tracer; @@ -264,7 +274,7 @@ export const httpServerSpansIntegration = _httpServerSpansIntegration as ( processEvent: (event: Event) => Event | null; }; -function isKnownPrefetchRequest(req: IncomingMessage): boolean { +function isKnownPrefetchRequest(req: HttpIncomingMessage): boolean { // Currently only handles Next.js prefetch requests but may check other frameworks in the future. return req.headers['next-router-prefetch'] === '1'; } @@ -290,13 +300,13 @@ export function isStaticAssetRequest(urlPath: string): boolean { } function shouldIgnoreSpansForIncomingRequest( - request: IncomingMessage, + request: HttpIncomingMessage, { ignoreStaticAssets, ignoreIncomingRequests, }: { ignoreStaticAssets?: boolean; - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; }, ): boolean { if (isTracingSuppressed(context.active())) { @@ -325,7 +335,7 @@ function shouldIgnoreSpansForIncomingRequest( return false; } -function getRequestContentLengthAttribute(request: IncomingMessage): SpanAttributes { +function getRequestContentLengthAttribute(request: HttpIncomingMessage): SpanAttributes { const length = getContentLength(request.headers); if (length == null) { return {}; @@ -358,7 +368,10 @@ function isCompressed(headers: IncomingHttpHeaders): boolean { return !!encoding && encoding !== 'identity'; } -function getIncomingRequestAttributesOnResponse(request: IncomingMessage, response: ServerResponse): SpanAttributes { +function getIncomingRequestAttributesOnResponse( + request: HttpIncomingMessage, + response: HttpServerResponse, +): SpanAttributes { // take socket from the request, // since it may be detached from the response object in keep-alive mode const { socket } = request; diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts index 34cb86704415..a59186875c76 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -1,4 +1,5 @@ -import type { IncomingMessage, RequestOptions } from 'node:http'; +import type { RequestOptions } from 'node:http'; +import type { HttpIncomingMessage } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '../../otel/instrument'; import type { NodeClient } from '../../sdk/client'; @@ -73,7 +74,7 @@ interface HttpOptions { * The `request` param contains the original {@type IncomingMessage} object of the incoming request. * You can use it to filter on additional properties like method, headers, etc. */ - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; /** * Do not capture spans for incoming HTTP requests with the given status codes. diff --git a/packages/node-core/src/integrations/http/outgoing-requests.ts b/packages/node-core/src/integrations/http/outgoing-requests.ts deleted file mode 100644 index b505904906fc..000000000000 --- a/packages/node-core/src/integrations/http/outgoing-requests.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - addRequestBreadcrumb, - addTracePropagationHeadersToOutgoingRequest, - getClientRequestUrl, - getRequestOptions, -} from '../../utils/outgoingHttpRequest'; diff --git a/packages/node-core/src/light/asyncLocalStorageStrategy.ts b/packages/node-core/src/light/asyncLocalStorageStrategy.ts index 1d6f3f413e59..00a7939d664f 100644 --- a/packages/node-core/src/light/asyncLocalStorageStrategy.ts +++ b/packages/node-core/src/light/asyncLocalStorageStrategy.ts @@ -1,6 +1,11 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { Scope } from '@sentry/core'; -import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; +import { + getDefaultCurrentScope, + getDefaultIsolationScope, + setAsyncContextStrategy, + SUPPRESS_TRACING_KEY, +} from '@sentry/core'; /** * Sets the async context strategy to use AsyncLocalStorage. @@ -62,7 +67,7 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { // In contrast to the browser, we can rely on async context isolation here function suppressTracing(callback: () => T): T { return withScope(scope => { - scope.setSDKProcessingMetadata({ __SENTRY_SUPPRESS_TRACING__: true }); + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); return callback(); }); } diff --git a/packages/node-core/src/light/integrations/httpIntegration.ts b/packages/node-core/src/light/integrations/httpIntegration.ts index 0f3d6f5c5cc4..e5a13fd6782d 100644 --- a/packages/node-core/src/light/integrations/httpIntegration.ts +++ b/packages/node-core/src/light/integrations/httpIntegration.ts @@ -1,30 +1,36 @@ -import type { ChannelListener } from 'node:diagnostics_channel'; import { subscribe } from 'node:diagnostics_channel'; -import type { ClientRequest, IncomingMessage, RequestOptions, Server } from 'node:http'; -import type { Integration, IntegrationFn } from '@sentry/core'; +import type { RequestOptions } from 'node:http'; +import type { HttpClientRequest, HttpIncomingMessage, Integration, IntegrationFn } from '@sentry/core'; import { + addOutgoingRequestBreadcrumb, continueTrace, debug, generateSpanId, getCurrentScope, + getHttpClientSubscriptions, getIsolationScope, + HTTP_ON_CLIENT_REQUEST, httpRequestToRequestData, - LRUMap, stripUrlQueryAndFragment, + SUPPRESS_TRACING_KEY, withIsolationScope, + getRequestOptions, + getRequestUrlFromClientRequest, } from '@sentry/core'; +import type { ClientRequest, IncomingMessage, Server } from 'node:http'; import { DEBUG_BUILD } from '../../debug-build'; import { patchRequestToCaptureBody } from '../../utils/captureRequestBody'; -import { - addRequestBreadcrumb, - addTracePropagationHeadersToOutgoingRequest, - getClientRequestUrl, - getRequestOptions, -} from '../../utils/outgoingHttpRequest'; import type { LightNodeClient } from '../client'; +import { errorMonitor } from 'node:events'; +import { NODE_VERSION } from '../../nodeVersion'; const INTEGRATION_NAME = 'Http'; +const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = + (NODE_VERSION.major === 22 && NODE_VERSION.minor >= 12) || + (NODE_VERSION.major === 23 && NODE_VERSION.minor >= 2) || + NODE_VERSION.major >= 24; + // We keep track of emit functions we wrapped, to avoid double wrapping const wrappedEmitFns = new WeakSet(); @@ -83,6 +89,8 @@ export interface HttpIntegrationOptions { const _httpIntegration = ((options: HttpIntegrationOptions = {}) => { const _options = { + ...options, + sessions: false, maxRequestBodySize: options.maxRequestBodySize ?? 'medium', ignoreRequestBody: options.ignoreRequestBody, breadcrumbs: options.breadcrumbs ?? true, @@ -90,40 +98,78 @@ const _httpIntegration = ((options: HttpIntegrationOptions = {}) => { ignoreOutgoingRequests: options.ignoreOutgoingRequests, }; - const propagationDecisionMap = new LRUMap(100); - const ignoreOutgoingRequestsMap = new WeakMap(); - return { name: INTEGRATION_NAME, setupOnce() { - const onHttpServerRequestStart = ((_data: unknown) => { + const onHttpServerRequestStart = (_data: unknown) => { const data = _data as { server: Server }; instrumentServer(data.server, _options); - }) satisfies ChannelListener; - - const onHttpClientRequestCreated = ((_data: unknown) => { - const data = _data as { request: ClientRequest }; - onOutgoingRequestCreated(data.request, _options, propagationDecisionMap, ignoreOutgoingRequestsMap); - }) satisfies ChannelListener; - - const onHttpClientResponseFinish = ((_data: unknown) => { - const data = _data as { request: ClientRequest; response: IncomingMessage }; - onOutgoingRequestFinish(data.request, data.response, _options, ignoreOutgoingRequestsMap); - }) satisfies ChannelListener; - - const onHttpClientRequestError = ((_data: unknown) => { - const data = _data as { request: ClientRequest }; - onOutgoingRequestFinish(data.request, undefined, _options, ignoreOutgoingRequestsMap); - }) satisfies ChannelListener; + }; + + const { ignoreOutgoingRequests } = _options; + + const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = getHttpClientSubscriptions({ + breadcrumbs: _options.breadcrumbs, + propagateTrace: _options.tracePropagation, + ignoreOutgoingRequests: ignoreOutgoingRequests + ? (url, request) => ignoreOutgoingRequests(url, getRequestOptions(request as ClientRequest)) + : undefined, + // No spans in light mode + // means we don't have pass modules to detect OTel double-wrap + spans: false, + errorMonitor, + }); subscribe('http.server.request.start', onHttpServerRequestStart); - subscribe('http.client.request.created', onHttpClientRequestCreated); - subscribe('http.client.response.finish', onHttpClientResponseFinish); - subscribe('http.client.request.error', onHttpClientRequestError); + + // Subscribe on the request creation in node versions that support it + subscribe(HTTP_ON_CLIENT_REQUEST, onHttpClientRequestCreated); + + // fall back to just doing breadcrumbs on the request.end() channel + // if we do not have earlier access to the request object at creation + // time. The http.client.request.error channel is only available on + // the same node versions as client.request.created, so no help. + if (_options.breadcrumbs && !FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL) { + subscribe('http.client.request.start', (data: unknown) => { + const { request } = data as { request: HttpClientRequest }; + request.on(errorMonitor, () => onOutgoingResponseFinish(request, undefined, _options)); + request.prependListener('response', response => { + if (request.listenerCount('response') <= 1) { + response.resume(); + } + onOutgoingResponseFinish(request, response, _options); + }); + }); + } }, }; }) satisfies IntegrationFn; +function onOutgoingResponseFinish( + request: HttpClientRequest, + response: HttpIncomingMessage | undefined, + options: { + breadcrumbs: boolean; + ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; + }, +): void { + if (!options.breadcrumbs) { + return; + } + // Check if tracing is suppressed (e.g. for Sentry's own transport requests) + if (getCurrentScope().getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY]) { + return; + } + const { ignoreOutgoingRequests } = options; + if (ignoreOutgoingRequests) { + const url = getRequestUrlFromClientRequest(request as ClientRequest); + if (ignoreOutgoingRequests(url, getRequestOptions(request as ClientRequest))) { + return; + } + } + addOutgoingRequestBreadcrumb(request, response); +} + /** * This integration handles incoming and outgoing HTTP requests in light mode (without OpenTelemetry). * @@ -221,65 +267,3 @@ function instrumentServer( wrappedEmitFns.add(newEmit); server.emit = newEmit; } - -function onOutgoingRequestCreated( - request: ClientRequest, - options: { tracePropagation: boolean; ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean }, - propagationDecisionMap: LRUMap, - ignoreOutgoingRequestsMap: WeakMap, -): void { - const shouldIgnore = shouldIgnoreOutgoingRequest(request, options); - ignoreOutgoingRequestsMap.set(request, shouldIgnore); - - if (shouldIgnore) { - return; - } - - if (options.tracePropagation) { - addTracePropagationHeadersToOutgoingRequest(request, propagationDecisionMap); - } -} - -function onOutgoingRequestFinish( - request: ClientRequest, - response: IncomingMessage | undefined, - options: { - breadcrumbs: boolean; - ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; - }, - ignoreOutgoingRequestsMap: WeakMap, -): void { - if (!options.breadcrumbs) { - return; - } - - // Note: We cannot rely on the map being set by `onOutgoingRequestCreated`, because that channel - // only exists since Node 22 - const shouldIgnore = ignoreOutgoingRequestsMap.get(request) ?? shouldIgnoreOutgoingRequest(request, options); - - if (shouldIgnore) { - return; - } - - addRequestBreadcrumb(request, response); -} - -/** Check if the given outgoing request should be ignored. */ -function shouldIgnoreOutgoingRequest( - request: ClientRequest, - options: { ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean }, -): boolean { - // Check if tracing is suppressed (e.g. for Sentry's own transport requests) - if (getCurrentScope().getScopeData().sdkProcessingMetadata.__SENTRY_SUPPRESS_TRACING__) { - return true; - } - - const { ignoreOutgoingRequests } = options; - - if (!ignoreOutgoingRequests) { - return false; - } - - const url = getClientRequestUrl(request); - return ignoreOutgoingRequests(url, getRequestOptions(request)); -} diff --git a/packages/node-core/src/utils/baggage.ts b/packages/node-core/src/utils/baggage.ts deleted file mode 100644 index 496c834d5c23..000000000000 --- a/packages/node-core/src/utils/baggage.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { objectToBaggageHeader, parseBaggageHeader, SENTRY_BAGGAGE_KEY_PREFIX } from '@sentry/core'; - -/** - * Merge two baggage headers into one. - * - Sentry-specific entries (keys starting with "sentry-") from the new baggage take precedence - * - Non-Sentry entries from existing baggage take precedence - * The order of the existing baggage will be preserved, and new entries will be added to the end. - * - * This matches the behavior of OTEL's propagation.inject() which uses baggage.setEntry() - * to overwrite existing entries with the same key. - */ -export function mergeBaggageHeaders( - existing: Existing, - baggage: string, -): string | undefined | Existing { - if (!existing) { - return baggage; - } - - const existingBaggageEntries = parseBaggageHeader(existing); - const newBaggageEntries = parseBaggageHeader(baggage); - - if (!newBaggageEntries) { - return existing; - } - - // Single pass over new entries to partition sentry vs non-sentry - const newSentryEntries: Record = {}; - const newNonSentryEntries: Record = {}; - for (const [key, value] of Object.entries(newBaggageEntries)) { - if (key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { - newSentryEntries[key] = value; - } else { - newNonSentryEntries[key] = value; - } - } - - const hasNewSentryEntries = Object.keys(newSentryEntries).length > 0; - - // If new baggage contains at least one sentry- value, we remove all old sentry- values - // otherwise, we keep old sentry- values. If we don't remove old sentry- values, we end - // up with an inconsistent dynamic sampling context propagation. - const mergedBaggageEntries: Record = {}; - if (existingBaggageEntries) { - for (const [key, value] of Object.entries(existingBaggageEntries)) { - if (hasNewSentryEntries && key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { - continue; - } - mergedBaggageEntries[key] = value; - } - } - - // Sentry entries from new baggage always overwrite; non-sentry only if not already present - Object.assign(mergedBaggageEntries, newSentryEntries); - for (const [key, value] of Object.entries(newNonSentryEntries)) { - if (!mergedBaggageEntries[key]) { - mergedBaggageEntries[key] = value; - } - } - - return objectToBaggageHeader(mergedBaggageEntries); -} diff --git a/packages/node-core/src/utils/captureRequestBody.ts b/packages/node-core/src/utils/captureRequestBody.ts index 023209223f82..7afb1e40c530 100644 --- a/packages/node-core/src/utils/captureRequestBody.ts +++ b/packages/node-core/src/utils/captureRequestBody.ts @@ -1,8 +1,7 @@ import type { IncomingMessage } from 'node:http'; import type { Scope } from '@sentry/core'; -import { debug } from '@sentry/core'; +import { debug, getMaxBodyByteLength, type MaxRequestBodySize } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; -import { MAX_BODY_BYTE_LENGTH } from '../integrations/http/constants'; /** * This method patches the request object to capture the body. @@ -13,7 +12,7 @@ import { MAX_BODY_BYTE_LENGTH } from '../integrations/http/constants'; export function patchRequestToCaptureBody( req: IncomingMessage, isolationScope: Scope, - maxIncomingRequestBodySize: 'small' | 'medium' | 'always', + maxIncomingRequestBodySize: Exclude, integrationName: string, ): void { let bodyByteLength = 0; @@ -28,12 +27,7 @@ export function patchRequestToCaptureBody( */ const callbackMap = new WeakMap(); - const maxBodySize = - maxIncomingRequestBodySize === 'small' - ? 1_000 - : maxIncomingRequestBodySize === 'medium' - ? 10_000 - : MAX_BODY_BYTE_LENGTH; + const maxBodySize = getMaxBodyByteLength(maxIncomingRequestBodySize); try { // eslint-disable-next-line @typescript-eslint/unbound-method diff --git a/packages/node-core/src/utils/getRequestUrl.ts b/packages/node-core/src/utils/getRequestUrl.ts deleted file mode 100644 index 73ddd33b447b..000000000000 --- a/packages/node-core/src/utils/getRequestUrl.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** Build a full URL from request options or a ClientRequest. */ -export function getRequestUrl(requestOptions: { - protocol?: string | null; - hostname?: string | null; - host?: string | null; - port?: string | number | null; - path?: string | null; -}): string { - const protocol = requestOptions.protocol || ''; - const hostname = requestOptions.hostname || requestOptions.host || ''; - // Don't log standard :80 (http) and :443 (https) ports to reduce the noise - // Also don't add port if the hostname already includes a port - const port = - !requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 || /^(.*):(\d+)$/.test(hostname) - ? '' - : `:${requestOptions.port}`; - const path = requestOptions.path ? requestOptions.path : '/'; - return `${protocol}//${hostname}${port}${path}`; -} diff --git a/packages/node-core/src/utils/outgoingFetchRequest.ts b/packages/node-core/src/utils/outgoingFetchRequest.ts index 85edd6a73b58..ea5a67ab1fde 100644 --- a/packages/node-core/src/utils/outgoingFetchRequest.ts +++ b/packages/node-core/src/utils/outgoingFetchRequest.ts @@ -7,9 +7,9 @@ import { getTraceData, parseUrl, shouldPropagateTraceForUrl, + mergeBaggageHeaders, } from '@sentry/core'; import type { UndiciRequest, UndiciResponse } from '../integrations/node-fetch/types'; -import { mergeBaggageHeaders } from './baggage'; import { debug } from '@sentry/core'; const SENTRY_TRACE_HEADER = 'sentry-trace'; const SENTRY_BAGGAGE_HEADER = 'baggage'; diff --git a/packages/node-core/src/utils/outgoingHttpRequest.ts b/packages/node-core/src/utils/outgoingHttpRequest.ts deleted file mode 100644 index 34624900b472..000000000000 --- a/packages/node-core/src/utils/outgoingHttpRequest.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { LRUMap, SanitizedRequestData } from '@sentry/core'; -import { - addBreadcrumb, - debug, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - getSanitizedUrlString, - getTraceData, - isError, - parseUrl, - shouldPropagateTraceForUrl, -} from '@sentry/core'; -import type { ClientRequest, IncomingMessage, RequestOptions } from 'http'; -import { DEBUG_BUILD } from '../debug-build'; -import { mergeBaggageHeaders } from './baggage'; - -const LOG_PREFIX = '@sentry/instrumentation-http'; - -/** Add a breadcrumb for outgoing requests. */ -export function addRequestBreadcrumb(request: ClientRequest, response: IncomingMessage | undefined): void { - const data = getBreadcrumbData(request); - - const statusCode = response?.statusCode; - const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); - - addBreadcrumb( - { - category: 'http', - data: { - status_code: statusCode, - ...data, - }, - type: 'http', - level, - }, - { - event: 'response', - request, - response, - }, - ); -} - -/** - * Add trace propagation headers to an outgoing request. - * This must be called _before_ the request is sent! - */ -// eslint-disable-next-line complexity -export function addTracePropagationHeadersToOutgoingRequest( - request: ClientRequest, - propagationDecisionMap: LRUMap, -): void { - const url = getClientRequestUrl(request); - - const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() || {}; - const headersToAdd = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap) - ? getTraceData({ propagateTraceparent }) - : undefined; - - if (!headersToAdd) { - return; - } - - const { 'sentry-trace': sentryTrace, baggage, traceparent } = headersToAdd; - - const hasExistingSentryTraceHeader = !!request.getHeader('sentry-trace'); - - if (hasExistingSentryTraceHeader) { - return; - } - - if (sentryTrace) { - try { - request.setHeader('sentry-trace', sentryTrace); - DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added sentry-trace header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - LOG_PREFIX, - 'Failed to add sentry-trace header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - - if (traceparent && !request.getHeader('traceparent')) { - try { - request.setHeader('traceparent', traceparent); - DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added traceparent header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - LOG_PREFIX, - 'Failed to add traceparent header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - - if (baggage) { - const existingBaggage = request.getHeader('baggage'); - const newBaggage = mergeBaggageHeaders(existingBaggage, baggage); - if (newBaggage) { - try { - request.setHeader('baggage', newBaggage); - DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added baggage header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - LOG_PREFIX, - 'Failed to add baggage header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - } -} - -function getBreadcrumbData(request: ClientRequest): Partial { - try { - // `request.host` does not contain the port, but the host header does - const host = request.getHeader('host') || request.host; - const url = new URL(request.path, `${request.protocol}//${host}`); - const parsedUrl = parseUrl(url.toString()); - - const data: Partial = { - url: getSanitizedUrlString(parsedUrl), - 'http.method': request.method || 'GET', - }; - - if (parsedUrl.search) { - data['http.query'] = parsedUrl.search; - } - if (parsedUrl.hash) { - data['http.fragment'] = parsedUrl.hash; - } - - return data; - } catch { - return {}; - } -} - -/** Convert an outgoing request to request options. */ -export function getRequestOptions(request: ClientRequest): RequestOptions { - return { - method: request.method, - protocol: request.protocol, - host: request.host, - hostname: request.host, - path: request.path, - headers: request.getHeaders(), - }; -} - -/** - * - */ -export function getClientRequestUrl(request: ClientRequest): string { - const hostname = request.getHeader('host') || request.host; - const protocol = request.protocol; - const path = request.path; - - return `${protocol}//${hostname}${path}`; -} diff --git a/packages/node-core/test/integrations/SentryHttpInstrumentation.test.ts b/packages/node-core/test/integrations/SentryHttpInstrumentation.test.ts deleted file mode 100644 index 182abaa3663f..000000000000 --- a/packages/node-core/test/integrations/SentryHttpInstrumentation.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type * as http from 'node:http'; -import { describe, expect, it } from 'vitest'; -import { _getOutgoingRequestEndedSpanData } from '../../src/integrations/http/SentryHttpInstrumentation'; - -function createResponse(overrides: Partial): http.IncomingMessage { - return { - statusCode: 200, - statusMessage: 'OK', - httpVersion: '1.1', - headers: {}, - socket: undefined, - ...overrides, - } as unknown as http.IncomingMessage; -} - -describe('_getOutgoingRequestEndedSpanData', () => { - it('sets ip_tcp transport for HTTP/1.1', () => { - const attributes = _getOutgoingRequestEndedSpanData(createResponse({ httpVersion: '1.1' })); - - expect(attributes['network.transport']).toBe('ip_tcp'); - expect(attributes['net.transport']).toBe('ip_tcp'); - expect(attributes['network.protocol.version']).toBe('1.1'); - expect(attributes['http.flavor']).toBe('1.1'); - }); - - it('sets ip_udp transport for QUIC', () => { - const attributes = _getOutgoingRequestEndedSpanData(createResponse({ httpVersion: 'QUIC' })); - - expect(attributes['network.transport']).toBe('ip_udp'); - expect(attributes['net.transport']).toBe('ip_udp'); - }); - - it('does not throw when httpVersion is null', () => { - expect(() => - _getOutgoingRequestEndedSpanData(createResponse({ httpVersion: null as unknown as string })), - ).not.toThrow(); - - const attributes = _getOutgoingRequestEndedSpanData(createResponse({ httpVersion: null as unknown as string })); - expect(attributes['network.transport']).toBe('ip_tcp'); - expect(attributes['net.transport']).toBe('ip_tcp'); - }); - - it('does not throw when httpVersion is undefined', () => { - expect(() => - _getOutgoingRequestEndedSpanData(createResponse({ httpVersion: undefined as unknown as string })), - ).not.toThrow(); - }); -}); diff --git a/packages/node-core/test/integrations/context.test.ts b/packages/node-core/test/integrations/context.test.ts index b8c3f8e3d49b..3bcc1af80589 100644 --- a/packages/node-core/test/integrations/context.test.ts +++ b/packages/node-core/test/integrations/context.test.ts @@ -1,6 +1,13 @@ import * as os from 'node:os'; +import type { StreamedSpanJSON } from '@sentry/core'; import { afterAll, describe, expect, it, vi } from 'vitest'; -import { getAppContext, getDeviceContext } from '../../src/integrations/context'; +import { + contextsToSpanAttributes, + getAppContext, + getDeviceContext, + getDynamicSpanAttributes, + nodeContextIntegration, +} from '../../src/integrations/context'; import { conditionalTest } from '../helpers/conditional'; vi.mock('node:os', async () => { @@ -53,4 +60,157 @@ describe('Context', () => { expect(deviceCtx.boot_time).toBeUndefined(); }); }); + + describe('contextsToSpanAttributes', () => { + it('maps app context', () => { + const attrs = contextsToSpanAttributes({ app: { app_start_time: '2026-01-01T00:00:00.000Z', app_memory: 100 } }); + expect(attrs).toEqual({ 'app.start_time': '2026-01-01T00:00:00.000Z' }); + }); + + it('maps device context', () => { + const attrs = contextsToSpanAttributes({ + device: { + arch: 'arm64', + boot_time: '2026-01-01T00:00:00.000Z', + memory_size: 1024, + processor_count: 8, + cpu_description: 'Apple M1', + processor_frequency: 3200, + free_memory: 512, + }, + }); + expect(attrs).toEqual({ + 'device.archs': ['arm64'], + 'device.boot_time': '2026-01-01T00:00:00.000Z', + 'device.memory_size': 1024, + 'device.processor_count': 8, + 'device.cpu_description': 'Apple M1', + 'device.processor_frequency': 3200, + }); + }); + + it('maps os context', () => { + const attrs = contextsToSpanAttributes({ os: { name: 'macOS', version: '15.0', kernel_version: '24.0.0' } }); + expect(attrs).toEqual({ 'os.name': 'macOS', 'os.version': '15.0', 'os.kernel_version': '24.0.0' }); + }); + + it('maps culture context', () => { + const attrs = contextsToSpanAttributes({ culture: { locale: 'en-US', timezone: 'America/New_York' } }); + expect(attrs).toEqual({ 'culture.locale': 'en-US', 'culture.timezone': 'America/New_York' }); + }); + + it('maps cloud resource context', () => { + const attrs = contextsToSpanAttributes({ + cloud_resource: { 'cloud.provider': 'aws', 'cloud.region': 'us-east-1' }, + }); + expect(attrs).toEqual({ 'cloud.provider': 'aws', 'cloud.region': 'us-east-1' }); + }); + + it('skips undefined values', () => { + const attrs = contextsToSpanAttributes({ app: {}, device: {}, os: {} }); + expect(attrs).toEqual({}); + }); + }); + + describe('getDynamicSpanAttributes', () => { + it('includes app memory when app context is provided', () => { + const attrs = getDynamicSpanAttributes(getAppContext(), undefined); + expect(attrs['app.memory']).toEqual(expect.any(Number)); + }); + + it('includes device free memory when device context has free_memory', () => { + const attrs = getDynamicSpanAttributes(undefined, { free_memory: 1024 }); + expect(attrs['device.free_memory']).toEqual(expect.any(Number)); + }); + + it('excludes device free memory when device context has no free_memory', () => { + const attrs = getDynamicSpanAttributes(undefined, { arch: 'arm64' }); + expect(attrs['device.free_memory']).toBeUndefined(); + }); + + it('returns empty when no contexts provided', () => { + const attrs = getDynamicSpanAttributes(undefined, undefined); + expect(attrs).toEqual({}); + }); + }); + + describe('processSegmentSpan', () => { + it('sets static and dynamic context attributes on segment span', () => { + const integration = nodeContextIntegration(); + + const span: StreamedSpanJSON = { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: Date.now(), + end_timestamp: Date.now(), + status: 'ok', + is_segment: true, + attributes: {}, + }; + + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toMatchObject({ + 'app.start_time': expect.any(String), + 'device.memory_size': expect.any(Number), + 'device.processor_count': expect.any(Number), + 'device.cpu_description': expect.any(String), + 'device.processor_frequency': expect.any(Number), + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + 'app.memory': expect.any(Number), + 'device.free_memory': expect.any(Number), + }); + }); + + it('does not overwrite existing attributes', () => { + const integration = nodeContextIntegration(); + + const span: StreamedSpanJSON = { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: Date.now(), + end_timestamp: Date.now(), + status: 'ok', + is_segment: true, + attributes: { + 'process.runtime.engine.name': 'custom-engine', + }, + }; + + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes!['process.runtime.engine.name']).toBe('custom-engine'); + }); + + it('respects disabled options', () => { + const integration = nodeContextIntegration({ + app: false, + device: false, + os: false, + culture: false, + cloudResource: false, + }); + + const span: StreamedSpanJSON = { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: Date.now(), + end_timestamp: Date.now(), + status: 'ok', + is_segment: true, + attributes: {}, + }; + + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toEqual({ + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + }); + }); + }); }); diff --git a/packages/node-core/test/utils/baggage.test.ts b/packages/node-core/test/utils/baggage.test.ts deleted file mode 100644 index 0d7ff5f757d5..000000000000 --- a/packages/node-core/test/utils/baggage.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { mergeBaggageHeaders } from '../../src/utils/baggage'; - -describe('mergeBaggageHeaders', () => { - it('returns new baggage when existing is undefined', () => { - const result = mergeBaggageHeaders(undefined, 'foo=bar'); - expect(result).toBe('foo=bar'); - }); - - it('returns existing baggage when new baggage is empty', () => { - const result = mergeBaggageHeaders('foo=bar', ''); - expect(result).toBe('foo=bar'); - }); - - it('returns existing baggage when new baggage is invalid', () => { - const result = mergeBaggageHeaders('foo=bar', 'invalid'); - expect(result).toBe('foo=bar'); - }); - - it('handles empty existing baggage', () => { - const result = mergeBaggageHeaders('', 'foo=bar,sentry-release=1.0.0'); - expect(result).toBe('foo=bar,sentry-release=1.0.0'); - }); - - it('preserves existing non-Sentry entries', () => { - const result = mergeBaggageHeaders('foo=bar,other=vendor', 'foo=newvalue,third=party'); - - const entries = result?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('third=party'); - expect(entries).not.toContain('foo=newvalue'); - }); - - it('overwrites existing Sentry entries with new ones', () => { - const result = mergeBaggageHeaders( - 'sentry-release=1.0.0,sentry-environment=prod', - 'sentry-release=2.0.0,sentry-environment=staging', - ); - - const entries = result?.split(','); - expect(entries).toContain('sentry-release=2.0.0'); - expect(entries).toContain('sentry-environment=staging'); - expect(entries).not.toContain('sentry-release=1.0.0'); - expect(entries).not.toContain('sentry-environment=prod'); - }); - - it('merges Sentry and non-Sentry entries correctly', () => { - const result = mergeBaggageHeaders('foo=bar,sentry-release=1.0.0,other=vendor', 'sentry-release=2.0.0,third=party'); - - const entries = result?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('third=party'); - expect(entries).toContain('sentry-release=2.0.0'); - expect(entries).not.toContain('sentry-release=1.0.0'); - }); - - it('handles third-party baggage with Sentry entries', () => { - const result = mergeBaggageHeaders( - 'other=vendor,foo=bar,third=party,sentry-release=9.9.9,sentry-environment=staging,sentry-sample_rate=0.54,last=item', - 'sentry-release=2.1.0,sentry-environment=myEnv', - ); - - const entries = result?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('last=item'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('third=party'); - expect(entries).toContain('sentry-environment=myEnv'); - expect(entries).toContain('sentry-release=2.1.0'); - expect(entries).not.toContain('sentry-environment=staging'); - expect(entries).not.toContain('sentry-release=9.9.9'); - }); - - it('adds new Sentry entries when they do not exist', () => { - const result = mergeBaggageHeaders('foo=bar,other=vendor', 'sentry-release=1.0.0,sentry-environment=prod'); - - const entries = result?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('sentry-release=1.0.0'); - expect(entries).toContain('sentry-environment=prod'); - }); - - it('handles array-type existing baggage', () => { - const result = mergeBaggageHeaders(['foo=bar', 'other=vendor'], 'sentry-release=1.0.0'); - - const entries = (result as string)?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('sentry-release=1.0.0'); - }); - - it('preserves order of existing entries', () => { - const result = mergeBaggageHeaders('first=1,second=2,third=3', 'fourth=4'); - expect(result).toBe('first=1,second=2,third=3,fourth=4'); - }); - - it('handles complex scenario with multiple Sentry keys', () => { - const result = mergeBaggageHeaders( - 'foo=bar,sentry-release=old,sentry-environment=old,other=vendor', - 'sentry-release=new,sentry-environment=new,sentry-transaction=test,new=entry', - ); - - const entries = result?.split(','); - expect(entries).toContain('foo=bar'); - expect(entries).toContain('other=vendor'); - expect(entries).toContain('sentry-release=new'); - expect(entries).toContain('sentry-environment=new'); - expect(entries).toContain('sentry-transaction=test'); - expect(entries).toContain('new=entry'); - expect(entries).not.toContain('sentry-release=old'); - expect(entries).not.toContain('sentry-environment=old'); - }); - - it('overwrites existing Sentry entries with new SDK values', () => { - const result = mergeBaggageHeaders( - 'sentry-trace_id=abc123,sentry-sampled=false,non-sentry=keep', - 'sentry-trace_id=xyz789,sentry-sampled=true', - ); - - const entries = result?.split(','); - expect(entries).toContain('sentry-trace_id=xyz789'); - expect(entries).toContain('sentry-sampled=true'); - expect(entries).toContain('non-sentry=keep'); - expect(entries).not.toContain('sentry-trace_id=abc123'); - expect(entries).not.toContain('sentry-sampled=false'); - }); - - it('merges non-conflicting baggage entries', () => { - const existing = 'custom-key=value'; - const newBaggage = 'sentry-environment=production'; - const result = mergeBaggageHeaders(existing, newBaggage); - expect(result).toBe('custom-key=value,sentry-environment=production'); - }); - - it('overwrites existing Sentry entries when keys conflict', () => { - const existing = 'sentry-environment=staging'; - const newBaggage = 'sentry-environment=production'; - const result = mergeBaggageHeaders(existing, newBaggage); - expect(result).toBe('sentry-environment=production'); - }); - - it('handles multiple entries with Sentry conflicts', () => { - const existing = 'custom-key=value1,sentry-environment=staging'; - const newBaggage = 'sentry-environment=production,sentry-trace_id=123'; - const result = mergeBaggageHeaders(existing, newBaggage); - expect(result).toContain('custom-key=value1'); - expect(result).toContain('sentry-environment=production'); - expect(result).toContain('sentry-trace_id=123'); - expect(result).not.toContain('sentry-environment=staging'); - }); - - it('removes all sentry- values from old baggage and only adds new ones (if at least one new sentry- value is present)', () => { - const existing = 'sentry-trace_id=old,sentry-sampled=false,non-sentry=keep'; - const newBaggage = 'sentry-trace_id=new,sentry-environment=new'; - const result = mergeBaggageHeaders(existing, newBaggage); - expect(result).toBe('non-sentry=keep,sentry-trace_id=new,sentry-environment=new'); - }); - - it('preserves existing sentry entries when new baggage has no sentry entries', () => { - const result = mergeBaggageHeaders('sentry-release=1.0.0,foo=bar', 'baz=qux'); - - expect(result).toBe('sentry-release=1.0.0,foo=bar,baz=qux'); - }); -}); diff --git a/packages/node-core/test/utils/getRequestUrl.test.ts b/packages/node-core/test/utils/getRequestUrl.test.ts deleted file mode 100644 index a96514380481..000000000000 --- a/packages/node-core/test/utils/getRequestUrl.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RequestOptions } from 'http'; -import { describe, expect, it } from 'vitest'; -import { getRequestUrl } from '../../src/utils/getRequestUrl'; - -describe('getRequestUrl', () => { - it.each([ - [{ protocol: 'http:', hostname: 'localhost', port: 80 }, 'http://localhost/'], - [{ protocol: 'http:', hostname: 'localhost', host: 'localhost:80', port: 80 }, 'http://localhost/'], - [{ protocol: 'http:', hostname: 'localhost', port: 3000 }, 'http://localhost:3000/'], - [{ protocol: 'http:', host: 'localhost:3000', port: 3000 }, 'http://localhost:3000/'], - [{ protocol: 'https:', hostname: 'localhost', port: 443 }, 'https://localhost/'], - [{ protocol: 'https:', hostname: 'localhost', port: 443, path: '/my-path' }, 'https://localhost/my-path'], - [ - { protocol: 'https:', hostname: 'www.example.com', port: 443, path: '/my-path' }, - 'https://www.example.com/my-path', - ], - ])('works with %s', (input: RequestOptions, expected: string | undefined) => { - expect(getRequestUrl(input)).toBe(expected); - }); -}); diff --git a/packages/node/package.json b/packages/node/package.json index 4c0ae2e5e5d8..c681a3ed7a46 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -76,7 +76,6 @@ "@opentelemetry/instrumentation-graphql": "0.62.0", "@opentelemetry/instrumentation-hapi": "0.60.0", "@opentelemetry/instrumentation-http": "0.214.0", - "@opentelemetry/instrumentation-ioredis": "0.62.0", "@opentelemetry/instrumentation-kafkajs": "0.23.0", "@opentelemetry/instrumentation-knex": "0.58.0", "@opentelemetry/instrumentation-koa": "0.62.0", @@ -86,7 +85,6 @@ "@opentelemetry/instrumentation-mysql": "0.60.0", "@opentelemetry/instrumentation-mysql2": "0.60.0", "@opentelemetry/instrumentation-pg": "0.66.0", - "@opentelemetry/instrumentation-redis": "0.62.0", "@opentelemetry/instrumentation-tedious": "0.33.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 93fd1d8c16ca..741c6ec27fe5 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -6,6 +6,7 @@ export default [ makeBaseNPMConfig({ entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts'], packageSpecificConfig: { + external: [/^@sentry\/opentelemetry/], output: { // set exports to 'named' or 'auto' so that rollup doesn't warn exports: 'named', diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 3e38c12f0c4b..7786747dc9ee 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -1,38 +1,29 @@ -import type { ClientRequest, IncomingMessage, RequestOptions, ServerResponse } from 'node:http'; -import { diag } from '@opentelemetry/api'; -import type { HttpInstrumentationConfig } from '@opentelemetry/instrumentation-http'; -import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import type { Span } from '@sentry/core'; +import type { RequestOptions } from 'node:http'; +import type { HttpClientRequest, HttpIncomingMessage, HttpServerResponse, Span } from '@sentry/core'; import { defineIntegration, - getClient, hasSpansEnabled, SEMANTIC_ATTRIBUTE_URL_FULL, stripDataUrlContent, + getRequestUrlFromClientRequest, } from '@sentry/core'; -import type { HTTPModuleRequestIncomingMessage, NodeClient, SentryHttpInstrumentationOptions } from '@sentry/node-core'; +import type { + NodeClient, + SentryHttpInstrumentationOptions, + HttpServerIntegrationOptions, + HttpServerSpansIntegrationOptions, +} from '@sentry/node-core'; import { - addOriginToSpan, generateInstrumentOnce, - getRequestUrl, httpServerIntegration, httpServerSpansIntegration, - NODE_VERSION, SentryHttpInstrumentation, } from '@sentry/node-core'; -import type { NodeClientOptions } from '../types'; const INTEGRATION_NAME = 'Http'; -const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http'; - -// The `http.client.request.created` diagnostics channel, needed for trace propagation, -// was added in Node 22.12.0 (backported from 23.2.0). Earlier 22.x versions don't have it. -const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = - (NODE_VERSION.major === 22 && NODE_VERSION.minor >= 12) || - (NODE_VERSION.major === 23 && NODE_VERSION.minor >= 2) || - NODE_VERSION.major >= 24; - +// TODO(v11): Consolidate all the various HTTP integration options into one, +// and deprecate the duplicated and aliased options. interface HttpOptions { /** * Whether breadcrumbs should be recorded for outgoing requests. @@ -97,13 +88,13 @@ interface HttpOptions { * The `request` param contains the original {@type IncomingMessage} object of the incoming request. * You can use it to filter on additional properties like method, headers, etc. */ - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; /** * A hook that can be used to mutate the span for incoming requests. * This is triggered after the span is created, but before it is recorded. */ - incomingRequestSpanHook?: (span: Span, request: IncomingMessage, response: ServerResponse) => void; + incomingRequestSpanHook?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; /** * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. @@ -157,12 +148,12 @@ interface HttpOptions { * Additional instrumentation options that are passed to the underlying HttpInstrumentation. */ instrumentation?: { - requestHook?: (span: Span, req: ClientRequest | HTTPModuleRequestIncomingMessage) => void; - responseHook?: (span: Span, response: HTTPModuleRequestIncomingMessage | ServerResponse) => void; + requestHook?: (span: Span, req: HttpIncomingMessage | HttpClientRequest) => void; + responseHook?: (span: Span, response: HttpIncomingMessage | HttpServerResponse) => void; applyCustomAttributesOnSpan?: ( span: Span, - request: ClientRequest | HTTPModuleRequestIncomingMessage, - response: HTTPModuleRequestIncomingMessage | ServerResponse, + request: HttpIncomingMessage | HttpClientRequest, + response: HttpIncomingMessage | HttpServerResponse, ) => void; }; } @@ -174,65 +165,6 @@ export const instrumentSentryHttp = generateInstrumentOnce(INTEGRATION_NAME, config => { - const instrumentation = new HttpInstrumentation({ - ...config, - // This is hard-coded and can never be overridden by the user - disableIncomingRequestInstrumentation: true, - }); - - // We want to update the logger namespace so we can better identify what is happening here - try { - instrumentation['_diag'] = diag.createComponentLogger({ - namespace: INSTRUMENTATION_NAME, - }); - // @ts-expect-error We are writing a read-only property here... - instrumentation.instrumentationName = INSTRUMENTATION_NAME; - } catch { - // ignore errors here... - } - - // The OTel HttpInstrumentation (>=0.213.0) has a guard (`_httpPatched`/`_httpsPatched`) - // that prevents patching `http`/`https` when loaded by both CJS `require()` and ESM `import`. - // In environments like AWS Lambda, the runtime loads `http` via CJS first (for the Runtime API), - // and then the user's ESM handler imports `node:http`. The guard blocks ESM patching after CJS, - // which breaks HTTP spans for ESM handlers. We disable this guard to allow both to be patched. - // TODO(andrei): Remove once https://github.com/open-telemetry/opentelemetry-js/issues/6489 is fixed. - try { - const noopDescriptor = { get: () => false, set: () => {} }; - Object.defineProperty(instrumentation, '_httpPatched', noopDescriptor); - Object.defineProperty(instrumentation, '_httpsPatched', noopDescriptor); - } catch { - // ignore errors here... - } - - return instrumentation; -}); - -/** Exported only for tests. */ -export function _shouldUseOtelHttpInstrumentation( - options: HttpOptions, - clientOptions: Partial = {}, -): boolean { - // If `spans` is passed in, it takes precedence - // Else, we by default emit spans, unless `skipOpenTelemetrySetup` is set to `true` or spans are not enabled - if (typeof options.spans === 'boolean') { - return options.spans; - } - - if (clientOptions.skipOpenTelemetrySetup) { - return false; - } - - // IMPORTANT: We only disable span instrumentation when spans are not enabled _and_ we are on a Node version - // that fully supports the necessary diagnostics channels for trace propagation - if (!hasSpansEnabled(clientOptions) && FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL) { - return false; - } - - return true; -} - /** * The http integration instruments Node's internal http and https modules. * It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span. @@ -240,27 +172,26 @@ export function _shouldUseOtelHttpInstrumentation( export const httpIntegration = defineIntegration((options: HttpOptions = {}) => { const spans = options.spans ?? true; const disableIncomingRequestSpans = options.disableIncomingRequestSpans; + const enableServerSpans = spans && !disableIncomingRequestSpans; const serverOptions = { sessions: options.trackIncomingRequestsAsSessions, sessionFlushingDelayMS: options.sessionFlushingDelayMS, ignoreRequestBody: options.ignoreIncomingRequestBody, maxRequestBodySize: options.maxIncomingRequestBodySize, - } satisfies Parameters[0]; + } satisfies HttpServerIntegrationOptions; - const serverSpansOptions = { + const serverSpansOptions: HttpServerSpansIntegrationOptions = { ignoreIncomingRequests: options.ignoreIncomingRequests, ignoreStaticAssets: options.ignoreStaticAssets, ignoreStatusCodes: options.dropSpansForIncomingRequestStatusCodes, instrumentation: options.instrumentation, onSpanCreated: options.incomingRequestSpanHook, - } satisfies Parameters[0]; + }; const server = httpServerIntegration(serverOptions); const serverSpans = httpServerSpansIntegration(serverSpansOptions); - const enableServerSpans = spans && !disableIncomingRequestSpans; - return { name: INTEGRATION_NAME, setup(client: NodeClient) { @@ -271,99 +202,42 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => } }, setupOnce() { - const clientOptions = (getClient()?.getOptions() || {}) satisfies Partial; - const useOtelHttpInstrumentation = _shouldUseOtelHttpInstrumentation(options, clientOptions); - server.setupOnce(); - const sentryHttpInstrumentationOptions = { + const sentryHttpInstrumentationOptions: SentryHttpInstrumentationOptions = { breadcrumbs: options.breadcrumbs, - propagateTraceInOutgoingRequests: - typeof options.tracePropagation === 'boolean' - ? options.tracePropagation - : FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL || !useOtelHttpInstrumentation, - createSpansForOutgoingRequests: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL, - spans: options.spans, + spans, + propagateTraceInOutgoingRequests: options.tracePropagation ?? true, + createSpansForOutgoingRequests: spans, ignoreOutgoingRequests: options.ignoreOutgoingRequests, - outgoingRequestHook: (span: Span, request: ClientRequest) => { + outgoingRequestHook: (span: Span, request: HttpClientRequest) => { // Sanitize data URLs to prevent long base64 strings in span attributes - const url = getRequestUrl(request); + const url = getRequestUrlFromClientRequest(request); if (url.startsWith('data:')) { const sanitizedUrl = stripDataUrlContent(url); + // TODO(v11): Update these to the Sentry semantic attributes. + // https://getsentry.github.io/sentry-conventions/attributes/ span.setAttribute('http.url', sanitizedUrl); span.setAttribute(SEMANTIC_ATTRIBUTE_URL_FULL, sanitizedUrl); span.updateName(`${request.method || 'GET'} ${sanitizedUrl}`); } - options.instrumentation?.requestHook?.(span, request); }, outgoingResponseHook: options.instrumentation?.responseHook, outgoingRequestApplyCustomAttributes: options.instrumentation?.applyCustomAttributesOnSpan, - } satisfies SentryHttpInstrumentationOptions; + }; - // This is Sentry-specific instrumentation for outgoing request breadcrumbs & trace propagation + // This is Sentry-specific instrumentation for outgoing request + // breadcrumbs & trace propagation. It uses the diagnostic channels on + // node versions that support it, falling back to monkey-patching when + // needed. instrumentSentryHttp(sentryHttpInstrumentationOptions); - - // This is the "regular" OTEL instrumentation that emits outgoing request spans - if (useOtelHttpInstrumentation) { - const instrumentationConfig = getConfigWithDefaults(options); - instrumentOtelHttp(instrumentationConfig); - } }, processEvent(event) { - // Note: We always run this, even if spans are disabled - // The reason being that e.g. the remix integration disables span creation here but still wants to use the ignore status codes option + // Always run this, even if spans are disabled + // The reason being that e.g. the remix integration disables span + // creation here but still wants to use the ignore status codes option return serverSpans.processEvent(event); }, }; }); - -function getConfigWithDefaults(options: Partial = {}): HttpInstrumentationConfig { - const instrumentationConfig = { - // This is handled by the SentryHttpInstrumentation on Node 22+ - disableOutgoingRequestInstrumentation: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL, - - ignoreOutgoingRequestHook: request => { - const url = getRequestUrl(request); - - if (!url) { - return false; - } - - const _ignoreOutgoingRequests = options.ignoreOutgoingRequests; - if (_ignoreOutgoingRequests?.(url, request)) { - return true; - } - - return false; - }, - - requireParentforOutgoingSpans: false, - requestHook: (span, req) => { - addOriginToSpan(span, 'auto.http.otel.http'); - - // Sanitize data URLs to prevent long base64 strings in span attributes - const url = getRequestUrl(req as ClientRequest); - if (url.startsWith('data:')) { - const sanitizedUrl = stripDataUrlContent(url); - span.setAttribute('http.url', sanitizedUrl); - span.setAttribute(SEMANTIC_ATTRIBUTE_URL_FULL, sanitizedUrl); - span.updateName(`${(req as ClientRequest).method || 'GET'} ${sanitizedUrl}`); - } - - options.instrumentation?.requestHook?.(span, req); - }, - responseHook: (span, res) => { - options.instrumentation?.responseHook?.(span, res); - }, - applyCustomAttributesOnSpan: ( - span: Span, - request: ClientRequest | HTTPModuleRequestIncomingMessage, - response: HTTPModuleRequestIncomingMessage | ServerResponse, - ) => { - options.instrumentation?.applyCustomAttributesOnSpan?.(span, request, response); - }, - } satisfies HttpInstrumentationConfig; - - return instrumentationConfig; -} diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index dcd2efa5595c..944d762f26b4 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { instrumentOtelHttp, instrumentSentryHttp } from '../http'; +import { instrumentSentryHttp } from '../http'; import { amqplibIntegration, instrumentAmqplib } from './amqplib'; import { anthropicAIIntegration, instrumentAnthropicAi } from './anthropic-ai'; import { connectIntegration, instrumentConnect } from './connect'; @@ -72,7 +72,6 @@ export function getAutoPerformanceIntegrations(): Integration[] { export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => void) & { id: string })[] { return [ instrumentSentryHttp, - instrumentOtelHttp, instrumentExpress, instrumentConnect, instrumentFastify, diff --git a/packages/node/src/integrations/tracing/redis.ts b/packages/node/src/integrations/tracing/redis/index.ts similarity index 75% rename from packages/node/src/integrations/tracing/redis.ts rename to packages/node/src/integrations/tracing/redis/index.ts index f8be12352ae0..2e5268c14c6f 100644 --- a/packages/node/src/integrations/tracing/redis.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -1,7 +1,4 @@ import type { Span } from '@opentelemetry/api'; -import type { RedisResponseCustomAttributeFunction } from '@opentelemetry/instrumentation-ioredis'; -import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'; -import { RedisInstrumentation } from '@opentelemetry/instrumentation-redis'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, @@ -9,11 +6,11 @@ import { SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, SEMANTIC_ATTRIBUTE_CACHE_KEY, SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, truncate, } from '@sentry/core'; import { generateInstrumentOnce } from '@sentry/node-core'; +import type { IORedisCommandArgs } from '../../../utils/redisCache'; import { calculateCacheItemSize, GET_COMMANDS, @@ -21,7 +18,11 @@ import { getCacheOperation, isInCommands, shouldConsiderForCache, -} from '../../utils/redisCache'; +} from '../../../utils/redisCache'; +import type { IORedisResponseCustomAttributeFunction } from './vendored/types'; +import { IORedisInstrumentation } from './vendored/ioredis-instrumentation'; +import { RedisInstrumentation } from './vendored/redis-instrumentation'; +import { subscribeRedisDiagnosticChannels } from './redis-dc-subscriber'; interface RedisOptions { /** @@ -46,14 +47,12 @@ const INTEGRATION_NAME = 'Redis'; export let _redisOptions: RedisOptions = {}; /* Only exported for testing purposes */ -export const cacheResponseHook: RedisResponseCustomAttributeFunction = ( +export const cacheResponseHook: IORedisResponseCustomAttributeFunction = ( span: Span, - redisCommand, - cmdArgs, - response, + redisCommand: string, + cmdArgs: IORedisCommandArgs, + response: unknown, ) => { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.redis'); - const safeKey = getCacheKeySafely(redisCommand, cmdArgs); const cacheOperation = getCacheOperation(redisCommand); @@ -69,8 +68,12 @@ export const cacheResponseHook: RedisResponseCustomAttributeFunction = ( // otel/ioredis seems to be using the old standard, as there was a change to those params: https://github.com/open-telemetry/opentelemetry-specification/issues/3199 // We are using params based on the docs: https://opentelemetry.io/docs/specs/semconv/attributes-registry/network/ - const networkPeerAddress = spanToJSON(span).data['net.peer.name']; - const networkPeerPort = spanToJSON(span).data['net.peer.port']; + // Fall back to stable semconv attributes (server.address/server.port) when + // old-semconv ones are absent, eg OTEL_SEMCONV_STABILITY_OPT_IN=database + // set for node-redis v4/v5. + const spanData = spanToJSON(span).data; + const networkPeerAddress = spanData['net.peer.name'] ?? spanData['server.address']; + const networkPeerPort = spanData['net.peer.port'] ?? spanData['server.port']; if (networkPeerPort && networkPeerAddress) { span.setAttributes({ 'network.peer.address': networkPeerAddress, 'network.peer.port': networkPeerPort }); } @@ -115,6 +118,11 @@ export const instrumentRedis = Object.assign( (): void => { instrumentIORedis(); instrumentRedisModule(); + // node-redis >= 5.12.0 publishes via diagnostics_channel. The subscriber uses + // `@sentry/opentelemetry/tracing-channel`, which needs the Sentry OTel context manager + // to be registered before it can `bindStore`. `initOpenTelemetry()` runs after integration + // `setupOnce`, so defer to the next tick. + void Promise.resolve().then(() => subscribeRedisDiagnosticChannels(cacheResponseHook)); // todo: implement them gradually // new LegacyRedisInstrumentation({}), diff --git a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts new file mode 100644 index 000000000000..4a2ddaf8a9b2 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts @@ -0,0 +1,231 @@ +import type { Span } from '@opentelemetry/api'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startSpanManual, +} from '@sentry/core'; +import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry/tracing-channel'; +import { defaultDbStatementSerializer } from './vendored/redis-common'; +import { + ATTR_DB_STATEMENT, + ATTR_DB_SYSTEM, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, + DB_SYSTEM_VALUE_REDIS, +} from './vendored/semconv'; +import type { IORedisInstrumentationConfig } from './vendored/types'; + +// Channel names as published by node-redis >= 5.12.0. +// Hardcoded so we don't import `redis` at module-load time. +const CHANNEL_COMMAND = 'node-redis:command'; +const CHANNEL_BATCH = 'node-redis:batch'; +const CHANNEL_CONNECT = 'node-redis:connect'; + +const ORIGIN = 'auto.db.redis.diagnostic_channel'; + +interface CommandData { + command: string; + args: Array; + database?: number; + serverAddress?: string; + serverPort?: number; + result?: unknown; + error?: Error; +} + +interface BatchData { + batchMode?: 'MULTI' | 'PIPELINE'; + batchSize?: number; + database?: number; + clientId?: string | number; + serverAddress?: string; + serverPort?: number; + result?: unknown[]; + error?: Error; +} + +interface ConnectData { + serverAddress?: string; + serverPort?: number; + url?: string; + error?: Error; +} + +const NOOP = (): void => {}; + +let subscribed = false; +let currentResponseHook: IORedisInstrumentationConfig['responseHook'] | undefined; + +/** + * Subscribe Sentry handlers to node-redis diagnostics_channel events (>= 5.12.0). + * + * Uses `@sentry/opentelemetry/tracing-channel` so OTel AsyncLocalStorage context propagates + * automatically via `bindStore` — without it, spans created in `start` would not become + * the active context for subsequent operations. + * + * Safe on every runtime that exposes `node:diagnostics_channel` (Node, Bun, Deno, Workers). + * In node-redis < 5.12.0 the channels are never published to, so subscribers are inert and + * there is no double-instrumentation against the IITM-based patcher (gated to < 5.12.0). + */ +export function subscribeRedisDiagnosticChannels(responseHook?: IORedisInstrumentationConfig['responseHook']): void { + currentResponseHook = responseHook; + if (subscribed) return; + + try { + setupCommandChannel(); + setupBatchChannel(); + setupConnectChannel(); + subscribed = true; + } catch { + // tracingChannel from @sentry/opentelemetry requires `node:diagnostics_channel`. + // On runtimes where it isn't available, fail closed. + } +} + +function setupCommandChannel(): void { + const channel = tracingChannel(CHANNEL_COMMAND, data => { + // node-redis >= 5.12.0 includes the command name as args[0] in the DC payload. + // Strip it so serialization and cache key extraction see only the actual arguments. + const actualArgs = data.args.slice(1); + const statement = safeSerialize(data.command, actualArgs); + return startSpanManual( + { + name: `redis-${data.command}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + ...(statement != null ? { [ATTR_DB_STATEMENT]: statement } : {}), + ...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}), + }, + }, + span => span, + ) as Span; + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + const span = data._sentrySpan; + // only end if error handler isn't going to + if (!span || data.error) return; + // Same slice: strip command name from args before passing to the response hook. + runResponseHook(span, data.command, data.args.slice(1), data.result); + span.end(); + }, + error: data => { + const span = data._sentrySpan; + if (!span) return; + if (data.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message }); + } + span.end(); + }, + }); +} + +function setupBatchChannel(): void { + const channel = tracingChannel(CHANNEL_BATCH, data => { + const operationName = data.batchMode === 'PIPELINE' ? 'PIPELINE' : 'MULTI'; + + return startSpanManual( + { + name: operationName, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + ...(data.batchSize != null ? { 'db.redis.batch_size': data.batchSize } : {}), + ...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}), + }, + }, + span => span, + ) as Span; + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + // only end if the error handler isn't going to + if (!data.error) data._sentrySpan?.end(); + }, + error: data => { + const span = data._sentrySpan; + if (!span) return; + if (data.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message }); + } + span.end(); + }, + }); +} + +function setupConnectChannel(): void { + const channel = tracingChannel(CHANNEL_CONNECT, data => { + return startSpanManual( + { + name: 'redis-connect', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis.connect', + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + ...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}), + }, + }, + span => span, + ) as Span; + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + // only end if the error handler isn't going to + if (!data.error) data._sentrySpan?.end(); + }, + error: data => { + const span = data._sentrySpan; + if (!span) return; + if (data.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message }); + } + span.end(); + }, + }); +} + +function runResponseHook(span: Span, command: string, args: Array, result: unknown): void { + const hook = currentResponseHook; + if (!hook) return; + try { + hook(span, command, args as unknown as Parameters[2], result); + } catch { + // never let user hooks break instrumentation + } +} + +function safeSerialize(command: string, args: Array): string | undefined { + try { + return defaultDbStatementSerializer(command, args); + } catch { + return undefined; + } +} + +// Test-only helper. +export function _resetRedisDiagnosticChannelsForTesting(): void { + subscribed = false; + currentResponseHook = undefined; +} + +// Suppress unused-import lint when only used in types. +export type { TracingChannelContextWithSpan }; diff --git a/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts b/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts new file mode 100644 index 000000000000..a97900ab4f9d --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts @@ -0,0 +1,274 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-ioredis-v0.62.0/packages/instrumentation-ioredis + * - Upstream version: @opentelemetry/instrumentation-ioredis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-ioredis */ + +import { context, diag, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { Span } from '@opentelemetry/api'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + isWrapped, + safeExecuteInTheMiddle, + SemconvStability, + semconvStabilityFromStr, +} from '@opentelemetry/instrumentation'; +import { + ATTR_DB_QUERY_TEXT, + ATTR_DB_SYSTEM_NAME, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, +} from '@opentelemetry/semantic-conventions'; + +import { defaultDbStatementSerializer } from './redis-common'; +import { + ATTR_DB_CONNECTION_STRING, + ATTR_DB_STATEMENT, + ATTR_DB_SYSTEM, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, + DB_SYSTEM_NAME_VALUE_REDIS, + DB_SYSTEM_VALUE_REDIS, +} from './semconv'; +import type { IORedisInstrumentationConfig } from './types'; + +const PACKAGE_NAME = '@opentelemetry/instrumentation-ioredis'; +const PACKAGE_VERSION = '0.62.0'; + +// ---- utils ---- + +function endSpan(span: Span, err: Error | null | undefined): void { + if (err) { + span.recordException(err); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }); + } + span.end(); +} + +// ---- IORedisInstrumentation ---- + +const DEFAULT_CONFIG: IORedisInstrumentationConfig = { + requireParentSpan: true, +}; + +export class IORedisInstrumentation extends InstrumentationBase { + _netSemconvStability!: SemconvStability; + _dbSemconvStability!: SemconvStability; + + constructor(config: IORedisInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, { ...DEFAULT_CONFIG, ...config }); + this._setSemconvStabilityFromEnv(); + } + + _setSemconvStabilityFromEnv(): void { + this._netSemconvStability = semconvStabilityFromStr('http', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + this._dbSemconvStability = semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + override setConfig(config: IORedisInstrumentationConfig = {}): void { + super.setConfig({ ...DEFAULT_CONFIG, ...config }); + } + + init() { + return [ + new InstrumentationNodeModuleDefinition( + 'ioredis', + ['>=2.0.0 <6'], + (module: any, moduleVersion?: string) => { + const moduleExports = + module[Symbol.toStringTag] === 'Module' + ? module.default // ESM + : module; // CommonJS + if (isWrapped(moduleExports.prototype.sendCommand)) { + this._unwrap(moduleExports.prototype, 'sendCommand'); + } + this._wrap(moduleExports.prototype, 'sendCommand', this._patchSendCommand(moduleVersion)); + if (isWrapped(moduleExports.prototype.connect)) { + this._unwrap(moduleExports.prototype, 'connect'); + } + this._wrap(moduleExports.prototype, 'connect', this._patchConnection()); + return module; + }, + (module: any) => { + if (module === undefined) return; + const moduleExports = + module[Symbol.toStringTag] === 'Module' + ? module.default // ESM + : module; // CommonJS + this._unwrap(moduleExports.prototype, 'sendCommand'); + this._unwrap(moduleExports.prototype, 'connect'); + }, + ), + ]; + } + + private _patchSendCommand(moduleVersion?: string) { + return (original: Function) => { + return this._traceSendCommand(original, moduleVersion); + }; + } + + private _patchConnection() { + return (original: Function) => { + return this._traceConnection(original); + }; + } + + private _traceSendCommand(original: Function, moduleVersion?: string) { + const instrumentation = this; + return function (this: any, cmd: any) { + if (arguments.length < 1 || typeof cmd !== 'object') { + return original.apply(this, arguments); + } + const config = instrumentation.getConfig(); + const dbStatementSerializer = config.dbStatementSerializer || defaultDbStatementSerializer; + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (config.requireParentSpan === true && hasNoParentSpan) { + return original.apply(this, arguments); + } + const attributes: Record = {}; + const { host, port } = this.options; + const dbQueryText = dbStatementSerializer(cmd.name, cmd.args); + if (instrumentation._dbSemconvStability & SemconvStability.OLD) { + attributes[ATTR_DB_SYSTEM] = DB_SYSTEM_VALUE_REDIS; + attributes[ATTR_DB_STATEMENT] = dbQueryText; + attributes[ATTR_DB_CONNECTION_STRING] = `redis://${host}:${port}`; + } + if (instrumentation._dbSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_SYSTEM_NAME] = DB_SYSTEM_NAME_VALUE_REDIS; + attributes[ATTR_DB_QUERY_TEXT] = dbQueryText; + } + if (instrumentation._netSemconvStability & SemconvStability.OLD) { + attributes[ATTR_NET_PEER_NAME] = host; + attributes[ATTR_NET_PEER_PORT] = port; + } + if (instrumentation._netSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_SERVER_ADDRESS] = host; + attributes[ATTR_SERVER_PORT] = port; + } + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = 'auto.db.otel.redis'; + const span = instrumentation.tracer.startSpan(cmd.name, { + kind: SpanKind.CLIENT, + attributes, + }); + const { requestHook } = config; + if (requestHook) { + safeExecuteInTheMiddle( + () => + requestHook(span, { + moduleVersion, + cmdName: cmd.name, + cmdArgs: cmd.args, + }), + (e: Error | undefined) => { + if (e) { + diag.error('ioredis instrumentation: request hook failed', e); + } + }, + true, + ); + } + try { + const result = original.apply(this, arguments); + const origResolve = cmd.resolve; + cmd.resolve = function (result: unknown) { + safeExecuteInTheMiddle( + () => config.responseHook?.(span, cmd.name, cmd.args, result), + (e: Error | undefined) => { + if (e) { + diag.error('ioredis instrumentation: response hook failed', e); + } + }, + true, + ); + endSpan(span, null); + origResolve(result); + }; + const origReject = cmd.reject; + cmd.reject = function (err: Error) { + endSpan(span, err); + origReject(err); + }; + return result; + } catch (error) { + endSpan(span, error as Error); + throw error; + } + }; + } + + private _traceConnection(original: Function) { + const instrumentation = this; + return function (this: any) { + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (instrumentation.getConfig().requireParentSpan === true && hasNoParentSpan) { + return original.apply(this, arguments); + } + const attributes: Record = {}; + const { host, port } = this.options; + if (instrumentation._dbSemconvStability & SemconvStability.OLD) { + attributes[ATTR_DB_SYSTEM] = DB_SYSTEM_VALUE_REDIS; + attributes[ATTR_DB_STATEMENT] = 'connect'; + attributes[ATTR_DB_CONNECTION_STRING] = `redis://${host}:${port}`; + } + if (instrumentation._dbSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_SYSTEM_NAME] = DB_SYSTEM_NAME_VALUE_REDIS; + attributes[ATTR_DB_QUERY_TEXT] = 'connect'; + } + if (instrumentation._netSemconvStability & SemconvStability.OLD) { + attributes[ATTR_NET_PEER_NAME] = host; + attributes[ATTR_NET_PEER_PORT] = port; + } + if (instrumentation._netSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_SERVER_ADDRESS] = host; + attributes[ATTR_SERVER_PORT] = port; + } + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = 'auto.db.otel.redis'; + const span = instrumentation.tracer.startSpan('connect', { + kind: SpanKind.CLIENT, + attributes, + }); + try { + const result = original.apply(this, arguments); + if (typeof result?.then === 'function') { + return result.then( + (value: unknown) => { + endSpan(span, null); + return value; + }, + (error: Error) => { + endSpan(span, error); + return Promise.reject(error); + }, + ); + } + endSpan(span, null); + return result; + } catch (error) { + endSpan(span, error as Error); + throw error; + } + }; + } +} diff --git a/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts b/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts new file mode 100644 index 000000000000..58f94cfb66c9 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts @@ -0,0 +1,69 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/redis-common + * - Upstream version: @opentelemetry/redis-common@0.38.2 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/redis-common */ + +/** + * List of regexes and the number of arguments that should be serialized for matching commands. + * For example, HSET should serialize which key and field it's operating on, but not its value. + * Setting the subset to -1 will serialize all arguments. + * Commands without a match will have their first argument serialized. + * + * Refer to https://redis.io/commands/ for the full list. + */ +const serializationSubsets = [ + { + regex: /^ECHO/i, + args: 0, + }, + { + regex: /^(LPUSH|MSET|PFA|PUBLISH|RPUSH|SADD|SET|SPUBLISH|XADD|ZADD)/i, + args: 1, + }, + { + regex: /^(HSET|HMSET|LSET|LINSERT)/i, + args: 2, + }, + { + regex: + /^(ACL|BIT|B[LRZ]|CLIENT|CLUSTER|CONFIG|COMMAND|DECR|DEL|EVAL|EX|FUNCTION|GEO|GET|HINCR|HMGET|HSCAN|INCR|L[TRLM]|MEMORY|P[EFISTU]|RPOP|S[CDIMORSU]|XACK|X[CDGILPRT]|Z[CDILMPRS])/i, + args: -1, + }, +]; + +/** + * Given the redis command name and arguments, return a combination of the + * command name + the allowed arguments according to `serializationSubsets`. + */ +export const defaultDbStatementSerializer = ( + cmdName: string, + cmdArgs: Array, +): string => { + if (Array.isArray(cmdArgs) && cmdArgs.length) { + const nArgsToSerialize = serializationSubsets.find(({ regex }) => regex.test(cmdName))?.args ?? 0; + const argsToSerialize: Array = + nArgsToSerialize >= 0 ? cmdArgs.slice(0, nArgsToSerialize) : cmdArgs.slice(); + if (cmdArgs.length > argsToSerialize.length) { + argsToSerialize.push(`[${cmdArgs.length - nArgsToSerialize} other arguments]`); + } + return `${cmdName} ${argsToSerialize.join(' ')}`; + } + return cmdName; +}; diff --git a/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts b/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts new file mode 100644 index 000000000000..5c2de9bc1be4 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts @@ -0,0 +1,735 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/instrumentation-redis + * - Upstream version: @opentelemetry/instrumentation-redis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-redis */ + +import { context, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { DiagLogger, Span, TracerProvider } from '@opentelemetry/api'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, + isWrapped, + safeExecuteInTheMiddle, + SemconvStability, + semconvStabilityFromStr, +} from '@opentelemetry/instrumentation'; +import { + ATTR_DB_OPERATION_NAME, + ATTR_DB_QUERY_TEXT, + ATTR_DB_SYSTEM_NAME, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, +} from '@opentelemetry/semantic-conventions'; + +import { defaultDbStatementSerializer } from './redis-common'; +import { + ATTR_DB_CONNECTION_STRING, + ATTR_DB_STATEMENT, + ATTR_DB_SYSTEM, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, + DB_SYSTEM_NAME_VALUE_REDIS, + DB_SYSTEM_VALUE_REDIS, +} from './semconv'; +import type { RedisInstrumentationConfig } from './types'; + +const PACKAGE_NAME = '@opentelemetry/instrumentation-redis'; +const PACKAGE_VERSION = '0.62.0'; + +// ---- Internal types ---- + +interface RedisPluginClientTypes { + connection_options?: { + port?: string | number; + host?: string; + }; + address?: string; +} + +interface RedisCommand { + command: string; + args: string[]; + buffer_args: boolean; + callback: (err: Error | null, reply: unknown) => void; + call_on_write: boolean; +} + +interface MultiErrorReply extends Error { + replies: unknown[]; + errorIndexes: Array; +} + +interface OpenSpanInfo { + span: Span; + commandName: string; + commandArgs: Array; +} + +const OTEL_OPEN_SPANS = Symbol('opentelemetry.instrumentation.redis.open_spans'); +const MULTI_COMMAND_OPTIONS = Symbol('opentelemetry.instrumentation.redis.multi_command_options'); + +// ---- v4-v5 utils ---- + +function removeCredentialsFromDBConnectionStringAttribute( + diagLogger: DiagLogger, + url: string | undefined, +): string | undefined { + if (typeof url !== 'string' || !url) { + return undefined; + } + try { + const u = new URL(url); + u.searchParams.delete('user_pwd'); + u.username = ''; + u.password = ''; + return u.href; + } catch (err) { + diagLogger.error('failed to sanitize redis connection url', err); + } + return undefined; +} + +function getClientAttributes( + diagLogger: DiagLogger, + options: any, + semconvStability: SemconvStability, +): Record { + const attributes: Record = {}; + if (semconvStability & SemconvStability.OLD) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + [ATTR_NET_PEER_NAME]: options?.socket?.host, + [ATTR_NET_PEER_PORT]: options?.socket?.port, + [ATTR_DB_CONNECTION_STRING]: removeCredentialsFromDBConnectionStringAttribute(diagLogger, options?.url), + }); + } + if (semconvStability & SemconvStability.STABLE) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, + [ATTR_SERVER_ADDRESS]: options?.socket?.host, + [ATTR_SERVER_PORT]: options?.socket?.port, + }); + } + return attributes; +} + +// ---- v2-v3 utils ---- + +function endSpanV2(span: Span, err: Error | null | undefined): void { + if (err) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }); + } + span.end(); +} + +function getTracedCreateClient(original: Function): Function { + return function createClientTrace(this: any) { + const client = original.apply(this, arguments); + return context.bind(context.active(), client); + }; +} + +function getTracedCreateStreamTrace(original: Function): Function { + return function create_stream_trace(this: any) { + if (!Object.prototype.hasOwnProperty.call(this, 'stream')) { + Object.defineProperty(this, 'stream', { + get() { + return this._patched_redis_stream; + }, + set(val: any) { + context.bind(context.active(), val); + this._patched_redis_stream = val; + }, + }); + } + return original.apply(this, arguments); + }; +} + +// ---- RedisInstrumentationV2_V3 ---- + +class RedisInstrumentationV2_V3 extends InstrumentationBase { + static COMPONENT = 'redis'; + _semconvStability: SemconvStability; + + constructor(config: RedisInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + override setConfig(config: RedisInstrumentationConfig = {}): void { + super.setConfig(config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + init() { + return [ + new InstrumentationNodeModuleDefinition( + 'redis', + ['>=2.6.0 <4'], + (moduleExports: any) => { + if (isWrapped(moduleExports.RedisClient.prototype['internal_send_command'])) { + this._unwrap(moduleExports.RedisClient.prototype, 'internal_send_command'); + } + this._wrap(moduleExports.RedisClient.prototype, 'internal_send_command', this._getPatchInternalSendCommand()); + if (isWrapped(moduleExports.RedisClient.prototype['create_stream'])) { + this._unwrap(moduleExports.RedisClient.prototype, 'create_stream'); + } + this._wrap(moduleExports.RedisClient.prototype, 'create_stream', this._getPatchCreateStream()); + if (isWrapped(moduleExports.createClient)) { + this._unwrap(moduleExports, 'createClient'); + } + this._wrap(moduleExports, 'createClient', this._getPatchCreateClient()); + return moduleExports; + }, + (moduleExports: any) => { + if (moduleExports === undefined) return; + this._unwrap(moduleExports.RedisClient.prototype, 'internal_send_command'); + this._unwrap(moduleExports.RedisClient.prototype, 'create_stream'); + this._unwrap(moduleExports, 'createClient'); + }, + ), + ]; + } + + private _getPatchInternalSendCommand() { + const instrumentation = this; + return function internal_send_command(original: Function) { + return function internal_send_command_trace(this: RedisPluginClientTypes, cmd: RedisCommand) { + if (arguments.length !== 1 || typeof cmd !== 'object') { + return original.apply(this, arguments); + } + const config = instrumentation.getConfig(); + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (config.requireParentSpan === true && hasNoParentSpan) { + return original.apply(this, arguments); + } + const dbStatementSerializer = config?.dbStatementSerializer || defaultDbStatementSerializer; + const attributes: Record = {}; + if (instrumentation._semconvStability & SemconvStability.OLD) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + [ATTR_DB_STATEMENT]: dbStatementSerializer(cmd.command, cmd.args), + }); + } + if (instrumentation._semconvStability & SemconvStability.STABLE) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, + [ATTR_DB_OPERATION_NAME]: cmd.command, + [ATTR_DB_QUERY_TEXT]: dbStatementSerializer(cmd.command, cmd.args), + }); + } + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = 'auto.db.otel.redis'; + const span = instrumentation.tracer.startSpan(`${RedisInstrumentationV2_V3.COMPONENT}-${cmd.command}`, { + kind: SpanKind.CLIENT, + attributes, + }); + if (this.connection_options) { + const connectionAttributes: Record = {}; + if (instrumentation._semconvStability & SemconvStability.OLD) { + Object.assign(connectionAttributes, { + [ATTR_NET_PEER_NAME]: this.connection_options.host, + [ATTR_NET_PEER_PORT]: this.connection_options.port, + }); + } + if (instrumentation._semconvStability & SemconvStability.STABLE) { + Object.assign(connectionAttributes, { + [ATTR_SERVER_ADDRESS]: this.connection_options.host, + [ATTR_SERVER_PORT]: this.connection_options.port, + }); + } + span.setAttributes(connectionAttributes); + } + if (this.address && instrumentation._semconvStability & SemconvStability.OLD) { + span.setAttribute(ATTR_DB_CONNECTION_STRING, `redis://${this.address}`); + } + const originalCallback = arguments[0].callback; + if (originalCallback) { + const originalContext = context.active(); + arguments[0].callback = function callback(this: any, err: Error | null, reply: unknown) { + if (config?.responseHook) { + const responseHook = config.responseHook; + safeExecuteInTheMiddle( + () => { + responseHook(span, cmd.command, cmd.args, reply); + }, + (e: Error | undefined) => { + if (e) { + instrumentation._diag.error('Error executing responseHook', e); + } + }, + true, + ); + } + endSpanV2(span, err); + return context.with(originalContext, originalCallback, this, ...arguments); + }; + } + try { + return original.apply(this, arguments); + } catch (rethrow) { + endSpanV2(span, rethrow as Error); + throw rethrow; + } + }; + }; + } + + private _getPatchCreateClient() { + return function createClient(original: Function) { + return getTracedCreateClient(original); + }; + } + + private _getPatchCreateStream() { + return function createReadStream(original: Function) { + return getTracedCreateStreamTrace(original); + }; + } +} + +// ---- RedisInstrumentationV4_V5 ---- + +class RedisInstrumentationV4_V5 extends InstrumentationBase { + static COMPONENT = 'redis'; + _semconvStability: SemconvStability; + + constructor(config: RedisInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + override setConfig(config: RedisInstrumentationConfig = {}): void { + super.setConfig(config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + init() { + return [ + this._getInstrumentationNodeModuleDefinition('@redis/client'), + this._getInstrumentationNodeModuleDefinition('@node-redis/client'), + ]; + } + + private _getInstrumentationNodeModuleDefinition(basePackageName: string) { + const commanderModuleFile = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/commander.js`, + ['^1.0.0'], + (moduleExports: any, moduleVersion?: string) => { + const transformCommandArguments = moduleExports.transformCommandArguments; + if (!transformCommandArguments) { + this._diag.error('internal instrumentation error, missing transformCommandArguments function'); + return moduleExports; + } + const functionToPatch = moduleVersion?.startsWith('1.0.') ? 'extendWithCommands' : 'attachCommands'; + if (isWrapped(moduleExports?.[functionToPatch])) { + this._unwrap(moduleExports, functionToPatch); + } + this._wrap(moduleExports, functionToPatch, this._getPatchExtendWithCommands(transformCommandArguments)); + return moduleExports; + }, + (moduleExports: any) => { + if (isWrapped(moduleExports?.extendWithCommands)) { + this._unwrap(moduleExports, 'extendWithCommands'); + } + if (isWrapped(moduleExports?.attachCommands)) { + this._unwrap(moduleExports, 'attachCommands'); + } + }, + ); + + const multiCommanderModule = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/client/multi-command.js`, + ['^1.0.0', '>=5.0.0 <5.12.0'], + (moduleExports: any) => { + const redisClientMultiCommandPrototype = moduleExports?.default?.prototype; + if (isWrapped(redisClientMultiCommandPrototype?.exec)) { + this._unwrap(redisClientMultiCommandPrototype, 'exec'); + } + this._wrap(redisClientMultiCommandPrototype, 'exec', this._getPatchMultiCommandsExec(false)); + if (isWrapped(redisClientMultiCommandPrototype?.execAsPipeline)) { + this._unwrap(redisClientMultiCommandPrototype, 'execAsPipeline'); + } + this._wrap(redisClientMultiCommandPrototype, 'execAsPipeline', this._getPatchMultiCommandsExec(true)); + if (isWrapped(redisClientMultiCommandPrototype?.addCommand)) { + this._unwrap(redisClientMultiCommandPrototype, 'addCommand'); + } + this._wrap(redisClientMultiCommandPrototype, 'addCommand', this._getPatchMultiCommandsAddCommand()); + return moduleExports; + }, + (moduleExports: any) => { + const redisClientMultiCommandPrototype = moduleExports?.default?.prototype; + if (isWrapped(redisClientMultiCommandPrototype?.exec)) { + this._unwrap(redisClientMultiCommandPrototype, 'exec'); + } + if (isWrapped(redisClientMultiCommandPrototype?.execAsPipeline)) { + this._unwrap(redisClientMultiCommandPrototype, 'execAsPipeline'); + } + if (isWrapped(redisClientMultiCommandPrototype?.addCommand)) { + this._unwrap(redisClientMultiCommandPrototype, 'addCommand'); + } + }, + ); + + const clientIndexModule = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/client/index.js`, + ['^1.0.0', '>=5.0.0 <5.12.0'], + (moduleExports: any) => { + const redisClientPrototype = moduleExports?.default?.prototype; + if (redisClientPrototype?.multi) { + if (isWrapped(redisClientPrototype?.multi)) { + this._unwrap(redisClientPrototype, 'multi'); + } + this._wrap(redisClientPrototype, 'multi', this._getPatchRedisClientMulti()); + } + if (redisClientPrototype?.MULTI) { + if (isWrapped(redisClientPrototype?.MULTI)) { + this._unwrap(redisClientPrototype, 'MULTI'); + } + this._wrap(redisClientPrototype, 'MULTI', this._getPatchRedisClientMulti()); + } + if (isWrapped(redisClientPrototype?.sendCommand)) { + this._unwrap(redisClientPrototype, 'sendCommand'); + } + this._wrap(redisClientPrototype, 'sendCommand', this._getPatchRedisClientSendCommand()); + if (isWrapped(redisClientPrototype?.connect)) { + this._unwrap(redisClientPrototype, 'connect'); + } + this._wrap(redisClientPrototype, 'connect', this._getPatchedClientConnect()); + return moduleExports; + }, + (moduleExports: any) => { + const redisClientPrototype = moduleExports?.default?.prototype; + if (isWrapped(redisClientPrototype?.multi)) { + this._unwrap(redisClientPrototype, 'multi'); + } + if (isWrapped(redisClientPrototype?.MULTI)) { + this._unwrap(redisClientPrototype, 'MULTI'); + } + if (isWrapped(redisClientPrototype?.sendCommand)) { + this._unwrap(redisClientPrototype, 'sendCommand'); + } + if (isWrapped(redisClientPrototype?.connect)) { + this._unwrap(redisClientPrototype, 'connect'); + } + }, + ); + + return new InstrumentationNodeModuleDefinition( + basePackageName, + ['^1.0.0', '>=5.0.0 <5.12.0'], + (moduleExports: any) => moduleExports, + () => {}, + [commanderModuleFile, multiCommanderModule, clientIndexModule], + ); + } + + private _getPatchExtendWithCommands(transformCommandArguments: Function) { + const plugin = this; + return function extendWithCommandsPatchWrapper(original: Function) { + return function extendWithCommandsPatch(this: any, config: any) { + if (config?.BaseClass?.name !== 'RedisClient') { + return original.apply(this, arguments); + } + const origExecutor = config.executor; + config.executor = function (this: any, command: any, args: any) { + const redisCommandArguments = transformCommandArguments(command, args).args; + return plugin._traceClientCommand(origExecutor, this, arguments, redisCommandArguments); + }; + return original.apply(this, arguments); + }; + }; + } + + private _getPatchMultiCommandsExec(isPipeline: boolean) { + const plugin = this; + return function execPatchWrapper(original: Function) { + return function execPatch(this: any) { + const execRes = original.apply(this, arguments); + if (typeof execRes?.then !== 'function') { + plugin._diag.error('non-promise result when patching exec/execAsPipeline'); + return execRes; + } + return execRes + .then((redisRes: unknown[]) => { + const openSpans: OpenSpanInfo[] = this[OTEL_OPEN_SPANS]; + plugin._endSpansWithRedisReplies(openSpans, redisRes, isPipeline); + return redisRes; + }) + .catch((err: any) => { + const openSpans: OpenSpanInfo[] = this[OTEL_OPEN_SPANS]; + if (!openSpans) { + plugin._diag.error('cannot find open spans to end for multi/pipeline'); + } else { + const replies = + err.constructor.name === 'MultiErrorReply' + ? (err as MultiErrorReply).replies + : new Array(openSpans.length).fill(err); + plugin._endSpansWithRedisReplies(openSpans, replies, isPipeline); + } + return Promise.reject(err); + }); + }; + }; + } + + private _getPatchMultiCommandsAddCommand() { + const plugin = this; + return function addCommandWrapper(original: Function) { + return function addCommandPatch(this: any, args: any) { + return plugin._traceClientCommand(original, this, arguments, args); + }; + }; + } + + private _getPatchRedisClientMulti() { + return function multiPatchWrapper(original: Function) { + return function multiPatch(this: any) { + const multiRes: any = original.apply(this, arguments); + multiRes[MULTI_COMMAND_OPTIONS] = this.options; + return multiRes; + }; + }; + } + + private _getPatchRedisClientSendCommand() { + const plugin = this; + return function sendCommandWrapper(original: Function) { + return function sendCommandPatch(this: any, args: any) { + return plugin._traceClientCommand(original, this, arguments, args); + }; + }; + } + + private _getPatchedClientConnect() { + const plugin = this; + return function connectWrapper(original: Function) { + return function patchedConnect(this: any) { + const options = this.options; + const attributes = getClientAttributes(plugin._diag, options, plugin._semconvStability); + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = 'auto.db.otel.redis'; + const span = plugin.tracer.startSpan(`${RedisInstrumentationV4_V5.COMPONENT}-connect`, { + kind: SpanKind.CLIENT, + attributes, + }); + const res = context.with(trace.setSpan(context.active(), span), () => { + return original.apply(this); + }); + return res + .then((result: any) => { + span.end(); + return result; + }) + .catch((error: Error) => { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + span.end(); + return Promise.reject(error); + }); + }; + }; + } + + _traceClientCommand( + origFunction: Function, + origThis: any, + origArguments: IArguments, + redisCommandArguments: Array, + ): any { + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (hasNoParentSpan && this.getConfig().requireParentSpan) { + return origFunction.apply(origThis, origArguments); + } + const clientOptions = origThis.options || origThis[MULTI_COMMAND_OPTIONS]; + const commandName = redisCommandArguments[0] as string; + const commandArgs = redisCommandArguments.slice(1); + const dbStatementSerializer = this.getConfig().dbStatementSerializer || defaultDbStatementSerializer; + const attributes = getClientAttributes(this._diag, clientOptions, this._semconvStability); + if (this._semconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_OPERATION_NAME] = commandName; + } + try { + const dbStatement = dbStatementSerializer(commandName, commandArgs); + if (dbStatement != null) { + if (this._semconvStability & SemconvStability.OLD) { + attributes[ATTR_DB_STATEMENT] = dbStatement; + } + if (this._semconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_QUERY_TEXT] = dbStatement; + } + } + } catch (e) { + this._diag.error('dbStatementSerializer throw an exception', e, { commandName }); + } + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = 'auto.db.otel.redis'; + const span = this.tracer.startSpan(`${RedisInstrumentationV4_V5.COMPONENT}-${commandName}`, { + kind: SpanKind.CLIENT, + attributes, + }); + const res = context.with(trace.setSpan(context.active(), span), () => { + return origFunction.apply(origThis, origArguments); + }); + if (typeof res?.then === 'function') { + res.then( + (redisRes: unknown) => { + this._endSpanWithResponse(span, commandName, commandArgs, redisRes, undefined); + }, + (err: Error) => { + this._endSpanWithResponse(span, commandName, commandArgs, null, err); + }, + ); + } else { + const redisClientMultiCommand: any = res; + redisClientMultiCommand[OTEL_OPEN_SPANS] = redisClientMultiCommand[OTEL_OPEN_SPANS] || []; + redisClientMultiCommand[OTEL_OPEN_SPANS].push({ + span, + commandName, + commandArgs, + }); + } + return res; + } + + _endSpansWithRedisReplies(openSpans: OpenSpanInfo[] | undefined, replies: unknown[], isPipeline = false): void { + if (!openSpans) { + return this._diag.error('cannot find open spans to end for redis multi/pipeline'); + } + if (replies.length !== openSpans.length) { + return this._diag.error('number of multi command spans does not match response from redis'); + } + const allCommands = openSpans.map(s => s.commandName); + const allSameCommand = allCommands.every(cmd => cmd === allCommands[0]); + const operationName = allSameCommand + ? (isPipeline ? 'PIPELINE ' : 'MULTI ') + allCommands[0] + : isPipeline + ? 'PIPELINE' + : 'MULTI'; + for (let i = 0; i < openSpans.length; i++) { + const { span, commandArgs } = openSpans[i]!; + const currCommandRes = replies[i]; + const [res, err] = currCommandRes instanceof Error ? [null, currCommandRes] : [currCommandRes, undefined]; + if (this._semconvStability & SemconvStability.STABLE) { + span.setAttribute(ATTR_DB_OPERATION_NAME, operationName); + } + this._endSpanWithResponse(span, allCommands[i]!, commandArgs, res, err); + } + } + + _endSpanWithResponse( + span: Span, + commandName: string, + commandArgs: Array, + response: unknown, + error: Error | null | undefined, + ): void { + const { responseHook } = this.getConfig(); + if (!error && responseHook) { + try { + responseHook(span, commandName, commandArgs, response); + } catch (err) { + this._diag.error('responseHook throw an exception', err); + } + } + if (error) { + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message }); + } + span.end(); + } +} + +// ---- RedisInstrumentation (wrapper) ---- + +const DEFAULT_CONFIG: RedisInstrumentationConfig = { + requireParentSpan: false, +}; + +export class RedisInstrumentation extends InstrumentationBase { + private instrumentationV2_V3: RedisInstrumentationV2_V3; + private instrumentationV4_V5: RedisInstrumentationV4_V5; + private initialized = false; + + constructor(config: RedisInstrumentationConfig = {}) { + const resolvedConfig = { ...DEFAULT_CONFIG, ...config }; + super(PACKAGE_NAME, PACKAGE_VERSION, resolvedConfig); + this.instrumentationV2_V3 = new RedisInstrumentationV2_V3(this.getConfig()); + this.instrumentationV4_V5 = new RedisInstrumentationV4_V5(this.getConfig()); + this.initialized = true; + } + + override setConfig(config: RedisInstrumentationConfig = {}): void { + const newConfig = { ...DEFAULT_CONFIG, ...config }; + super.setConfig(newConfig); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.setConfig(newConfig); + this.instrumentationV4_V5.setConfig(newConfig); + } + + init() {} + + override getModuleDefinitions() { + return [...this.instrumentationV2_V3.getModuleDefinitions(), ...this.instrumentationV4_V5.getModuleDefinitions()]; + } + + override setTracerProvider(tracerProvider: TracerProvider): void { + super.setTracerProvider(tracerProvider); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.setTracerProvider(tracerProvider); + this.instrumentationV4_V5.setTracerProvider(tracerProvider); + } + + override enable(): void { + super.enable(); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.enable(); + this.instrumentationV4_V5.enable(); + } + + override disable(): void { + super.disable(); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.disable(); + this.instrumentationV4_V5.disable(); + } +} diff --git a/packages/node/src/integrations/tracing/redis/vendored/semconv.ts b/packages/node/src/integrations/tracing/redis/vendored/semconv.ts new file mode 100644 index 000000000000..ab26f76282d9 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/semconv.ts @@ -0,0 +1,36 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/instrumentation-redis + * - Upstream version: @opentelemetry/instrumentation-redis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-redis */ + +/* + * This file contains a copy of unstable semantic convention definitions + * used by the vendored redis/ioredis instrumentations. + * @see https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv + */ + +// Deprecated constants kept for backwards compatibility with older semconv +export const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +export const ATTR_DB_STATEMENT = 'db.statement'; +export const ATTR_DB_SYSTEM = 'db.system'; +export const ATTR_NET_PEER_NAME = 'net.peer.name'; +export const ATTR_NET_PEER_PORT = 'net.peer.port'; +export const DB_SYSTEM_NAME_VALUE_REDIS = 'redis'; +export const DB_SYSTEM_VALUE_REDIS = 'redis'; diff --git a/packages/node/src/integrations/tracing/redis/vendored/types.ts b/packages/node/src/integrations/tracing/redis/vendored/types.ts new file mode 100644 index 000000000000..24b3817857d5 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/types.ts @@ -0,0 +1,92 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/instrumentation-redis + * - Upstream version: @opentelemetry/instrumentation-redis@0.62.0 and @opentelemetry/instrumentation-ioredis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-redis */ + +import type { Span } from '@opentelemetry/api'; +import type { InstrumentationConfig, SemconvStability } from '@opentelemetry/instrumentation'; + +// ---- redis types ---- + +/** + * Function that can be used to serialize db.statement tag + * @param cmdName - The name of the command (eg. set, get, mset) + * @param cmdArgs - Array of arguments passed to the command + * @returns serialized string that will be used as the db.statement attribute. + */ +export type DbStatementSerializer = (cmdName: string, cmdArgs: Array) => string; + +/** + * Function that can be used to add custom attributes to span on response from redis server + */ +export interface RedisResponseCustomAttributeFunction { + (span: Span, cmdName: string, cmdArgs: Array, response: unknown): void; +} + +export interface RedisInstrumentationConfig extends InstrumentationConfig { + /** Custom serializer function for the db.statement tag */ + dbStatementSerializer?: DbStatementSerializer; + /** Function for adding custom attributes on db response */ + responseHook?: RedisResponseCustomAttributeFunction; + /** Require parent to create redis span, default when unset is false */ + requireParentSpan?: boolean; + /** + * Controls which semantic-convention attributes are emitted on spans. + * Default: 'OLD'. + */ + semconvStability?: SemconvStability; +} + +// ---- ioredis types ---- + +export type CommandArgs = Array; + +/** + * Function that can be used to serialize db.statement tag for ioredis + */ +export type IORedisDbStatementSerializer = (cmdName: string, cmdArgs: CommandArgs) => string; + +export interface IORedisRequestHookInformation { + moduleVersion?: string; + cmdName: string; + cmdArgs: CommandArgs; +} + +export interface RedisRequestCustomAttributeFunction { + (span: Span, requestInfo: IORedisRequestHookInformation): void; +} + +/** + * Function that can be used to add custom attributes to span on response from redis server (ioredis) + */ +export interface IORedisResponseCustomAttributeFunction { + (span: Span, cmdName: string, cmdArgs: CommandArgs, response: unknown): void; +} + +export interface IORedisInstrumentationConfig extends InstrumentationConfig { + /** Custom serializer function for the db.statement tag */ + dbStatementSerializer?: IORedisDbStatementSerializer; + /** Function for adding custom attributes on db request */ + requestHook?: RedisRequestCustomAttributeFunction; + /** Function for adding custom attributes on db response */ + responseHook?: IORedisResponseCustomAttributeFunction; + /** Require parent to create ioredis span, default when unset is true */ + requireParentSpan?: boolean; +} diff --git a/packages/node/src/utils/redisCache.ts b/packages/node/src/utils/redisCache.ts index 476a257fbc6d..60e8218efdeb 100644 --- a/packages/node/src/utils/redisCache.ts +++ b/packages/node/src/utils/redisCache.ts @@ -1,4 +1,4 @@ -import type { CommandArgs as IORedisCommandArgs } from '@opentelemetry/instrumentation-ioredis'; +export type IORedisCommandArgs = Array; const SINGLE_ARG_COMMANDS = ['get', 'set', 'setex']; diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts deleted file mode 100644 index d02bc12393c6..000000000000 --- a/packages/node/test/integrations/http.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { _shouldUseOtelHttpInstrumentation } from '../../src/integrations/http'; -import { conditionalTest } from '../helpers/conditional'; - -describe('httpIntegration', () => { - describe('_shouldInstrumentSpans', () => { - it.each([ - [{ spans: true }, {}, true], - [{ spans: false }, {}, false], - [{ spans: true }, { skipOpenTelemetrySetup: true }, true], - [{ spans: false }, { skipOpenTelemetrySetup: true }, false], - [{}, { skipOpenTelemetrySetup: true }, false], - [{}, { tracesSampleRate: 0, skipOpenTelemetrySetup: true }, false], - [{}, { tracesSampleRate: 0 }, true], - ])('returns the correct value for options=%j and clientOptions=%j', (options, clientOptions, expected) => { - const actual = _shouldUseOtelHttpInstrumentation(options, clientOptions); - expect(actual).toBe(expected); - }); - - conditionalTest({ min: 22 })('returns false without tracesSampleRate on Node >=22.12', () => { - const actual = _shouldUseOtelHttpInstrumentation({}, {}); - expect(actual).toBe(false); - }); - - conditionalTest({ max: 21 })('returns true without tracesSampleRate on Node <22', () => { - const actual = _shouldUseOtelHttpInstrumentation({}, {}); - expect(actual).toBe(true); - }); - }); -}); diff --git a/packages/node/test/integrations/tracing/redis.test.ts b/packages/node/test/integrations/tracing/redis.test.ts index ae5b879c0b8e..0eb31d6aea5f 100644 --- a/packages/node/test/integrations/tracing/redis.test.ts +++ b/packages/node/test/integrations/tracing/redis.test.ts @@ -38,12 +38,12 @@ describe('Redis', () => { { desc: 'unsupported command', cmd: 'exists', args: ['key'], response: 'test' }, { desc: 'no cache prefixes', cmd: 'get', args: ['key'], response: 'test', options: {} }, { desc: 'non-matching prefix', cmd: 'get', args: ['key'], response: 'test', options: { cachePrefixes: ['c'] } }, - ])('should always set sentry.origin but return early when $desc', ({ cmd, args, response, options = {} }) => { + ])('should return early without modifying span when $desc', ({ cmd, args, response, options = {} }) => { Object.assign(_redisOptions, options); cacheResponseHook(mockSpan, cmd, args, response); - expect(mockSpan.setAttribute).toHaveBeenCalledWith('sentry.origin', 'auto.db.otel.redis'); + expect(mockSpan.setAttribute).not.toHaveBeenCalled(); expect(mockSpan.setAttributes).not.toHaveBeenCalled(); expect(mockSpan.updateName).not.toHaveBeenCalled(); }); diff --git a/packages/node/test/integrations/tracing/redis/ioredis-instrumentation.test.ts b/packages/node/test/integrations/tracing/redis/ioredis-instrumentation.test.ts new file mode 100644 index 000000000000..9e7b24d3dd0a --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/ioredis-instrumentation.test.ts @@ -0,0 +1,151 @@ +/* + * Tests ported from @opentelemetry/instrumentation-ioredis@0.62.0 + * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-ioredis + * Licensed under the Apache License, Version 2.0 + */ + +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { IORedisInstrumentation } from '../../../../src/integrations/tracing/redis/vendored/ioredis-instrumentation'; + +const memoryExporter = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(memoryExporter)] }); + +describe('IORedisInstrumentation', () => { + let instrumentation: IORedisInstrumentation; + + beforeEach(() => { + instrumentation = new IORedisInstrumentation(); + instrumentation.setTracerProvider(provider); + memoryExporter.reset(); + }); + + afterEach(() => { + instrumentation.disable(); + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create an instance with default config (requireParentSpan = true)', () => { + const inst = new IORedisInstrumentation(); + expect(inst).toBeInstanceOf(IORedisInstrumentation); + expect(inst.getConfig().requireParentSpan).toBe(true); + }); + + it('should create an instance with custom config', () => { + const inst = new IORedisInstrumentation({ requireParentSpan: false }); + expect(inst.getConfig().requireParentSpan).toBe(false); + }); + }); + + describe('setConfig', () => { + it('should preserve default requireParentSpan = true when config is empty', () => { + instrumentation.setConfig({}); + expect(instrumentation.getConfig().requireParentSpan).toBe(true); + }); + + it('should allow overriding requireParentSpan', () => { + instrumentation.setConfig({ requireParentSpan: false }); + expect(instrumentation.getConfig().requireParentSpan).toBe(false); + }); + }); + + describe('init', () => { + it('should return module definitions for ioredis', () => { + const defs = instrumentation.init(); + expect(Array.isArray(defs)).toBe(true); + expect(defs).toHaveLength(1); + expect(defs[0]!.name).toBe('ioredis'); + }); + + it('should support ioredis versions >=2.0.0 <6', () => { + const defs = instrumentation.init(); + const supportedVersions = defs[0]!.supportedVersions; + expect(supportedVersions).toContain('>=2.0.0 <6'); + }); + }); + + describe('_patchSendCommand', () => { + it('should skip tracing when no parent span and requireParentSpan is true', () => { + instrumentation.setConfig({ requireParentSpan: true }); + const original = vi.fn().mockReturnValue(Promise.resolve('OK')); + + const patchFn = (instrumentation as any)._patchSendCommand(); + const patched = patchFn(original); + + const fakeThis = { + options: { host: 'localhost', port: 6379 }, + }; + const fakeCmd = { + name: 'get', + args: ['mykey'], + resolve: vi.fn(), + reject: vi.fn(), + }; + + patched.call(fakeThis, fakeCmd); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + + it('should not trace when called with less than 1 argument', () => { + const original = vi.fn().mockReturnValue(undefined); + const patchFn = (instrumentation as any)._patchSendCommand(); + const patched = patchFn(original); + + const fakeThis = { options: { host: 'localhost', port: 6379 } }; + + patched.call(fakeThis); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + + it('should not trace when cmd is not an object', () => { + const original = vi.fn().mockReturnValue(undefined); + const patchFn = (instrumentation as any)._patchSendCommand(); + const patched = patchFn(original); + + const fakeThis = { options: { host: 'localhost', port: 6379 } }; + + patched.call(fakeThis, 'not-an-object'); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + }); + + describe('_patchConnection', () => { + it('should skip tracing when no parent span and requireParentSpan is true', () => { + instrumentation.setConfig({ requireParentSpan: true }); + const original = vi.fn().mockReturnValue({ connected: true }); + + const patchFn = (instrumentation as any)._patchConnection(); + const patched = patchFn(original); + + const fakeThis = { options: { host: 'localhost', port: 6379 } }; + + patched.call(fakeThis); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + }); + + describe('semconv stability', () => { + it('should initialize semconv stability from env', () => { + const inst = new IORedisInstrumentation(); + expect((inst as any)._netSemconvStability).toBeDefined(); + expect((inst as any)._dbSemconvStability).toBeDefined(); + }); + + it('should allow resetting semconv stability', () => { + const inst = new IORedisInstrumentation(); + const originalNet = (inst as any)._netSemconvStability; + inst._setSemconvStabilityFromEnv(); + expect((inst as any)._netSemconvStability).toBe(originalNet); + }); + }); +}); diff --git a/packages/node/test/integrations/tracing/redis/redis-common.test.ts b/packages/node/test/integrations/tracing/redis/redis-common.test.ts new file mode 100644 index 000000000000..6ceba6e63b12 --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/redis-common.test.ts @@ -0,0 +1,93 @@ +/* + * Tests ported from @opentelemetry/redis-common@0.38.2 + * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/redis-common + * Licensed under the Apache License, Version 2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { defaultDbStatementSerializer } from '../../../../src/integrations/tracing/redis/vendored/redis-common'; + +describe('defaultDbStatementSerializer()', () => { + const testCases: Array<{ + cmdName: string; + cmdArgs: Array; + expected: string; + }> = [ + { + cmdName: 'UNKNOWN', + cmdArgs: ['something'], + expected: 'UNKNOWN [1 other arguments]', + }, + { + cmdName: 'ECHO', + cmdArgs: ['echo'], + expected: 'ECHO [1 other arguments]', + }, + { + cmdName: 'LPUSH', + cmdArgs: ['list', 'value'], + expected: 'LPUSH list [1 other arguments]', + }, + { + cmdName: 'HSET', + cmdArgs: ['hash', 'field', 'value'], + expected: 'HSET hash field [1 other arguments]', + }, + { + cmdName: 'INCRBY', + cmdArgs: ['key', 5], + expected: 'INCRBY key 5', + }, + { + cmdName: 'GET', + cmdArgs: ['mykey'], + expected: 'GET mykey', + }, + { + cmdName: 'SET', + cmdArgs: ['mykey', 'myvalue'], + expected: 'SET mykey [1 other arguments]', + }, + { + cmdName: 'MSET', + cmdArgs: ['key1', 'val1', 'key2', 'val2'], + expected: 'MSET key1 [3 other arguments]', + }, + { + cmdName: 'HSET', + cmdArgs: ['myhash', 'field1', 'Hello'], + expected: 'HSET myhash field1 [1 other arguments]', + }, + { + cmdName: 'SET', + cmdArgs: [], + expected: 'SET', + }, + { + cmdName: 'DEL', + cmdArgs: ['key1', 'key2'], + expected: 'DEL key1 key2', + }, + { + cmdName: 'ZADD', + cmdArgs: ['myset', '1', 'one', '2', 'two'], + expected: 'ZADD myset [4 other arguments]', + }, + ]; + + it.each(testCases)( + 'should serialize the correct number of arguments for $cmdName', + ({ cmdName, cmdArgs, expected }) => { + expect(defaultDbStatementSerializer(cmdName, cmdArgs as any)).toBe(expected); + }, + ); + + it('should handle empty args array', () => { + expect(defaultDbStatementSerializer('GET', [])).toBe('GET'); + }); + + it('should handle Buffer arguments', () => { + const result = defaultDbStatementSerializer('GET', [Buffer.from('mykey')]); + expect(result).toBe('GET mykey'); + }); +}); diff --git a/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts b/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts new file mode 100644 index 000000000000..852298b3370c --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts @@ -0,0 +1,214 @@ +import { SPAN_STATUS_ERROR } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// channels registry must be created before the vi.mock factory runs +const channels = vi.hoisted(() => ({}) as Record void> }>); + +vi.mock('@sentry/opentelemetry/tracing-channel', () => ({ + tracingChannel: (name: string, _transform: unknown) => { + const subs: Record void> = {}; + channels[name] = { subs }; + return { subscribe: (s: Record void>) => Object.assign(subs, s) }; + }, +})); + +import { + _resetRedisDiagnosticChannelsForTesting, + subscribeRedisDiagnosticChannels, +} from '../../../../src/integrations/tracing/redis/redis-dc-subscriber'; + +const CHANNEL_COMMAND = 'node-redis:command'; +const CHANNEL_BATCH = 'node-redis:batch'; +const CHANNEL_CONNECT = 'node-redis:connect'; + +const subs = (name: string) => + channels[name]?.subs as { + asyncEnd: (data: any) => void; + error: (data: any) => void; + }; + +function makeSpan() { + return { + end: vi.fn(), + setStatus: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), + updateName: vi.fn(), + spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id', traceFlags: 1 }), + }; +} + +describe('redis-dc-subscriber', () => { + let mockSpan: ReturnType; + let responseHook: ReturnType; + + beforeEach(() => { + _resetRedisDiagnosticChannelsForTesting(); + mockSpan = makeSpan(); + responseHook = vi.fn(); + subscribeRedisDiagnosticChannels(responseHook); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('command channel', () => { + describe('asyncEnd (success path)', () => { + it('calls the response hook with sliced args and ends the span', () => { + const data = { + command: 'GET', + args: ['GET', 'cache:key'], + result: 'hit-value', + _sentrySpan: mockSpan, + }; + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).toHaveBeenCalledWith(mockSpan, 'GET', ['cache:key'], 'hit-value'); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('strips the command name from args before passing to the response hook', () => { + const data = { + command: 'MGET', + args: ['MGET', 'key1', 'key2', 'key3'], + result: ['v1', 'v2', 'v3'], + _sentrySpan: mockSpan, + }; + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).toHaveBeenCalledWith(mockSpan, 'MGET', ['key1', 'key2', 'key3'], ['v1', 'v2', 'v3']); + }); + + it('bails early when _sentrySpan is absent', () => { + subs(CHANNEL_COMMAND).asyncEnd({ command: 'GET', args: ['GET', 'k'], result: 'v' }); + + expect(responseHook).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + it('sets error status and ends the span in the error handler', () => { + const error = new Error('ECONNREFUSED'); + const data = { command: 'SET', args: ['SET', 'k', 'v'], error, _sentrySpan: mockSpan }; + subs(CHANNEL_COMMAND).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'ECONNREFUSED' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not call the response hook or end the span a second time in asyncEnd when error is set', () => { + const error = new Error('ECONNREFUSED'); + const data = { command: 'GET', args: ['GET', 'k'], error, _sentrySpan: mockSpan }; + + // TracingChannel fires error first, then asyncEnd, on the same data object + subs(CHANNEL_COMMAND).error(data); + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).not.toHaveBeenCalled(); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('bails early in error handler when _sentrySpan is absent', () => { + subs(CHANNEL_COMMAND).error({ command: 'GET', args: ['GET', 'k'], error: new Error('x') }); + + expect(mockSpan.setStatus).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + }); + + describe('batch channel', () => { + describe('asyncEnd (success path)', () => { + it('ends the span', () => { + const data = { batchMode: 'PIPELINE', batchSize: 3, _sentrySpan: mockSpan }; + subs(CHANNEL_BATCH).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('bails early when _sentrySpan is absent', () => { + subs(CHANNEL_BATCH).asyncEnd({ batchMode: 'MULTI' }); + + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + it('sets error status and ends the span in the error handler', () => { + const error = new Error('MULTI aborted'); + const data = { batchMode: 'MULTI', error, _sentrySpan: mockSpan }; + subs(CHANNEL_BATCH).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'MULTI aborted' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not end the span a second time in asyncEnd when error is set', () => { + const error = new Error('MULTI aborted'); + const data = { batchMode: 'MULTI', error, _sentrySpan: mockSpan }; + + subs(CHANNEL_BATCH).error(data); + subs(CHANNEL_BATCH).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('connect channel', () => { + describe('asyncEnd (success path)', () => { + it('ends the span', () => { + const data = { serverAddress: '127.0.0.1', serverPort: 6379, _sentrySpan: mockSpan }; + subs(CHANNEL_CONNECT).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('bails early when _sentrySpan is absent', () => { + subs(CHANNEL_CONNECT).asyncEnd({ serverAddress: '127.0.0.1' }); + + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + it('sets error status and ends the span in the error handler', () => { + const error = new Error('connect ECONNREFUSED'); + const data = { serverAddress: '127.0.0.1', serverPort: 6379, error, _sentrySpan: mockSpan }; + subs(CHANNEL_CONNECT).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'connect ECONNREFUSED' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not end the span a second time in asyncEnd when error is set', () => { + const error = new Error('connect ECONNREFUSED'); + const data = { serverAddress: '127.0.0.1', error, _sentrySpan: mockSpan }; + + subs(CHANNEL_CONNECT).error(data); + subs(CHANNEL_CONNECT).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('subscribeRedisDiagnosticChannels', () => { + it('is idempotent — does not re-subscribe if called again', () => { + // subscribeRedisDiagnosticChannels was already called in beforeEach. + // Calling again should not throw or overwrite subscribers. + const secondHook = vi.fn(); + subscribeRedisDiagnosticChannels(secondHook); + + // The second hook should still be active (currentResponseHook is updated regardless) + // but no new channel setup should occur. + const data = { command: 'GET', args: ['GET', 'k'], result: 'v', _sentrySpan: mockSpan }; + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(secondHook).toHaveBeenCalledTimes(1); + expect(responseHook).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/node/test/integrations/tracing/redis/redis-instrumentation.test.ts b/packages/node/test/integrations/tracing/redis/redis-instrumentation.test.ts new file mode 100644 index 000000000000..5ae0c5724033 --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/redis-instrumentation.test.ts @@ -0,0 +1,193 @@ +/* + * Tests ported from @opentelemetry/instrumentation-redis@0.62.0 + * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-redis + * Licensed under the Apache License, Version 2.0 + */ + +import { SpanStatusCode } from '@opentelemetry/api'; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RedisInstrumentation } from '../../../../src/integrations/tracing/redis/vendored/redis-instrumentation'; + +const memoryExporter = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(memoryExporter)] }); + +describe('RedisInstrumentation', () => { + let instrumentation: RedisInstrumentation; + + beforeEach(() => { + instrumentation = new RedisInstrumentation(); + instrumentation.setTracerProvider(provider); + memoryExporter.reset(); + }); + + afterEach(() => { + instrumentation.disable(); + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create an instance with default config', () => { + const inst = new RedisInstrumentation(); + expect(inst).toBeInstanceOf(RedisInstrumentation); + expect(inst.getConfig().requireParentSpan).toBe(false); + }); + + it('should create an instance with custom config', () => { + const inst = new RedisInstrumentation({ requireParentSpan: true }); + expect(inst.getConfig().requireParentSpan).toBe(true); + }); + + it('should enable and disable without throwing', () => { + const inst = new RedisInstrumentation(); + expect(() => inst.enable()).not.toThrow(); + expect(() => inst.disable()).not.toThrow(); + }); + }); + + describe('setConfig', () => { + it('should keep requireParentSpan default as false when config is empty', () => { + instrumentation.setConfig({}); + expect(instrumentation.getConfig().requireParentSpan).toBe(false); + }); + + it('should propagate config updates', () => { + const responseHook = vi.fn(); + instrumentation.setConfig({ responseHook }); + expect(instrumentation.getConfig().responseHook).toBe(responseHook); + }); + }); + + describe('getModuleDefinitions', () => { + it('should return module definitions from both v2-v3 and v4-v5 instrumentations', () => { + const defs = instrumentation.getModuleDefinitions(); + // v2-v3 instruments 'redis', v4-v5 instruments '@redis/client' and '@node-redis/client' + expect(defs.length).toBeGreaterThanOrEqual(3); + const moduleNames = defs.map((d: any) => d.name); + expect(moduleNames).toContain('redis'); + expect(moduleNames).toContain('@redis/client'); + expect(moduleNames).toContain('@node-redis/client'); + }); + }); + + describe('setTracerProvider', () => { + it('should accept a tracer provider', () => { + expect(() => instrumentation.setTracerProvider(provider)).not.toThrow(); + }); + }); + + describe('_endSpanWithResponse (v4-v5)', () => { + it('should call responseHook when no error occurs', () => { + const responseHook = vi.fn(); + const inst = new RedisInstrumentation({ responseHook }); + inst.setTracerProvider(provider); + + const span = provider.getTracer('test').startSpan('test-span'); + const v4v5 = (inst as any).instrumentationV4_V5; + v4v5._endSpanWithResponse(span, 'GET', ['mykey'], 'myvalue', undefined); + + expect(responseHook).toHaveBeenCalledWith(span, 'GET', ['mykey'], 'myvalue'); + }); + + it('should not call responseHook when error occurs', () => { + const responseHook = vi.fn(); + const inst = new RedisInstrumentation({ responseHook }); + inst.setTracerProvider(provider); + + const span = provider.getTracer('test').startSpan('test-span'); + const v4v5 = (inst as any).instrumentationV4_V5; + const error = new Error('connection failed'); + v4v5._endSpanWithResponse(span, 'GET', ['mykey'], null, error); + + expect(responseHook).not.toHaveBeenCalled(); + }); + + it('should set error status on span when error occurs', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + + const span = provider.getTracer('test').startSpan('test-span'); + const v4v5 = (inst as any).instrumentationV4_V5; + const error = new Error('connection failed'); + v4v5._endSpanWithResponse(span, 'GET', ['mykey'], null, error); + + const exportedSpans = memoryExporter.getFinishedSpans(); + expect(exportedSpans).toHaveLength(1); + expect(exportedSpans[0]!.status.code).toBe(SpanStatusCode.ERROR); + expect(exportedSpans[0]!.status.message).toBe('connection failed'); + }); + }); + + describe('_endSpansWithRedisReplies (v4-v5 multi/pipeline)', () => { + it('should end all spans with their corresponding replies', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + + const tracer = provider.getTracer('test'); + const span1 = tracer.startSpan('redis-SET'); + const span2 = tracer.startSpan('redis-GET'); + + const openSpans = [ + { span: span1, commandName: 'SET', commandArgs: ['key1', 'value1'] }, + { span: span2, commandName: 'GET', commandArgs: ['key1'] }, + ]; + + v4v5._endSpansWithRedisReplies(openSpans, ['OK', 'value1'], false); + + const exportedSpans = memoryExporter.getFinishedSpans(); + expect(exportedSpans).toHaveLength(2); + exportedSpans.forEach(s => { + expect(s.status.code).not.toBe(SpanStatusCode.ERROR); + }); + }); + + it('should handle error replies in multi commands', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + + const tracer = provider.getTracer('test'); + const span1 = tracer.startSpan('redis-SET'); + + const openSpans = [{ span: span1, commandName: 'SET', commandArgs: ['key1', 'value1'] }]; + const error = new Error('command error'); + + v4v5._endSpansWithRedisReplies(openSpans, [error], false); + + const exportedSpans = memoryExporter.getFinishedSpans(); + expect(exportedSpans).toHaveLength(1); + expect(exportedSpans[0]!.status.code).toBe(SpanStatusCode.ERROR); + }); + + it('should log error when openSpans is undefined', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + const diagSpy = vi.spyOn(v4v5._diag, 'error'); + + v4v5._endSpansWithRedisReplies(undefined, [], false); + + expect(diagSpy).toHaveBeenCalled(); + }); + + it('should log error when replies length does not match open spans', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + const diagSpy = vi.spyOn(v4v5._diag, 'error'); + + const tracer = provider.getTracer('test'); + const span1 = tracer.startSpan('redis-GET'); + + v4v5._endSpansWithRedisReplies( + [{ span: span1, commandName: 'GET', commandArgs: ['key'] }], + [], // wrong number of replies + false, + ); + + expect(diagSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/opentelemetry/src/resource.ts b/packages/opentelemetry/src/resource.ts index 9c1f95a7179c..89b84927ee65 100644 --- a/packages/opentelemetry/src/resource.ts +++ b/packages/opentelemetry/src/resource.ts @@ -39,18 +39,58 @@ class SentryResource { } } +/** + * Parses `OTEL_RESOURCE_ATTRIBUTES` env var (comma-separated `key=value` pairs). + * Values are URL-decoded per the OTel spec. + */ +function parseOtelResourceAttributes(raw: string | undefined): Attributes { + if (!raw) { + return {}; + } + const result: Attributes = {}; + for (const pair of raw.split(',')) { + const eq = pair.indexOf('='); + if (eq === -1) { + continue; + } + const key = pair.substring(0, eq).trim(); + const value = pair.substring(eq + 1).trim(); + if (key) { + try { + result[key] = decodeURIComponent(value); + } catch { + result[key] = value; + } + } + } + return result; +} + /** * Returns a Resource for use in Sentry's OpenTelemetry TracerProvider setup. * * Combines the default OTel SDK telemetry attributes with Sentry-specific * service attributes, equivalent to what was previously done via: * `defaultResource().merge(resourceFromAttributes({ ... }))` + * + * Respects OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES environment variables + * per the OpenTelemetry specification. */ -export function getSentryResource(serviceName: string): SentryResource { +export function getSentryResource(serviceNameFallback: string): SentryResource { + const env = typeof process !== 'undefined' ? process.env : {}; + const otelServiceName = env.OTEL_SERVICE_NAME; + const otelResourceAttrs = parseOtelResourceAttributes(env.OTEL_RESOURCE_ATTRIBUTES); + return new SentryResource({ - [ATTR_SERVICE_NAME]: serviceName, + // Lowest priority: Sentry defaults // eslint-disable-next-line deprecation/deprecation [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', + [ATTR_SERVICE_NAME]: serviceNameFallback, + // OTEL_RESOURCE_ATTRIBUTES overrides defaults (including service.name and service.namespace) + ...otelResourceAttrs, + // OTEL_SERVICE_NAME explicitly overrides service.name + ...(otelServiceName ? { [ATTR_SERVICE_NAME]: otelServiceName } : {}), + // Highest priority: Sentry SDK telemetry attrs (cannot be overridden by env vars) [ATTR_SERVICE_VERSION]: SDK_VERSION, [ATTR_TELEMETRY_SDK_LANGUAGE]: SDK_INFO[ATTR_TELEMETRY_SDK_LANGUAGE], [ATTR_TELEMETRY_SDK_NAME]: SDK_INFO[ATTR_TELEMETRY_SDK_NAME], diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 235ff3247f5d..05dc0758458b 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -75,10 +75,13 @@ export class SentrySampler implements Sampler { const maybeSpanHttpMethod = spanAttributes[SEMATTRS_HTTP_METHOD] || spanAttributes[ATTR_HTTP_REQUEST_METHOD]; // If we have a http.client span that has no local parent, we never want to sample it - // but we want to leave downstream sampling decisions up to the server + // but we want to leave downstream sampling decisions up to the server. + // Exception: when span streaming is enabled, we always emit these spans. if (spanKind === SpanKind.CLIENT && maybeSpanHttpMethod && (!parentSpan || parentContext?.isRemote)) { - this._client.recordDroppedEvent('no_parent_span', 'span'); - return wrapSamplingDecision({ decision: undefined, context, spanAttributes }); + if (!this._isSpanStreaming) { + this._client.recordDroppedEvent('no_parent_span', 'span'); + return wrapSamplingDecision({ decision: undefined, context, spanAttributes }); + } } const parentSampled = parentSpan ? getParentSampled(parentSpan, traceId, spanName) : undefined; diff --git a/packages/opentelemetry/test/resource.test.ts b/packages/opentelemetry/test/resource.test.ts new file mode 100644 index 000000000000..1a6ebedf34d4 --- /dev/null +++ b/packages/opentelemetry/test/resource.test.ts @@ -0,0 +1,131 @@ +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, + ATTR_TELEMETRY_SDK_LANGUAGE, + ATTR_TELEMETRY_SDK_NAME, + ATTR_TELEMETRY_SDK_VERSION, + SEMRESATTRS_SERVICE_NAMESPACE, +} from '@opentelemetry/semantic-conventions'; +import { SDK_VERSION } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getSentryResource } from '../src/resource'; +import { SDK_INFO } from '@opentelemetry/core'; + +describe('getSentryResource', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Clone env so mutations are isolated + process.env = { ...originalEnv }; + delete process.env['OTEL_SERVICE_NAME']; + delete process.env['OTEL_RESOURCE_ATTRIBUTES']; + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('uses serviceNameFallback when no env vars are set', () => { + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('node'); + }); + + it('uses OTEL_SERVICE_NAME over the fallback', () => { + process.env['OTEL_SERVICE_NAME'] = 'my-service'; + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('my-service'); + }); + + it('ignores empty OTEL_SERVICE_NAME and falls back to serviceNameFallback', () => { + process.env['OTEL_SERVICE_NAME'] = ''; + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('node'); + }); + + it('includes OTEL_RESOURCE_ATTRIBUTES key=value pairs', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'custom.key=custom-value,another.key=another-value'; + const resource = getSentryResource('node'); + expect(resource.attributes['custom.key']).toBe('custom-value'); + expect(resource.attributes['another.key']).toBe('another-value'); + }); + + it('OTEL_RESOURCE_ATTRIBUTES can override service.name (but OTEL_SERVICE_NAME takes precedence over it)', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'service.name=from-attrs'; + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('from-attrs'); + }); + + it('OTEL_SERVICE_NAME takes precedence over service.name from OTEL_RESOURCE_ATTRIBUTES', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'service.name=from-attrs'; + process.env['OTEL_SERVICE_NAME'] = 'from-service-name'; + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('from-service-name'); + }); + + it('OTEL_RESOURCE_ATTRIBUTES can override service.namespace', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'service.namespace=my-namespace'; + const resource = getSentryResource('node'); + // eslint-disable-next-line deprecation/deprecation + expect(resource.attributes[SEMRESATTRS_SERVICE_NAMESPACE]).toBe('my-namespace'); + }); + + it('Sentry SDK telemetry attrs cannot be overridden by OTEL_RESOURCE_ATTRIBUTES', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = + 'telemetry.sdk.name=evil,telemetry.sdk.language=evil,telemetry.sdk.version=0.0.0'; + const resource = getSentryResource('node'); + // not evil or 0.0.0 + expect(resource.attributes[ATTR_TELEMETRY_SDK_NAME]).toBe(SDK_INFO[ATTR_TELEMETRY_SDK_NAME]); + expect(resource.attributes[ATTR_TELEMETRY_SDK_LANGUAGE]).toBe(SDK_INFO[ATTR_TELEMETRY_SDK_LANGUAGE]); + expect(resource.attributes[ATTR_TELEMETRY_SDK_VERSION]).toBe(SDK_INFO[ATTR_TELEMETRY_SDK_VERSION]); + }); + + it('Sentry SDK telemetry attrs cannot be overridden by OTEL_SERVICE_NAME (service.version)', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'service.version=0.0.0'; + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_SERVICE_VERSION]).toBe(SDK_VERSION); + }); + + it('always includes Sentry SDK telemetry attributes', () => { + const resource = getSentryResource('node'); + expect(resource.attributes[ATTR_TELEMETRY_SDK_LANGUAGE]).toBeDefined(); + expect(resource.attributes[ATTR_TELEMETRY_SDK_NAME]).toBeDefined(); + expect(resource.attributes[ATTR_TELEMETRY_SDK_VERSION]).toBeDefined(); + expect(resource.attributes[ATTR_SERVICE_VERSION]).toBe(SDK_VERSION); + }); + + it('always sets service.namespace to sentry by default', () => { + const resource = getSentryResource('node'); + // eslint-disable-next-line deprecation/deprecation + expect(resource.attributes[SEMRESATTRS_SERVICE_NAMESPACE]).toBe('sentry'); + }); + + it('URL-decodes values in OTEL_RESOURCE_ATTRIBUTES', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'custom.key=hello%20world'; + const resource = getSentryResource('node'); + expect(resource.attributes['custom.key']).toBe('hello world'); + }); + + it('handles malformed OTEL_RESOURCE_ATTRIBUTES gracefully (no = sign)', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = 'badentry,custom.key=value'; + expect(() => getSentryResource('node')).not.toThrow(); + const resource = getSentryResource('node'); + expect(resource.attributes['custom.key']).toBe('value'); + }); + + it('handles empty OTEL_RESOURCE_ATTRIBUTES gracefully', () => { + process.env['OTEL_RESOURCE_ATTRIBUTES'] = ''; + expect(() => getSentryResource('node')).not.toThrow(); + }); + + it('does not crash when process is undefined', () => { + const saved = global.process; + // @ts-expect-error — simulating edge runtime where process may be undefined + global.process = undefined; + try { + expect(() => getSentryResource('node')).not.toThrow(); + } finally { + global.process = saved; + } + }); +}); diff --git a/packages/opentelemetry/test/sampler.test.ts b/packages/opentelemetry/test/sampler.test.ts index 22fa724fa161..55c3cff8ac32 100644 --- a/packages/opentelemetry/test/sampler.test.ts +++ b/packages/opentelemetry/test/sampler.test.ts @@ -348,5 +348,23 @@ describe('SentrySampler', () => { expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'span'); }); + + it('always emits streamed http.client spans without a local parent', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, traceLifecycle: 'stream' })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET http://example.com/api'; + const spanKind = SpanKind.CLIENT; + const spanAttributes = { + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + }; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); + expect(spyOnDroppedEvent).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/react-router/package.json b/packages/react-router/package.json index e0baac716684..2aaad75f6e04 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -62,7 +62,7 @@ "@react-router/node": "^7.13.1", "react": "^18.3.1", "react-router": "^7.13.0", - "vite": "^6.1.0" + "vite": "^6.4.2" }, "peerDependencies": { "@react-router/node": "7.x", diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index 24431843710c..0f5de3975b3f 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -24,7 +24,7 @@ export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; */ export type { ErrorBoundaryProps, FallbackRender } from '@sentry/react'; -// React Router instrumentation API for use with unstable_instrumentations (React Router 7.x) +// React Router instrumentation API for use with HydratedRouter's `instrumentations` prop export { createSentryClientInstrumentation, isClientInstrumentationApiUsed, diff --git a/packages/react-router/src/client/tracingIntegration.ts b/packages/react-router/src/client/tracingIntegration.ts index a711eb986508..c4d0bd452123 100644 --- a/packages/react-router/src/client/tracingIntegration.ts +++ b/packages/react-router/src/client/tracingIntegration.ts @@ -19,7 +19,7 @@ export interface ReactRouterTracingIntegrationOptions { /** * Enable React Router's instrumentation API. - * When true, prepares for use with HydratedRouter's `unstable_instrumentations` prop. + * When true, prepares for use with HydratedRouter's `instrumentations` prop. * @experimental * @default false */ diff --git a/packages/react-router/src/common/types.ts b/packages/react-router/src/common/types.ts index 23cbb174f167..245ffdb8c378 100644 --- a/packages/react-router/src/common/types.ts +++ b/packages/react-router/src/common/types.ts @@ -1,8 +1,7 @@ /** * Types for React Router's instrumentation API. * - * Derived from React Router v7.x `unstable_instrumentations` API. - * The stable `instrumentations` API is planned for React Router v8. + * Derived from React Router's `instrumentations` API. * If React Router changes these types, this file must be updated. * * @see https://reactrouter.com/how-to/instrumentation diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index e0b8c8981632..82b20668271a 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -12,7 +12,7 @@ export { wrapServerLoader } from './wrapServerLoader'; export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; export { getMetaTagTransformer } from './getMetaTagTransformer'; -// React Router instrumentation API support (works with both unstable_instrumentations and instrumentations) +// React Router instrumentation API support export { createSentryServerInstrumentation, isInstrumentationApiUsed, diff --git a/packages/react-router/test/client/hydratedRouter.test.ts b/packages/react-router/test/client/hydratedRouter.test.ts index eb0a27073a9f..b5a5fec1f84d 100644 --- a/packages/react-router/test/client/hydratedRouter.test.ts +++ b/packages/react-router/test/client/hydratedRouter.test.ts @@ -163,7 +163,7 @@ describe('instrumentHydratedRouter', () => { it('creates navigation span in Framework Mode (flag not set means router() was never called)', () => { // This is a regression test for Framework Mode (e.g., Remix) where: // 1. createSentryClientInstrumentation() may be called during SDK init - // 2. But the framework doesn't support unstable_instrumentations, so router() is never called + // 2. But the framework doesn't invoke the instrumentations API, so router() is never called // 3. In this case, the legacy navigation instrumentation should still create spans // // We simulate this by ensuring the flag is NOT set (since router() was never called) diff --git a/packages/react-router/test/client/tracingIntegration.test.ts b/packages/react-router/test/client/tracingIntegration.test.ts index 81a3360f1457..2a4326246b83 100644 --- a/packages/react-router/test/client/tracingIntegration.test.ts +++ b/packages/react-router/test/client/tracingIntegration.test.ts @@ -156,7 +156,7 @@ describe('reactRouterTracingIntegration', () => { // Scenario: // 1. User sets useInstrumentationAPI: true in reactRouterTracingIntegration options // 2. createSentryClientInstrumentation() is called eagerly during SDK init - // 3. BUT in Framework Mode, React Router doesn't support unstable_instrumentations, + // 3. BUT in Framework Mode, React Router doesn't invoke the instrumentations API, // so router() method is NEVER called by the framework // 4. The SENTRY_CLIENT_INSTRUMENTATION_FLAG must NOT be set in this case // 5. isClientInstrumentationApiUsed() must return false diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx index 424821a9ad98..c158f831c381 100644 --- a/packages/react/test/reactrouter-cross-usage.test.tsx +++ b/packages/react/test/reactrouter-cross-usage.test.tsx @@ -627,9 +627,14 @@ describe('React Router cross usage of wrappers', () => { await act(async () => { router.navigate('/settings'); - await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); }); + await waitFor(() => { + expect(router.state.navigation.state).toBe('idle'); + expect(router.state.location.pathname).toBe('/settings'); + }); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { name: '/settings', attributes: { @@ -641,9 +646,14 @@ describe('React Router cross usage of wrappers', () => { await act(async () => { router.navigate('/profile'); - await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); }); + await waitFor(() => { + expect(router.state.navigation.state).toBe('idle'); + expect(router.state.location.pathname).toBe('/profile'); + }); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); const calls = mockStartBrowserTracingNavigationSpan.mock.calls; @@ -734,9 +744,14 @@ describe('React Router cross usage of wrappers', () => { await act(async () => { router.navigate('/user/2'); - await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); }); + await waitFor(() => { + expect(router.state.navigation.state).toBe('idle'); + expect(router.state.location.pathname).toBe('/user/2'); + }); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledWith(expect.any(BrowserClient), { name: '/user/:id', @@ -749,9 +764,14 @@ describe('React Router cross usage of wrappers', () => { await act(async () => { router.navigate('/user/3'); - await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); }); + await waitFor(() => { + expect(router.state.navigation.state).toBe('idle'); + expect(router.state.location.pathname).toBe('/user/3'); + }); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); + // Should create 2 spans - different concrete paths are different user actions expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenNthCalledWith(2, expect.any(BrowserClient), { diff --git a/packages/remix/package.json b/packages/remix/package.json index 6efceb2daeb1..5f99d0eba51d 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -81,7 +81,7 @@ "@types/express": "^4.17.14", "react": "^18.3.1", "react-dom": "^18.3.1", - "vite": "^6.0.0" + "vite": "^6.4.2" }, "peerDependencies": { "@remix-run/node": "2.x", diff --git a/packages/remix/test/integration/package.json b/packages/remix/test/integration/package.json index 40652b48b905..71a325233cb6 100644 --- a/packages/remix/test/integration/package.json +++ b/packages/remix/test/integration/package.json @@ -22,7 +22,7 @@ "@types/react-dom": "^18", "nock": "^13.5.5", "typescript": "~5.8.0", - "vite": "^6.0.0" + "vite": "^6.4.2" }, "resolutions": { "@sentry/browser": "file:../../../browser", @@ -39,7 +39,7 @@ "@vanilla-extract/css": "1.13.0", "@vanilla-extract/integration": "6.2.4", "@types/mime": "^3.0.0", - "vite": "^6.0.0" + "vite": "^6.4.2" }, "engines": { "node": ">=18" diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index b28d4547265e..3a7ea6a87fc6 100644 --- a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts @@ -4,6 +4,7 @@ import { saveSession } from '../session/saveSession'; import type { ReplayContainer } from '../types'; import { isErrorEvent, isFeedbackEvent, isReplayEvent, isTransactionEvent } from '../util/eventUtils'; import { isRrwebError } from '../util/isRrwebError'; +import { shouldRefreshSession } from '../session/shouldRefreshSession'; import { debug } from '../util/logger'; import { resetReplayIdOnDynamicSamplingContext } from '../util/resetReplayIdOnDynamicSamplingContext'; import { addFeedbackBreadcrumb } from './util/addFeedbackBreadcrumb'; @@ -15,6 +16,21 @@ import { shouldSampleForBufferEvent } from './util/shouldSampleForBufferEvent'; export function handleGlobalEventListener(replay: ReplayContainer): (event: Event, hint: EventHint) => Event | null { return Object.assign( (event: Event, hint: EventHint) => { + // Check for expired session and clean stale replay_id from DSC. + // This must run BEFORE the isEnabled/isPaused guards because when paused, + // the guards short-circuit without cleaning DSC. Uses shouldRefreshSession + // instead of isSessionExpired to respect the buffer-mode carve-out: + // buffer sessions with segmentId === 0 are kept alive even when time-expired. + if ( + replay.session && + shouldRefreshSession(replay.session, { + maxReplayDuration: replay.getOptions().maxReplayDuration, + sessionIdleExpire: replay.timeouts.sessionIdleExpire, + }) + ) { + resetReplayIdOnDynamicSamplingContext(); + } + // Do nothing if replay has been disabled or paused if (!replay.isEnabled() || replay.isPaused()) { return event; diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index d80f47a6704b..de2e596a0be1 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -50,9 +50,11 @@ import { debounce } from './util/debounce'; import { getRecordingSamplingOptions } from './util/getRecordingSamplingOptions'; import { getHandleRecordingEmit } from './util/handleRecordingEmit'; import { isExpired } from './util/isExpired'; -import { isSessionExpired } from './util/isSessionExpired'; import { debug } from './util/logger'; -import { resetReplayIdOnDynamicSamplingContext } from './util/resetReplayIdOnDynamicSamplingContext'; +import { + resetReplayIdOnDynamicSamplingContext, + setReplayIdOnDynamicSamplingContext, +} from './util/resetReplayIdOnDynamicSamplingContext'; import { closestElementOfNode } from './util/rrweb'; import { sendReplay } from './util/sendReplay'; import { RateLimitError, ReplayDurationLimitError } from './util/sendReplayRequest'; @@ -876,6 +878,13 @@ export class ReplayContainer implements ReplayContainerInterface { } this.startRecording(); + + // Update the cached DSC with the new replay_id when in session mode. + // The cached DSC on the scope (set by browserTracingIntegration) persists + // across session refreshes, and the `createDsc` hook won't fire for it. + if (this.recordingMode === 'session' && this.session) { + setReplayIdOnDynamicSamplingContext(this.session.id); + } } /** @@ -1001,12 +1010,13 @@ export class ReplayContainer implements ReplayContainerInterface { return; } - const expired = isSessionExpired(this.session, { + const expired = shouldRefreshSession(this.session, { maxReplayDuration: this._options.maxReplayDuration, sessionIdleExpire: this.timeouts.sessionIdleExpire, }); if (expired) { + resetReplayIdOnDynamicSamplingContext(); return; } diff --git a/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts b/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts index 7d3139aa447d..4839300d7fd2 100644 --- a/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts +++ b/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts @@ -18,3 +18,24 @@ export function resetReplayIdOnDynamicSamplingContext(): void { delete (dsc as Partial).replay_id; } } + +/** + * Set the `replay_id` field on the cached DSC. + * This is needed after a session refresh because the cached DSC on the scope + * (set by browserTracingIntegration when the idle span ended) persists across + * session boundaries. Without updating it, the new session's replay_id would + * never appear in DSC since `getDynamicSamplingContextFromClient` (and its + * `createDsc` hook) is not called when a cached DSC already exists. + */ +export function setReplayIdOnDynamicSamplingContext(replayId: string): void { + const dsc = getCurrentScope().getPropagationContext().dsc; + if (dsc) { + dsc.replay_id = replayId; + } + + const activeSpan = getActiveSpan(); + if (activeSpan) { + const dsc = getDynamicSamplingContextFromSpan(activeSpan); + (dsc as Partial).replay_id = replayId; + } +} diff --git a/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts b/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts index 956b8a93e72b..6c7230e2c680 100644 --- a/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts +++ b/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts @@ -6,7 +6,7 @@ import '../../utils/mock-internal-setTimeout'; import type { Event } from '@sentry/core'; import { getClient } from '@sentry/core'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { REPLAY_EVENT_NAME, SESSION_IDLE_EXPIRE_DURATION } from '../../../src/constants'; +import { MAX_REPLAY_DURATION, REPLAY_EVENT_NAME, SESSION_IDLE_EXPIRE_DURATION } from '../../../src/constants'; import { handleGlobalEventListener } from '../../../src/coreHandlers/handleGlobalEvent'; import type { ReplayContainer } from '../../../src/replay'; import { makeSession } from '../../../src/session/Session'; @@ -435,4 +435,154 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => { expect(resetReplayIdSpy).toHaveBeenCalledTimes(2); }); + + it('resets replayId on DSC when replay is paused and session has expired', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1, + started: now - SESSION_IDLE_EXPIRE_DURATION - 1, + sampled: 'session', + }); + + replay['_isPaused'] = true; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + // Should have been called even though replay is paused + expect(resetReplayIdSpy).toHaveBeenCalledTimes(1); + }); + + it('does not reset replayId on DSC when replay is paused but session is still valid', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + lastActivity: now, + started: now, + sampled: 'session', + }); + + replay['_isPaused'] = true; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + // Should NOT have been called because session is still valid + expect(resetReplayIdSpy).not.toHaveBeenCalled(); + }); + + it('resets replayId on DSC when replay is paused and session exceeds max duration', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + // Recent activity, but session started too long ago + lastActivity: now, + started: now - MAX_REPLAY_DURATION - 1, + sampled: 'session', + }); + + replay['_isPaused'] = true; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + expect(resetReplayIdSpy).toHaveBeenCalledTimes(1); + }); + + it('does not reset replayId on DSC for expired buffer session with segmentId 0', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1, + started: now - SESSION_IDLE_EXPIRE_DURATION - 1, + sampled: 'buffer', + }); + + replay['_isPaused'] = true; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + // Should NOT reset DSC: buffer sessions with segmentId 0 are kept alive + // even when time-expired (shouldRefreshSession carve-out) + expect(resetReplayIdSpy).not.toHaveBeenCalled(); + }); + + it('resets replayId on DSC for expired buffer session with segmentId > 0', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 1, + lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1, + started: now - SESSION_IDLE_EXPIRE_DURATION - 1, + sampled: 'buffer', + }); + + replay['_isPaused'] = true; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + // Buffer session with segmentId > 0 that is expired SHOULD have DSC reset + expect(resetReplayIdSpy).toHaveBeenCalledTimes(1); + }); + + it('resets replayId on DSC when replay is disabled and session has expired', () => { + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1, + started: now - SESSION_IDLE_EXPIRE_DURATION - 1, + sampled: 'session', + }); + + replay['_isEnabled'] = false; + + const resetReplayIdSpy = vi.spyOn( + resetReplayIdOnDynamicSamplingContextModule, + 'resetReplayIdOnDynamicSamplingContext', + ); + + const errorEvent = Error(); + handleGlobalEventListener(replay)(errorEvent, {}); + + expect(resetReplayIdSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/replay-internal/test/integration/session.test.ts b/packages/replay-internal/test/integration/session.test.ts index f867c43efbe8..1c4b49bb1fad 100644 --- a/packages/replay-internal/test/integration/session.test.ts +++ b/packages/replay-internal/test/integration/session.test.ts @@ -438,6 +438,57 @@ describe('Integration | session', () => { ); }); + it('updates DSC with new replay_id after session refresh', async () => { + const { getCurrentScope } = await import('@sentry/core'); + + const initialSession = { ...replay.session } as Session; + + // Simulate a cached DSC on the scope (as browserTracingIntegration does + // when the idle span ends) with the old session's replay_id. + const scope = getCurrentScope(); + scope.setPropagationContext({ + ...scope.getPropagationContext(), + dsc: { + trace_id: 'test-trace-id', + public_key: 'test-public-key', + replay_id: initialSession.id, + }, + }); + + // Idle past expiration + const ELAPSED = SESSION_IDLE_EXPIRE_DURATION + 1; + vi.advanceTimersByTime(ELAPSED); + + // Emit a recording event to put replay into paused state (mirrors the + // "creates a new session" test which does this before clicking) + const TEST_EVENT = getTestEventIncremental({ + data: { name: 'lost event' }, + timestamp: BASE_TIMESTAMP, + }); + mockRecord._emitter(TEST_EVENT); + await new Promise(process.nextTick); + + expect(replay.isPaused()).toBe(true); + + // Trigger user activity to cause session refresh + domHandler({ + name: 'click', + event: new Event('click'), + }); + + // _refreshSession is async (calls await stop() then initializeSampling) + await vi.advanceTimersByTimeAsync(DEFAULT_FLUSH_MIN_DELAY); + await new Promise(process.nextTick); + + // Should be a new session + expect(replay).not.toHaveSameSession(initialSession); + + // The cached DSC should now have the NEW session's replay_id, not the old one + const dsc = scope.getPropagationContext().dsc; + expect(dsc?.replay_id).toBe(replay.session?.id); + expect(dsc?.replay_id).not.toBe(initialSession.id); + }); + it('increases segment id after each event', async () => { clearSession(replay); replay['_initializeSessionForSampling'](); diff --git a/scripts/pr-review-reminder.mjs b/scripts/pr-review-reminder.mjs index 535f2d430331..aba64c62512e 100644 --- a/scripts/pr-review-reminder.mjs +++ b/scripts/pr-review-reminder.mjs @@ -174,6 +174,16 @@ export default async function run({ github, context, core }) { const pendingTeams = requested.teams; // team reviewers if (pendingReviewers.length === 0 && pendingTeams.length === 0) continue; + // Skip if the PR already has at least one approval — no need to nudge remaining reviewers + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + const hasApproval = reviews.some(r => r.state === 'APPROVED'); + if (hasApproval) continue; + // Fetch the PR timeline to determine when each review was (last) requested const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, diff --git a/scripts/report-ci-failures.mjs b/scripts/report-ci-failures.mjs index b407eac157c0..f57278e5a9d4 100644 --- a/scripts/report-ci-failures.mjs +++ b/scripts/report-ci-failures.mjs @@ -102,7 +102,7 @@ export default async function run({ github, context, core }) { repo, title, body: issueBody.trim(), - labels: ['Tests'], + labels: ['Tests', 'Bug'], }); core.info(`Created issue #${newIssue.data.number} for "${testName}" in ${jobName}`); } diff --git a/yarn.lock b/yarn.lock index 40d531ea0275..5876796341f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -107,16 +107,16 @@ tunnel "^0.0.6" undici "^6.23.0" -"@actions/io@1.1.3", "@actions/io@^1.0.1": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@actions/io/-/io-1.1.3.tgz#4cdb6254da7962b07473ff5c335f3da485d94d71" - integrity sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q== - -"@actions/io@^3.0.2": +"@actions/io@3.0.2", "@actions/io@^3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@actions/io/-/io-3.0.2.tgz#6f89b27a159d109836d983efa283997c23b92284" integrity sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw== +"@actions/io@^1.0.1": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@actions/io/-/io-1.1.3.tgz#4cdb6254da7962b07473ff5c335f3da485d94d71" + integrity sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q== + "@adobe/css-tools@^4.0.1", "@adobe/css-tools@^4.4.0": version "4.4.3" resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.3.tgz#beebbefb0264fdeb32d3052acae0e0d94315a9a2" @@ -734,136 +734,93 @@ "@smithy/util-utf8" "^4.2.0" tslib "^2.6.2" -"@aws-sdk/client-s3@^3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.993.0.tgz#3e40e3631d88100dab51bcde017abbb14e3274b6" - integrity sha512-0slCxdbo9O3rfzqD7/PsBOrZ6vcwFzPAvGeUu5NZApI5WyjEfMLLi2T9QW8R9N9TQeUfiUQiHkg/NV0LPS61/g== +"@aws-sdk/client-s3@^3.1041.0": + version "3.1041.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.1041.0.tgz#474b3f99a688554b51d5c65aed713605408adbc8" + integrity sha512-sQV14bIqslnBHuSlLMD+fc3pH+ajop6vnrFlJ4wM4JDqcYwVik4O+9srnZUrkesFw5y+CN0GfOQ06CAgtC4mjQ== dependencies: "@aws-crypto/sha1-browser" "5.2.0" "@aws-crypto/sha256-browser" "5.2.0" "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/credential-provider-node" "^3.972.10" - "@aws-sdk/middleware-bucket-endpoint" "^3.972.3" - "@aws-sdk/middleware-expect-continue" "^3.972.3" - "@aws-sdk/middleware-flexible-checksums" "^3.972.9" - "@aws-sdk/middleware-host-header" "^3.972.3" - "@aws-sdk/middleware-location-constraint" "^3.972.3" - "@aws-sdk/middleware-logger" "^3.972.3" - "@aws-sdk/middleware-recursion-detection" "^3.972.3" - "@aws-sdk/middleware-sdk-s3" "^3.972.11" - "@aws-sdk/middleware-ssec" "^3.972.3" - "@aws-sdk/middleware-user-agent" "^3.972.11" - "@aws-sdk/region-config-resolver" "^3.972.3" - "@aws-sdk/signature-v4-multi-region" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.993.0" - "@aws-sdk/util-user-agent-browser" "^3.972.3" - "@aws-sdk/util-user-agent-node" "^3.972.9" - "@smithy/config-resolver" "^4.4.6" - "@smithy/core" "^3.23.2" - "@smithy/eventstream-serde-browser" "^4.2.8" - "@smithy/eventstream-serde-config-resolver" "^4.3.8" - "@smithy/eventstream-serde-node" "^4.2.8" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/hash-blob-browser" "^4.2.9" - "@smithy/hash-node" "^4.2.8" - "@smithy/hash-stream-node" "^4.2.8" - "@smithy/invalid-dependency" "^4.2.8" - "@smithy/md5-js" "^4.2.8" - "@smithy/middleware-content-length" "^4.2.8" - "@smithy/middleware-endpoint" "^4.4.16" - "@smithy/middleware-retry" "^4.4.33" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/node-http-handler" "^4.4.10" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.32" - "@smithy/util-defaults-mode-node" "^4.2.35" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/util-stream" "^4.5.12" - "@smithy/util-utf8" "^4.2.0" - "@smithy/util-waiter" "^4.2.8" - tslib "^2.6.2" - -"@aws-sdk/client-sso@3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso/-/client-sso-3.993.0.tgz#6948256598d84eb4b5ee953a8a1be1ed375aafef" - integrity sha512-VLUN+wIeNX24fg12SCbzTUBnBENlL014yMKZvRhPkcn4wHR6LKgNrjsG3fZ03Xs0XoKaGtNFi1VVrq666sGBoQ== - dependencies: - "@aws-crypto/sha256-browser" "5.2.0" - "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/middleware-host-header" "^3.972.3" - "@aws-sdk/middleware-logger" "^3.972.3" - "@aws-sdk/middleware-recursion-detection" "^3.972.3" - "@aws-sdk/middleware-user-agent" "^3.972.11" - "@aws-sdk/region-config-resolver" "^3.972.3" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.993.0" - "@aws-sdk/util-user-agent-browser" "^3.972.3" - "@aws-sdk/util-user-agent-node" "^3.972.9" - "@smithy/config-resolver" "^4.4.6" - "@smithy/core" "^3.23.2" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/hash-node" "^4.2.8" - "@smithy/invalid-dependency" "^4.2.8" - "@smithy/middleware-content-length" "^4.2.8" - "@smithy/middleware-endpoint" "^4.4.16" - "@smithy/middleware-retry" "^4.4.33" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/node-http-handler" "^4.4.10" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.32" - "@smithy/util-defaults-mode-node" "^4.2.35" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/util-utf8" "^4.2.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/credential-provider-node" "^3.972.39" + "@aws-sdk/middleware-bucket-endpoint" "^3.972.10" + "@aws-sdk/middleware-expect-continue" "^3.972.10" + "@aws-sdk/middleware-flexible-checksums" "^3.974.16" + "@aws-sdk/middleware-host-header" "^3.972.10" + "@aws-sdk/middleware-location-constraint" "^3.972.10" + "@aws-sdk/middleware-logger" "^3.972.10" + "@aws-sdk/middleware-recursion-detection" "^3.972.11" + "@aws-sdk/middleware-sdk-s3" "^3.972.37" + "@aws-sdk/middleware-ssec" "^3.972.10" + "@aws-sdk/middleware-user-agent" "^3.972.38" + "@aws-sdk/region-config-resolver" "^3.972.13" + "@aws-sdk/signature-v4-multi-region" "^3.996.25" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-endpoints" "^3.996.8" + "@aws-sdk/util-user-agent-browser" "^3.972.10" + "@aws-sdk/util-user-agent-node" "^3.973.24" + "@smithy/config-resolver" "^4.4.17" + "@smithy/core" "^3.23.17" + "@smithy/eventstream-serde-browser" "^4.2.14" + "@smithy/eventstream-serde-config-resolver" "^4.3.14" + "@smithy/eventstream-serde-node" "^4.2.14" + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/hash-blob-browser" "^4.2.15" + "@smithy/hash-node" "^4.2.14" + "@smithy/hash-stream-node" "^4.2.14" + "@smithy/invalid-dependency" "^4.2.14" + "@smithy/md5-js" "^4.2.14" + "@smithy/middleware-content-length" "^4.2.14" + "@smithy/middleware-endpoint" "^4.4.32" + "@smithy/middleware-retry" "^4.5.7" + "@smithy/middleware-serde" "^4.2.20" + "@smithy/middleware-stack" "^4.2.14" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/protocol-http" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.49" + "@smithy/util-defaults-mode-node" "^4.2.54" + "@smithy/util-endpoints" "^3.4.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" + "@smithy/util-waiter" "^4.3.0" tslib "^2.6.2" -"@aws-sdk/core@^3.973.11", "@aws-sdk/core@^3.973.5", "@aws-sdk/core@^3.973.6": - version "3.973.11" - resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.973.11.tgz#3aaf1493dc1d1793a348c84fe302e59a198996c1" - integrity sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA== - dependencies: - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/xml-builder" "^3.972.5" - "@smithy/core" "^3.23.2" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/property-provider" "^4.2.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/signature-v4" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-utf8" "^4.2.0" +"@aws-sdk/core@^3.973.5", "@aws-sdk/core@^3.973.6", "@aws-sdk/core@^3.974.8": + version "3.974.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.974.8.tgz#cdd51195a31322f1e429e66919eb18da8944c081" + integrity sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/xml-builder" "^3.972.22" + "@smithy/core" "^3.23.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/signature-v4" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/crc64-nvme@3.972.0": - version "3.972.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz#c5e6d14428c9fb4e6bb0646b73a0fa68e6007e24" - integrity sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw== +"@aws-sdk/crc64-nvme@^3.972.7": + version "3.972.7" + resolved "https://registry.yarnpkg.com/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz#0e56fb3ccc0242ed05ffd0bc993d724ce8b3dde2" + integrity sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" "@aws-sdk/credential-provider-cognito-identity@^3.972.3": @@ -877,122 +834,122 @@ "@smithy/types" "^4.12.0" tslib "^2.6.2" -"@aws-sdk/credential-provider-env@^3.972.4", "@aws-sdk/credential-provider-env@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.9.tgz#1290fb0aa49fb2a8d650e3f7886512add3ed97a1" - integrity sha512-ZptrOwQynfupubvcngLkbdIq/aXvl/czdpEG8XJ8mN8Nb19BR0jaK0bR+tfuMU36Ez9q4xv7GGkHFqEEP2hUUQ== +"@aws-sdk/credential-provider-env@^3.972.34", "@aws-sdk/credential-provider-env@^3.972.4": + version "3.972.34" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz#9d420adf02e7604094a641ae613a353aa86e1b83" + integrity sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ== dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/types" "^4.12.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-http@^3.972.11", "@aws-sdk/credential-provider-http@^3.972.6": - version "3.972.11" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.11.tgz#5af1e077aca5d6173c49eb63deaffc7f1184370a" - integrity sha512-hECWoOoH386bGr89NQc9vA/abkGf5TJrMREt+lhNcnSNmoBS04fK7vc3LrJBSQAUGGVj0Tz3f4dHB3w5veovig== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/types" "^3.973.1" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/node-http-handler" "^4.4.10" - "@smithy/property-provider" "^4.2.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/util-stream" "^4.5.12" +"@aws-sdk/credential-provider-http@^3.972.36", "@aws-sdk/credential-provider-http@^3.972.6": + version "3.972.36" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz#842268559da2ffc5855cde1e90e7302d53639c08" + integrity sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/property-provider" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-stream" "^4.5.25" tslib "^2.6.2" -"@aws-sdk/credential-provider-ini@^3.972.4", "@aws-sdk/credential-provider-ini@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.9.tgz#befbaefe54384bdb4c677d03127e627e733b35aa" - integrity sha512-zr1csEu9n4eDiHMTYJabX1mDGuGLgjgUnNckIivvk43DocJC9/f6DefFrnUPZXE+GHtbW50YuXb+JIxKykU74A== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/credential-provider-env" "^3.972.9" - "@aws-sdk/credential-provider-http" "^3.972.11" - "@aws-sdk/credential-provider-login" "^3.972.9" - "@aws-sdk/credential-provider-process" "^3.972.9" - "@aws-sdk/credential-provider-sso" "^3.972.9" - "@aws-sdk/credential-provider-web-identity" "^3.972.9" - "@aws-sdk/nested-clients" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/credential-provider-imds" "^4.2.8" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" +"@aws-sdk/credential-provider-ini@^3.972.38", "@aws-sdk/credential-provider-ini@^3.972.4": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz#e20955fdfe4a88149b20dc7e25a517542e1dfd9f" + integrity sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/credential-provider-env" "^3.972.34" + "@aws-sdk/credential-provider-http" "^3.972.36" + "@aws-sdk/credential-provider-login" "^3.972.38" + "@aws-sdk/credential-provider-process" "^3.972.34" + "@aws-sdk/credential-provider-sso" "^3.972.38" + "@aws-sdk/credential-provider-web-identity" "^3.972.38" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/credential-provider-imds" "^4.2.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-login@^3.972.4", "@aws-sdk/credential-provider-login@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.9.tgz#ce71a9b2a42f4294fdc035adde8173fc99331bae" - integrity sha512-m4RIpVgZChv0vWS/HKChg1xLgZPpx8Z+ly9Fv7FwA8SOfuC6I3htcSaBz2Ch4bneRIiBUhwP4ziUo0UZgtJStQ== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/nested-clients" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" +"@aws-sdk/credential-provider-login@^3.972.38", "@aws-sdk/credential-provider-login@^3.972.4": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz#278437712c02a3ad1785f70c93b4f591cb3f6491" + integrity sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-node@^3.972.10", "@aws-sdk/credential-provider-node@^3.972.4", "@aws-sdk/credential-provider-node@^3.972.5": - version "3.972.10" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.10.tgz#577df01a8511ef6602b090e96832fc612bc81b03" - integrity sha512-70nCESlvnzjo4LjJ8By8MYIiBogkYPSXl3WmMZfH9RZcB/Nt9qVWbFpYj6Fk1vLa4Vk8qagFVeXgxdieMxG1QA== - dependencies: - "@aws-sdk/credential-provider-env" "^3.972.9" - "@aws-sdk/credential-provider-http" "^3.972.11" - "@aws-sdk/credential-provider-ini" "^3.972.9" - "@aws-sdk/credential-provider-process" "^3.972.9" - "@aws-sdk/credential-provider-sso" "^3.972.9" - "@aws-sdk/credential-provider-web-identity" "^3.972.9" - "@aws-sdk/types" "^3.973.1" - "@smithy/credential-provider-imds" "^4.2.8" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" +"@aws-sdk/credential-provider-node@^3.972.39", "@aws-sdk/credential-provider-node@^3.972.4", "@aws-sdk/credential-provider-node@^3.972.5": + version "3.972.39" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz#71f87848b7615dda4f31a57b113be9666e4bbd1a" + integrity sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.34" + "@aws-sdk/credential-provider-http" "^3.972.36" + "@aws-sdk/credential-provider-ini" "^3.972.38" + "@aws-sdk/credential-provider-process" "^3.972.34" + "@aws-sdk/credential-provider-sso" "^3.972.38" + "@aws-sdk/credential-provider-web-identity" "^3.972.38" + "@aws-sdk/types" "^3.973.8" + "@smithy/credential-provider-imds" "^4.2.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-process@^3.972.4", "@aws-sdk/credential-provider-process@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.9.tgz#efe60d47e54b42ac4ce901810a96152371249744" - integrity sha512-gOWl0Fe2gETj5Bk151+LYKpeGi2lBDLNu+NMNpHRlIrKHdBmVun8/AalwMK8ci4uRfG5a3/+zvZBMpuen1SZ0A== +"@aws-sdk/credential-provider-process@^3.972.34", "@aws-sdk/credential-provider-process@^3.972.4": + version "3.972.34" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz#c964275be1a528ac73ade6d98c309fb6b7cdfb68" + integrity sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q== dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-sso@^3.972.4", "@aws-sdk/credential-provider-sso@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.9.tgz#d9c79aa26a6a90dc4f4b527546e5fb9cb5b845de" - integrity sha512-ey7S686foGTArvFhi3ifQXmgptKYvLSGE2250BAQceMSXZddz7sUSNERGJT2S7u5KIe/kgugxrt01hntXVln6w== - dependencies: - "@aws-sdk/client-sso" "3.993.0" - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/token-providers" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" +"@aws-sdk/credential-provider-sso@^3.972.38", "@aws-sdk/credential-provider-sso@^3.972.4": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz#ec754bfecb2426a3307e19ef7e6c6b6438a327c6" + integrity sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/token-providers" "3.1041.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/credential-provider-web-identity@^3.972.4", "@aws-sdk/credential-provider-web-identity@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.9.tgz#147c6daefdbb03f718daf86d1286558759510769" - integrity sha512-8LnfS76nHXoEc9aRRiMMpxZxJeDG0yusdyo3NvPhCgESmBUgpMa4luhGbClW5NoX/qRcGxxM6Z/esqANSNMTow== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/nested-clients" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" +"@aws-sdk/credential-provider-web-identity@^3.972.38", "@aws-sdk/credential-provider-web-identity@^3.972.4": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz#149951ef6e12db5292118e8ed5d95133c24ad719" + integrity sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" "@aws-sdk/credential-providers@^3.186.0": @@ -1021,128 +978,129 @@ "@smithy/types" "^4.12.0" tslib "^2.6.2" -"@aws-sdk/middleware-bucket-endpoint@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.3.tgz#158507d55505e5e7b5b8cdac9f037f6aa326f202" - integrity sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg== - dependencies: - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-arn-parser" "^3.972.2" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-config-provider" "^4.2.0" +"@aws-sdk/middleware-bucket-endpoint@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.10.tgz#d26aa88b441d6d1b6e9275ffdc61e0fbfb55a513" + integrity sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-arn-parser" "^3.972.3" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/middleware-expect-continue@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.3.tgz#c60bd81e81dde215b9f3f67e3c5448b608afd530" - integrity sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg== +"@aws-sdk/middleware-expect-continue@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.10.tgz#b685287951156a5d093cfdd37364894c6a8c966c" + integrity sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-flexible-checksums@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.9.tgz#37d2662dc00854fe121d5d090c855d40487bbfdc" - integrity sha512-E663+r/UQpvF3aJkD40p5ZANVQFsUcbE39jifMtN7wc0t1M0+2gJJp3i75R49aY9OiSX5lfVyPUNjN/BNRCCZA== +"@aws-sdk/middleware-flexible-checksums@^3.974.16": + version "3.974.16" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.16.tgz#89b78cb0ad389aba7d12d060f46017e1fa3784a9" + integrity sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA== dependencies: "@aws-crypto/crc32" "5.2.0" "@aws-crypto/crc32c" "5.2.0" "@aws-crypto/util" "5.2.0" - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/crc64-nvme" "3.972.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/is-array-buffer" "^4.2.0" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-stream" "^4.5.12" - "@smithy/util-utf8" "^4.2.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/crc64-nvme" "^3.972.7" + "@aws-sdk/types" "^3.973.8" + "@smithy/is-array-buffer" "^4.2.2" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/middleware-host-header@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz#47c161dec62d89c66c89f4d17ff4434021e04af5" - integrity sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA== +"@aws-sdk/middleware-host-header@^3.972.10", "@aws-sdk/middleware-host-header@^3.972.3": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz#e63b91959ce46948d789582351b2a44c4876e924" + integrity sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-location-constraint@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.3.tgz#b4f504f75baa19064b7457e5c6e3c8cecb4c32eb" - integrity sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g== +"@aws-sdk/middleware-location-constraint@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz#5265ea472f735c50b016bb5d1b46c7a616653733" + integrity sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-logger@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz#ef1afd4a0b70fe72cf5f7c817f82da9f35c7e836" - integrity sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA== +"@aws-sdk/middleware-logger@^3.972.10", "@aws-sdk/middleware-logger@^3.972.3": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz#d92b3374dcaddd523930bdff441207946343c270" + integrity sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-recursion-detection@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz#5b95dcecff76a0d2963bd954bdef87700d1b1c8c" - integrity sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q== +"@aws-sdk/middleware-recursion-detection@^3.972.11", "@aws-sdk/middleware-recursion-detection@^3.972.3": + version "3.972.11" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz#5659982a34fa58c69cbd358c2987c32aefd2bd91" + integrity sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ== dependencies: - "@aws-sdk/types" "^3.973.1" + "@aws-sdk/types" "^3.973.8" "@aws/lambda-invoke-store" "^0.2.2" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-sdk-s3@^3.972.11": - version "3.972.11" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.11.tgz#db6fc30c5ff70ee9b0a616f7fe3802bccbf73777" - integrity sha512-Qr0T7ZQTRMOuR6ahxEoJR1thPVovfWrKB2a6KBGR+a8/ELrFodrgHwhq50n+5VMaGuLtGhHiISU3XGsZmtmVXQ== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-arn-parser" "^3.972.2" - "@smithy/core" "^3.23.2" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/signature-v4" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/util-config-provider" "^4.2.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-stream" "^4.5.12" - "@smithy/util-utf8" "^4.2.0" +"@aws-sdk/middleware-sdk-s3@^3.972.37": + version "3.972.37" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz#82ef4953cddd3373d2942d07a5d2baf443bbf3ea" + integrity sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-arn-parser" "^3.972.3" + "@smithy/core" "^3.23.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/signature-v4" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/middleware-ssec@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.3.tgz#4f81d310fd91164e6e18ba3adab6bcf906920333" - integrity sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg== +"@aws-sdk/middleware-ssec@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz#46b5c030c0116f51110e18042ad3cf863ab5c81c" + integrity sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/middleware-user-agent@^3.972.11", "@aws-sdk/middleware-user-agent@^3.972.5", "@aws-sdk/middleware-user-agent@^3.972.6": - version "3.972.11" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.11.tgz#9723b323fd67ee4b96ff613877bb2fca4e3fc560" - integrity sha512-R8CvPsPHXwzIHCAza+bllY6PrctEk4lYq/SkHJz9NLoBHCcKQrbOcsfXxO6xmipSbUNIbNIUhH0lBsJGgsRdiw== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.993.0" - "@smithy/core" "^3.23.2" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" +"@aws-sdk/middleware-user-agent@^3.972.38", "@aws-sdk/middleware-user-agent@^3.972.5", "@aws-sdk/middleware-user-agent@^3.972.6": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz#626d9a2499f5a6398a4db917abeeaac14b54c6cb" + integrity sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-endpoints" "^3.996.8" + "@smithy/core" "^3.23.17" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-retry" "^4.3.6" tslib "^2.6.2" "@aws-sdk/nested-clients@3.983.0": @@ -1189,98 +1147,99 @@ "@smithy/util-utf8" "^4.2.0" tslib "^2.6.2" -"@aws-sdk/nested-clients@3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.993.0.tgz#9d93d9b3bf3f031d6addd9ee58cd90148f59362d" - integrity sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ== +"@aws-sdk/nested-clients@^3.997.6": + version "3.997.6" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz#17433cfac2160ec620a14cbff9d2b33675712cae" + integrity sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w== dependencies: "@aws-crypto/sha256-browser" "5.2.0" "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/middleware-host-header" "^3.972.3" - "@aws-sdk/middleware-logger" "^3.972.3" - "@aws-sdk/middleware-recursion-detection" "^3.972.3" - "@aws-sdk/middleware-user-agent" "^3.972.11" - "@aws-sdk/region-config-resolver" "^3.972.3" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.993.0" - "@aws-sdk/util-user-agent-browser" "^3.972.3" - "@aws-sdk/util-user-agent-node" "^3.972.9" - "@smithy/config-resolver" "^4.4.6" - "@smithy/core" "^3.23.2" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/hash-node" "^4.2.8" - "@smithy/invalid-dependency" "^4.2.8" - "@smithy/middleware-content-length" "^4.2.8" - "@smithy/middleware-endpoint" "^4.4.16" - "@smithy/middleware-retry" "^4.4.33" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/node-http-handler" "^4.4.10" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.32" - "@smithy/util-defaults-mode-node" "^4.2.35" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/util-utf8" "^4.2.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/middleware-host-header" "^3.972.10" + "@aws-sdk/middleware-logger" "^3.972.10" + "@aws-sdk/middleware-recursion-detection" "^3.972.11" + "@aws-sdk/middleware-user-agent" "^3.972.38" + "@aws-sdk/region-config-resolver" "^3.972.13" + "@aws-sdk/signature-v4-multi-region" "^3.996.25" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-endpoints" "^3.996.8" + "@aws-sdk/util-user-agent-browser" "^3.972.10" + "@aws-sdk/util-user-agent-node" "^3.973.24" + "@smithy/config-resolver" "^4.4.17" + "@smithy/core" "^3.23.17" + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/hash-node" "^4.2.14" + "@smithy/invalid-dependency" "^4.2.14" + "@smithy/middleware-content-length" "^4.2.14" + "@smithy/middleware-endpoint" "^4.4.32" + "@smithy/middleware-retry" "^4.5.7" + "@smithy/middleware-serde" "^4.2.20" + "@smithy/middleware-stack" "^4.2.14" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/protocol-http" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.49" + "@smithy/util-defaults-mode-node" "^4.2.54" + "@smithy/util-endpoints" "^3.4.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/region-config-resolver@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz#25af64235ca6f4b6b21f85d4b3c0b432efc4ae04" - integrity sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow== +"@aws-sdk/region-config-resolver@^3.972.13", "@aws-sdk/region-config-resolver@^3.972.3": + version "3.972.13" + resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz#bd32748c2d41b62be838fec76c4b87d4370939c6" + integrity sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/config-resolver" "^4.4.6" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/config-resolver" "^4.4.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/signature-v4-multi-region@3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.993.0.tgz#1bc2fe7936e53c33c6cb396568042ec3d5d1bc1c" - integrity sha512-6l20k27TJdqTozJOm+s20/1XDey3aj+yaeIdbtqXuYNhQiWHajvYThcI1sHx2I1W4NelZTOmYEF+dj1mya01eg== +"@aws-sdk/signature-v4-multi-region@^3.996.25": + version "3.996.25" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz#b50651b7e4f9c82482416caa9953ad17645d4a2d" + integrity sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw== dependencies: - "@aws-sdk/middleware-sdk-s3" "^3.972.11" - "@aws-sdk/types" "^3.973.1" - "@smithy/protocol-http" "^5.3.8" - "@smithy/signature-v4" "^5.3.8" - "@smithy/types" "^4.12.0" + "@aws-sdk/middleware-sdk-s3" "^3.972.37" + "@aws-sdk/types" "^3.973.8" + "@smithy/protocol-http" "^5.3.14" + "@smithy/signature-v4" "^5.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/token-providers@3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.993.0.tgz#4d52a67e7699acbea356a50504943ace883fe03c" - integrity sha512-+35g4c+8r7sB9Sjp1KPdM8qxGn6B/shBjJtEUN4e+Edw9UEQlZKIzioOGu3UAbyE0a/s450LdLZr4wbJChtmww== - dependencies: - "@aws-sdk/core" "^3.973.11" - "@aws-sdk/nested-clients" "3.993.0" - "@aws-sdk/types" "^3.973.1" - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" +"@aws-sdk/token-providers@3.1041.0": + version "3.1041.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz#f3f068010780fc85fc4a7faa6a080cfb8afd73a4" + integrity sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.1": - version "3.973.1" - resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.1.tgz#1b2992ec6c8380c3e74c9bd2c74703e9a807d6e0" - integrity sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg== +"@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.1", "@aws-sdk/types@^3.973.8": + version "3.973.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.8.tgz#7352cb74a5f8bae1218eee63e714cf94302911c5" + integrity sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@aws-sdk/util-arn-parser@^3.972.2": - version "3.972.2" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.2.tgz#ef18ba889e8ef35f083f1e962018bc0ce70acef3" - integrity sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg== +"@aws-sdk/util-arn-parser@^3.972.3": + version "3.972.3" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz#ed989862bbb172ce16d9e1cd5790e5fe367219c2" + integrity sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA== dependencies: tslib "^2.6.2" @@ -1306,15 +1265,15 @@ "@smithy/util-endpoints" "^3.2.8" tslib "^2.6.2" -"@aws-sdk/util-endpoints@3.993.0": - version "3.993.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.993.0.tgz#60a11de23df02e76142a06dd20878b47255fee56" - integrity sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw== +"@aws-sdk/util-endpoints@^3.996.8": + version "3.996.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz#ad5c4f09b93482c0861d49d8a025edc2b0d2f5ec" + integrity sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-endpoints" "^3.2.8" + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-endpoints" "^3.4.2" tslib "^2.6.2" "@aws-sdk/util-locate-window@^3.0.0": @@ -1324,34 +1283,36 @@ dependencies: tslib "^2.6.2" -"@aws-sdk/util-user-agent-browser@^3.972.3": - version "3.972.3" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz#1363b388cb3af86c5322ef752c0cf8d7d25efa8a" - integrity sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw== +"@aws-sdk/util-user-agent-browser@^3.972.10", "@aws-sdk/util-user-agent-browser@^3.972.3": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz#e29be10389db9db12b2d8246ad247a89038f4c60" + integrity sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g== dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/types" "^4.12.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" bowser "^2.11.0" tslib "^2.6.2" -"@aws-sdk/util-user-agent-node@^3.972.3", "@aws-sdk/util-user-agent-node@^3.972.4", "@aws-sdk/util-user-agent-node@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.9.tgz#23f03f29daa06192d2308e5c52757c2515e761c8" - integrity sha512-JNswdsLdQemxqaSIBL2HRhsHPUBBziAgoi5RQv6/9avmE5g5RSdt1hWr3mHJ7OxqRYf+KeB11ExWbiqfrnoeaA== +"@aws-sdk/util-user-agent-node@^3.972.3", "@aws-sdk/util-user-agent-node@^3.972.4", "@aws-sdk/util-user-agent-node@^3.973.24": + version "3.973.24" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz#cf44a63b92adfecaeb8cb9f948b390456310566a" + integrity sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw== dependencies: - "@aws-sdk/middleware-user-agent" "^3.972.11" - "@aws-sdk/types" "^3.973.1" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/types" "^4.12.0" + "@aws-sdk/middleware-user-agent" "^3.972.38" + "@aws-sdk/types" "^3.973.8" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" tslib "^2.6.2" -"@aws-sdk/xml-builder@^3.972.5": - version "3.972.5" - resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.5.tgz#cde05cf1fa9021a8935e1e594fe8eacdce05f5a8" - integrity sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA== +"@aws-sdk/xml-builder@^3.972.22": + version "3.972.22" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz#1e44ca9fd9c3fdc3d9af9540ced024f34cfc60b2" + integrity sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA== dependencies: - "@smithy/types" "^4.12.0" - fast-xml-parser "5.3.6" + "@nodable/entities" "2.1.0" + "@smithy/types" "^4.14.1" + fast-xml-parser "5.7.2" tslib "^2.6.2" "@aws/lambda-invoke-store@^0.2.2": @@ -4959,10 +4920,10 @@ "@hapi/bourne" "^3.0.0" "@hapi/hoek" "^11.0.2" -"@hono/node-server@^1.19.10": - version "1.19.10" - resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.10.tgz#e230fbb7fb31891cafc653d01deee03f437dd66b" - integrity sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw== +"@hono/node-server@^1.19.13": + version "1.19.14" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.14.tgz#e30f844bc77e3ce7be442aac3b1f73ad8b58d181" + integrity sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw== "@humanwhocodes/config-array@^0.11.14": version "0.11.14" @@ -5485,12 +5446,12 @@ tslib "2.8.1" "@nestjs/common@^11": - version "11.1.17" - resolved "https://registry.npmjs.org/@nestjs/common/-/common-11.1.17.tgz" - integrity sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg== + version "11.1.19" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.19.tgz#50ba93ae45ebaeda6163554b8e2ecec545a25c92" + integrity sha512-qeiTt2tv+e5QyDKqG8HlVZb2wx64FEaSGFJouqTSRs+kG44iTfl3xlz1XqVped+rihx4hmjWgL5gkhtdK3E6+Q== dependencies: uid "2.0.2" - file-type "21.3.2" + file-type "21.3.4" iterare "1.2.1" load-esm "1.0.3" tslib "2.8.1" @@ -5508,26 +5469,26 @@ tslib "2.8.1" "@nestjs/core@^11": - version "11.1.6" - resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.6.tgz#9d54882f121168b2fa2b07fa1db0858161a80626" - integrity sha512-siWX7UDgErisW18VTeJA+x+/tpNZrJewjTBsRPF3JVxuWRuAB1kRoiJcxHgln8Lb5UY9NdvklITR84DUEXD0Cg== + version "11.1.19" + resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.19.tgz#d724f1afc0caac29e005464f0f659425fc80235b" + integrity sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw== dependencies: uid "2.0.2" "@nuxt/opencollective" "0.4.1" fast-safe-stringify "2.1.1" iterare "1.2.1" - path-to-regexp "8.2.0" + path-to-regexp "8.4.2" tslib "2.8.1" "@nestjs/platform-express@^11": - version "11.1.13" - resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.13.tgz#272e350cb3938ec0f383aa083c7f1d5d44fae2dc" - integrity sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w== + version "11.1.19" + resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.19.tgz#e55f5078396b2285344f95f2b530b648e844cd4c" + integrity sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg== dependencies: cors "2.8.6" express "5.2.1" - multer "2.0.2" - path-to-regexp "8.3.0" + multer "2.1.1" + path-to-regexp "8.4.2" tslib "2.8.1" "@next/env@14.2.35": @@ -5585,6 +5546,11 @@ resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-14.2.13.tgz#859b38aaa57ffe1351d08f9166724936c9e6b365" integrity sha512-RQx/rGX7K/+R55x1R6Ax1JzyeHi8cW11dEXpzHWipyuSpusQLUN53F02eMB4VTakXsL3mFNWWy4bX3/LSq8/9w== +"@nodable/entities@2.1.0", "@nodable/entities@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-2.1.0.tgz#f543e5c6446720d4cf9e498a83019dd159973bc2" + integrity sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -6305,15 +6271,6 @@ "@opentelemetry/semantic-conventions" "^1.29.0" forwarded-parse "2.1.2" -"@opentelemetry/instrumentation-ioredis@0.62.0": - version "0.62.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.62.0.tgz#4fd1775577132de5d92165caee6bbc0ae16a8c8a" - integrity sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ== - dependencies: - "@opentelemetry/instrumentation" "^0.214.0" - "@opentelemetry/redis-common" "^0.38.2" - "@opentelemetry/semantic-conventions" "^1.33.0" - "@opentelemetry/instrumentation-kafkajs@0.23.0": version "0.23.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.23.0.tgz#6b7d449d88d674ddc295a0d0cf2156f0f7d5889f" @@ -6401,15 +6358,6 @@ "@types/pg" "8.15.6" "@types/pg-pool" "2.0.7" -"@opentelemetry/instrumentation-redis@0.62.0": - version "0.62.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.62.0.tgz#ecde90337fa49fec8d243bcbb8d470ce1a9ee7a1" - integrity sha512-y3pPpot7WzR/8JtHcYlTYsyY8g+pbFhAqbwAuG5bLPnR6v6pt1rQc0DpH0OlGP/9CZbWBP+Zhwp9yFoygf/ZXQ== - dependencies: - "@opentelemetry/instrumentation" "^0.214.0" - "@opentelemetry/redis-common" "^0.38.2" - "@opentelemetry/semantic-conventions" "^1.27.0" - "@opentelemetry/instrumentation-tedious@0.33.0": version "0.33.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.33.0.tgz#00f6698f8afae1b350bf0c463a59eeae3c8d25d7" @@ -6467,11 +6415,6 @@ "@opentelemetry/sdk-trace-base" "2.6.1" protobufjs "^7.0.0" -"@opentelemetry/redis-common@^0.38.2": - version "0.38.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz#cefa4f3e79db1cd54f19e233b7dfb56621143955" - integrity sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA== - "@opentelemetry/resources@2.6.1", "@opentelemetry/resources@^2.6.1": version "2.6.1" resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.6.1.tgz#e1b02772c5f65c0e074d59e4743188f7575e97c7" @@ -7711,6 +7654,11 @@ "@angular-devkit/schematics" "14.2.13" jsonc-parser "3.1.0" +"@sec-ant/readable-stream@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" + integrity sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg== + "@sentry-internal/node-cpu-profiler@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.2.0.tgz#0640d4aebb4d36031658ccff83dc22b76f437ede" @@ -7957,6 +7905,18 @@ resolved "https://registry.yarnpkg.com/@simple-dom/interface/-/interface-1.4.0.tgz#e8feea579232017f89b0138e2726facda6fbb71f" integrity sha512-l5qumKFWU0S+4ZzMaLXFU8tQZsicHEMEyAxI5kDFGhJsRqDwe0a7/iPA/GdxlGyDKseQQAgIz5kzU7eXTrlSpA== +"@simple-git/args-pathspec@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz#9ef4a2ad5f49ab4056362d03f93f775b93118ca5" + integrity sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA== + +"@simple-git/argv-parser@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz#275b839c6eeb5030872c73b1ea839a416885da9d" + integrity sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw== + dependencies: + "@simple-git/args-pathspec" "^1.0.3" + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -8020,159 +7980,151 @@ nanoid "^5.1.7" webpack "^5.106.1" -"@smithy/abort-controller@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/abort-controller/-/abort-controller-4.2.8.tgz#3bfd7a51acce88eaec9a65c3382542be9f3a053a" - integrity sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw== +"@smithy/chunked-blob-reader-native@^4.2.3": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz#9e79a80d8d44798e7ce7a8f968cbbbaf5a40d950" + integrity sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/util-base64" "^4.3.2" tslib "^2.6.2" -"@smithy/chunked-blob-reader-native@^4.2.1": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz#380266951d746b522b4ab2b16bfea6b451147b41" - integrity sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ== +"@smithy/chunked-blob-reader@^5.2.2": + version "5.2.2" + resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz#3af48e37b10e5afed478bb31d2b7bc03c81d196c" + integrity sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw== dependencies: - "@smithy/util-base64" "^4.3.0" tslib "^2.6.2" -"@smithy/chunked-blob-reader@^5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz#776fec5eaa5ab5fa70d0d0174b7402420b24559c" - integrity sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA== +"@smithy/config-resolver@^4.4.17", "@smithy/config-resolver@^4.4.6": + version "4.4.17" + resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.4.17.tgz#5bd7ccf461e126c79072ce84c6b0f3d00b3409bc" + integrity sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ== dependencies: + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" + "@smithy/util-endpoints" "^3.4.2" + "@smithy/util-middleware" "^4.2.14" tslib "^2.6.2" -"@smithy/config-resolver@^4.4.6": - version "4.4.6" - resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.4.6.tgz#bd7f65b3da93f37f1c97a399ade0124635c02297" - integrity sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ== - dependencies: - "@smithy/node-config-provider" "^4.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-config-provider" "^4.2.0" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" +"@smithy/core@^3.22.0", "@smithy/core@^3.23.17": + version "3.23.17" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.23.17.tgz#23d02277c8d6d30a1605afd756696265e48ed67e" + integrity sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ== + dependencies: + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" + "@smithy/uuid" "^1.1.2" tslib "^2.6.2" -"@smithy/core@^3.22.0", "@smithy/core@^3.23.2": - version "3.23.2" - resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.23.2.tgz#9300fe6fa6e8ceb19ecbbb9090ccea04942a37f0" - integrity sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA== +"@smithy/credential-provider-imds@^4.2.14", "@smithy/credential-provider-imds@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz#b5dcc198ee240eaf68069e7449bcec29ce279827" + integrity sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg== dependencies: - "@smithy/middleware-serde" "^4.2.9" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-stream" "^4.5.12" - "@smithy/util-utf8" "^4.2.0" - "@smithy/uuid" "^1.1.0" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" tslib "^2.6.2" -"@smithy/credential-provider-imds@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz#b2f4bf759ab1c35c0dd00fa3470263c749ebf60f" - integrity sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw== - dependencies: - "@smithy/node-config-provider" "^4.3.8" - "@smithy/property-provider" "^4.2.8" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - tslib "^2.6.2" - -"@smithy/eventstream-codec@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz#2f431f4bac22e40aa6565189ea350c6fcb5efafd" - integrity sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw== +"@smithy/eventstream-codec@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz#4963ca27242b80c5b1d11dcd3ea1bee2a3c5f96d" + integrity sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw== dependencies: "@aws-crypto/crc32" "5.2.0" - "@smithy/types" "^4.12.0" - "@smithy/util-hex-encoding" "^4.2.0" + "@smithy/types" "^4.14.1" + "@smithy/util-hex-encoding" "^4.2.2" tslib "^2.6.2" -"@smithy/eventstream-serde-browser@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz#04e2e1fad18e286d5595fbc0bff22e71251fca38" - integrity sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw== +"@smithy/eventstream-serde-browser@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz#b483667ea358975afb2170cd2618b9aa53a0fb29" + integrity sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ== dependencies: - "@smithy/eventstream-serde-universal" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/eventstream-serde-universal" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/eventstream-serde-config-resolver@^4.3.8": - version "4.3.8" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz#b913d23834c6ebf1646164893e1bec89dffe4f3b" - integrity sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ== +"@smithy/eventstream-serde-config-resolver@^4.3.14": + version "4.3.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz#2eb23acad43414b9bc0b43f34ae9afbd5464e484" + integrity sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/eventstream-serde-node@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz#5f2dfa2cbb30bf7564c8d8d82a9832e9313f5243" - integrity sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A== +"@smithy/eventstream-serde-node@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz#402c2a3b0437b7ac9747090a38a60d3642813490" + integrity sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw== dependencies: - "@smithy/eventstream-serde-universal" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/eventstream-serde-universal" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/eventstream-serde-universal@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz#a62b389941c28a8c3ab44a0c8ba595447e0258a7" - integrity sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ== +"@smithy/eventstream-serde-universal@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz#1e1d29c111e580a93f3c197139c5ca8c976ec205" + integrity sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg== dependencies: - "@smithy/eventstream-codec" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/eventstream-codec" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/fetch-http-handler@^5.3.9": - version "5.3.9" - resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz#edfc9e90e0c7538c81e22e748d62c0066cc91d58" - integrity sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA== +"@smithy/fetch-http-handler@^5.3.17", "@smithy/fetch-http-handler@^5.3.9": + version "5.3.17" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz#bf13a4b03eb8afe101775fef59a1758f8fb5cd4b" + integrity sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw== dependencies: - "@smithy/protocol-http" "^5.3.8" - "@smithy/querystring-builder" "^4.2.8" - "@smithy/types" "^4.12.0" - "@smithy/util-base64" "^4.3.0" + "@smithy/protocol-http" "^5.3.14" + "@smithy/querystring-builder" "^4.2.14" + "@smithy/types" "^4.14.1" + "@smithy/util-base64" "^4.3.2" tslib "^2.6.2" -"@smithy/hash-blob-browser@^4.2.9": - version "4.2.9" - resolved "https://registry.yarnpkg.com/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz#4f8e19b12b5a1000b7292b30f5ee237d32216af3" - integrity sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg== +"@smithy/hash-blob-browser@^4.2.15": + version "4.2.15" + resolved "https://registry.yarnpkg.com/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.15.tgz#1323f9717cad352b3e18065b738387bb9684f993" + integrity sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA== dependencies: - "@smithy/chunked-blob-reader" "^5.2.0" - "@smithy/chunked-blob-reader-native" "^4.2.1" - "@smithy/types" "^4.12.0" + "@smithy/chunked-blob-reader" "^5.2.2" + "@smithy/chunked-blob-reader-native" "^4.2.3" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/hash-node@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.2.8.tgz#c21eb055041716cd492dda3a109852a94b6d47bb" - integrity sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA== +"@smithy/hash-node@^4.2.14", "@smithy/hash-node@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.2.14.tgz#e3ed33dc614e26fff5f043e097750c6931b48592" + integrity sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g== dependencies: - "@smithy/types" "^4.12.0" - "@smithy/util-buffer-from" "^4.2.0" - "@smithy/util-utf8" "^4.2.0" + "@smithy/types" "^4.14.1" + "@smithy/util-buffer-from" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/hash-stream-node@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz#d541a31c714ac9c85ae9fec91559e81286707ddb" - integrity sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w== +"@smithy/hash-stream-node@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/hash-stream-node/-/hash-stream-node-4.2.14.tgz#98bc14e79e2be852d04ff6cbfe4b0babe48fb10d" + integrity sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ== dependencies: - "@smithy/types" "^4.12.0" - "@smithy/util-utf8" "^4.2.0" + "@smithy/types" "^4.14.1" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/invalid-dependency@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz#c578bc6d5540c877aaed5034b986b5f6bd896451" - integrity sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ== +"@smithy/invalid-dependency@^4.2.14", "@smithy/invalid-dependency@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz#a52766f9d4299abcd9d6cd23b5a76f34fc59c7a0" + integrity sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" "@smithy/is-array-buffer@^2.2.0": @@ -8182,209 +8134,210 @@ dependencies: tslib "^2.6.2" -"@smithy/is-array-buffer@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz#b0f874c43887d3ad44f472a0f3f961bcce0550c2" - integrity sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ== +"@smithy/is-array-buffer@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz#c401ce54b12a16529eb1c938a0b6c2247cb763b8" + integrity sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow== dependencies: tslib "^2.6.2" -"@smithy/md5-js@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/md5-js/-/md5-js-4.2.8.tgz#d354dbf9aea7a580be97598a581e35eef324ce22" - integrity sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ== +"@smithy/md5-js@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/md5-js/-/md5-js-4.2.14.tgz#c066572ec84def147af24e55a402c44d0d7dcd7b" + integrity sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA== dependencies: - "@smithy/types" "^4.12.0" - "@smithy/util-utf8" "^4.2.0" + "@smithy/types" "^4.14.1" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/middleware-content-length@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz#82c1df578fa70fe5800cf305b8788b9d2836a3e4" - integrity sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A== +"@smithy/middleware-content-length@^4.2.14", "@smithy/middleware-content-length@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz#d8b17f94c4d8f9c3b7992f1db84d3299c83efe78" + integrity sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw== dependencies: - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/middleware-endpoint@^4.4.12", "@smithy/middleware-endpoint@^4.4.16": - version "4.4.16" - resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.16.tgz#46408512c6737c4719c5d8abb9f99820824441e7" - integrity sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA== - dependencies: - "@smithy/core" "^3.23.2" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-middleware" "^4.2.8" +"@smithy/middleware-endpoint@^4.4.12", "@smithy/middleware-endpoint@^4.4.32": + version "4.4.32" + resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz#4c7dcf06b637b40dfcc53d3b18d1a784a747c530" + integrity sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q== + dependencies: + "@smithy/core" "^3.23.17" + "@smithy/middleware-serde" "^4.2.20" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-middleware" "^4.2.14" tslib "^2.6.2" -"@smithy/middleware-retry@^4.4.29", "@smithy/middleware-retry@^4.4.33": - version "4.4.33" - resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.4.33.tgz#37ac0f72683757a83074f66f7328d4f7d5150d75" - integrity sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA== - dependencies: - "@smithy/node-config-provider" "^4.3.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/service-error-classification" "^4.2.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/uuid" "^1.1.0" +"@smithy/middleware-retry@^4.4.29", "@smithy/middleware-retry@^4.5.7": + version "4.5.7" + resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz#a2da0c472d631ee408ff566186c99571b3efb70b" + integrity sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg== + dependencies: + "@smithy/core" "^3.23.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/service-error-classification" "^4.3.1" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/uuid" "^1.1.2" tslib "^2.6.2" -"@smithy/middleware-serde@^4.2.9": - version "4.2.9" - resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz#fd9d9b02b265aef67c9a30f55c2a5038fc9ca791" - integrity sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ== +"@smithy/middleware-serde@^4.2.20", "@smithy/middleware-serde@^4.2.9": + version "4.2.20" + resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz#76862c8f9b39b08501539440a2e6bca7a77de508" + integrity sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ== dependencies: - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" + "@smithy/core" "^3.23.17" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/middleware-stack@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz#4fa9cfaaa05f664c9bb15d45608f3cb4f6da2b76" - integrity sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA== +"@smithy/middleware-stack@^4.2.14", "@smithy/middleware-stack@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz#23a4cf643ccdbde52c8780fe5cc080611efef1c7" + integrity sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/node-config-provider@^4.3.8": - version "4.3.8" - resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz#85a0683448262b2eb822f64c14278d4887526377" - integrity sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg== +"@smithy/node-config-provider@^4.3.14", "@smithy/node-config-provider@^4.3.8": + version "4.3.14" + resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz#8ca13b86b6123cbb0425d669bd847fcd333ca4bd" + integrity sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg== dependencies: - "@smithy/property-provider" "^4.2.8" - "@smithy/shared-ini-file-loader" "^4.4.3" - "@smithy/types" "^4.12.0" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/node-http-handler@^4.4.10", "@smithy/node-http-handler@^4.4.8": - version "4.4.10" - resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.4.10.tgz#4945e2c2e61174ec1471337e3ddd50b8e4921204" - integrity sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA== +"@smithy/node-http-handler@^4.4.8", "@smithy/node-http-handler@^4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz#cb25b9445e46294a6f0dfb1566dbf2a1a19510af" + integrity sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg== dependencies: - "@smithy/abort-controller" "^4.2.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/querystring-builder" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/protocol-http" "^5.3.14" + "@smithy/querystring-builder" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/property-provider@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.2.8.tgz#6e37b30923d2d31370c50ce303a4339020031472" - integrity sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w== +"@smithy/property-provider@^4.2.14", "@smithy/property-provider@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.2.14.tgz#8072418672d8c29d3f9ef35e452437ba2c59100a" + integrity sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/protocol-http@^5.3.8": - version "5.3.8" - resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.8.tgz#0938f69a3c3673694c2f489a640fce468ce75006" - integrity sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ== +"@smithy/protocol-http@^5.3.14", "@smithy/protocol-http@^5.3.8": + version "5.3.14" + resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.14.tgz#ed1e65cdb0fffb7fd00dce997c04baa236f180cc" + integrity sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/querystring-builder@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz#2fa72d29eb1844a6a9933038bbbb14d6fe385e93" - integrity sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw== +"@smithy/querystring-builder@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz#102429e0fb004108babf219edfcf6f111e66d782" + integrity sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A== dependencies: - "@smithy/types" "^4.12.0" - "@smithy/util-uri-escape" "^4.2.0" + "@smithy/types" "^4.14.1" + "@smithy/util-uri-escape" "^4.2.2" tslib "^2.6.2" -"@smithy/querystring-parser@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz#aa3f2456180ce70242e89018d0b1ebd4782a6347" - integrity sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA== +"@smithy/querystring-parser@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz#c479ba1f346656b9f8ce46d9a91c229e4e50420f" + integrity sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/service-error-classification@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz#6d89dbad4f4978d7b75a44af8c18c22455a16cdc" - integrity sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ== +"@smithy/service-error-classification@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz#5303d4fc3c3eea0f79c3b88cb4436498a31e9f12" + integrity sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" -"@smithy/shared-ini-file-loader@^4.4.3": - version "4.4.3" - resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz#6054215ecb3a6532b13aa49a9fbda640b63be50e" - integrity sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg== +"@smithy/shared-ini-file-loader@^4.4.9": + version "4.4.9" + resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz#fb3719b401d101a65a682380b40efd3a116162f0" + integrity sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/signature-v4@^5.3.8": - version "5.3.8" - resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.8.tgz#796619b10b7cc9467d0625b0ebd263ae04fdfb76" - integrity sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg== - dependencies: - "@smithy/is-array-buffer" "^4.2.0" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-hex-encoding" "^4.2.0" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-uri-escape" "^4.2.0" - "@smithy/util-utf8" "^4.2.0" +"@smithy/signature-v4@^5.3.14": + version "5.3.14" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.14.tgz#2b28c7d190301a67a520227a2343d1e7bb1c6d22" + integrity sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA== + dependencies: + "@smithy/is-array-buffer" "^4.2.2" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-hex-encoding" "^4.2.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-uri-escape" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/smithy-client@^4.11.1", "@smithy/smithy-client@^4.11.5": - version "4.11.5" - resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.11.5.tgz#4e2de632a036cffbf77337aac277131e85fcf399" - integrity sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ== - dependencies: - "@smithy/core" "^3.23.2" - "@smithy/middleware-endpoint" "^4.4.16" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/types" "^4.12.0" - "@smithy/util-stream" "^4.5.12" +"@smithy/smithy-client@^4.11.1", "@smithy/smithy-client@^4.12.13": + version "4.12.13" + resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.12.13.tgz#dec184a1d2d5027370ae1582bddbdbc068c97da5" + integrity sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA== + dependencies: + "@smithy/core" "^3.23.17" + "@smithy/middleware-endpoint" "^4.4.32" + "@smithy/middleware-stack" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-stream" "^4.5.25" tslib "^2.6.2" -"@smithy/types@^4.12.0": - version "4.12.0" - resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.12.0.tgz#55d2479080922bda516092dbf31916991d9c6fee" - integrity sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw== +"@smithy/types@^4.12.0", "@smithy/types@^4.14.1": + version "4.14.1" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.14.1.tgz#aba92b4cdb406f2a2b062e82f1e3728d809a7c23" + integrity sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg== dependencies: tslib "^2.6.2" -"@smithy/url-parser@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/url-parser/-/url-parser-4.2.8.tgz#b44267cd704abe114abcd00580acdd9e4acc1177" - integrity sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA== +"@smithy/url-parser@^4.2.14", "@smithy/url-parser@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/url-parser/-/url-parser-4.2.14.tgz#349a442a62eb5907533f204b73a010618198b073" + integrity sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ== dependencies: - "@smithy/querystring-parser" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/querystring-parser" "^4.2.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-base64@^4.3.0": - version "4.3.0" - resolved "https://registry.yarnpkg.com/@smithy/util-base64/-/util-base64-4.3.0.tgz#5e287b528793aa7363877c1a02cd880d2e76241d" - integrity sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ== +"@smithy/util-base64@^4.3.0", "@smithy/util-base64@^4.3.2": + version "4.3.2" + resolved "https://registry.yarnpkg.com/@smithy/util-base64/-/util-base64-4.3.2.tgz#be02bcb29a87be744356467ea25ffa413e695cea" + integrity sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ== dependencies: - "@smithy/util-buffer-from" "^4.2.0" - "@smithy/util-utf8" "^4.2.0" + "@smithy/util-buffer-from" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/util-body-length-browser@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz#04e9fc51ee7a3e7f648a4b4bcdf96c350cfa4d61" - integrity sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg== +"@smithy/util-body-length-browser@^4.2.0", "@smithy/util-body-length-browser@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz#c4404277d22039872abdb80e7800f9a63f263862" + integrity sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ== dependencies: tslib "^2.6.2" -"@smithy/util-body-length-node@^4.2.1": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz#79c8a5d18e010cce6c42d5cbaf6c1958523e6fec" - integrity sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA== +"@smithy/util-body-length-node@^4.2.1", "@smithy/util-body-length-node@^4.2.3": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz#f923ca530defb86a9ac3ca2d3066bcca7b304fbc" + integrity sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g== dependencies: tslib "^2.6.2" @@ -8396,95 +8349,95 @@ "@smithy/is-array-buffer" "^2.2.0" tslib "^2.6.2" -"@smithy/util-buffer-from@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz#7abd12c4991b546e7cee24d1e8b4bfaa35c68a9d" - integrity sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew== +"@smithy/util-buffer-from@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz#2c6b7857757dfd88f6cd2d36016179a40ccc913b" + integrity sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q== dependencies: - "@smithy/is-array-buffer" "^4.2.0" + "@smithy/is-array-buffer" "^4.2.2" tslib "^2.6.2" -"@smithy/util-config-provider@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz#2e4722937f8feda4dcb09672c59925a4e6286cfc" - integrity sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q== +"@smithy/util-config-provider@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz#52ebf9d8942838d18bc5fb1520de1e8699d7aad6" + integrity sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ== dependencies: tslib "^2.6.2" -"@smithy/util-defaults-mode-browser@^4.3.28", "@smithy/util-defaults-mode-browser@^4.3.32": - version "4.3.32" - resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.32.tgz#683496a0b38a3e5231a25ca7cce8028eb437f3b2" - integrity sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg== +"@smithy/util-defaults-mode-browser@^4.3.28", "@smithy/util-defaults-mode-browser@^4.3.49": + version "4.3.49" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz#926ce84bf65e56307f25cce7a13b427d33442939" + integrity sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw== dependencies: - "@smithy/property-provider" "^4.2.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" + "@smithy/property-provider" "^4.2.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-defaults-mode-node@^4.2.31", "@smithy/util-defaults-mode-node@^4.2.35": - version "4.2.35" - resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.35.tgz#110575d6e85c282bb9b9283da886a8cf2fb68c6a" - integrity sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q== - dependencies: - "@smithy/config-resolver" "^4.4.6" - "@smithy/credential-provider-imds" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/property-provider" "^4.2.8" - "@smithy/smithy-client" "^4.11.5" - "@smithy/types" "^4.12.0" +"@smithy/util-defaults-mode-node@^4.2.31", "@smithy/util-defaults-mode-node@^4.2.54": + version "4.2.54" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz#32c4ea9f8a8c74ef9fe0ca5e3d6a10df0327f87e" + integrity sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw== + dependencies: + "@smithy/config-resolver" "^4.4.17" + "@smithy/credential-provider-imds" "^4.2.14" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-endpoints@^3.2.8": - version "3.2.8" - resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz#5650bda2adac989ff2e562606088c5de3dcb1b36" - integrity sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw== +"@smithy/util-endpoints@^3.2.8", "@smithy/util-endpoints@^3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz#ee59c42d039a642b6c6eb2d38e0ae3db6fc48e97" + integrity sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg== dependencies: - "@smithy/node-config-provider" "^4.3.8" - "@smithy/types" "^4.12.0" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-hex-encoding@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz#1c22ea3d1e2c3a81ff81c0a4f9c056a175068a7b" - integrity sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw== +"@smithy/util-hex-encoding@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz#4abf3335dd1eb884041d8589ca7628d81a6fd1d3" + integrity sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg== dependencies: tslib "^2.6.2" -"@smithy/util-middleware@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.2.8.tgz#1da33f29a74c7ebd9e584813cb7e12881600a80a" - integrity sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A== +"@smithy/util-middleware@^4.2.14", "@smithy/util-middleware@^4.2.8": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.2.14.tgz#9985dd82b4036db2d03835229b9b0c63d2bb85fa" + integrity sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw== dependencies: - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-retry@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.2.8.tgz#23f3f47baf0681233fd0c37b259e60e268c73b11" - integrity sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg== +"@smithy/util-retry@^4.2.8", "@smithy/util-retry@^4.3.6": + version "4.3.8" + resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.3.8.tgz#7f904ed8e5bad2b5f2e6aa1e193db2b46b2c57df" + integrity sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw== dependencies: - "@smithy/service-error-classification" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/service-error-classification" "^4.3.1" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/util-stream@^4.5.12": - version "4.5.12" - resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-4.5.12.tgz#f8734a01dce2e51530231e6afc8910397d3e300a" - integrity sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg== - dependencies: - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/node-http-handler" "^4.4.10" - "@smithy/types" "^4.12.0" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-buffer-from" "^4.2.0" - "@smithy/util-hex-encoding" "^4.2.0" - "@smithy/util-utf8" "^4.2.0" +"@smithy/util-stream@^4.5.25": + version "4.5.25" + resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-4.5.25.tgz#f48385a284151c7e099395af4e5fb0978fffe4ff" + integrity sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA== + dependencies: + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/types" "^4.14.1" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-buffer-from" "^4.2.2" + "@smithy/util-hex-encoding" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" -"@smithy/util-uri-escape@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz#096a4cec537d108ac24a68a9c60bee73fc7e3a9e" - integrity sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA== +"@smithy/util-uri-escape@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz#48e40206e7fe9daefc8d44bb43a1ab17e76abf4a" + integrity sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw== dependencies: tslib "^2.6.2" @@ -8496,27 +8449,26 @@ "@smithy/util-buffer-from" "^2.2.0" tslib "^2.6.2" -"@smithy/util-utf8@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-4.2.0.tgz#8b19d1514f621c44a3a68151f3d43e51087fed9d" - integrity sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw== +"@smithy/util-utf8@^4.2.0", "@smithy/util-utf8@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-4.2.2.tgz#21db686982e6f3393ac262e49143b42370130f13" + integrity sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw== dependencies: - "@smithy/util-buffer-from" "^4.2.0" + "@smithy/util-buffer-from" "^4.2.2" tslib "^2.6.2" -"@smithy/util-waiter@^4.2.8": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@smithy/util-waiter/-/util-waiter-4.2.8.tgz#35d7bd8b2be7a2ebc12d8c38a0818c501b73e928" - integrity sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg== +"@smithy/util-waiter@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@smithy/util-waiter/-/util-waiter-4.3.0.tgz#6122ce27939edb5550d1d6c7c8d506323f3a17f7" + integrity sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA== dependencies: - "@smithy/abort-controller" "^4.2.8" - "@smithy/types" "^4.12.0" + "@smithy/types" "^4.14.1" tslib "^2.6.2" -"@smithy/uuid@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@smithy/uuid/-/uuid-1.1.0.tgz#9fd09d3f91375eab94f478858123387df1cda987" - integrity sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw== +"@smithy/uuid@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@smithy/uuid/-/uuid-1.1.2.tgz#b6e97c7158615e4a3c775e809c00d8c269b5a12e" + integrity sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g== dependencies: tslib "^2.6.2" @@ -10038,7 +9990,7 @@ "@types/node" "*" "@types/webidl-conversions" "*" -"@types/ws@*", "@types/ws@^8.5.1", "@types/ws@^8.5.10": +"@types/ws@*", "@types/ws@^8.18.1", "@types/ws@^8.5.1", "@types/ws@^8.5.10": version "8.18.1" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== @@ -10923,10 +10875,10 @@ dependencies: tslib "^2.6.3" -"@xmldom/xmldom@^0.8.0": - version "0.8.12" - resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz#cf488a5435fa06c7374ad1449c69cea0f823624b" - integrity sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg== +"@xmldom/xmldom@^0.9.9": + version "0.9.10" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz#a0ad5a26fe8aa996310870726e1704977f769dee" + integrity sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw== "@xstate/fsm@^1.4.0": version "1.6.5" @@ -11046,7 +10998,7 @@ acorn-typescript@^1.4.3: resolved "https://registry.yarnpkg.com/acorn-typescript/-/acorn-typescript-1.4.13.tgz#5f851c8bdda0aa716ffdd5f6ac084df8acc6f5ea" integrity sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q== -acorn-walk@^8.0.0, acorn-walk@^8.0.2, acorn-walk@^8.1.1: +acorn-walk@^8.0.2, acorn-walk@^8.1.1: version "8.3.3" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.3.tgz#9caeac29eefaa0c41e3d4c65137de4d6f34df43e" integrity sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw== @@ -11058,7 +11010,7 @@ acorn@8.11.3: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== -acorn@^8.0.4, acorn@^8.1.0, acorn@^8.10.0, acorn@^8.11.0, acorn@^8.12.1, acorn@^8.14.0, acorn@^8.14.1, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.6.0, acorn@^8.7.0, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.1.0, acorn@^8.10.0, acorn@^8.11.0, acorn@^8.12.1, acorn@^8.14.0, acorn@^8.14.1, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.6.0, acorn@^8.7.0, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: version "8.16.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== @@ -11762,11 +11714,6 @@ async@^3.2.3, async@^3.2.4: resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== -async@~0.2.9: - version "0.2.10" - resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" - integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E= - asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -11819,6 +11766,15 @@ axios@1.15.0: form-data "^4.0.5" proxy-from-env "^2.1.0" +axios@1.15.2: + version "1.15.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.2.tgz#eb8fb6d30349abace6ade5b4cb4d9e8a0dc23e5b" + integrity sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A== + dependencies: + follow-redirects "^1.15.11" + form-data "^4.0.5" + proxy-from-env "^2.1.0" + axobject-query@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -12065,10 +12021,10 @@ babel-preset-solid@^1.8.4: dependencies: babel-plugin-jsx-dom-expressions "^0.37.20" -backbone@^1.1.2: - version "1.4.0" - resolved "https://registry.yarnpkg.com/backbone/-/backbone-1.4.0.tgz#54db4de9df7c3811c3f032f34749a4cd27f3bd12" - integrity sha512-RLmDrRXkVdouTg38jcgHhyQ/2zjg7a8E6sz2zxfz21Hh17xDJYUHBZimVIt5fUyS8vbfpeSmTL3gUjTEvUV3qQ== +backbone@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/backbone/-/backbone-1.6.1.tgz#6e067777767f54b9e150d3de825f7d66e7ed77d0" + integrity sha512-YQzWxOrIgL6BoFnZjThVN99smKYhyEXXFyJJ2lsF1wJLyo4t+QjmkLrH8/fN22FZ4ykF70Xq7PgTugJVR4zS9Q== dependencies: underscore ">=1.8.3" @@ -12283,11 +12239,6 @@ blank-object@^1.0.1: resolved "https://registry.yarnpkg.com/blank-object/-/blank-object-1.0.2.tgz#f990793fbe9a8c8dd013fb3219420bec81d5f4b9" integrity sha1-+ZB5P76ajI3QE/syGUIL7IHV9Lk= -bluebird@^3.4.6, bluebird@^3.7.2: - version "3.7.2" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" - integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== - body-parser@^2.2.1, body-parser@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.2.tgz#1a32cdb966beaf68de50a9dfbe5b58f83cb8890c" @@ -13307,10 +13258,10 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== -charm@^1.0.0: +charm@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/charm/-/charm-1.0.2.tgz#8add367153a6d9a581331052c4090991da995e35" - integrity sha1-it02cVOm2aWBMxBSxAkJkdqZXjU= + integrity sha512-wqW3VdPnlSWT4eRiYX+hcs+C6ViBPUWk1qTCd+37qw9kEm/a5n2qcyQDMBWvSYKN/ctqZzeXNQaeBjOetJJUkw== dependencies: inherits "^2.0.1" @@ -13715,7 +13666,12 @@ commander@^12.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== -commander@^2.20.0, commander@^2.6.0: +commander@^14.0.3: + version "14.0.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" + integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + +commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -13740,7 +13696,7 @@ comment-parser@1.4.1, comment-parser@^1.1.2: resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.4.1.tgz#bdafead37961ac079be11eb7ec65c4d021eaf9cc" integrity sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg== -commenting@~1.1.0: +commenting@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/commenting/-/commenting-1.1.0.tgz#fae14345c6437b8554f30bc6aa6c1e1633033590" integrity sha512-YeNK4tavZwtH7jEgK1ZINXzLKm6DZdEMfsaaieOsCAN0S8vsY7UeuO3Q7d/M018EFgE+IeUAuBOKkFccBZsUZA== @@ -13783,7 +13739,7 @@ compressible@~2.0.18: dependencies: mime-db ">= 1.43.0 < 2" -compression@^1.7.4: +compression@^1.7.4, compression@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/compression/-/compression-1.8.1.tgz#4a45d909ac16509195a9a28bd91094889c180d79" integrity sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w== @@ -13881,12 +13837,10 @@ console-ui@^3.0.4, console-ui@^3.1.2: ora "^3.4.0" through2 "^3.0.1" -consolidate@^0.16.0: - version "0.16.0" - resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.16.0.tgz#a11864768930f2f19431660a65906668f5fbdc16" - integrity sha512-Nhl1wzCslqXYTJVDyJCu3ODohy9OfBMB5uD2BiBTzd7w+QY0lBzafkR8y8755yMYHAaMD4NuzbAw03/xzfw+eQ== - dependencies: - bluebird "^3.7.2" +consolidate@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-1.0.4.tgz#9052e88bf3cf89a444df3cb61f1d4c6b9c8afcf0" + integrity sha512-RuZ3xnqEDsxiwaoIkqVeeK3gg9qxw7+YKYX2tKhLs1eukVKMgSr4VYI3iYFsRHi4TloHYDlugrz3kvkjs3nynA== content-disposition@^1.0.0: version "1.0.1" @@ -14422,11 +14376,6 @@ db0@^0.3.4: resolved "https://registry.yarnpkg.com/db0/-/db0-0.3.4.tgz#fb109b0d9823ba1f787a4a3209fa1f3cf9ae9cf9" integrity sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw== -debounce@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" - integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== - debug@2, debug@2.6.9, debug@^2.1.0, debug@^2.1.1, debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -14455,7 +14404,7 @@ debug@^3.0.1, debug@^3.1.0, debug@^3.2.6, debug@^3.2.7: dependencies: ms "^2.1.1" -debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: +debug@~4.3.1, debug@~4.3.4: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== @@ -16961,10 +16910,10 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== -events-to-array@^1.0.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/events-to-array/-/events-to-array-1.1.2.tgz#2d41f563e1fe400ed4962fe1a4d5c6a7539df7f6" - integrity sha1-LUH1Y+H+QA7Uli/hpNXGp1Od9/Y= +events-to-array@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/events-to-array/-/events-to-array-2.0.3.tgz#0cd5ee538baae3ea9ec07539d778a2a6056699bc" + integrity sha512-f/qE2gImHRa4Cp2y1stEOSgw8wTFyUdVJX7G//bMwbaV9JqISFxg99NbmVQeP7YLnDUZ2un851jlaDrlpmGehQ== events@^3.0.0, events@^3.2.0, events@^3.3.0: version "3.3.0" @@ -17054,6 +17003,24 @@ execa@^8.0.1: signal-exit "^4.1.0" strip-final-newline "^3.0.0" +execa@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-9.6.1.tgz#5b90acedc6bdc0fa9b9a6ddf8f9cbb0c75a7c471" + integrity sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA== + dependencies: + "@sindresorhus/merge-streams" "^4.0.0" + cross-spawn "^7.0.6" + figures "^6.1.0" + get-stream "^9.0.0" + human-signals "^8.0.1" + is-plain-obj "^4.1.0" + is-stream "^4.0.1" + npm-run-path "^6.0.0" + pretty-ms "^9.2.0" + signal-exit "^4.1.0" + strip-final-newline "^4.0.0" + yoctocolors "^2.1.1" + exit-hook@2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593" @@ -17094,7 +17061,7 @@ expect-type@^1.2.1: resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.1.tgz#af76d8b357cf5fa76c41c09dafb79c549e75f71f" integrity sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw== -express@5.2.1: +express@5.2.1, express@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== @@ -17128,7 +17095,7 @@ express@5.2.1: type-is "^2.0.1" vary "^1.1.2" -express@^4.10.7, express@^4.17.3, express@^4.18.1, express@^4.21.2: +express@^4.17.3, express@^4.18.1, express@^4.21.2: version "4.22.1" resolved "https://registry.yarnpkg.com/express/-/express-4.22.1.tgz#1de23a09745a4fffdb39247b344bb5eaff382069" integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g== @@ -17351,36 +17318,30 @@ fast-uri@^3.0.1: resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.6.tgz#88f130b77cfaea2378d56bf970dea21257a68748" integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw== -fast-xml-builder@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz#0c407a1d9d5996336c0cd76f7ff785cac6413017" - integrity sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg== +fast-xml-builder@^1.1.5: + version "1.1.7" + resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.1.7.tgz#b445dfa48d5e7636a50d7ff39c7f4254552bfdff" + integrity sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ== dependencies: path-expression-matcher "^1.1.3" -fast-xml-parser@5.3.6: - version "5.3.6" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz#85a69117ca156b1b3c52e426495b6de266cb6a4b" - integrity sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA== +fast-xml-parser@5.7.2, fast-xml-parser@^5.0.7: + version "5.7.2" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz#fecd0b054c6c132fc03dab994a413da781e0eb9f" + integrity sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w== dependencies: - strnum "^2.1.2" + "@nodable/entities" "^2.1.0" + fast-xml-builder "^1.1.5" + path-expression-matcher "^1.5.0" + strnum "^2.2.3" fast-xml-parser@^4.4.1: - version "4.5.4" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz#64e52ddf1308001893bd225d5b1768840511c797" - integrity sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ== + version "4.5.6" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz#4ff57d4aca13a2d11aa42ad460495cf00f32b655" + integrity sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A== dependencies: strnum "^1.0.5" -fast-xml-parser@^5.0.7: - version "5.5.8" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz#929571ed8c5eb96e6d9bd572ba14fc4b84875716" - integrity sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ== - dependencies: - fast-xml-builder "^1.1.4" - path-expression-matcher "^1.2.0" - strnum "^2.2.0" - fastq@^1.6.0: version "1.19.1" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" @@ -17402,7 +17363,7 @@ fb-watchman@^2.0.0, fb-watchman@^2.0.1: dependencies: bser "2.1.1" -fdir@^6.2.0, fdir@^6.4.4, fdir@^6.5.0: +fdir@^6.2.0, fdir@^6.4.3, fdir@^6.4.4, fdir@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== @@ -17436,6 +17397,13 @@ figures@^2.0.0: dependencies: escape-string-regexp "^1.0.5" +figures@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-6.1.0.tgz#935479f51865fa7479f6fa94fc6fc7ac14e62c4a" + integrity sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg== + dependencies: + is-unicode-supported "^2.0.0" + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -17443,10 +17411,10 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-type@21.3.2: - version "21.3.2" - resolved "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz" - integrity sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w== +file-type@21.3.4: + version "21.3.4" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-21.3.4.tgz#e3f902faee8ec4aa152909fc902a7a77f9c06725" + integrity sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g== dependencies: "@tokenizer/inflate" "^0.4.1" strtok3 "^10.3.4" @@ -17641,17 +17609,6 @@ findup-sync@^4.0.0: micromatch "^4.0.2" resolve-dir "^1.0.1" -fireworm@^0.7.2: - version "0.7.2" - resolved "https://registry.yarnpkg.com/fireworm/-/fireworm-0.7.2.tgz#bc5736515b48bd30bf3293a2062e0b0e0361537a" - integrity sha512-GjebTzq+NKKhfmDxjKq3RXwQcN9xRmZWhnnuC9L+/x5wBQtR0aaQM50HsjrzJ2wc28v1vSdfOpELok0TKR4ddg== - dependencies: - async "~0.2.9" - is-type "0.0.1" - lodash.debounce "^3.1.1" - lodash.flatten "^3.0.2" - minimatch "^3.0.2" - fixturify-project@^1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/fixturify-project/-/fixturify-project-1.10.0.tgz#091c452a9bb15f09b6b9cc7cf5c0ad559f1d9aad" @@ -17716,9 +17673,9 @@ fn.name@1.x.x: integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== follow-redirects@^1.0.0, follow-redirects@^1.15.11: - version "1.15.11" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== for-each@^0.3.3, for-each@^0.3.5: version "0.3.5" @@ -18142,6 +18099,14 @@ get-stream@^8.0.1: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-8.0.1.tgz#def9dfd71742cd7754a7761ed43749a27d02eca2" integrity sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA== +get-stream@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-9.0.1.tgz#95157d21df8eb90d1647102b63039b1df60ebd27" + integrity sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA== + dependencies: + "@sec-ant/readable-stream" "^0.4.1" + is-stream "^4.0.1" + get-symbol-description@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" @@ -18260,7 +18225,7 @@ glob@^5.0.10: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.0, glob@^7.0.4, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.3, glob@~7.2.0: +glob@^7.0.0, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.3: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -18514,13 +18479,6 @@ gtoken@^7.0.0: gaxios "^6.0.0" jws "^4.0.0" -gzip-size@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" - integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q== - dependencies: - duplexer "^0.1.2" - gzip-size@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-7.0.0.tgz#9f9644251f15bc78460fccef4055ae5a5562ac60" @@ -18913,10 +18871,10 @@ homedir-polyfill@^1.0.1: dependencies: parse-passwd "^1.0.0" -hono@^4.12.12: - version "4.12.12" - resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.12.tgz#1f14b0ffb47c386ff50d457d66e706d9c9a7f09c" - integrity sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q== +hono@^4.12.14: + version "4.12.14" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.14.tgz#4777c9512b7c84138e4f09e61e3d2fa305eb1414" + integrity sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w== hookable@^5.5.3: version "5.5.3" @@ -18969,7 +18927,7 @@ html-entities@^2.3.2: resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.5.2.tgz#201a3cf95d3a15be7099521620d19dfb4f65359f" integrity sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA== -html-escaper@^2.0.0, html-escaper@^2.0.2: +html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== @@ -19007,7 +18965,7 @@ html-void-elements@^3.0.0: resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== -html-webpack-plugin@^5.5.0, html-webpack-plugin@^5.6.0: +html-webpack-plugin@^5.5.0: version "5.6.0" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz#50a8fa6709245608cb00e811eacecb8e0d7b7ea0" integrity sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw== @@ -19101,7 +19059,7 @@ http-proxy-middleware@^2.0.3: is-plain-obj "^3.0.0" micromatch "^4.0.2" -http-proxy@^1.13.1, http-proxy@^1.18.1: +http-proxy@^1.18.1: version "1.18.1" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== @@ -19171,6 +19129,11 @@ human-signals@^5.0.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== +human-signals@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-8.0.1.tgz#f08bb593b6d1db353933d06156cedec90abe51fb" + integrity sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ== + humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" @@ -19510,9 +19473,9 @@ ioredis@^5.4.1, ioredis@^5.9.1: standard-as-callback "^2.1.0" ip-address@^10.0.1: - version "10.1.0" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.1.0.tgz#d8dcffb34d0e02eb241427444a6e23f5b0595aa4" - integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q== + version "10.2.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206" + integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== ipaddr.js@1.9.1: version "1.9.1" @@ -19850,7 +19813,7 @@ is-plain-obj@^3.0.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA== -is-plain-obj@^4.0.0: +is-plain-obj@^4.0.0, is-plain-obj@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== @@ -19938,6 +19901,11 @@ is-stream@^3.0.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== +is-stream@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-4.0.1.tgz#375cf891e16d2e4baec250b85926cffc14720d9b" + integrity sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A== + is-string@^1.0.7, is-string@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" @@ -19962,13 +19930,6 @@ is-symbol@^1.0.4, is-symbol@^1.1.1: has-symbols "^1.1.0" safe-regex-test "^1.1.0" -is-type@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/is-type/-/is-type-0.0.1.tgz#f651d85c365d44955d14a51d8d7061f3f6b4779c" - integrity sha1-9lHYXDZdRJVdFKUdjXBh8/a0d5w= - dependencies: - core-util-is "~1.0.0" - is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15, is-typed-array@^1.1.3: version "1.1.15" resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" @@ -19991,6 +19952,11 @@ is-unicode-supported@^1.1.0, is-unicode-supported@^1.3.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz#d824984b616c292a2e198207d4a609983842f714" integrity sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ== +is-unicode-supported@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" + integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== + is-url-superb@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/is-url-superb/-/is-url-superb-4.0.0.tgz#b54d1d2499bb16792748ac967aa3ecb41a33a8c2" @@ -20278,7 +20244,7 @@ js-tokens@^9.0.1: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== -js-yaml@^3.10.0, js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.2.5, js-yaml@^3.2.7: +js-yaml@^3.10.0, js-yaml@^3.13.0, js-yaml@^3.13.1: version "3.14.2" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz" integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg== @@ -20286,7 +20252,7 @@ js-yaml@^3.10.0, js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.2.5, js-yaml@^3.2. argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0: +js-yaml@^4.1.0, js-yaml@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz" integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== @@ -20905,14 +20871,6 @@ lodash._basecopy@^3.0.0: resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36" integrity sha1-jaDmqHbPNEwK2KVIghEd08XHyjY= -lodash._baseflatten@^3.0.0: - version "3.1.4" - resolved "https://registry.yarnpkg.com/lodash._baseflatten/-/lodash._baseflatten-3.1.4.tgz#0770ff80131af6e34f3b511796a7ba5214e65ff7" - integrity sha1-B3D/gBMa9uNPO1EXlqe6UhTmX/c= - dependencies: - lodash.isarguments "^3.0.0" - lodash.isarray "^3.0.0" - lodash._bindcallback@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" @@ -20961,13 +20919,6 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= -lodash.debounce@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-3.1.1.tgz#812211c378a94cc29d5aa4e3346cf0bfce3a7df5" - integrity sha1-gSIRw3ipTMKdWqTjNGzwv846ffU= - dependencies: - lodash._getnative "^3.0.0" - lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -20983,14 +20934,6 @@ lodash.defaultsdeep@^4.6.1: resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz#512e9bd721d272d94e3d3a63653fa17516741ca6" integrity sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA== -lodash.flatten@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-3.0.2.tgz#de1cf57758f8f4479319d35c3e9cc60c4501938c" - integrity sha1-3hz1d1j49EeTGdNcPpzGDEUBk4w= - dependencies: - lodash._baseflatten "^3.0.0" - lodash._isiterateecall "^3.0.0" - lodash.foreach@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" @@ -21100,12 +21043,12 @@ lodash.uniq@^4.2.0, lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@4.17.23, lodash@~4.17.21: +lodash@4.17.23: version "4.17.23" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== -lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21: +lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.18.1: version "4.18.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== @@ -22162,7 +22105,7 @@ minimalistic-assert@^1.0.0: resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimatch@10.2.4, minimatch@10.2.5, minimatch@^10.2.2, minimatch@^10.2.4: +minimatch@10.2.4, minimatch@10.2.5, minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5: version "10.2.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== @@ -22261,14 +22204,6 @@ minipass-sized@^1.0.3: dependencies: minipass "^3.0.0" -minipass@^2.2.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" - integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - minipass@^3.0.0, minipass@^3.1.1, minipass@^3.1.6: version "3.3.6" resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" @@ -22336,7 +22271,7 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mkdirp@^3.0.1, mkdirp@~3.0.0: +mkdirp@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== @@ -22403,7 +22338,7 @@ module-lookup-amd@^9.0.3: requirejs "^2.3.7" requirejs-config-file "^4.0.0" -moment@~2.30.1: +moment@^2.30.1: version "2.30.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== @@ -22581,18 +22516,15 @@ msgpackr@^1.11.9: optionalDependencies: msgpackr-extract "^3.0.2" -multer@2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/multer/-/multer-2.0.2.tgz#08a8aa8255865388c387aaf041426b0c87bf58dd" - integrity sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw== +multer@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/multer/-/multer-2.1.1.tgz#122d819244fbdfee1efddd9147426691014385b7" + integrity sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A== dependencies: append-field "^1.0.0" busboy "^1.6.0" concat-stream "^2.0.0" - mkdirp "^0.5.6" - object-assign "^4.1.1" type-is "^1.6.18" - xtend "^4.0.2" multicast-dns@^7.2.5: version "7.2.5" @@ -23073,7 +23005,7 @@ node-modules-path@^1.0.0: resolved "https://registry.yarnpkg.com/node-modules-path/-/node-modules-path-1.0.2.tgz#e3acede9b7baf4bc336e3496b58e5b40d517056e" integrity sha512-6Gbjq+d7uhkO7epaKi5DNgUJn7H0gEyA4Jg0Mo1uQOi3Rk50G83LtmhhFyw0LxnAFhtlspkiiw52ISP13qzcBg== -node-notifier@^10.0.0: +node-notifier@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-10.0.1.tgz#0e82014a15a8456c4cfcdb25858750399ae5f1c7" integrity sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ== @@ -23766,11 +23698,6 @@ openai@^5.3.0: resolved "https://registry.yarnpkg.com/openai/-/openai-5.23.2.tgz#f13e2dc2ef6b88aab6a9b97cdc68d41a1d083c68" integrity sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg== -opener@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" - integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== - optional-require@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/optional-require/-/optional-require-1.1.8.tgz#16364d76261b75d964c482b2406cb824d8ec44b7" @@ -24134,7 +24061,7 @@ package-json-from-dist@^1.0.0, package-json-from-dist@^1.0.1: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== -package-name-regex@~2.0.6: +package-name-regex@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/package-name-regex/-/package-name-regex-2.0.6.tgz#b54bcb04d950e38082b7bb38fa558e01c1679334" integrity sha512-gFL35q7kbE/zBaPA3UKhp2vSzcPYx2ecbYuwv1ucE9Il6IIgBDweBlH8D68UFGZic2MkllKa2KHCfC1IQBQUYA== @@ -24217,6 +24144,11 @@ parse-ms@^2.1.0: resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d" integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA== +parse-ms@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-4.0.0.tgz#c0c058edd47c2a590151a718990533fd62803df4" + integrity sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw== + parse-node-version@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" @@ -24304,10 +24236,10 @@ path-exists@^5.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-5.0.0.tgz#a6aad9489200b21fab31e49cf09277e5116fb9e7" integrity sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ== -path-expression-matcher@^1.1.3, path-expression-matcher@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz#9bdae3787f43b0857b0269e9caaa586c12c8abee" - integrity sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ== +path-expression-matcher@^1.1.3, path-expression-matcher@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz#3b98545dc88ffebb593e2d8458d0929da9275f4a" + integrity sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ== path-is-absolute@1.0.1, path-is-absolute@^1.0.0: version "1.0.1" @@ -24382,15 +24314,10 @@ path-to-regexp@6.3.0, path-to-regexp@^6.2.1: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz#2b6a26a337737a8e1416f9272ed0766b1c0389f4" integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== -path-to-regexp@8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4" - integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== - -path-to-regexp@8.3.0, path-to-regexp@^8.0.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz#aa818a6981f99321003a08987d3cec9c3474cd1f" - integrity sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA== +path-to-regexp@8.4.2, path-to-regexp@^8.0.0: + version "8.4.2" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" + integrity sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== path-to-regexp@^1.5.3, path-to-regexp@^1.7.0: version "1.9.0" @@ -24528,9 +24455,9 @@ picocolors@1.1.1, picocolors@^1.0.0, picocolors@^1.1.0, picocolors@^1.1.1: integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: version "4.0.4" @@ -25303,9 +25230,9 @@ postcss@8.4.31: source-map-js "^1.0.2" postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.27, postcss@^8.4.39, postcss@^8.4.43, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.3, postcss@^8.5.6: - version "8.5.6" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" - integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + version "8.5.14" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c" + integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" @@ -25479,6 +25406,13 @@ pretty-ms@^7.0.1: dependencies: parse-ms "^2.1.0" +pretty-ms@^9.2.0: + version "9.3.0" + resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-9.3.0.tgz#dd2524fcb3c326b4931b2272dfd1e1a8ed9a9f5a" + integrity sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ== + dependencies: + parse-ms "^4.0.0" + printf@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/printf/-/printf-0.6.1.tgz#b9afa3d3b55b7f2e8b1715272479fc756ed88650" @@ -25521,6 +25455,11 @@ proc-log@^3.0.0: resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-3.0.0.tgz#fb05ef83ccd64fd7b20bbe9c8c1070fc08338dd8" integrity sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A== +proc-log@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-6.1.0.tgz#18519482a37d5198e231133a70144a50f21f0215" + integrity sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ== + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -26790,20 +26729,19 @@ rollup-plugin-dts@^6.0.0: optionalDependencies: "@babel/code-frame" "^7.24.2" -rollup-plugin-license@^3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/rollup-plugin-license/-/rollup-plugin-license-3.3.1.tgz#73b68e33477524198d6f3f9befc905f59bf37c53" - integrity sha512-lwZ/J8QgSnP0unVOH2FQuOBkeiyp0EBvrbYdNU33lOaYD8xP9Zoki+PGoWMD31EUq8Q07GGocSABTYlWMKkwuw== - dependencies: - commenting "~1.1.0" - glob "~7.2.0" - lodash "~4.17.21" - magic-string "~0.30.0" - mkdirp "~3.0.0" - moment "~2.30.1" - package-name-regex "~2.0.6" - spdx-expression-validate "~2.0.0" - spdx-satisfies "~5.0.1" +rollup-plugin-license@^3.7.1: + version "3.7.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-license/-/rollup-plugin-license-3.7.1.tgz#b99329f1c840142559789e3d6cb9f69e9e5b36ef" + integrity sha512-FcGXUbAmPvRSLxjVdjp/r/MUtKBlttVQd+ApUyvKfREnsoAfAZA6Ic2fE1Tz4RL0f9XqEQU9UIRNUMdtQtliDw== + dependencies: + commenting "^1.1.0" + fdir "^6.4.3" + lodash "^4.17.21" + magic-string "^0.30.0" + moment "^2.30.1" + package-name-regex "^2.0.6" + spdx-expression-validate "^2.0.0" + spdx-satisfies "^5.0.1" rollup-plugin-sourcemaps@^0.6.3: version "0.6.3" @@ -27581,12 +27519,14 @@ simple-get@^4.0.0, simple-get@^4.0.1: simple-concat "^1.0.0" simple-git@^3.28.0: - version "3.33.0" - resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.33.0.tgz#b903dc70f5b93535a4f64ff39172da43058cfb88" - integrity sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng== + version "3.36.0" + resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.36.0.tgz#019b28c0a35847ee34299c6fb63770ab1b2dffb7" + integrity sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q== dependencies: "@kwsites/file-exists" "^1.1.1" "@kwsites/promise-deferred" "^1.1.1" + "@simple-git/args-pathspec" "^1.0.3" + "@simple-git/argv-parser" "^1.1.0" debug "^4.4.0" simple-html-tokenizer@^0.5.11: @@ -27624,15 +27564,6 @@ sinon@21.0.1: diff "^8.0.2" supports-color "^7.2.0" -sirv@^2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/sirv/-/sirv-2.0.4.tgz#5dd9a725c578e34e449f332703eb2a74e46a29b0" - integrity sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ== - dependencies: - "@polka/url" "^1.0.0-next.24" - mrmime "^2.0.0" - totalist "^3.0.0" - sirv@^3.0.0, sirv@^3.0.1, sirv@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.2.tgz#f775fccf10e22a40832684848d636346f41cd970" @@ -27752,15 +27683,15 @@ socket.io-parser@~4.2.4: "@socket.io/component-emitter" "~3.1.0" debug "~4.4.1" -socket.io@^4.5.4: - version "4.8.1" - resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.1.tgz#fa0eaff965cc97fdf4245e8d4794618459f7558a" - integrity sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg== +socket.io@^4.8.3: + version "4.8.3" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.3.tgz#ca6ba1431c69532e1e0a6f496deebeb601dbc4df" + integrity sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A== dependencies: accepts "~1.3.4" base64id "~2.0.0" cors "~2.8.5" - debug "~4.3.2" + debug "~4.4.1" engine.io "~6.6.0" socket.io-adapter "~2.5.2" socket.io-parser "~4.2.4" @@ -28007,7 +27938,7 @@ spdx-expression-parse@^4.0.0: spdx-exceptions "^2.1.0" spdx-license-ids "^3.0.0" -spdx-expression-validate@~2.0.0: +spdx-expression-validate@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/spdx-expression-validate/-/spdx-expression-validate-2.0.0.tgz#25c9408e1c63fad94fff5517bb7101ffcd23350b" integrity sha512-b3wydZLM+Tc6CFvaRDBOF9d76oGIHNCLYFeHbftFXUWjnfZWganmDmvtM5sm1cRwJc/VDBMLyGGrsLFd1vOxbg== @@ -28024,7 +27955,7 @@ spdx-ranges@^2.0.0: resolved "https://registry.yarnpkg.com/spdx-ranges/-/spdx-ranges-2.1.1.tgz#87573927ba51e92b3f4550ab60bfc83dd07bac20" integrity sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA== -spdx-satisfies@~5.0.1: +spdx-satisfies@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/spdx-satisfies/-/spdx-satisfies-5.0.1.tgz#9feeb2524686c08e5f7933c16248d4fdf07ed6a6" integrity sha512-Nwor6W6gzFp8XX4neaKQ7ChV4wmpSh2sSDemMFSzHxpTw460jxFYeOn+jq4ybnSSw/5sc3pjka9MQPouksQNpw== @@ -28445,6 +28376,11 @@ strip-final-newline@^3.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== +strip-final-newline@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz#35a369ec2ac43df356e3edd5dcebb6429aa1fa5c" + integrity sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw== + strip-indent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" @@ -28474,10 +28410,10 @@ strnum@^1.0.5: resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4" integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA== -strnum@^2.1.2, strnum@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.1.tgz#d28f896b4ef9985212494ce8bcf7ca304fad8368" - integrity sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg== +strnum@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.3.tgz#0119fce02749a11bb126a4d686ac5dbdf6e57586" + integrity sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg== strtok3@^10.3.4: version "10.3.4" @@ -28553,6 +28489,7 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" + uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" @@ -28720,14 +28657,21 @@ tagged-tag@^1.0.0: resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6" integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== -tap-parser@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/tap-parser/-/tap-parser-7.0.0.tgz#54db35302fda2c2ccc21954ad3be22b2cba42721" - integrity sha512-05G8/LrzqOOFvZhhAk32wsGiPZ1lfUrl+iV7+OkKgfofZxiceZWMHkKmow71YsyVQ8IvGBP2EjcIjE5gL4l5lA== +tap-parser@^18.3.0: + version "18.3.4" + resolved "https://registry.yarnpkg.com/tap-parser/-/tap-parser-18.3.4.tgz#503b6c8f20f37476d2e802e7e30b5a25d220cbbb" + integrity sha512-CiqzdpWn2CvONcWp7UNMF9/rCPJwCz0es+qykkgJruu1Y/rAS8A5MEQujmjx9NErfst3dGiZJU3lDS2jBsgbPA== + dependencies: + events-to-array "^2.0.3" + tap-yaml "4.4.2" + +tap-yaml@4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/tap-yaml/-/tap-yaml-4.4.2.tgz#450ee4dcefcb6261bdf7d299b81ee6d9aca61d97" + integrity sha512-03mQI7QhfVZHJqGgFyxNTgUbgsG41ZzpWSb7k1Gangmf9hF71Jpb0Fczs7KtOdUDaHx+KxlPUdM2pQJaijebGA== dependencies: - events-to-array "^1.0.1" - js-yaml "^3.2.7" - minipass "^2.2.0" + yaml "^2.8.3" + yaml-types "^0.4.0" tapable@^2.0.0, tapable@^2.1.1, tapable@^2.3.0: version "2.3.0" @@ -28894,35 +28838,34 @@ test-exclude@^7.0.1: minimatch "^9.0.4" testem@^3.10.1: - version "3.15.2" - resolved "https://registry.yarnpkg.com/testem/-/testem-3.15.2.tgz#abd6a96077a6576cd730f3d2e476039044c5cb34" - integrity sha512-mRzqZktqTCWi/rUP/RQOKXvMtuvY3lxuzBVb1xGXPnRNGMEj/1DaLGn6X447yOsz6SlWxSsZfcNuiE7fT1MOKg== - dependencies: - "@xmldom/xmldom" "^0.8.0" - backbone "^1.1.2" - bluebird "^3.4.6" - charm "^1.0.0" - commander "^2.6.0" - compression "^1.7.4" - consolidate "^0.16.0" - execa "^1.0.0" - express "^4.10.7" - fireworm "^0.7.2" - glob "^7.0.4" - http-proxy "^1.13.1" - js-yaml "^3.2.5" - lodash "^4.17.21" + version "3.20.0" + resolved "https://registry.yarnpkg.com/testem/-/testem-3.20.0.tgz#7d6cf0e5ed9e271cf0d6b6617c555fa5c823e7e9" + integrity sha512-SSFfJQK/SGruISFjoKG2jCYwK596wWNPJFj2Wo77GzeIUxZ8ZjuwpyF01uekTLu4ITL6i9R4m1sWaKPK/HsunA== + dependencies: + "@xmldom/xmldom" "^0.9.9" + backbone "^1.6.1" + charm "^1.0.2" + chokidar "^5.0.0" + commander "^14.0.3" + compression "^1.8.1" + consolidate "^1.0.4" + execa "^9.6.1" + express "^5.2.1" + glob "^13.0.6" + http-proxy "^1.18.1" + js-yaml "^4.1.1" + lodash "^4.18.1" + minimatch "^10.2.5" mkdirp "^3.0.1" mustache "^4.2.0" - node-notifier "^10.0.0" - npmlog "^6.0.0" + node-notifier "^10.0.1" printf "^0.6.1" - rimraf "^3.0.2" - socket.io "^4.5.4" + proc-log "^6.1.0" + rimraf "^6.1.3" + socket.io "^4.8.3" spawn-args "^0.2.0" styled_string "0.0.1" - tap-parser "^7.0.0" - tmp "0.0.33" + tap-parser "^18.3.0" text-decoder@^1.1.0: version "1.2.3" @@ -29083,7 +29026,7 @@ tmp@0.0.28: dependencies: os-tmpdir "~1.0.1" -tmp@0.0.33, tmp@^0.0.33: +tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== @@ -30467,10 +30410,10 @@ vite@^5.0.0, vite@^5.4.11, vite@^5.4.21: optionalDependencies: fsevents "~2.3.3" -"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.0.0, vite@^6.1.0, vite@^6.3.5, vite@^6.4.1: - version "6.4.1" - resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.1.tgz#afbe14518cdd6887e240a4b0221ab6d0ce733f96" - integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g== +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.5, vite@^6.4.1, vite@^6.4.2: + version "6.4.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.2.tgz#a4e548ca3a90ca9f3724582cab35e1ba15efc6f2" + integrity sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ== dependencies: esbuild "^0.25.0" fdir "^6.4.4" @@ -30694,24 +30637,6 @@ webidl-conversions@^7.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== -webpack-bundle-analyzer@^4.10.2: - version "4.10.2" - resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz#633af2862c213730be3dbdf40456db171b60d5bd" - integrity sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw== - dependencies: - "@discoveryjs/json-ext" "0.5.7" - acorn "^8.0.4" - acorn-walk "^8.0.0" - commander "^7.2.0" - debounce "^1.2.1" - escape-string-regexp "^4.0.0" - gzip-size "^6.0.0" - html-escaper "^2.0.2" - opener "^1.5.2" - picocolors "^1.0.0" - sirv "^2.0.3" - ws "^7.3.1" - webpack-dev-middleware@5.3.3: version "5.3.3" resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz#efae67c2793908e7311f1d9b06f2a08dcc97e51f" @@ -31275,15 +31200,10 @@ ws@8.18.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== -ws@^7.3.1: - version "7.5.10" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" - integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== - -ws@^8.13.0, ws@^8.18.0, ws@^8.18.3, ws@^8.4.2: - version "8.19.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.19.0.tgz#ddc2bdfa5b9ad860204f5a72a4863a8895fd8c8b" - integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== +ws@^8.13.0, ws@^8.18.0, ws@^8.18.3, ws@^8.20.0, ws@^8.4.2: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.0.tgz#4cd9532358eba60bc863aad1623dfb045a4d4af8" + integrity sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA== ws@~8.17.1: version "8.17.1" @@ -31312,7 +31232,7 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xtend@^4.0.0, xtend@^4.0.2: +xtend@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== @@ -31348,7 +31268,7 @@ yallist@4.0.0, yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yallist@^3.0.0, yallist@^3.0.2: +yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== @@ -31366,15 +31286,20 @@ yam@^1.0.0: fs-extra "^4.0.2" lodash.merge "^4.6.0" +yaml-types@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/yaml-types/-/yaml-types-0.4.0.tgz#e0cab9fb563cbf6f5fc0a40dd3b8cc7bfa06365e" + integrity sha512-XfbA30NUg4/LWUiplMbiufUiwYhgB9jvBhTWel7XQqjV+GaB79c2tROu/8/Tu7jO0HvDvnKWtBk5ksWRrhQ/0g== + yaml@^1.10.0: - version "1.10.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + version "1.10.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3" + integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA== yaml@^2.6.0, yaml@^2.8.0, yaml@^2.8.3: - version "2.8.3" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" - integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== + version "2.8.4" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.4.tgz#4b5f411dd25f9544914d8673d4da7f29248e5e2e" + integrity sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog== yargs-parser@21.1.1, yargs-parser@^21.0.0, yargs-parser@^21.1.1: version "21.1.1" @@ -31458,6 +31383,11 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +yoctocolors@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yoctocolors/-/yoctocolors-2.1.2.tgz#d795f54d173494e7d8db93150cec0ed7f678c83a" + integrity sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug== + youch-core@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/youch-core/-/youch-core-0.3.3.tgz#c5d3d85aeea0d8bc7b36e9764ed3f14b7ceddc7d"