From c8df5ab4f70b7951a1673a3c38f5e94f21600cb1 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 17 Mar 2026 17:06:26 +0100 Subject: [PATCH 01/20] test(react): Add gql tests for react router (#19844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds E2E tests verifying that GraphQL fetch spans are attributed to the correct navigation transaction in React Router 7 lazy routes - Test 1: Navigate from index to lazy GQL page → asserts UserAQuery span is in the navigation transaction (not the pageload) - Test 2: Navigate between two lazy GQL pages → asserts UserAQuery only in first nav, UserBQuery only in second nav, no cross-leaking Closes #19845 (added automatically) --- .../react-router-7-lazy-routes/src/index.tsx | 14 ++ .../src/pages/Index.tsx | 4 + .../src/pages/LazyFetchRoutes.tsx | 34 +++++ .../src/pages/LazyFetchSubRoutes.tsx | 34 +++++ .../tests/transactions.test.ts | 137 ++++++++++++++++++ 5 files changed, 223 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LazyFetchRoutes.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LazyFetchSubRoutes.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx index 7ec92c33f0ce..4d47ddbf58bf 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx @@ -73,6 +73,8 @@ const lazyRouteManifest = [ '/deep/level2/level3/:id', '/slow-fetch/:id', '/wildcard-lazy/:id', + '/lazy-gql-a/fetch', + '/lazy-gql-b/fetch', ]; Sentry.init({ @@ -169,6 +171,18 @@ const router = sentryCreateBrowserRouter( lazyChildren: () => import('./pages/WildcardLazyRoutes').then(module => module.wildcardRoutes), }, }, + { + path: '/lazy-gql-a', + handle: { + lazyChildren: () => import('./pages/LazyFetchRoutes').then(module => module.lazyGqlARoutes), + }, + }, + { + path: '/lazy-gql-b', + handle: { + lazyChildren: () => import('./pages/LazyFetchSubRoutes').then(module => module.lazyGqlBRoutes), + }, + }, ], { async patchRoutesOnNavigation({ matches, patch }: Parameters[0]) { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx index c22153441862..f2e6bee7d7d1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx @@ -39,6 +39,10 @@ const Index = () => { Navigate to Wildcard Lazy Route (500ms delay, no fetch) +
+ + Navigate to GQL Page A + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LazyFetchRoutes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LazyFetchRoutes.tsx new file mode 100644 index 000000000000..6c45a6231371 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LazyFetchRoutes.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +const GqlPageA = () => { + const [data, setData] = React.useState<{ data?: unknown } | null>(null); + + React.useEffect(() => { + fetch('/api/graphql?op=UserAQuery', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ userA { id name } }', operationName: 'UserAQuery' }), + }) + .then(res => res.json()) + .then(setData) + .catch(() => setData({ data: { error: 'failed' } })); + }, []); + + return ( +
+

GQL Page A

+

{data ? JSON.stringify(data) : 'loading...'}

+ + Navigate to GQL Page B + +
+ ); +}; + +export const lazyGqlARoutes = [ + { + path: 'fetch', + element: , + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LazyFetchSubRoutes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LazyFetchSubRoutes.tsx new file mode 100644 index 000000000000..3501103cdc82 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LazyFetchSubRoutes.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +const GqlPageB = () => { + const [data, setData] = React.useState<{ data?: unknown } | null>(null); + + React.useEffect(() => { + fetch('/api/graphql?op=UserBQuery', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ userB { id email } }', operationName: 'UserBQuery' }), + }) + .then(res => res.json()) + .then(setData) + .catch(() => setData({ data: { error: 'failed' } })); + }, []); + + return ( +
+

GQL Page B

+

{data ? JSON.stringify(data) : 'loading...'}

+ + Go Home + +
+ ); +}; + +export const lazyGqlBRoutes = [ + { + path: 'fetch', + element: , + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index 16950d3dabdb..1eafc263022e 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -1484,3 +1484,140 @@ test('Route manifest provides correct name when pageload span ends before lazy r expect(event.contexts?.trace?.op).toBe('pageload'); expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); }); + +test('GQL fetch span is attributed to the correct navigation transaction when navigating from index to lazy GQL page', async ({ + page, +}) => { + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/' + ); + }); + + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy-gql-a/fetch' + ); + }); + + await page.goto('/'); + const pageloadEvent = await pageloadPromise; + + // Pageload should NOT contain any /api/graphql spans (neither UserAQuery nor UserBQuery) + const pageloadSpans = pageloadEvent.spans || []; + const pageloadGqlSpans = pageloadSpans.filter( + (span: { op?: string; description?: string; data?: { url?: string } }) => + span.op === 'http.client' && + (span.description?.includes('/api/graphql') || span.data?.url?.includes('/api/graphql')), + ); + expect(pageloadGqlSpans.length).toBe(0); + + // Navigate to lazy GQL page A + const gqlLink = page.locator('id=navigation-to-gql-a'); + await expect(gqlLink).toBeVisible(); + await gqlLink.click(); + + const navigationEvent = await navigationPromise; + + // Verify the lazy GQL page rendered + await expect(page.locator('id=gql-page-a')).toBeVisible(); + + // Verify the navigation transaction has the correct name + expect(navigationEvent.transaction).toBe('/lazy-gql-a/fetch'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + + // Verify the UserAQuery GQL fetch span is inside this navigation transaction + const navSpans = navigationEvent.spans || []; + const userASpans = navSpans.filter( + (span: { op?: string; description?: string; data?: { url?: string } }) => + span.op === 'http.client' && (span.description?.includes('UserAQuery') || span.data?.url?.includes('UserAQuery')), + ); + expect(userASpans.length).toBe(1); + + // Verify NO UserBQuery spans leaked into this transaction + const userBSpans = navSpans.filter( + (span: { op?: string; description?: string; data?: { url?: string } }) => + span.op === 'http.client' && (span.description?.includes('UserBQuery') || span.data?.url?.includes('UserBQuery')), + ); + expect(userBSpans.length).toBe(0); +}); + +test('GQL fetch spans are attributed to correct navigation transactions when navigating between two lazy GQL pages', async ({ + page, +}) => { + await page.goto('/'); + await page.waitForTimeout(500); + + // Navigate to GQL page A + const firstNavPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy-gql-a/fetch' + ); + }); + + const gqlALink = page.locator('id=navigation-to-gql-a'); + await expect(gqlALink).toBeVisible(); + await gqlALink.click(); + + const firstNavEvent = await firstNavPromise; + await expect(page.locator('id=gql-page-a')).toBeVisible(); + + // First navigation should have exactly the UserAQuery span + const firstNavSpans = firstNavEvent.spans || []; + const firstUserASpans = firstNavSpans.filter( + (span: { op?: string; description?: string; data?: { url?: string } }) => + span.op === 'http.client' && (span.description?.includes('UserAQuery') || span.data?.url?.includes('UserAQuery')), + ); + expect(firstUserASpans.length).toBe(1); + + // First navigation must NOT contain UserBQuery spans + const firstUserBSpans = firstNavSpans.filter( + (span: { op?: string; description?: string; data?: { url?: string } }) => + span.op === 'http.client' && (span.description?.includes('UserBQuery') || span.data?.url?.includes('UserBQuery')), + ); + expect(firstUserBSpans.length).toBe(0); + + // Now navigate from GQL page A to GQL page B + const secondNavPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy-gql-b/fetch' + ); + }); + + const gqlBLink = page.locator('id=navigate-to-gql-b'); + await expect(gqlBLink).toBeVisible(); + await gqlBLink.click(); + + const secondNavEvent = await secondNavPromise; + await expect(page.locator('id=gql-page-b')).toBeVisible(); + + // Second navigation should have exactly the UserBQuery span + const secondNavSpans = secondNavEvent.spans || []; + const secondUserBSpans = secondNavSpans.filter( + (span: { op?: string; description?: string; data?: { url?: string } }) => + span.op === 'http.client' && (span.description?.includes('UserBQuery') || span.data?.url?.includes('UserBQuery')), + ); + expect(secondUserBSpans.length).toBe(1); + + // Second navigation must NOT contain UserAQuery spans (no leaking from first nav) + const secondUserASpans = secondNavSpans.filter( + (span: { op?: string; description?: string; data?: { url?: string } }) => + span.op === 'http.client' && (span.description?.includes('UserAQuery') || span.data?.url?.includes('UserAQuery')), + ); + expect(secondUserASpans.length).toBe(0); + + // Verify the two transactions have different trace IDs + const firstTraceId = firstNavEvent.contexts?.trace?.trace_id; + const secondTraceId = secondNavEvent.contexts?.trace?.trace_id; + expect(firstTraceId).toBeDefined(); + expect(secondTraceId).toBeDefined(); + expect(firstTraceId).not.toBe(secondTraceId); +}); From 8d364d62ed9b30f1dfa6d9a48660cb251fe33008 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 17 Mar 2026 17:14:03 +0100 Subject: [PATCH 02/20] fix(deps): bump undici 6.23.0 to 6.24.1 to fix multiple CVEs (#19841) Fixes Dependabot alerts #1156, #1158, #1159, #1160, #1161. CVEs: CVE-2026-2229, CVE-2026-1525, CVE-2026-1526, CVE-2026-1527, CVE-2026-1528 Co-authored-by: Claude Sonnet 4.6 --- yarn.lock | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 8b037c404496..0518e5215117 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28307,7 +28307,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" @@ -29431,9 +29430,9 @@ undici@^5.25.4, undici@^5.28.5: "@fastify/busboy" "^2.0.0" undici@^6.21.2, undici@^6.23.0: - version "6.23.0" - resolved "https://registry.yarnpkg.com/undici/-/undici-6.23.0.tgz#7953087744d9095a96f115de3140ca3828aff3a4" - integrity sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g== + version "6.24.1" + resolved "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz" + integrity sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA== unenv@2.0.0-rc.24, unenv@^2.0.0-rc.18, unenv@^2.0.0-rc.24: version "2.0.0-rc.24" From f01fcc9af944ee39e6127874e4cb681064c6b9f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:44:22 +0100 Subject: [PATCH 03/20] chore(deps): bump next from 16.1.5 to 16.1.7 in /dev-packages/e2e-tests/test-applications/nextjs-16 (#19851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [next](https://github.com/vercel/next.js) from 16.1.5 to 16.1.7.
Release notes

Sourced from next's releases.

v16.1.7

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

Core Changes

  • [Cache Components] Prevent streaming fetch calls from hanging in dev (#89194)
  • Apply server actions transform to node_modules in route handlers (#89380)
  • ensure maxPostponedStateSize is always respected (See: CVE-2026-27979)
  • feat(next/image): add lru disk cache and images.maximumDiskCacheSize (See: CVE-2026-27980)
  • Allow blocking cross-site dev-only websocket connections from privacy-sensitive origins (See: CVE-2026-27977)
  • Disallow Server Action submissions from privacy-sensitive contexts by default (See: CVE-2026-27978)
  • fix: patch http-proxy to prevent request smuggling in rewrites (See: CVE-2026-29057)

Credits

Huge thanks to @​unstubbable, @​styfle, @​eps1lon, and @​ztanner for helping!

v16.1.6

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

Core Changes

  • Upgrade to swc 54 (#88207)
  • implement LRU cache with invocation ID scoping for minimal mode response cache (#88509)
  • tweak LRU sentinel key (#89123)

Credits

Huge thanks to @​mischnic, @​wyattjoh, and @​ztanner for helping!

Commits
  • bdf3e35 v16.1.7
  • dc98c04 [backport]: fix: patch http-proxy to prevent request smuggling in rewrites (#...
  • 9023c0a [backport] Disallow Server Action submissions from privacy-sensitive contexts...
  • 36a97b9 Allow blocking cross-site dev-only websocket connections from privacy-sensiti...
  • 93c3993 [backport]: feat(next/image): add lru disk cache and `images.maximumDiskCache...
  • c68d62d Backport documentation fixes for 16.1.x (#90655)
  • 5214ac1 [backport]: ensure maxPostponedStateSize is always respected (#90060) (#90471)
  • c95e357 Backport/docs fixes 16.1.x (#90125)
  • cba6144 [backport] Apply server actions transform to node_modules in route handlers...
  • 3db9063 [backport] [Cache Components] Prevent streaming fetch calls from hanging in d...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=next&package-manager=npm_and_yarn&previous-version=16.1.5&new-version=16.1.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/e2e-tests/test-applications/nextjs-16/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fc5613a1b44e..4f90b2bc9fe8 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.5", + "next": "16.1.7", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^8", From 5c47722cbc5d180f0b3bec0b20d9571e0c2b9310 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 17 Mar 2026 17:59:04 +0100 Subject: [PATCH 04/20] fix(deps): bump hono 4.12.5 to 4.12.7 in cloudflare-hono E2E test app (#19850) Fixes Dependabot alert #1138 (prototype pollution via parseBody). Co-authored-by: Claude Sonnet 4.6 --- .../e2e-tests/test-applications/cloudflare-hono/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json index 68599e27dbf9..a8e6c9d538ae 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@sentry/cloudflare": "latest || *", - "hono": "4.12.5" + "hono": "4.12.7" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.31", From 3a3bb51a3efc387e6f67fcb7c8b4d39baccf2a54 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 17 Mar 2026 18:10:31 +0100 Subject: [PATCH 05/20] fix(deps): bump tar 7.5.10 to 7.5.11 to fix CVE-2026-31802 (#19846) Fixes Dependabot alert #1137. Co-authored-by: Claude Sonnet 4.6 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0518e5215117..d697e7b80f0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28542,9 +28542,9 @@ tar@^6.1.11, tar@^6.1.2: yallist "^4.0.0" tar@^7.4.0: - version "7.5.10" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.10.tgz#2281541123f5507db38bc6eb22619f4bbaef73ad" - integrity sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw== + version "7.5.11" + resolved "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz" + integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0" From 406ce22f49d8289d810b0649266e0778199d9665 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 17 Mar 2026 16:42:59 -0400 Subject: [PATCH 06/20] fix(deno): Clear pre-existing OTel global before registering TracerProvider (#19723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Calls `trace.disable()` before `trace.setGlobalTracerProvider()` in `@sentry/deno`'s OTel tracer setup - This fixes silent registration failure when Supabase Edge Runtime (or Deno's native OTel) pre-registers a `TracerProvider` on the `@opentelemetry/api` global (`Symbol.for('opentelemetry.js.api.1')`) - Without this fix, **OTel-instrumented spans** (e.g. `gen_ai.*` from AI SDK, or any library using `@opentelemetry/api`) never reach Sentry because Sentry's `TracerProvider` fails to register as the global. Sentry's own `startSpan()` API is unaffected since it bypasses the OTel global. ## Context Supabase Edge Runtime (Deno 2.1.4+) registers its own `TracerProvider` before user code runs. The OTel API's `trace.setGlobalTracerProvider()` is a no-op if a provider is already registered (it only logs a diag warning), so Sentry's tracer silently gets ignored. **What works without the fix:** `Sentry.startSpan()` — goes through Sentry's internal pipeline, not the OTel global. **What breaks without the fix:** Any spans created via `@opentelemetry/api` (AI SDK's `gen_ai.*` spans, HTTP instrumentations, etc.) — these hit the pre-existing Supabase provider instead of Sentry's. Calling `trace.disable()` clears the global, allowing `trace.setGlobalTracerProvider()` to succeed. This matches the pattern already used in `cleanupOtel()` in the test file and is safe because: 1. It only runs once during `Sentry.init()` 2. Any pre-existing provider is immediately replaced by Sentry's 3. It's gated behind `skipOpenTelemetrySetup` so users with custom OTel setups can opt out 4. The Cloudflare package was investigated and doesn't have the same issue ## Test plan - [x] Updated `should override pre-existing OTel provider with Sentry provider` unit test — simulates a pre-existing provider and verifies Sentry overrides it - [x] Updated `should override native Deno OpenTelemetry when enabled` unit test — verifies Sentry captures spans even when `OTEL_DENO=true` - [x] **E2E test app** (`dev-packages/e2e-tests/test-applications/deno/`) — Deno server with pre-existing OTel provider, 5 tests: - Error capture (`Sentry.captureException`) - `Sentry.startSpan` transaction - OTel `tracer.startSpan` despite pre-existing provider (core regression test) - OTel `tracer.startActiveSpan` (AI SDK pattern) - Sentry + OTel interop (OTel child inside Sentry parent) - [x] Verified manually with Supabase Edge Function + AI SDK: `Sentry.startSpan()` spans appeared in Sentry both before and after the fix, but `gen_ai.*` OTel spans only appeared after the fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) Closes #19724 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/build.yml | 5 ++ .../e2e-tests/test-applications/deno/.npmrc | 2 + .../test-applications/deno/deno.json | 8 ++ .../test-applications/deno/package.json | 23 +++++ .../deno/playwright.config.mjs | 8 ++ .../test-applications/deno/src/app.ts | 90 +++++++++++++++++++ .../deno/start-event-proxy.mjs | 6 ++ .../deno/tests/errors.test.ts | 15 ++++ .../deno/tests/transactions.test.ts | 90 +++++++++++++++++++ packages/deno/src/opentelemetry/tracer.ts | 3 + packages/deno/test/opentelemetry.test.ts | 64 +++++++------ 11 files changed, 280 insertions(+), 34 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/deno/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/deno/deno.json create mode 100644 dev-packages/e2e-tests/test-applications/deno/package.json create mode 100644 dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3228c64e6059..69523f544f2f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -969,6 +969,11 @@ jobs: with: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Deno + if: matrix.test-application == 'deno' + uses: denoland/setup-deno@v2.0.3 + with: + deno-version: v2.1.5 - name: Restore caches uses: ./.github/actions/restore-cache with: diff --git a/dev-packages/e2e-tests/test-applications/deno/.npmrc b/dev-packages/e2e-tests/test-applications/deno/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/.npmrc @@ -0,0 +1,2 @@ +@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/deno/deno.json b/dev-packages/e2e-tests/test-applications/deno/deno.json new file mode 100644 index 000000000000..c78a9bccb60a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/deno.json @@ -0,0 +1,8 @@ +{ + "imports": { + "@sentry/deno": "npm:@sentry/deno", + "@sentry/core": "npm:@sentry/core", + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0" + }, + "nodeModulesDir": "manual" +} diff --git a/dev-packages/e2e-tests/test-applications/deno/package.json b/dev-packages/e2e-tests/test-applications/deno/package.json new file mode 100644 index 000000000000..8ec92fbd3985 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/package.json @@ -0,0 +1,23 @@ +{ + "name": "deno-app", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "deno run --allow-net --allow-env --allow-read src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/deno": "latest || *", + "@opentelemetry/api": "^1.9.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs new file mode 100644 index 000000000000..3d3ab7d8df02 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/deno/src/app.ts b/dev-packages/e2e-tests/test-applications/deno/src/app.ts new file mode 100644 index 000000000000..fb34053e29d7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/src/app.ts @@ -0,0 +1,90 @@ +import { trace } from '@opentelemetry/api'; + +// Simulate a pre-existing OTel provider (like Supabase Edge Runtime registers +// before user code runs). Without trace.disable() in Sentry's setup, this would +// cause setGlobalTracerProvider to be a no-op, silently dropping all OTel spans. +const fakeProvider = { + getTracer: () => ({ + startSpan: () => ({ end: () => {}, setAttributes: () => {} }), + startActiveSpan: (_name: string, fn: Function) => fn({ end: () => {}, setAttributes: () => {} }), + }), +}; +trace.setGlobalTracerProvider(fakeProvider as any); + +// Sentry.init() must call trace.disable() to clear the fake provider above +import * as Sentry from '@sentry/deno'; + +Sentry.init({ + environment: 'qa', + dsn: Deno.env.get('E2E_TEST_DSN'), + debug: !!Deno.env.get('DEBUG'), + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1, +}); + +const port = 3030; + +Deno.serve({ port }, (req: Request) => { + const url = new URL(req.url); + + if (url.pathname === '/test-success') { + return new Response(JSON.stringify({ version: 'v1' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url.pathname === '/test-error') { + const exceptionId = Sentry.captureException(new Error('This is an error')); + return new Response(JSON.stringify({ exceptionId }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test Sentry.startSpan — uses Sentry's internal pipeline + if (url.pathname === '/test-sentry-span') { + Sentry.startSpan({ name: 'test-sentry-span' }, () => { + // noop + }); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test OTel tracer.startSpan — goes through the global TracerProvider + if (url.pathname === '/test-otel-span') { + const tracer = trace.getTracer('test-tracer'); + const span = tracer.startSpan('test-otel-span'); + span.end(); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test OTel tracer.startActiveSpan — what AI SDK and most instrumentations use + if (url.pathname === '/test-otel-active-span') { + const tracer = trace.getTracer('test-tracer'); + tracer.startActiveSpan('test-otel-active-span', span => { + span.setAttributes({ 'test.active': true }); + span.end(); + }); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test interop: OTel span inside a Sentry span + if (url.pathname === '/test-interop') { + Sentry.startSpan({ name: 'sentry-parent' }, () => { + const tracer = trace.getTracer('test-tracer'); + const span = tracer.startSpan('otel-child'); + span.end(); + }); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response('Not found', { status: 404 }); +}); + +console.log(`Deno test app listening on port ${port}`); diff --git a/dev-packages/e2e-tests/test-applications/deno/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno/start-event-proxy.mjs new file mode 100644 index 000000000000..a97ce6aa005c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'deno', +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/errors.test.ts new file mode 100644 index 000000000000..5b4018291a18 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/errors.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('deno', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an error'; + }); + + await fetch(`${baseURL}/test-error`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts new file mode 100644 index 000000000000..3cd0892cebdc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends transaction with Sentry.startSpan', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno', event => { + return event?.spans?.some(span => span.description === 'test-sentry-span') ?? false; + }); + + await fetch(`${baseURL}/test-sentry-span`); + + const transaction = await transactionPromise; + + expect(transaction.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'test-sentry-span', + origin: 'manual', + }), + ]), + ); +}); + +test('Sends transaction with OTel tracer.startSpan despite pre-existing provider', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno', event => { + return event?.spans?.some(span => span.description === 'test-otel-span') ?? false; + }); + + await fetch(`${baseURL}/test-otel-span`); + + const transaction = await transactionPromise; + + expect(transaction.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'test-otel-span', + op: 'otel.span', + origin: 'manual', + }), + ]), + ); +}); + +test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno', event => { + return event?.spans?.some(span => span.description === 'test-otel-active-span') ?? false; + }); + + await fetch(`${baseURL}/test-otel-active-span`); + + const transaction = await transactionPromise; + + expect(transaction.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'test-otel-active-span', + op: 'otel.span', + origin: 'manual', + }), + ]), + ); +}); + +test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno', event => { + return event?.spans?.some(span => span.description === 'sentry-parent') ?? false; + }); + + await fetch(`${baseURL}/test-interop`); + + const transaction = await transactionPromise; + + expect(transaction.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'sentry-parent', + origin: 'manual', + }), + expect.objectContaining({ + description: 'otel-child', + op: 'otel.span', + origin: 'manual', + }), + ]), + ); + + // Verify the OTel span is a child of the Sentry span + const sentrySpan = transaction.spans!.find((s: any) => s.description === 'sentry-parent'); + const otelSpan = transaction.spans!.find((s: any) => s.description === 'otel-child'); + expect(otelSpan!.parent_span_id).toBe(sentrySpan!.span_id); +}); diff --git a/packages/deno/src/opentelemetry/tracer.ts b/packages/deno/src/opentelemetry/tracer.ts index 3176616bc04c..7bc704446d37 100644 --- a/packages/deno/src/opentelemetry/tracer.ts +++ b/packages/deno/src/opentelemetry/tracer.ts @@ -12,6 +12,9 @@ import { * This is not perfect but handles easy/common use cases. */ export function setupOpenTelemetryTracer(): void { + // Clear any pre-existing OTel global registration (e.g. from Supabase Edge Runtime + // or Deno's built-in OTel) so Sentry's TracerProvider gets registered successfully. + trace.disable(); trace.setGlobalTracerProvider(new SentryDenoTraceProvider()); } diff --git a/packages/deno/test/opentelemetry.test.ts b/packages/deno/test/opentelemetry.test.ts index 30723e033dd4..492dead3339c 100644 --- a/packages/deno/test/opentelemetry.test.ts +++ b/packages/deno/test/opentelemetry.test.ts @@ -144,38 +144,39 @@ Deno.test('opentelemetry spans should interop with Sentry spans', async () => { assertEquals(otelSpan?.data?.['sentry.origin'], 'manual'); }); -Deno.test('should be compatible with native Deno OpenTelemetry', async () => { +Deno.test('should override pre-existing OTel provider with Sentry provider', async () => { resetSdk(); - const providerBefore = trace.getTracerProvider(); + // Simulate a pre-existing OTel registration (e.g. from Supabase Edge Runtime) + const fakeProvider = { getTracer: () => ({}) }; + trace.setGlobalTracerProvider(fakeProvider as any); + + const transactionEvents: any[] = []; const client = init({ dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: () => null, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, }) as DenoClient; + // Sentry should have overridden the pre-existing provider via trace.disable() const providerAfter = trace.getTracerProvider(); - assertEquals(providerBefore, providerAfter); + assertNotEquals(providerAfter, fakeProvider); + // Verify Sentry's tracer actually captures spans const tracer = trace.getTracer('compat-test'); const span = tracer.startSpan('test-span'); span.setAttributes({ 'test.compatibility': true }); span.end(); - tracer.startActiveSpan('active-span', activeSpan => { - activeSpan.end(); - }); - - const otelSpan = tracer.startSpan('post-init-span'); - otelSpan.end(); - - startSpan({ name: 'sentry-span' }, () => { - const nestedOtelSpan = tracer.startSpan('nested-otel-span'); - nestedOtelSpan.end(); - }); - await client.flush(); + + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.transaction, 'test-span'); + assertEquals(transactionEvents[0]?.contexts?.trace?.data?.['sentry.deno_tracer'], true); }); // Test that name parameter takes precedence over options.name for both startSpan and startActiveSpan @@ -238,7 +239,7 @@ Deno.test('name parameter should take precedence over options.name in startActiv assertEquals(transactionEvent?.transaction, 'prisma:client:operation'); }); -Deno.test('should verify native Deno OpenTelemetry works when enabled', async () => { +Deno.test('should override native Deno OpenTelemetry when enabled', async () => { resetSdk(); // Set environment variable to enable native OTel @@ -246,34 +247,29 @@ Deno.test('should verify native Deno OpenTelemetry works when enabled', async () Deno.env.set('OTEL_DENO', 'true'); try { + const transactionEvents: any[] = []; + const client = init({ dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: () => null, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, }) as DenoClient; - const provider = trace.getTracerProvider(); + // Sentry's trace.disable() + setGlobalTracerProvider should have overridden + // any native Deno OTel provider, so spans go through Sentry's tracer. const tracer = trace.getTracer('native-verification'); const span = tracer.startSpan('verification-span'); - - if (provider.constructor.name === 'Function') { - // Native OTel is active - assertNotEquals(span.constructor.name, 'NonRecordingSpan'); - - let contextWorks = false; - tracer.startActiveSpan('parent-span', parentSpan => { - if (trace.getActiveSpan() === parentSpan) { - contextWorks = true; - } - parentSpan.end(); - }); - assertEquals(contextWorks, true); - } - span.setAttributes({ 'test.native_otel': true }); span.end(); await client.flush(); + + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.transaction, 'verification-span'); + assertEquals(transactionEvents[0]?.contexts?.trace?.data?.['sentry.deno_tracer'], true); } finally { // Restore original environment if (originalValue === undefined) { From d1ea777524d9c7d6f225182cdfb0969d1524e6c3 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 18 Mar 2026 09:43:12 +0100 Subject: [PATCH 07/20] fix(deps): bump flatted 3.3.1 to 3.4.2 to fix CVE-2026-32141 (#19842) Fixes Dependabot alert #1146. Co-authored-by: Claude Sonnet 4.6 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index d697e7b80f0c..54001d52f3b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17561,9 +17561,9 @@ flat@^5.0.2: integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== flatted@^3.1.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" - integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== + version "3.4.2" + resolved "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== fn.name@1.x.x: version "1.1.0" From f95e4defa65f6cc0c9f622db38557fff22bff665 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 18 Mar 2026 09:59:20 +0100 Subject: [PATCH 08/20] fix(deps): bump unhead 2.1.4 to 2.1.12 to fix CVE-2026-31860 and CVE-2026-31873 (#19848) Fixes Dependabot alerts #1143 and #1144. Co-authored-by: Claude Sonnet 4.6 --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index 54001d52f3b4..9144b3091c56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10146,12 +10146,12 @@ integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== "@unhead/vue@^2.0.12", "@unhead/vue@^2.1.3": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@unhead/vue/-/vue-2.1.4.tgz#360360b683708a59802753a59e32133eae8af911" - integrity sha512-MFvywgkHMt/AqbhmKOqRuzvuHBTcmmmnUa7Wm/Sg11leXAeRShv2PcmY7IiYdeeJqBMCm1jwhcs6201jj6ggZg== + version "2.1.12" + resolved "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.12.tgz" + integrity sha512-zEWqg0nZM8acpuTZE40wkeUl8AhIe0tU0OkilVi1D4fmVjACrwoh5HP6aNqJ8kUnKsoy6D+R3Vi/O+fmdNGO7g== dependencies: hookable "^6.0.1" - unhead "2.1.4" + unhead "2.1.12" "@vercel/nft@^1.2.0", "@vercel/nft@^1.3.0": version "1.3.0" @@ -29452,10 +29452,10 @@ unenv@^1.10.0: node-fetch-native "^1.6.4" pathe "^1.1.2" -unhead@2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/unhead/-/unhead-2.1.4.tgz#be0d25e2bdc801a0c91eb7568a9be0c698356a89" - integrity sha512-+5091sJqtNNmgfQ07zJOgUnMIMKzVKAWjeMlSrTdSGPB6JSozhpjUKuMfWEoLxlMAfhIvgOU8Me0XJvmMA/0fA== +unhead@2.1.12: + version "2.1.12" + resolved "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz" + integrity sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA== dependencies: hookable "^6.0.1" From 6a6fa993ce8e4306443ea594c15d539efbc2c0ee Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 18 Mar 2026 10:02:36 +0100 Subject: [PATCH 09/20] fix(deps): bump file-type to 21.3.2 and @nestjs/common to 11.1.17 (#19847) Fixes Dependabot alerts #1141 (CVE-2026-31808) and #1155 (CVE-2026-32630). Co-authored-by: Claude Sonnet 4.6 --- yarn.lock | 51 ++++++++++++++++----------------------------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9144b3091c56..f2f8dfca7e54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5385,14 +5385,14 @@ tslib "2.8.1" "@nestjs/common@^11": - version "11.1.6" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.6.tgz#704ae26f09ccd135bf3e6f44b6ef4e3407ea3c54" - integrity sha512-krKwLLcFmeuKDqngG2N/RuZHCs2ycsKcxWIDgcm7i1lf3sQ0iG03ci+DsP/r3FcT/eJDFsIHnKtNta2LIi7PzQ== + version "11.1.17" + resolved "https://registry.npmjs.org/@nestjs/common/-/common-11.1.17.tgz" + integrity sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg== dependencies: uid "2.0.2" - file-type "21.0.0" + file-type "21.3.2" iterare "1.2.1" - load-esm "1.0.2" + load-esm "1.0.3" tslib "2.8.1" "@nestjs/core@^10.0.0": @@ -8956,15 +8956,6 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd" integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ== -"@tokenizer/inflate@^0.2.7": - version "0.2.7" - resolved "https://registry.yarnpkg.com/@tokenizer/inflate/-/inflate-0.2.7.tgz#32dd9dfc9abe457c89b3d9b760fc0690c85a103b" - integrity sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg== - dependencies: - debug "^4.4.0" - fflate "^0.8.2" - token-types "^6.0.0" - "@tokenizer/inflate@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@tokenizer/inflate/-/inflate-0.4.1.tgz#fa6cdb8366151b3cc8426bf9755c1ea03a2fba08" @@ -17255,7 +17246,7 @@ fecha@^4.2.0: resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== -fflate@0.8.2, fflate@^0.8.2: +fflate@0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea" integrity sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A== @@ -17286,20 +17277,10 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-type@21.0.0: - version "21.0.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-21.0.0.tgz#b6c5990064bc4b704f8e5c9b6010c59064d268bc" - integrity sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg== - dependencies: - "@tokenizer/inflate" "^0.2.7" - strtok3 "^10.2.2" - token-types "^6.0.0" - uint8array-extras "^1.4.0" - -file-type@^21.3.1: - version "21.3.1" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-21.3.1.tgz#a49e103e3491e0e52d13f5b2d99d4d7204a34a5e" - integrity sha512-SrzXX46I/zsRDjTb82eucsGg0ODq2NpGDp4HcsFKApPy8P8vACjpJRDoGGMfEzhFC0ry61ajd7f72J3603anBA== +file-type@21.3.2, file-type@^21.3.1: + version "21.3.2" + resolved "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz" + integrity sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w== dependencies: "@tokenizer/inflate" "^0.4.1" strtok3 "^10.3.4" @@ -20689,10 +20670,10 @@ livereload-js@^3.3.1: resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-3.3.2.tgz#c88b009c6e466b15b91faa26fd7c99d620e12651" integrity sha512-w677WnINxFkuixAoUEXOStewzLYGI76XVag+0JWMMEyjJQKs0ibWZMxkTlB96Lm3EjZ7IeOxVziBEbtxVQqQZA== -load-esm@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/load-esm/-/load-esm-1.0.2.tgz#35dbac8a1a3abdb802cf236008048fcc8a9289a6" - integrity sha512-nVAvWk/jeyrWyXEAs84mpQCYccxRqgKY4OznLuJhJCa0XsPSfdOIr2zvBZEj3IHEHbX97jjscKRRV539bW0Gpw== +load-esm@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/load-esm/-/load-esm-1.0.3.tgz#2073afe3da63902c323e80d9f135c301173ac92c" + integrity sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA== load-yaml-file@^0.2.0: version "0.2.0" @@ -28233,7 +28214,7 @@ strnum@^2.1.2: resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.1.2.tgz#a5e00ba66ab25f9cafa3726b567ce7a49170937a" integrity sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ== -strtok3@^10.2.2, strtok3@^10.3.4: +strtok3@^10.3.4: version "10.3.4" resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-10.3.4.tgz#793ebd0d59df276a085586134b73a406e60be9c1" integrity sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg== @@ -28913,7 +28894,7 @@ toidentifier@~1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -token-types@^6.0.0, token-types@^6.1.1: +token-types@^6.1.1: version "6.1.2" resolved "https://registry.yarnpkg.com/token-types/-/token-types-6.1.2.tgz#18d0fd59b996d421f9f83914d6101c201bd08129" integrity sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww== From 76e038b6cf14c4900539dc355338bbe84fce8c04 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 18 Mar 2026 10:11:41 +0100 Subject: [PATCH 10/20] fix(deps): bump devalue 5.6.3 to 5.6.4 to fix CVE-2026-30226 (#19849) Fixes Dependabot alerts #1142 and #1145. Co-authored-by: Claude Sonnet 4.6 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index f2f8dfca7e54..1237ed9f3caa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14747,9 +14747,9 @@ devalue@^4.3.2: integrity sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg== devalue@^5.1.1, devalue@^5.6.2, devalue@^5.6.3: - version "5.6.3" - resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.6.3.tgz#fee7b50bf072f4a4cdf18d1f27de3ce92131f699" - integrity sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg== + version "5.6.4" + resolved "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz" + integrity sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA== devlop@^1.0.0: version "1.1.0" From 2b6235770c03ff70ea217ef055578c9ca58c364a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Mar 2026 10:55:28 +0000 Subject: [PATCH 11/20] ci(release): Switch from action-prepare-release to Craft (#18763) ## Summary This PR migrates from the deprecated `action-prepare-release` to the new Craft GitHub Actions. ## Changes - Migrated `.github/workflows/auto-release.yml` to Craft reusable workflow ## Documentation See https://getsentry.github.io/craft/github-actions/ for more information. Closes #18765 (added automatically) --------- Co-authored-by: Charly Gomez --- .craft.yml | 3 ++- .github/workflows/auto-release.yml | 8 ++++++-- .github/workflows/changelog-preview.yml | 19 +++++++++++++++++++ .github/workflows/release.yml | 10 +++++++--- 4 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/changelog-preview.yml diff --git a/.craft.yml b/.craft.yml index f64414ea19a4..dd6f1a7f3453 100644 --- a/.craft.yml +++ b/.craft.yml @@ -1,5 +1,6 @@ minVersion: '0.23.1' -changelogPolicy: simple +changelog: + policy: simple preReleaseCommand: bash scripts/craft-pre-release.sh targets: # NPM Targets diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 02a1f47b611a..241900f4b6ff 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -6,7 +6,11 @@ on: branches: - master -# This workflow tirggers a release when merging a branch with the pattern `prepare-release/VERSION` into master. +# This workflow triggers a release when merging a branch with the pattern `prepare-release/VERSION` into master. +permissions: + contents: write + pull-requests: write + jobs: release: runs-on: ubuntu-24.04 @@ -47,7 +51,7 @@ jobs: node-version-file: 'package.json' - name: Prepare release - uses: getsentry/action-prepare-release@v1 + uses: getsentry/craft@013a7b2113c2cac0ff32d5180cfeaefc7c9ce5b6 # v2.24.1 if: github.event.pull_request.merged == true && steps.version-regex.outputs.match != '' && steps.get_version.outputs.version != '' diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml new file mode 100644 index 000000000000..b1a0e4f25b05 --- /dev/null +++ b/.github/workflows/changelog-preview.yml @@ -0,0 +1,19 @@ +name: Changelog Preview +on: + pull_request_target: + types: + - opened + - synchronize + - reopened + - edited + - labeled + - unlabeled +permissions: + contents: write + pull-requests: write + statuses: write + +jobs: + changelog-preview: + uses: getsentry/craft/.github/workflows/changelog-preview.yml@2.24.1 + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcb44598c722..d966e35e9671 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,8 @@ on: workflow_dispatch: inputs: version: - description: Version to release - required: true + description: Version to release (or "auto") + required: false force: description: Force a release even when there are release-blockers (optional) required: false @@ -12,6 +12,10 @@ on: description: Target branch to merge into. Uses the default branch as a fallback (optional) required: false default: master +permissions: + contents: write + pull-requests: write + jobs: release: runs-on: ubuntu-24.04 @@ -32,7 +36,7 @@ jobs: with: node-version-file: 'package.json' - name: Prepare release - uses: getsentry/action-prepare-release@v1 + uses: getsentry/craft@013a7b2113c2cac0ff32d5180cfeaefc7c9ce5b6 # v2.24.1 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From de7f71e27e348abafd0a5d812b4ec4d52af724ec Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 18 Mar 2026 15:49:00 +0100 Subject: [PATCH 12/20] fix(node-core): Recycle propagationContext for each request (#19835) This PR fixes a bug in our node-core `httpServerIntegration` (user-facing it's `httpIntegration`), which caused traceIds (or rather our propagationContext) to stay the same across requests. This would surface in SDK setups where tracing is not explicitly enabled (e.g. missing `tracesSampleRate`), causing caught errors across request to be associated with the same trace. This PR now recycles the propagationContext on the current as well as isolation scope to ensure traces are isolated on a request level. Added node(-core) integration tests to demonstrate that traceIds are now scoped to requests, when tracing is enabled or disabled. Prior to this PR, the test for tracing being disabled failed. Note: This should only have an effect on SDKs configured for tracing without spans (i.e. (and confusingly) no `tracesSampleRate` set), as for tracing with spans, we take the trace data from the active span directly. I added a test demonstrating this, just to be sure. closes https://github.com/getsentry/sentry-javascript/issues/19815 ref https://github.com/getsentry/sentry-javascript/issues/17101 --------- Co-authored-by: Charly Gomez --- .../traceid-recycling-with-spans/server.js | 23 ++++++++ .../traceid-recycling-with-spans/test.ts | 39 +++++++++++++ .../tracing/traceid-recycling/server.js | 23 ++++++++ .../suites/tracing/traceid-recycling/test.ts | 43 ++++++++++++++ .../traceid-recycling-with-spans/server.ts | 23 ++++++++ .../traceid-recycling-with-spans/test.ts | 57 +++++++++++++++++++ .../tracing/traceid-recycling/server.ts | 20 +++++++ .../suites/tracing/traceid-recycling/test.ts | 43 ++++++++++++++ .../http/httpServerIntegration.ts | 19 +++++-- 9 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js create mode 100644 dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts create mode 100644 dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling/server.js create mode 100644 dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/traceid-recycling/server.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/traceid-recycling/test.ts diff --git a/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js b/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js new file mode 100644 index 000000000000..bdf75643111b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js @@ -0,0 +1,23 @@ +const { loggingTransport } = require('@sentry-internal/node-core-integration-tests'); +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + tracesSampleRate: 1.0, +}); + +setupOtel(client); + +const express = require('express'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-core-integration-tests'); + +const app = express(); + +app.get('/test', (_req, res) => { + Sentry.captureException(new Error('test error')); + res.json({ success: true }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts new file mode 100644 index 000000000000..4917fa4191c6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts @@ -0,0 +1,39 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('errors from in different requests each get a unique traceId when tracing is enabled', async () => { + const eventTraceIds: string[] = []; + + const runner = createRunner(__dirname, 'server.js') + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .start(); + + await runner.makeRequest('get', '/test'); + await runner.makeRequest('get', '/test'); + await runner.makeRequest('get', '/test'); + + await runner.completed(); + + expect(new Set(eventTraceIds).size).toBe(3); + for (const traceId of eventTraceIds) { + expect(traceId).toMatch(/^[a-f\d]{32}$/); + } +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling/server.js b/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling/server.js new file mode 100644 index 000000000000..f6ff7b354b19 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling/server.js @@ -0,0 +1,23 @@ +const { loggingTransport } = require('@sentry-internal/node-core-integration-tests'); +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, +}); + +setupOtel(client); + +const express = require('express'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-core-integration-tests'); + +const app = express(); + +app.get('/test', (_req, res) => { + Sentry.captureException(new Error('test error')); + const traceId = Sentry.getCurrentScope().getPropagationContext().traceId; + res.json({ traceId }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling/test.ts new file mode 100644 index 000000000000..c83d9de4cac4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/traceid-recycling/test.ts @@ -0,0 +1,43 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('each request gets a unique traceId when tracing is disabled', async () => { + const eventTraceIds: string[] = []; + + const runner = createRunner(__dirname, 'server.js') + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .start(); + + const propagationContextTraceIds = [ + ((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId, + ((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId, + ((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId, + ]; + + await runner.completed(); + + expect(new Set(propagationContextTraceIds).size).toBe(3); + for (const traceId of propagationContextTraceIds) { + expect(traceId).toMatch(/^[a-f\d]{32}$/); + } + + expect(eventTraceIds).toEqual(propagationContextTraceIds); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.ts b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.ts new file mode 100644 index 000000000000..77e83d839e3b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + tracesSampleRate: 1.0, +}); + +import express from 'express'; + +const app = express(); + +app.get('/test', async (_req, res) => { + Sentry.captureException(new Error('test error')); + // calling Sentry.flush() here to ensure that the order in which we send transaction and errors + // is guaranteed to be 1. error, 2. transaction (repeated 3x in test) + await Sentry.flush(); + res.json({ success: true }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts new file mode 100644 index 000000000000..93d7e9d41ce6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts @@ -0,0 +1,57 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('errors and transactions get a unique traceId per request, when tracing is enabled', async () => { + const eventTraceIds: string[] = []; + const transactionTraceIds: string[] = []; + + const runner = createRunner(__dirname, 'server.ts') + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .expect({ + transaction: transaction => { + transactionTraceIds.push(transaction.spans?.[0]?.trace_id || ''); + }, + }) + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .expect({ + transaction: transaction => { + transactionTraceIds.push(transaction.spans?.[0]?.trace_id || ''); + }, + }) + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .expect({ + transaction: transaction => { + transactionTraceIds.push(transaction.spans?.[0]?.trace_id || ''); + }, + }) + .start(); + + await runner.makeRequest('get', '/test'); + await runner.makeRequest('get', '/test'); + await runner.makeRequest('get', '/test'); + + await runner.completed(); + + expect(new Set(transactionTraceIds).size).toBe(3); + for (const traceId of transactionTraceIds) { + expect(traceId).toMatch(/^[a-f\d]{32}$/); + } + + expect(eventTraceIds).toEqual(transactionTraceIds); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling/server.ts b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling/server.ts new file mode 100644 index 000000000000..11f436a1885f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling/server.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +import express from 'express'; + +const app = express(); + +app.get('/test', (_req, res) => { + Sentry.captureException(new Error('test error')); + const traceId = Sentry.getCurrentScope().getPropagationContext().traceId; + res.json({ traceId }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling/test.ts b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling/test.ts new file mode 100644 index 000000000000..d2be070cb091 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling/test.ts @@ -0,0 +1,43 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('each request gets a unique traceId when tracing is disabled', async () => { + const eventTraceIds: string[] = []; + + const runner = createRunner(__dirname, 'server.ts') + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .expect({ + event: event => { + eventTraceIds.push(event.contexts?.trace?.trace_id || ''); + }, + }) + .start(); + + const propagationContextTraceIds = [ + ((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId, + ((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId, + ((await runner.makeRequest('get', '/test')) as { traceId: string }).traceId, + ]; + + await runner.completed(); + + expect(new Set(propagationContextTraceIds).size).toBe(3); + for (const traceId of propagationContextTraceIds) { + expect(traceId).toMatch(/^[a-f\d]{32}$/); + } + + expect(eventTraceIds).toEqual(propagationContextTraceIds); +}); diff --git a/packages/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts index f5833f1b007b..986be8d4c8ff 100644 --- a/packages/node-core/src/integrations/http/httpServerIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -6,9 +6,11 @@ import type { Socket } from 'node:net'; import { context, createContextKey, propagation } from '@opentelemetry/api'; import type { AggregationCounts, Client, Integration, IntegrationFn, Scope } from '@sentry/core'; import { + _INTERNAL_safeMathRandom, addNonEnumerableProperty, debug, generateSpanId, + generateTraceId, getClient, getCurrentScope, getIsolationScope, @@ -218,10 +220,19 @@ function instrumentServer( } return withIsolationScope(isolationScope, () => { - // Set a new propagationSpanId for this request - // We rely on the fact that `withIsolationScope()` will implicitly also fork the current scope - // This way we can save an "unnecessary" `withScope()` invocation - getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId(); + const newPropagationContext = { + traceId: generateTraceId(), + sampleRand: _INTERNAL_safeMathRandom(), + propagationSpanId: generateSpanId(), + }; + // - Set a fresh propagation context so each request gets a unique traceId. + // When there are incoming trace headers, propagation.extract() below sets a remote + // span on the OTel context which takes precedence in getTraceContextForScope(). + // - We can write directly to the current scope here because it is forked implicitly via + // `context.with` in `withIsolationScope` (See `SentryContextManager`). + // - explicitly making a deep copy to avoid mutation of original PC on the other scope + getCurrentScope().setPropagationContext({ ...newPropagationContext }); + isolationScope.setPropagationContext({ ...newPropagationContext }); const ctx = propagation .extract(context.active(), normalizedRequest.headers) From ae7206f0ef316ae06ed7d4b4380bec5a018ed010 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 18 Mar 2026 16:31:09 +0000 Subject: [PATCH 13/20] feat(remix): Server Timing Headers Trace Propagation (#18653) Adds automatic trace propagation from server to client via the Server-Timing HTTP header for Remix applications. The client-side reading of Server-Timing headers via the Performance API was added in #18673. Adds: - `generateSentryServerTimingHeader(span)` public utility that generates a Server-Timing header value containing Sentry trace context - Automatic injection in the document request handler for normal page responses - Automatic injection on redirect responses from loaders and actions, which bypass the document request handler entirely. This is an advantage over meta tag injection, which cannot work on redirect responses since they have no HTML body - For Cloudflare/Hydrogen apps: call `generateSentryServerTimingHeader()` manually and append the value to the response's `Server-Timing` header in entry.server.tsx (see remix-hydrogen e2e test for example) Works on both Node.js and Cloudflare Workers environments. Closes #18696 --------- Co-authored-by: Lukas Stracke --- .../remix-hydrogen/app/entry.server.tsx | 10 +- .../tests/server-timing-header.test.ts | 17 +++ .../remix-server-timing/.eslintrc.js | 4 + .../remix-server-timing/.gitignore | 3 + .../remix-server-timing/.npmrc | 2 + .../remix-server-timing/app/entry.client.tsx | 44 ++++++ .../remix-server-timing/app/entry.server.tsx | 115 ++++++++++++++ .../remix-server-timing/app/root.tsx | 63 ++++++++ .../remix-server-timing/app/routes/_index.tsx | 28 ++++ .../app/routes/redirect-test.tsx | 5 + .../app/routes/user.$id.tsx | 19 +++ .../remix-server-timing/globals.d.ts | 2 + .../remix-server-timing/instrument.server.cjs | 8 + .../remix-server-timing/package.json | 42 +++++ .../remix-server-timing/playwright.config.mjs | 8 + .../remix-server-timing/remix.env.d.ts | 2 + .../remix-server-timing/start-event-proxy.mjs | 6 + .../server-timing-trace-propagation.test.ts | 103 +++++++++++++ .../remix-server-timing/tsconfig.json | 23 +++ .../remix-server-timing/vite.config.ts | 15 ++ packages/remix/src/cloudflare/index.ts | 1 + packages/remix/src/server/index.ts | 1 + packages/remix/src/server/instrumentServer.ts | 39 +++-- .../server/serverTimingTracePropagation.ts | 58 +++++++ packages/remix/src/utils/utils.ts | 6 + .../serverTimingTracePropagation.test.ts | 143 ++++++++++++++++++ 26 files changed, 755 insertions(+), 12 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-timing-header.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/.eslintrc.js create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.server.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/app/root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/_index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/redirect-test.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/user.$id.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/instrument.server.cjs create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/package.json create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/remix.env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-trace-propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts create mode 100644 packages/remix/src/server/serverTimingTracePropagation.ts create mode 100644 packages/remix/test/server/serverTimingTracePropagation.test.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx index afae990db239..1db0c9e23207 100644 --- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx @@ -1,4 +1,5 @@ import { RemixServer } from '@remix-run/react'; +import { generateSentryServerTimingHeader } from '@sentry/remix/cloudflare'; import { createContentSecurityPolicy } from '@shopify/hydrogen'; import type { EntryContext } from '@shopify/remix-oxygen'; import isbot from 'isbot'; @@ -43,8 +44,15 @@ export default async function handleRequest( // This is required for Sentry's profiling integration responseHeaders.set('Document-Policy', 'js-profiling'); - return new Response(body, { + const response = new Response(body, { headers: responseHeaders, status: responseStatusCode, }); + + const serverTimingValue = generateSentryServerTimingHeader(); + if (serverTimingValue) { + response.headers.append('Server-Timing', serverTimingValue); + } + + return response; } diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-timing-header.test.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-timing-header.test.ts new file mode 100644 index 000000000000..194afa2fa0a4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-timing-header.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +test('Server-Timing header contains sentry-trace on page load', async ({ page }) => { + const responsePromise = page.waitForResponse( + response => + response.url().endsWith('/') && response.status() === 200 && response.request().resourceType() === 'document', + ); + + await page.goto('/'); + + const response = await responsePromise; + const serverTimingHeader = response.headers()['server-timing']; + + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + expect(serverTimingHeader).toContain('baggage'); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/.eslintrc.js b/dev-packages/e2e-tests/test-applications/remix-server-timing/.eslintrc.js new file mode 100644 index 000000000000..f2faf1470fd8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/.eslintrc.js @@ -0,0 +1,4 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'], +}; diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/.gitignore b/dev-packages/e2e-tests/test-applications/remix-server-timing/.gitignore new file mode 100644 index 000000000000..a735ebed5b56 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/.gitignore @@ -0,0 +1,3 @@ +node_modules +build +.env diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/.npmrc b/dev-packages/e2e-tests/test-applications/remix-server-timing/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/.npmrc @@ -0,0 +1,2 @@ +@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/remix-server-timing/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.client.tsx new file mode 100644 index 000000000000..85c29d310c1a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.client.tsx @@ -0,0 +1,44 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` + * For more information, see https://remix.run/file-conventions/entry.client + */ + +// Extend the Window interface to include ENV +declare global { + interface Window { + ENV: { + SENTRY_DSN: string; + [key: string]: unknown; + }; + } +} + +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; +import { StrictMode, startTransition, useEffect } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: window.ENV.SENTRY_DSN, + integrations: [ + Sentry.browserTracingIntegration({ + useEffect, + useLocation, + useMatches, + }), + ], + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions + tunnel: 'http://localhost:3031/', // proxy server +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.server.tsx new file mode 100644 index 000000000000..3eb9423dff22 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.server.tsx @@ -0,0 +1,115 @@ +import * as Sentry from '@sentry/remix'; + +import { PassThrough } from 'node:stream'; + +import type { AppLoadContext, EntryContext } from '@remix-run/node'; +import { createReadableStreamFromReadable } from '@remix-run/node'; +import { installGlobals } from '@remix-run/node'; +import { RemixServer } from '@remix-run/react'; +import isbot from 'isbot'; +import { renderToPipeableStream } from 'react-dom/server'; + +installGlobals(); + +const ABORT_DELAY = 5_000; + +export const handleError = Sentry.sentryHandleError; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loadContext: AppLoadContext, +) { + return isbot(request.headers.get('user-agent')) + ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) + : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/root.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/root.tsx new file mode 100644 index 000000000000..beb9fdb70357 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/root.tsx @@ -0,0 +1,63 @@ +import { cssBundleHref } from '@remix-run/css-bundle'; +import { LinksFunction, json } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, + useRouteError, +} from '@remix-run/react'; +import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix'; + +export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : [])]; + +export const loader = () => { + return json({ + ENV: { + SENTRY_DSN: process.env.E2E_TEST_DSN, + }, + }); +}; + +export function ErrorBoundary() { + const error = useRouteError(); + const eventId = captureRemixErrorBoundaryError(error); + + return ( +
+ ErrorBoundary Error + {eventId} +
+ ); +} + +function App() { + const { ENV } = useLoaderData() as { ENV: { SENTRY_DSN: string } }; + + return ( + + + + +