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/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/.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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f85d90681d4..eb998b2deffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,43 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.45.0 + +### Important Changes + +- **feat(remix): Server Timing Headers Trace Propagation ([#18653](https://github.com/getsentry/sentry-javascript/pull/18653))** + + The Remix SDK now supports automatic trace propagation via `Server-Timing` response headers to continue pageload traces on the client side. + This means, you no longer have to define a custom `meta` function to add Sentry `` tags to your page as previously. + We'll update out Remix tracing docs after this release. + +### Other Changes + +- fix(cloudflare): Use correct env types for `withSentry` ([#19836](https://github.com/getsentry/sentry-javascript/pull/19836)) +- fix(core): Align error span status message with core `SpanStatusType` for langchain/google-genai ([#19863](https://github.com/getsentry/sentry-javascript/pull/19863)) +- fix(deno): Clear pre-existing OTel global before registering TracerProvider ([#19723](https://github.com/getsentry/sentry-javascript/pull/19723)) +- fix(nextjs): Skip tracing for tunnel requests ([#19861](https://github.com/getsentry/sentry-javascript/pull/19861)) +- fix(node-core): Recycle propagationContext for each request ([#19835](https://github.com/getsentry/sentry-javascript/pull/19835)) +- ref(core): Simplify core utility functions for smaller bundle ([#19854](https://github.com/getsentry/sentry-javascript/pull/19854)) + + + Internal Changes + +- chore(deps): bump next from 16.1.5 to 16.1.7 in /dev-packages/e2e-tests/test-applications/nextjs-16 ([#19851](https://github.com/getsentry/sentry-javascript/pull/19851)) +- ci(release): Switch from action-prepare-release to Craft ([#18763](https://github.com/getsentry/sentry-javascript/pull/18763)) +- fix(deps): bump devalue 5.6.3 to 5.6.4 to fix CVE-2026-30226 ([#19849](https://github.com/getsentry/sentry-javascript/pull/19849)) +- fix(deps): bump file-type to 21.3.2 and @nestjs/common to 11.1.17 ([#19847](https://github.com/getsentry/sentry-javascript/pull/19847)) +- fix(deps): bump flatted 3.3.1 to 3.4.2 to fix CVE-2026-32141 ([#19842](https://github.com/getsentry/sentry-javascript/pull/19842)) +- fix(deps): bump hono 4.12.5 to 4.12.7 in cloudflare-hono E2E test app ([#19850](https://github.com/getsentry/sentry-javascript/pull/19850)) +- fix(deps): bump next to 15.5.13/16.1.7 to fix CVE-2026-1525, CVE-202-33036 and related ([#19870](https://github.com/getsentry/sentry-javascript/pull/19870)) +- fix(deps): bump tar 7.5.10 to 7.5.11 to fix CVE-2026-31802 ([#19846](https://github.com/getsentry/sentry-javascript/pull/19846)) +- fix(deps): bump undici 6.23.0 to 6.24.1 to fix multiple CVEs ([#19841](https://github.com/getsentry/sentry-javascript/pull/19841)) +- fix(deps): bump unhead 2.1.4 to 2.1.12 to fix CVE-2026-31860 and CVE-2026-31873 ([#19848](https://github.com/getsentry/sentry-javascript/pull/19848)) +- test(nextjs): Skip broken ISR tests ([#19871](https://github.com/getsentry/sentry-javascript/pull/19871)) +- test(react): Add gql tests for react router ([#19844](https://github.com/getsentry/sentry-javascript/pull/19844)) + + + ## 10.44.0 ### Important Changes 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", 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/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json index 724312c14873..9e18defda67b 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.10", + "next": "15.5.13", "next-intl": "^4.3.12", "react": "latest", "react-dom": "latest", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json index b3cf747a0866..380e2ce0f66f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json @@ -20,7 +20,7 @@ "@trpc/client": "~11.8.0", "@trpc/react-query": "~11.8.0", "@trpc/server": "~11.8.0", - "next": "^15.5.9", + "next": "^15.5.13", "react": "^19.1.0", "react-dom": "^19.1.0", "server-only": "^0.0.1", 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 897e5a2ea243..fc876dc41ba7 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.10", + "next": "15.5.13", "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 2540ca74678c..75e51867a38c 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": "latest || *", "@sentry/core": "latest || *", "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" 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 9f9e2481073f..bc306ef7dab7 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": "latest || *", "@sentry/core": "latest || *", "import-in-the-middle": "^1", - "next": "16.1.5", + "next": "16.1.7", "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 7cfd9ee18c99..9695657cbd3f 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 @@ -20,7 +20,7 @@ "@opennextjs/cloudflare": "^1.14.9", "@sentry/nextjs": "latest || *", "@sentry/core": "latest || *", - "next": "16.1.5", + "next": "16.1.7", "react": "19.1.0", "react-dom": "19.1.0" }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts index cba53fa1970d..8b6349a97e5f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts @@ -1,7 +1,8 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; -test.describe('Cloudflare Runtime', () => { +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.describe.skip('Cloudflare Runtime', () => { test('Should report cloudflare as the runtime in API route error events', async ({ request }) => { const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => { return !!errorEvent?.exception?.values?.some(value => diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isr-routes.test.ts index b42d2cd61b93..1ff2d2b1cabb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isr-routes.test.ts @@ -1,7 +1,8 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.skip('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { // Navigate to ISR page await page.goto('/isr-test/laptop'); @@ -13,7 +14,8 @@ test('should remove sentry-trace and baggage meta tags on ISR dynamic route page await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); }); -test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => { +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.skip('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => { // Navigate to ISR static page await page.goto('/isr-test/static'); @@ -25,7 +27,8 @@ test('should remove sentry-trace and baggage meta tags on ISR static route', asy await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); }); -test('should remove meta tags for different ISR dynamic route values', async ({ page }) => { +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.skip('should remove meta tags for different ISR dynamic route values', async ({ page }) => { // Test with 'phone' (one of the pre-generated static params) await page.goto('/isr-test/phone'); await expect(page.locator('#isr-product-id')).toHaveText('phone'); @@ -41,7 +44,8 @@ test('should remove meta tags for different ISR dynamic route values', async ({ await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); }); -test('should create unique transactions for ISR pages on each visit', async ({ page }) => { +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.skip('should create unique transactions for ISR pages on each visit', async ({ page }) => { const traceIds: string[] = []; // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed @@ -71,7 +75,8 @@ test('should create unique transactions for ISR pages on each visit', async ({ p expect(uniqueTraceIds.size).toBe(5); }); -test('ISR route should be identified correctly in the route manifest', async ({ page }) => { +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.skip('ISR route should be identified correctly in the route manifest', async ({ page }) => { const transactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts index 5d2925375688..3c9ab427b3de 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts @@ -1,7 +1,8 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.skip('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { const transactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { return ( transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' @@ -47,7 +48,8 @@ test('should create a parameterized transaction when the `app` directory is used }); }); -test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.skip('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ page, }) => { const transactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { @@ -95,7 +97,8 @@ test('should create a static transaction when the `app` directory is used and th }); }); -test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.skip('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { const transactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { return ( transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' @@ -141,7 +144,8 @@ test('should create a partially parameterized transaction when the `app` directo }); }); -test('should create a nested parameterized transaction when the `app` directory is used.', async ({ page }) => { +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.skip('should create a nested parameterized transaction when the `app` directory is used.', async ({ page }) => { const transactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { return ( transactionEvent.transaction === '/parameterized/:one/beep/:two' && diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/prefetch-spans.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/prefetch-spans.test.ts index f48158a54697..59ec6d504382 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/prefetch-spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/prefetch-spans.test.ts @@ -2,7 +2,8 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; -test('Prefetch client spans should have a http.request.prefetch attribute', async ({ page }) => { +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.skip('Prefetch client spans should have a http.request.prefetch attribute', async ({ page }) => { test.skip(isDevMode, "Prefetch requests don't have the prefetch header in dev mode"); const pageloadTransactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts index ba42d9fadbb9..38cb628cb9ce 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts @@ -1,7 +1,8 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -test('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ +// TODO(https://github.com/opennextjs/opennextjs-cloudflare/issues/1141): Unskip once opennext supports prefetch-hints.json +test.skip('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ page, }) => { const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => { 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 c097fd9e2b98..ea0475e5ed61 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": "latest || *", "@sentry/core": "latest || *", "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" 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 483f1019f93e..5a1fed010500 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": "latest || *", "ai": "^3.0.0", "import-in-the-middle": "^1", - "next": "16.1.5", + "next": "16.1.7", "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/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", 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 84bb06365b76..16d2ef6d6050 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": "latest || *", - "next": "16.1.6", + "next": "16.1.7", "react": "19.1.0", "react-dom": "19.1.0", "typescript": "~5.0.0" 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); +}); 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 ( + + + + + + + + + + + + + + + + ); +} + +export default withSentry(App); diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/_index.tsx new file mode 100644 index 000000000000..89449786a33e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/_index.tsx @@ -0,0 +1,28 @@ +import { json, LoaderFunctionArgs } from '@remix-run/node'; +import { Link, useSearchParams } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + return json({}); +}; + +export default function Index() { + const [searchParams] = useSearchParams(); + + if (searchParams.get('tag')) { + Sentry.setTag('sentry_test', searchParams.get('tag')); + } + + return ( + + Server-Timing Trace Propagation Test + + + + Navigate to User 123 + + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/redirect-test.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/redirect-test.tsx new file mode 100644 index 000000000000..f41020cf3bca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/redirect-test.tsx @@ -0,0 +1,5 @@ +import { redirect } from '@remix-run/node'; + +export const loader = async () => { + return redirect('/user/redirected'); +}; diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/user.$id.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/user.$id.tsx new file mode 100644 index 000000000000..a4ce451adf36 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/user.$id.tsx @@ -0,0 +1,19 @@ +import { json, LoaderFunctionArgs } from '@remix-run/node'; +import { Link, useLoaderData } from '@remix-run/react'; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + await new Promise(resolve => setTimeout(resolve, 10)); + return json({ userId: params.id }); +}; + +export default function User() { + const { userId } = useLoaderData(); + + return ( + + User {userId} + This is a parameterized route for user {userId}. + Back to Home + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/globals.d.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/globals.d.ts new file mode 100644 index 000000000000..78ed2345c6e4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/globals.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/instrument.server.cjs b/dev-packages/e2e-tests/test-applications/remix-server-timing/instrument.server.cjs new file mode 100644 index 000000000000..6d211cac4592 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/instrument.server.cjs @@ -0,0 +1,8 @@ +const Sentry = require('@sentry/remix'); + +Sentry.init({ + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json b/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json new file mode 100644 index 000000000000..d31e86ff0cdc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json @@ -0,0 +1,42 @@ +{ + "private": true, + "sideEffects": false, + "scripts": { + "build": "remix vite:build && pnpm typecheck", + "dev": "remix vite:dev", + "start": "NODE_OPTIONS='--require=./instrument.server.cjs' remix-serve build/server/index.js", + "typecheck": "tsc", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm playwright test" + }, + "dependencies": { + "@sentry/remix": "latest || *", + "@remix-run/css-bundle": "2.17.4", + "@remix-run/node": "2.17.4", + "@remix-run/react": "2.17.4", + "@remix-run/serve": "2.17.4", + "isbot": "^3.6.8", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@remix-run/dev": "2.17.4", + "@remix-run/eslint-config": "2.17.4", + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.34", + "@types/prop-types": "15.7.7", + "eslint": "^8.38.0", + "typescript": "^5.1.6", + "vite": "^5.4.11", + "vite-tsconfig-paths": "^4.2.1" + }, + "resolutions": { + "@types/react": "18.2.22" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/remix-server-timing/playwright.config.mjs new file mode 100644 index 000000000000..b52ff06a5105 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/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/remix-server-timing/remix.env.d.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/remix.env.d.ts new file mode 100644 index 000000000000..dcf8c45e1d4c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/remix-server-timing/start-event-proxy.mjs new file mode 100644 index 000000000000..2fbbf5087be1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'remix-server-timing', +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-trace-propagation.test.ts new file mode 100644 index 000000000000..38b53d2bcc90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-trace-propagation.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('propagates trace context from server-timing header to client pageload', async ({ page }) => { + const testTag = crypto.randomUUID(); + + const responsePromise = page.waitForResponse( + response => response.url().includes(`tag=${testTag}`) && response.status() === 200, + ); + + const pageLoadTransactionPromise = waitForTransaction('remix-server-timing', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag; + }); + + const httpServerTransactionPromise = waitForTransaction('remix-server-timing', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto(`/?tag=${testTag}`); + + const response = await responsePromise; + const serverTimingHeader = response.headers()['server-timing']; + + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + expect(serverTimingHeader).toContain('baggage'); + + const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/); + expect(sentryTraceMatch).toBeTruthy(); + const [headerTraceId, headerSpanId, headerSampled] = sentryTraceMatch?.[1]?.split('-') || []; + + expect(headerTraceId).toHaveLength(32); + expect(headerSpanId).toHaveLength(16); + expect(headerSampled).toBe('1'); + + const pageloadTransaction = await pageLoadTransactionPromise; + const httpServerTransaction = await httpServerTransactionPromise; + + expect(pageloadTransaction).toBeDefined(); + expect(pageloadTransaction.transaction).toBe('/'); + + expect(httpServerTransaction.transaction).toMatch(/^GET http:\/\/localhost:\d+\/$/); + + expect(pageloadTransaction.contexts?.trace?.trace_id).toEqual(headerTraceId); + expect(pageloadTransaction.contexts?.trace?.parent_span_id).toEqual(headerSpanId); + + expect(httpServerTransaction.contexts?.trace?.trace_id).toEqual(headerTraceId); + expect(httpServerTransaction.contexts?.trace?.span_id).toEqual(headerSpanId); +}); + +test('includes server-timing header on redirect responses', async ({ page }) => { + const redirectResponsePromise = page.waitForResponse(response => response.url().includes('/redirect-test')); + const redirectedPageloadResponsePromise = page.waitForResponse(response => + response.url().includes('/user/redirected'), + ); + + const pageLoadTransactionPromise = waitForTransaction('remix-server-timing', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/redirect-test'); + + const redirectResponse = await redirectResponsePromise; + const redirectServerTimingHeader = redirectResponse.headers()['server-timing']; + + expect(redirectServerTimingHeader).toBeDefined(); + expect(redirectServerTimingHeader).toContain('sentry-trace'); + expect(redirectServerTimingHeader).toContain('baggage'); + + const redirectSentryTraceMatch = redirectServerTimingHeader?.match(/sentry-trace;desc="([^"]+)"/); + expect(redirectSentryTraceMatch).toBeTruthy(); + expect(redirectSentryTraceMatch![1]).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-1/); + + const redirectedPageloadResponse = await redirectedPageloadResponsePromise; + + const serverTimingHeader = redirectedPageloadResponse.headers()['server-timing']; + const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/); + expect(sentryTraceMatch).toBeTruthy(); + const [traceId, spanId] = sentryTraceMatch![1].split('-'); + expect(traceId).toHaveLength(32); + expect(spanId).toHaveLength(16); + + await page.waitForURL(/\/user\/redirected/); + await expect(page.locator('h1')).toContainText('User redirected'); + + const pageLoadTransaction = await pageLoadTransactionPromise; + expect(pageLoadTransaction.transaction).toBe('/user/:id'); + expect(pageLoadTransaction.contexts?.trace?.trace_id).toEqual(traceId); + expect(pageLoadTransaction.contexts?.trace?.parent_span_id).toEqual(spanId); +}); + +test('excludes server-timing header from client-side navigation data fetches', async ({ page }) => { + await page.goto('/'); + await page.locator('#navigation').waitFor({ state: 'visible' }); + + const navDataFetchPromise = page.waitForResponse( + response => + response.url().includes('/user/123') && (response.url().includes('_data=') || response.url().endsWith('.data')), + ); + await page.click('#navigation'); + const navDataFetch = await navDataFetchPromise; + expect(navDataFetch.headers()['server-timing']).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/tsconfig.json b/dev-packages/e2e-tests/test-applications/remix-server-timing/tsconfig.json new file mode 100644 index 000000000000..91f6b263f2c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "globals.d.ts"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["@remix-run/node", "vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "noEmit": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts new file mode 100644 index 000000000000..d4d7f23895c1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts @@ -0,0 +1,15 @@ +import { vitePlugin as remix } from '@remix-run/dev'; +import { sentryRemixVitePlugin } from '@sentry/remix'; +import { defineConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [ + remix({ + ignoredRouteFiles: ['**/.*'], + serverModuleFormat: 'cjs', + }), + sentryRemixVitePlugin(), + tsconfigPaths(), + ], +}); 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/cloudflare/src/withSentry.ts b/packages/cloudflare/src/withSentry.ts index addc82429b85..4655ab1a154d 100644 --- a/packages/cloudflare/src/withSentry.ts +++ b/packages/cloudflare/src/withSentry.ts @@ -1,12 +1,13 @@ +import type { env } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { isInstrumented, markAsInstrumented } from './instrument'; -import { getHonoIntegration } from './integrations/hono'; import { instrumentExportedHandlerEmail } from './instrumentations/worker/instrumentEmail'; import { instrumentExportedHandlerFetch } from './instrumentations/worker/instrumentFetch'; import { instrumentExportedHandlerQueue } from './instrumentations/worker/instrumentQueue'; import { instrumentExportedHandlerScheduled } from './instrumentations/worker/instrumentScheduled'; import { instrumentExportedHandlerTail } from './instrumentations/worker/instrumentTail'; +import { getHonoIntegration } from './integrations/hono'; /** * Wrapper for Cloudflare handlers. @@ -20,7 +21,7 @@ import { instrumentExportedHandlerTail } from './instrumentations/worker/instrum * @returns The wrapped handler. */ export function withSentry< - Env = unknown, + Env = typeof env, QueueHandlerMessage = unknown, CfHostMetadata = unknown, T extends ExportedHandler = ExportedHandler< diff --git a/packages/cloudflare/test/withSentry.test.ts b/packages/cloudflare/test/withSentry.test.ts index 5b1f3ca9b17d..95f312c9ec54 100644 --- a/packages/cloudflare/test/withSentry.test.ts +++ b/packages/cloudflare/test/withSentry.test.ts @@ -7,7 +7,15 @@ import { withSentry } from '../src/withSentry'; import { markAsInstrumented } from '../src/instrument'; import * as HonoIntegration from '../src/integrations/hono'; -type HonoLikeApp = ExportedHandler< +declare global { + namespace Cloudflare { + interface Env { + SENTRY_DSN: string; + } + } +} + +type HonoLikeApp = ExportedHandler< Env, QueueHandlerMessage, CfHostMetadata @@ -16,11 +24,6 @@ type HonoLikeApp Response; }; -const MOCK_ENV = { - SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', - SENTRY_RELEASE: '1.1.1', -}; - describe('withSentry', () => { beforeEach(() => { vi.clearAllMocks(); @@ -33,7 +36,7 @@ describe('withSentry', () => { const handleHonoException = vi.fn(); vi.spyOn(HonoIntegration, 'getHonoIntegration').mockReturnValue({ handleHonoException } as any); - const honoApp = { + const honoApp: HonoLikeApp = { fetch(_request, _env, _context) { return new Response('test'); }, @@ -41,7 +44,7 @@ describe('withSentry', () => { errorHandler(err: Error) { return new Response(`Error: ${err.message}`, { status: 500 }); }, - } satisfies HonoLikeApp; + }; withSentry(env => ({ dsn: env.SENTRY_DSN }), honoApp); @@ -59,13 +62,13 @@ describe('withSentry', () => { const error = new Error('test hono error'); - const honoApp = { + const honoApp: HonoLikeApp = { fetch(_request, _env, _context) { return new Response('test'); }, onError() {}, errorHandler: originalErrorHandlerSpy, - } satisfies HonoLikeApp; + }; withSentry(env => ({ dsn: env.SENTRY_DSN }), honoApp); @@ -86,13 +89,13 @@ describe('withSentry', () => { markAsInstrumented(originalErrorHandler); - const honoApp = { + const honoApp: HonoLikeApp = { fetch(_request, _env, _context) { return new Response('test'); }, onError() {}, errorHandler: originalErrorHandler, - } satisfies HonoLikeApp; + }; withSentry(env => ({ dsn: env.SENTRY_DSN }), honoApp); diff --git a/packages/core/src/integrations/eventFilters.ts b/packages/core/src/integrations/eventFilters.ts index 84ae5d4c4139..b801cab91fae 100644 --- a/packages/core/src/integrations/eventFilters.ts +++ b/packages/core/src/integrations/eventFilters.ts @@ -17,10 +17,10 @@ const DEFAULT_IGNORE_ERRORS = [ /^Cannot redefine property: googletag$/, // This is thrown when google tag manager is used in combination with an ad blocker /^Can't find variable: gmo$/, // Error from Google Search App https://issuetracker.google.com/issues/396043331 /^undefined is not an object \(evaluating 'a\.[A-Z]'\)$/, // Random error that happens but not actionable or noticeable to end-users. - 'can\'t redefine non-configurable property "solana"', // Probably a browser extension or custom browser (Brave) throwing this error - "vv().getRestrictions is not a function. (In 'vv().getRestrictions(1,a)', 'vv().getRestrictions' is undefined)", // Error thrown by GTM, seemingly not affecting end-users - "Can't find variable: _AutofillCallbackHandler", // Unactionable error in instagram webview https://developers.facebook.com/community/threads/320013549791141/ - /^Non-Error promise rejection captured with value: Object Not Found Matching Id:\d+, MethodName:simulateEvent, ParamCount:\d+$/, // unactionable error from CEFSharp, a .NET library that embeds chromium in .NET apps + /can't redefine non-configurable property "solana"/, // Probably a browser extension or custom browser (Brave) throwing this error + /vv\(\)\.getRestrictions is not a function/, // Error thrown by GTM, seemingly not affecting end-users + /Can't find variable: _AutofillCallbackHandler/, // Unactionable error in instagram webview https://developers.facebook.com/community/threads/320013549791141/ + /Object Not Found Matching Id:\d+, MethodName:simulateEvent/, // unactionable error from CEFSharp, a .NET library that embeds chromium in .NET apps /^Java exception was raised during method invocation$/, // error from Facebook Mobile browser (https://github.com/getsentry/sentry-javascript/issues/15065) ]; diff --git a/packages/core/src/tracing/anthropic-ai/streaming.ts b/packages/core/src/tracing/anthropic-ai/streaming.ts index 86f5c25baa8a..940ec53e8030 100644 --- a/packages/core/src/tracing/anthropic-ai/streaming.ts +++ b/packages/core/src/tracing/anthropic-ai/streaming.ts @@ -11,6 +11,7 @@ import { } from '../ai/gen-ai-attributes'; import { setTokenUsageAttributes } from '../ai/utils'; import type { AnthropicAiStreamingEvent } from './types'; +import { mapAnthropicErrorToStatusMessage } from './utils'; /** * State object used to accumulate information from a stream of Anthropic AI events. @@ -59,7 +60,7 @@ function isErrorEvent(event: AnthropicAiStreamingEvent, span: Span): boolean { // If the event is an error, set the span status and capture the error // These error events are not rejected by the API by default, but are sent as metadata of the response if (event.type === 'error') { - span.setStatus({ code: SPAN_STATUS_ERROR, message: event.error?.type ?? 'internal_error' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: mapAnthropicErrorToStatusMessage(event.error?.type) }); captureException(event.error, { mechanism: { handled: false, @@ -377,7 +378,7 @@ export function instrumentMessageStream }); if (span.isRecording()) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'stream_error' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); span.end(); } }); diff --git a/packages/core/src/tracing/anthropic-ai/utils.ts b/packages/core/src/tracing/anthropic-ai/utils.ts index b9cf31b4aeea..e2cadcec331b 100644 --- a/packages/core/src/tracing/anthropic-ai/utils.ts +++ b/packages/core/src/tracing/anthropic-ai/utils.ts @@ -1,6 +1,7 @@ import { captureException } from '../../exports'; import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types-hoist/span'; +import type { SpanStatusType } from '../../types-hoist/spanStatus'; import { GEN_AI_INPUT_MESSAGES_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, @@ -41,13 +42,35 @@ export function setMessagesAttribute(span: Span, messages: unknown): void { }); } +const ANTHROPIC_ERROR_TYPE_TO_SPAN_STATUS: Record = { + invalid_request_error: 'invalid_argument', + authentication_error: 'unauthenticated', + permission_error: 'permission_denied', + not_found_error: 'not_found', + request_too_large: 'failed_precondition', + rate_limit_error: 'resource_exhausted', + api_error: 'internal_error', + overloaded_error: 'unavailable', +}; + +/** + * Map an Anthropic API error type to a SpanStatusType value. + * @see https://docs.anthropic.com/en/api/errors#error-shapes + */ +export function mapAnthropicErrorToStatusMessage(errorType: string | undefined): SpanStatusType { + if (!errorType) { + return 'internal_error'; + } + return ANTHROPIC_ERROR_TYPE_TO_SPAN_STATUS[errorType] || 'internal_error'; +} + /** * Capture error information from the response * @see https://docs.anthropic.com/en/api/errors#error-shapes */ export function handleResponseError(span: Span, response: AnthropicAiResponse): void { if (response.error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: response.error.type || 'internal_error' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: mapAnthropicErrorToStatusMessage(response.error.type) }); captureException(response.error, { mechanism: { diff --git a/packages/core/src/tracing/google-genai/streaming.ts b/packages/core/src/tracing/google-genai/streaming.ts index b9462e8c90dd..d3f6598b8fd7 100644 --- a/packages/core/src/tracing/google-genai/streaming.ts +++ b/packages/core/src/tracing/google-genai/streaming.ts @@ -46,7 +46,7 @@ function isErrorChunk(chunk: GoogleGenAIResponse, span: Span): boolean { const feedback = chunk?.promptFeedback; if (feedback?.blockReason) { const message = feedback.blockReasonMessage ?? feedback.blockReason; - span.setStatus({ code: SPAN_STATUS_ERROR, message: `Content blocked: ${message}` }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); captureException(`Content blocked: ${message}`, { mechanism: { handled: false, type: 'auto.ai.google_genai' }, }); diff --git a/packages/core/src/tracing/langchain/index.ts b/packages/core/src/tracing/langchain/index.ts index 54b581af9f2d..0984131e4296 100644 --- a/packages/core/src/tracing/langchain/index.ts +++ b/packages/core/src/tracing/langchain/index.ts @@ -171,7 +171,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): handleLLMError(error: Error, runId: string) { const span = spanMap.get(runId); if (span?.isRecording()) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'llm_error' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); exitSpan(runId); } @@ -239,7 +239,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): handleChainError(error: Error, runId: string) { const span = spanMap.get(runId); if (span?.isRecording()) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'chain_error' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); exitSpan(runId); } @@ -298,7 +298,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): handleToolError(error: Error, runId: string) { const span = spanMap.get(runId); if (span?.isRecording()) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'tool_error' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); exitSpan(runId); } diff --git a/packages/core/src/types-hoist/spanStatus.ts b/packages/core/src/types-hoist/spanStatus.ts index 8347d37adb1a..151530b5cb69 100644 --- a/packages/core/src/types-hoist/spanStatus.ts +++ b/packages/core/src/types-hoist/spanStatus.ts @@ -1,4 +1,4 @@ -type SpanStatusType = +export type SpanStatusType = /** The operation completed successfully. */ | 'ok' /** Deadline expired before operation could complete. */ diff --git a/packages/core/src/utils/baggage.ts b/packages/core/src/utils/baggage.ts index e94bb3d896e6..9f4f85313951 100644 --- a/packages/core/src/utils/baggage.ts +++ b/packages/core/src/utils/baggage.ts @@ -33,7 +33,7 @@ export function baggageHeaderToDynamicSamplingContext( // Read all "sentry-" prefixed values out of the baggage object and put it onto a dynamic sampling context object. const dynamicSamplingContext = Object.entries(baggageObject).reduce>((acc, [key, value]) => { - if (key.match(SENTRY_BAGGAGE_KEY_PREFIX_REGEX)) { + if (key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { const nonPrefixedKey = key.slice(SENTRY_BAGGAGE_KEY_PREFIX.length); acc[nonPrefixedKey] = value; } diff --git a/packages/core/src/utils/browser.ts b/packages/core/src/utils/browser.ts index c051cd70f234..6c062f8f6f60 100644 --- a/packages/core/src/utils/browser.ts +++ b/packages/core/src/utils/browser.ts @@ -117,8 +117,7 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { } } } - const allowedAttrs = ['aria-label', 'type', 'name', 'title', 'alt']; - for (const k of allowedAttrs) { + for (const k of ['aria-label', 'type', 'name', 'title', 'alt']) { const attr = elem.getAttribute(k); if (attr) { out.push(`[${k}="${attr}"]`); diff --git a/packages/core/src/utils/envelope.ts b/packages/core/src/utils/envelope.ts index 8f21a00dc590..276e46460a9f 100644 --- a/packages/core/src/utils/envelope.ts +++ b/packages/core/src/utils/envelope.ts @@ -204,32 +204,33 @@ export function createAttachmentEnvelopeItem(attachment: Attachment): Attachment ]; } -const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = { - session: 'session', +type OverriddenItemType = Exclude; + +// Map of envelope item types to data categories where the category differs from the type. +// Types that map to themselves (session, attachment, transaction, profile, feedback, span, metric) fall through. +const DATA_CATEGORY_OVERRIDES: Record = { sessions: 'session', - attachment: 'attachment', - transaction: 'transaction', event: 'error', client_report: 'internal', user_report: 'default', - profile: 'profile', profile_chunk: 'profile', replay_event: 'replay', replay_recording: 'replay', check_in: 'monitor', - feedback: 'feedback', - span: 'span', raw_security: 'security', log: 'log_item', - metric: 'metric', trace_metric: 'metric', }; +function _isOverriddenType(type: EnvelopeItemType): type is OverriddenItemType { + return type in DATA_CATEGORY_OVERRIDES; +} + /** * Maps the type of an envelope item to a data category. */ export function envelopeItemTypeToDataCategory(type: EnvelopeItemType): DataCategory { - return ITEM_TYPE_TO_DATA_CATEGORY_MAP[type]; + return _isOverriddenType(type) ? DATA_CATEGORY_OVERRIDES[type] : type; } /** Extracts the minimal SDK info from the metadata or an events */ diff --git a/packages/core/src/utils/object.ts b/packages/core/src/utils/object.ts index 06f80e12d7f7..1ffabca10bb2 100644 --- a/packages/core/src/utils/object.ts +++ b/packages/core/src/utils/object.ts @@ -56,7 +56,7 @@ export function addNonEnumerableProperty(obj: object, name: string, value: unkno try { Object.defineProperty(obj, name, { // enumerable: false, // the default, so we can save on bundle size by not explicitly setting it - value: value, + value, writable: true, configurable: true, }); @@ -158,16 +158,9 @@ function serializeEventTarget(target: unknown): string { /** Filters out all but an object's own properties */ function getOwnProperties(obj: unknown): { [key: string]: unknown } { if (typeof obj === 'object' && obj !== null) { - const extractedProps: { [key: string]: unknown } = {}; - for (const property in obj) { - if (Object.prototype.hasOwnProperty.call(obj, property)) { - extractedProps[property] = (obj as Record)[property]; - } - } - return extractedProps; - } else { - return {}; + return Object.fromEntries(Object.entries(obj)); } + return {}; } /** diff --git a/packages/core/test/lib/utils/anthropic-utils.test.ts b/packages/core/test/lib/utils/anthropic-utils.test.ts index 797bb9bc8186..b912af40e35e 100644 --- a/packages/core/test/lib/utils/anthropic-utils.test.ts +++ b/packages/core/test/lib/utils/anthropic-utils.test.ts @@ -1,8 +1,34 @@ import { describe, expect, it } from 'vitest'; -import { messagesFromParams, setMessagesAttribute, shouldInstrument } from '../../../src/tracing/anthropic-ai/utils'; +import { + mapAnthropicErrorToStatusMessage, + messagesFromParams, + setMessagesAttribute, + shouldInstrument, +} from '../../../src/tracing/anthropic-ai/utils'; import type { Span } from '../../../src/types-hoist/span'; describe('anthropic-ai-utils', () => { + describe('mapAnthropicErrorToStatusMessage', () => { + it('maps known Anthropic error types to SpanStatusType values', () => { + expect(mapAnthropicErrorToStatusMessage('invalid_request_error')).toBe('invalid_argument'); + expect(mapAnthropicErrorToStatusMessage('authentication_error')).toBe('unauthenticated'); + expect(mapAnthropicErrorToStatusMessage('permission_error')).toBe('permission_denied'); + expect(mapAnthropicErrorToStatusMessage('not_found_error')).toBe('not_found'); + expect(mapAnthropicErrorToStatusMessage('request_too_large')).toBe('failed_precondition'); + expect(mapAnthropicErrorToStatusMessage('rate_limit_error')).toBe('resource_exhausted'); + expect(mapAnthropicErrorToStatusMessage('api_error')).toBe('internal_error'); + expect(mapAnthropicErrorToStatusMessage('overloaded_error')).toBe('unavailable'); + }); + + it('falls back to internal_error for unknown error types', () => { + expect(mapAnthropicErrorToStatusMessage('some_new_error')).toBe('internal_error'); + }); + + it('falls back to internal_error for undefined', () => { + expect(mapAnthropicErrorToStatusMessage(undefined)).toBe('internal_error'); + }); + }); + describe('shouldInstrument', () => { it('should instrument known methods', () => { expect(shouldInstrument('models.get')).toBe(true); 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) { diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 9eb2e70c8a43..ce54e8e25f85 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -9,10 +9,10 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { }; /** - * Drops spans for tunnel requests from middleware or fetch instrumentation. - * This catches both: - * 1. Requests to the local tunnel route (before rewrite) - * 2. Requests to Sentry ingest (after rewrite) + * Drops spans for tunnel requests from middleware, fetch instrumentation, or BaseServer.handleRequest. + * This catches: + * 1. Requests to the local tunnel route (before rewrite) via middleware or BaseServer.handleRequest + * 2. Requests to Sentry ingest (after rewrite) via fetch spans */ export function dropMiddlewareTunnelRequests(span: Span, attrs: SpanAttributes | undefined): void { // When the user brings their own OTel setup (skipOpenTelemetrySetup: true), we should not @@ -21,14 +21,15 @@ export function dropMiddlewareTunnelRequests(span: Span, attrs: SpanAttributes | return; } - // Only filter middleware spans or HTTP fetch spans + // Only filter middleware spans, HTTP fetch spans, or BaseServer.handleRequest spans const isMiddleware = attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute'; // The fetch span could be originating from rewrites re-writing a tunnel request // So we want to filter it out const isFetchSpan = attrs?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.http.otel.node_fetch'; + const isBaseServerHandleRequest = attrs?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest'; - // If the span is not a middleware span or a fetch span, return - if (!isMiddleware && !isFetchSpan) { + // If the span is not a middleware span, fetch span, or BaseServer.handleRequest span, return + if (!isMiddleware && !isFetchSpan && !isBaseServerHandleRequest) { return; } @@ -58,7 +59,7 @@ function isTunnelRouteSpan(spanAttributes: Record): boolean { // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") const pathname = httpTarget.split('?')[0] || ''; - return pathname.startsWith(tunnelPath); + return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); } return false; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 343cfd8bb218..0483ab6448ff 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -48,7 +48,6 @@ export { startSpan, startSpanManual, startInactiveSpan } from '../common/utils/n const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; - _sentryRewritesTunnelPath?: string; _sentryRelease?: string; }; @@ -207,16 +206,6 @@ export function init(options: NodeOptions): NodeClient | undefined { return null; } - // Filter out transactions for requests to the tunnel route - if ( - (globalWithInjectedValues._sentryRewritesTunnelPath && - event.transaction === `POST ${globalWithInjectedValues._sentryRewritesTunnelPath}`) || - (process.env._sentryRewritesTunnelPath && - event.transaction === `POST ${process.env._sentryRewritesTunnelPath}`) - ) { - return null; - } - // Filter out requests to resolve source maps for stack frames in dev mode if (event.transaction?.match(/\/__nextjs_original-stack-frame/)) { return null; diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts new file mode 100644 index 000000000000..31624e3bffc6 --- /dev/null +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { dropMiddlewareTunnelRequests } from '../../src/common/utils/dropMiddlewareTunnelRequests'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached'; + +const globalWithInjectedValues = global as typeof global & { + _sentryRewritesTunnelPath?: string; +}; + +vi.mock('@sentry/core', async requireActual => { + return { + ...(await requireActual()), + getClient: () => ({ + getOptions: () => ({}), + }), + }; +}); + +vi.mock('@sentry/opentelemetry', () => ({ + isSentryRequestSpan: () => false, +})); + +function createMockSpan(): { setAttribute: ReturnType; attributes: Record } { + const attributes: Record = {}; + return { + attributes, + setAttribute: vi.fn((key: string, value: unknown) => { + attributes[key] = value; + }), + }; +} + +beforeEach(() => { + globalWithInjectedValues._sentryRewritesTunnelPath = undefined; +}); + +describe('dropMiddlewareTunnelRequests', () => { + describe('BaseServer.handleRequest spans', () => { + it('marks BaseServer.handleRequest span for dropping when http.target matches tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/monitoring?o=123&p=456', + }); + + expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + }); + + it('marks BaseServer.handleRequest span for dropping when http.target exactly matches tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/monitoring', + }); + + expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + }); + + it('does not mark BaseServer.handleRequest span for dropping when http.target does not match tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/api/users', + }); + + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + + it('does not mark BaseServer.handleRequest span for dropping when http.target shares tunnel path prefix', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/monitoring-dashboard', + }); + + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + + it('does not mark BaseServer.handleRequest span when no tunnel path is configured', () => { + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/monitoring', + }); + + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + + it('handles BaseServer.handleRequest span with basePath prefix in http.target', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/basepath/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/basepath/monitoring?o=123&p=456', + }); + + expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + }); + }); + + describe('Middleware.execute spans', () => { + it('marks middleware span for dropping when http.target matches tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'Middleware.execute', + 'http.target': '/monitoring?o=123&p=456', + }); + + expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + }); + }); + + describe('unrelated spans', () => { + it('does not process spans without matching span type or origin', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'SomeOtherSpanType', + 'http.target': '/monitoring', + }); + + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + }); + + describe('skipOpenTelemetrySetup', () => { + it('does not process spans when skipOpenTelemetrySetup is true', async () => { + const core = await import('@sentry/core'); + const originalGetClient = core.getClient; + vi.spyOn(core, 'getClient').mockReturnValueOnce({ + getOptions: () => ({ skipOpenTelemetrySetup: true }), + } as any); + + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/monitoring', + }); + + expect(span.setAttribute).not.toHaveBeenCalled(); + + vi.mocked(core.getClient).mockRestore(); + }); + }); +}); 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) diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index 9b78855ae2d3..ede127a694ce 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -13,6 +13,7 @@ export { captureRemixErrorBoundaryError } from '../client/errors'; export { withSentry } from '../client/performance'; export { ErrorBoundary, browserTracingIntegration } from '../client'; export { makeWrappedCreateRequestHandler, sentryHandleError }; +export { generateSentryServerTimingHeader } from '../server/serverTimingTracePropagation'; /** * Instruments a Remix build to capture errors and performance data. diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 1533a1ca7221..cafec61ac9af 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -139,3 +139,4 @@ export * from '@sentry/node'; export { init, getRemixDefaultIntegrations } from './sdk'; export { captureRemixServerException } from './errors'; export { sentryHandleError, wrapHandleErrorWithSentry, instrumentBuild } from './instrumentServer'; +export { generateSentryServerTimingHeader } from './serverTimingTracePropagation'; diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index d8864d254a99..f4b19926f802 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -36,9 +36,10 @@ import { withIsolationScope, } from '@sentry/core'; import { DEBUG_BUILD } from '../utils/debug-build'; -import { createRoutes, getTransactionName } from '../utils/utils'; +import { createRoutes, getTransactionName, isCloudflareEnv } from '../utils/utils'; import { extractData, isResponse, json } from '../utils/vendor/response'; import { captureRemixServerException, errorHandleDataFunction } from './errors'; +import { generateSentryServerTimingHeader, injectServerTimingHeaderValue } from './serverTimingTracePropagation'; type AppData = unknown; type RemixRequest = Parameters[0]; @@ -95,11 +96,6 @@ export function wrapHandleErrorWithSentry( }; } -function isCloudflareEnv(): boolean { - // eslint-disable-next-line no-restricted-globals - return navigator?.userAgent?.includes('Cloudflare'); -} - function getTraceAndBaggage(): { sentryTrace?: string; sentryBaggage?: string; @@ -119,13 +115,16 @@ function getTraceAndBaggage(): { function makeWrappedDocumentRequestFunction(instrumentTracing?: boolean) { return function (origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction { return async function (this: unknown, request: Request, ...args: unknown[]): Promise { + const serverTimingHeader = generateSentryServerTimingHeader(); + + let response: Response; + if (instrumentTracing) { const activeSpan = getActiveSpan(); const rootSpan = activeSpan && getRootSpan(activeSpan); - const name = rootSpan ? spanToJSON(rootSpan).description : undefined; - return startSpan( + response = await startSpan( { // If we don't have a root span, `onlyIfParent` will lead to the span not being created anyhow // So we don't need to care too much about the fallback name, it's just for typing purposes.... @@ -143,8 +142,14 @@ function makeWrappedDocumentRequestFunction(instrumentTracing?: boolean) { }, ); } else { - return origDocumentRequestFunction.call(this, request, ...args); + response = await origDocumentRequestFunction.call(this, request, ...args); + } + + if (serverTimingHeader && response instanceof Response) { + return injectServerTimingHeaderValue(response, serverTimingHeader); } + + return response; }; }; } @@ -186,13 +191,15 @@ function makeWrappedDataFunction( build?: ServerBuild, ): DataFunction { return async function (this: unknown, args: DataFunctionArgs): Promise { + let res: Response | AppData; + if (instrumentTracing) { // Update span name for Cloudflare Workers/Hydrogen environments if (build) { updateSpanWithRoute(args, build); } - return startSpan( + res = await startSpan( { op: `function.remix.${name}`, name: id, @@ -207,8 +214,18 @@ function makeWrappedDataFunction( }, ); } else { - return errorHandleDataFunction.call(this, origFn, name, args); + res = await errorHandleDataFunction.call(this, origFn, name, args); } + + // Redirects bypass makeWrappedDocumentRequestFunction, so we inject Server-Timing here. + if (isResponse(res) && isRedirectResponse(res)) { + const serverTimingHeader = generateSentryServerTimingHeader(); + if (serverTimingHeader) { + return injectServerTimingHeaderValue(res, serverTimingHeader); + } + } + + return res; }; } diff --git a/packages/remix/src/server/serverTimingTracePropagation.ts b/packages/remix/src/server/serverTimingTracePropagation.ts new file mode 100644 index 000000000000..fd8440f3578d --- /dev/null +++ b/packages/remix/src/server/serverTimingTracePropagation.ts @@ -0,0 +1,58 @@ +import type { Span } from '@sentry/core'; +import { debug, getTraceData, isNodeEnv } from '@sentry/core'; +import { DEBUG_BUILD } from '../utils/debug-build'; +import { isCloudflareEnv } from '../utils/utils'; + +/** Generate a Server-Timing header value containing Sentry trace context. */ +export function generateSentryServerTimingHeader(): string | null { + if (!isNodeEnv() && !isCloudflareEnv()) { + return null; + } + + try { + const traceData = getTraceData(); + const sentryTrace = traceData['sentry-trace']; + const baggage = traceData.baggage; + + if (!sentryTrace) { + return null; + } + + const parts: string[] = []; + + parts.push(`sentry-trace;desc="${sentryTrace}"`); + + if (baggage) { + parts.push(`baggage;desc="${baggage}"`); + } + + return parts.join(', '); + } catch (e) { + DEBUG_BUILD && debug.warn('Failed to generate Server-Timing header', e); + return null; + } +} + +/** @internal */ +export function injectServerTimingHeaderValue(response: Response, serverTimingValue: string): Response { + try { + const headers = new Headers(response.headers); + const existing = headers.get('Server-Timing'); + + // Avoid duplicate entries when manually injected in entry.server.tsx + if (existing?.includes('sentry-trace')) { + return response; + } + + headers.set('Server-Timing', existing ? `${existing}, ${serverTimingValue}` : serverTimingValue); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } catch (e) { + DEBUG_BUILD && debug.warn('Failed to add Server-Timing header to response', e); + return response; + } +} diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts index c179bc43f61f..2e9fa21687bc 100644 --- a/packages/remix/src/utils/utils.ts +++ b/packages/remix/src/utils/utils.ts @@ -109,6 +109,12 @@ export function convertRemixRouteIdToPath(routeId: string): string { return routePath; } +/** Check if running in Cloudflare Workers environment. */ +export function isCloudflareEnv(): boolean { + // eslint-disable-next-line no-restricted-globals + return typeof navigator !== 'undefined' && navigator?.userAgent?.includes('Cloudflare'); +} + /** * Get transaction name from routes and url */ diff --git a/packages/remix/test/server/serverTimingTracePropagation.test.ts b/packages/remix/test/server/serverTimingTracePropagation.test.ts new file mode 100644 index 000000000000..7e9852e97c6b --- /dev/null +++ b/packages/remix/test/server/serverTimingTracePropagation.test.ts @@ -0,0 +1,143 @@ +import { getActiveSpan, getTraceData, isNodeEnv, spanToBaggageHeader, spanToTraceHeader } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + generateSentryServerTimingHeader, + injectServerTimingHeaderValue, +} from '../../src/server/serverTimingTracePropagation'; + +const mockSpan = { + spanId: 'test-span-id', + spanContext: () => ({ traceId: '12345678901234567890123456789012' }), +}; +const mockRootSpan = { + spanId: 'root-span-id', + spanContext: () => ({ traceId: '12345678901234567890123456789012' }), +}; + +vi.mock('@sentry/core', () => ({ + debug: { + log: vi.fn(), + warn: vi.fn(), + }, + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(() => mockRootSpan), + getTraceData: vi.fn(() => ({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production,sentry-release=1.0.0', + })), + spanToTraceHeader: vi.fn(() => '12345678901234567890123456789012-1234567890123456-1'), + spanToBaggageHeader: vi.fn(() => 'sentry-environment=production,sentry-release=1.0.0'), + isNodeEnv: vi.fn(() => true), +})); + +describe('serverTimingTracePropagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isNodeEnv).mockReturnValue(true); + vi.mocked(getActiveSpan).mockReturnValue(mockSpan); + vi.mocked(getTraceData).mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production,sentry-release=1.0.0', + }); + }); + + describe('generateSentryServerTimingHeader', () => { + it('returns null in browser environments', () => { + vi.mocked(isNodeEnv).mockReturnValueOnce(false); + + expect(generateSentryServerTimingHeader()).toBeNull(); + }); + + it('returns null without trace data', () => { + vi.mocked(getActiveSpan).mockReturnValueOnce(undefined); + vi.mocked(getTraceData).mockReturnValueOnce({}); + + expect(generateSentryServerTimingHeader()).toBeNull(); + }); + + it('produces correct Server-Timing format', () => { + const result = generateSentryServerTimingHeader(); + + expect(result).toBe( + 'sentry-trace;desc="12345678901234567890123456789012-1234567890123456-1", baggage;desc="sentry-environment=production,sentry-release=1.0.0"', + ); + }); + + it('falls back to getTraceData without active span', () => { + vi.mocked(getActiveSpan).mockReturnValueOnce(undefined); + vi.mocked(getTraceData).mockReturnValueOnce({ + 'sentry-trace': 'fallback-trace-id-1234567890123456-0', + baggage: 'sentry-fallback=true', + }); + + const result = generateSentryServerTimingHeader(); + + expect(result).toContain('sentry-trace;desc="fallback-trace-id-1234567890123456-0"'); + expect(result).toContain('sentry-fallback=true'); + }); + + it('generates header in Cloudflare environment when isNodeEnv is false', () => { + vi.mocked(isNodeEnv).mockReturnValueOnce(false); + + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'Cloudflare' }, + configurable: true, + }); + + const result = generateSentryServerTimingHeader(); + expect(result).not.toBeNull(); + + // Restore + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true, + }); + }); + }); + + describe('injectServerTimingHeaderValue', () => { + it('adds Server-Timing header to response', () => { + const mockResponse = new Response('test body', { + status: 200, + statusText: 'OK', + headers: new Headers(), + }); + + const result = injectServerTimingHeaderValue(mockResponse, 'sentry-trace;desc="test"'); + + expect(result.headers.get('Server-Timing')).toBe('sentry-trace;desc="test"'); + expect(result.status).toBe(200); + expect(result.statusText).toBe('OK'); + }); + + it('merges with existing Server-Timing header', () => { + const mockResponse = new Response('test body', { + status: 200, + headers: new Headers({ 'Server-Timing': 'cache;dur=100' }), + }); + + const result = injectServerTimingHeaderValue(mockResponse, 'sentry-trace;desc="test"'); + + expect(result.headers.get('Server-Timing')).toBe('cache;dur=100, sentry-trace;desc="test"'); + }); + + it('skips injection when sentry-trace already exists in Server-Timing header', () => { + const mockResponse = new Response('test body', { + status: 200, + headers: new Headers({ + 'Server-Timing': 'sentry-trace;desc="existing-trace", baggage;desc="existing-baggage"', + }), + }); + + const result = injectServerTimingHeaderValue( + mockResponse, + 'sentry-trace;desc="new-trace", baggage;desc="new-baggage"', + ); + + expect(result.headers.get('Server-Timing')).toBe( + 'sentry-trace;desc="existing-trace", baggage;desc="existing-baggage"', + ); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8b037c404496..1237ed9f3caa 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" @@ -10146,12 +10137,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" @@ -14756,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" @@ -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" @@ -17561,9 +17542,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" @@ -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== @@ -28307,7 +28288,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" @@ -28543,9 +28523,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" @@ -28914,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== @@ -29431,9 +29411,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" @@ -29453,10 +29433,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"
{data ? JSON.stringify(data) : 'loading...'}
This is a parameterized route for user {userId}.