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 ( + + + + +