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 (
+
+ );
+}
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 (
+
+ );
+}
+
+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 (
+
+ );
+}
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)
[](https://www.npmjs.com/package/@sentry/hono)
[](https://www.npmjs.com/package/@sentry/hono)
[](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"