From 1d6eae1f6aa3495251b64e02b9c3b0d52dd0d99f Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 5 Oct 2026 11:02:46 +0200 Subject: [PATCH] test(e2e): Cover isolation, trace propagation and logs on all react-router-8-framework runtimes Adds tests for per-request isolation, incoming trace continuation, trace propagation to an outgoing `fetch`, and logs to `react-router-8-framework`. They pass on Node, Bun, Deno and Cloudflare without a runtime branch. With all runtimes green, the Bun, Deno and Cloudflare variants move from `optionalVariants` to the required `variants`. Co-Authored-By: Claude Opus 5.5 --- dev-packages/e2e-tests/README.md | 3 +- .../react-router-8-framework/app/routes.ts | 4 +++ .../app/routes/api/headers.ts | 8 +++++ .../app/routes/isolation.ts | 28 +++++++++++++++++ .../app/routes/logs.ts | 6 ++++ .../app/routes/performance/outgoing-fetch.ts | 6 ++++ .../react-router-8-framework/package.json | 4 +-- .../tests/isolation.server.test.ts | 13 ++++++++ .../tests/logs.server.test.ts | 22 +++++++++++++ .../performance/incoming-trace.server.test.ts | 23 ++++++++++++++ .../performance/outgoing-fetch.server.test.ts | 31 +++++++++++++++++++ 11 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/api/headers.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/isolation.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/logs.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/outgoing-fetch.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/isolation.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/logs.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/incoming-trace.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/outgoing-fetch.server.test.ts diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index e6424f4fa55f..f4fd271d5c74 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -139,7 +139,8 @@ Apps like `cloudflare-workers-send-to-sentry` deploy the built app as a real Wor ## Runtime variants (Bun, Deno, Cloudflare) To test a framework on a runtime other than Node.js, add an `optionalVariants` entry to the existing test app instead of -creating a new app. `react-router-8-framework` is the reference setup. +creating a new app. When the variant is green, move it to `variants`, so that it is required. `react-router-8-framework` +is the reference setup. - **`runtime`**: set `"runtime": "bun"`, `"deno"` or `"cloudflare"` on the variant. For such a variant, CI installs Bun or Deno, and the runner and CI set the `RUNTIME` env var for the build and the assert command. Without `runtime`, diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes.ts index 1e2b1b1dfd10..4da99dc5b86c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes.ts @@ -3,6 +3,9 @@ import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes export default [ index('routes/home.tsx'), route('__sentry-flush', 'routes/sentry-flush.tsx'), + route('api/headers', 'routes/api/headers.ts'), + route('isolation/:id', 'routes/isolation.ts'), + route('logs', 'routes/logs.ts'), ...prefix('errors', [ route('client', 'routes/errors/client.tsx'), route('client/:client-param', 'routes/errors/client-param.tsx'), @@ -21,5 +24,6 @@ export default [ route('with-middleware', 'routes/performance/with-middleware.tsx'), route('redis', 'routes/performance/redis.tsx'), route('db-mysql', 'routes/performance/db-mysql.tsx'), + route('outgoing-fetch', 'routes/performance/outgoing-fetch.ts'), ]), ] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/api/headers.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/api/headers.ts new file mode 100644 index 000000000000..900a5f946174 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/api/headers.ts @@ -0,0 +1,8 @@ +import type { Route } from './+types/headers'; + +export function loader({ request }: Route.LoaderArgs) { + return Response.json({ + 'sentry-trace': request.headers.get('sentry-trace'), + baggage: request.headers.get('baggage'), + }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/isolation.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/isolation.ts new file mode 100644 index 000000000000..1a0783990068 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/isolation.ts @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/react-router'; +import type { Route } from './+types/isolation'; + +const waitingRequests = new Map void>(); + +// Holds the first of two requests until the second one has set its tag, so both requests use their scopes at the +// same time. +function waitForPartner(id: string, partner: string): Promise { + const releasePartner = waitingRequests.get(partner); + if (releasePartner) { + waitingRequests.delete(partner); + releasePartner(); + return Promise.resolve(); + } + return new Promise(resolve => waitingRequests.set(id, resolve)); +} + +export async function loader({ params, request }: Route.LoaderArgs) { + Sentry.setTag('isolation-id', params.id); + + const partner = new URL(request.url).searchParams.get('partner'); + if (partner) { + await waitForPartner(params.id, partner); + } + + Sentry.captureMessage(`isolation ${params.id}`); + return Response.json({ id: params.id }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/logs.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/logs.ts new file mode 100644 index 000000000000..d218fd4c649c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/logs.ts @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/react-router'; + +export function loader() { + Sentry.logger.info('react-router server log', { route: 'logs' }); + return Response.json({ logged: true }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/outgoing-fetch.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/outgoing-fetch.ts new file mode 100644 index 000000000000..0f4535bb4943 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/outgoing-fetch.ts @@ -0,0 +1,6 @@ +import type { Route } from './+types/outgoing-fetch'; + +export async function loader({ request }: Route.LoaderArgs) { + const response = await fetch(new URL('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/api/headers', request.url)); + return Response.json(await response.json()); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 7f22e8ce10f1..4cf3a981b6be 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -73,9 +73,7 @@ { "build-command": "pnpm test:build-latest", "label": "react-router-8-framework (latest)" - } - ], - "optionalVariants": [ + }, { "runtime": "bun", "label": "react-router-8-framework (bun)" diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/isolation.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/isolation.server.test.ts new file mode 100644 index 000000000000..dc2c775a080b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/isolation.server.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +test('keeps the scope data of concurrent requests apart', async ({ request }) => { + const eventAPromise = waitForError(APP_NAME, event => event.message === 'isolation a'); + const eventBPromise = waitForError(APP_NAME, event => event.message === 'isolation b'); + + await Promise.all([request.get('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/isolation/a?partner=b'), request.get('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/isolation/b?partner=a')]); + + expect((await eventAPromise).tags).toMatchObject({ 'isolation-id': 'a' }); + expect((await eventBPromise).tags).toMatchObject({ 'isolation-id': 'b' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/logs.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/logs.server.test.ts new file mode 100644 index 000000000000..59566b6e208f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/logs.server.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +test('sends a log from a server loader', async ({ request }) => { + const logEnvelopeItemPromise = waitForEnvelopeItem(APP_NAME, ([header, body]) => { + return ( + header.type === 'log' && + (body as { items: { body: string }[] }).items.some(log => log.body === 'react-router server log') + ); + }); + + await request.get('/logs'); + + const [, body] = await logEnvelopeItemPromise; + const log = (body as { items: { body: string }[] }).items.find(item => item.body === 'react-router server log'); + + expect(log).toMatchObject({ + level: 'info', + attributes: { route: { value: 'logs', type: 'string' } }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/incoming-trace.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/incoming-trace.server.test.ts new file mode 100644 index 000000000000..5cf9d9f0bcbd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/incoming-trace.server.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test('continues the trace of an incoming request', async ({ request }) => { + const traceId = '12312012123120121231201212312012'; + const parentSpanId = '1121201211212012'; + + const spanPromise = waitForStreamedSpan(APP_NAME, span => span.is_segment && span.trace_id === traceId); + + await request.get('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/performance/server-loader', { + headers: { + 'sentry-trace': `${traceId}-${parentSpanId}-1`, + baggage: `sentry-trace_id=${traceId},sentry-environment=qa,sentry-public_key=username,sentry-sample_rate=1,sentry-sampled=true`, + }, + }); + + const span = await spanPromise; + + expect(span.name).toBe('GET /performance/server-loader'); + expect(getSpanOp(span)).toBe('http.server'); + expect(span.parent_span_id).toBe(parentSpanId); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/outgoing-fetch.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/outgoing-fetch.server.test.ts new file mode 100644 index 000000000000..b24f0576f71c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/outgoing-fetch.server.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from '@playwright/test'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test('propagates the trace to an outgoing fetch', async ({ request }) => { + const streamedSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans(APP_NAME, spans => { + streamedSpans.push(...spans); + return false; + }); + + const response = await request.get('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/performance/outgoing-fetch'); + const headers = (await response.json()) as { 'sentry-trace': string | null; baggage: string | null }; + + expect(headers['sentry-trace']).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-1$/); + const [traceId, fetchSpanId] = headers['sentry-trace']!.split('-'); + expect(headers.baggage).toContain(`sentry-trace_id=${traceId}`); + + // The header names the span of the outgoing fetch, which belongs to the trace of the incoming request. + await expect.poll(() => streamedSpans.find(span => span.span_id === fetchSpanId)).toBeDefined(); + const fetchSpan = streamedSpans.find(span => span.span_id === fetchSpanId)!; + expect(getSpanOp(fetchSpan)).toBe('http.client'); + expect(fetchSpan.trace_id).toBe(traceId); + + await expect + .poll(() => streamedSpans.find(span => span.is_segment && span.trace_id === traceId && !span.parent_span_id)) + .toBeDefined(); + const serverSpan = streamedSpans.find(span => span.is_segment && span.trace_id === traceId && !span.parent_span_id)!; + expect(getSpanOp(serverSpan)).toBe('http.server'); +});