From 999e354362d8d2e0a3ff8e4349d84d03f38fa7ae Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 4 Sep 2026 10:44:14 +0200 Subject: [PATCH 1/2] test(e2e): Port the create-remix-app-v2-non-vite E2E app to span streaming Drops the `traceLifecycle: 'static'` pin and rewrites the specs to assert on streamed spans. --- .../app/entry.client.tsx | 1 - .../app/entry.server.tsx | 1 - .../instrument.server.cjs | 1 - .../tests/client-transactions.test.ts | 52 +++++------ .../tests/server-transactions.test.ts | 93 ++++++++++--------- 5 files changed, 72 insertions(+), 76 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.client.tsx index ca79286c53d5..f540f3c35c1d 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.client.tsx @@ -20,7 +20,6 @@ import { StrictMode, startTransition, useEffect } from 'react'; import { hydrateRoot } from 'react-dom/client'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: window.ENV.SENTRY_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.server.tsx index a46ae96b458d..41974897eeae 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.server.tsx @@ -19,7 +19,6 @@ installGlobals(); const ABORT_DELAY = 5_000; Sentry.init({ - traceLifecycle: 'static', 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/create-remix-app-v2-non-vite/instrument.server.cjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/instrument.server.cjs index 46e08e39192b..557ab8d18f17 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/instrument.server.cjs +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/instrument.server.cjs @@ -1,7 +1,6 @@ const Sentry = require('@sentry/remix'); Sentry.init({ - traceLifecycle: 'static', 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, diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/client-transactions.test.ts index 9e0d55a16fce..20f839fb6dd7 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/client-transactions.test.ts @@ -1,31 +1,28 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('Sends a pageload transaction to Sentry', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-v2-non-vite', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === 'routes/_index'; +test('Sends a pageload span to Sentry', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-v2-non-vite', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === 'routes/_index'; }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const span = await spanPromise; - expect(transactionEvent).toBeDefined(); - expect(transactionEvent.contexts?.trace?.data).toEqual( - expect.objectContaining({ - // No manifest available (legacy app without the Sentry Vite plugin), so source falls back to 'route' - // and url.template uses the route id instead of a parameterized URL path. - 'sentry.segment.name.source': 'route', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - 'url.path': '/', - 'url.template': 'routes/_index', - }), - ); + expect(span.attributes).toMatchObject({ + // No manifest available (legacy app without the Sentry Vite plugin), so source falls back to 'route' + // and url.template uses the route id instead of a parameterized URL path. + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.template': { value: 'routes/_index', type: 'string' }, + }); }); -test('Sends a navigation transaction to Sentry', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-v2-non-vite', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === 'routes/user.$id'; +test('Sends a navigation span to Sentry', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-v2-non-vite', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === 'routes/user.$id'; }); await page.goto('/'); @@ -33,17 +30,14 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => { const linkElement = page.locator('id=navigation'); await linkElement.click(); - const transactionEvent = await transactionPromise; + const span = await spanPromise; - expect(transactionEvent).toBeDefined(); - expect(transactionEvent.contexts?.trace?.data).toEqual( - expect.objectContaining({ - 'sentry.segment.name.source': 'route', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - 'url.path': '/user/5', - 'url.template': 'routes/user.$id', - }), - ); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.template': { value: 'routes/user.$id', type: 'string' }, + }); }); test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts index 643d09099e96..d8739c223fc6 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts @@ -1,58 +1,63 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; + +const APP_NAME = 'create-remix-app-v2-non-vite'; test.describe.configure({ mode: 'serial' }); -test('Sends parameterized transaction name to Sentry', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-v2-non-vite', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server'; +test('Sends a parameterized span name to Sentry', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return getSpanOp(span) === 'http.server' && span.is_segment && span.name === 'GET user/:id'; }); await page.goto('/user/123'); - const transaction = await transactionPromise; + const span = await spanPromise; - expect(transaction).toBeDefined(); - expect(transaction.transaction).toBe('GET user/:id'); + expect(span.attributes['http.route']?.value).toBe('user/:id'); }); -test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => { - // We use this to identify the transactions - const testTag = crypto.randomUUID(); - - const httpServerTransactionPromise = waitForTransaction('create-remix-app-v2-non-vite', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.tags?.['sentry_test'] === testTag; - }); - - const pageLoadTransactionPromise = waitForTransaction('create-remix-app-v2-non-vite', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag; +test('Sends two linked spans (server & client) to Sentry', async ({ page }) => { + // Streamed spans are buffered before they flush, so spans from an earlier page load can still be + // arriving here. The document advertises its own trace in the `sentry-trace` meta tag, so that is + // what tells this page load's spans apart rather than the op or the URL. + const streamedSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans(APP_NAME, spans => { + streamedSpans.push(...spans); + return false; }); - page.goto(`/?tag=${testTag}`); - - const pageloadTransaction = await pageLoadTransactionPromise; - const httpServerTransaction = await httpServerTransactionPromise; - - expect(pageloadTransaction).toBeDefined(); - expect(httpServerTransaction).toBeDefined(); - - const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id; - const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id; - const loaderSpanId = httpServerTransaction?.spans?.find( - span => span.data && span.data['code.function.name'] === 'loader', - )?.span_id; - - const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id; - const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id; - const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id; - - expect(httpServerTransaction.transaction).toBe('GET /'); - expect(pageloadTransaction.transaction).toBe('routes/_index'); - - expect(httpServerTraceId).toBeDefined(); - expect(httpServerSpanId).toBeDefined(); - - expect(pageLoadTraceId).toEqual(httpServerTraceId); - expect(pageLoadParentSpanId).toEqual(loaderSpanId); - expect(pageLoadSpanId).not.toEqual(httpServerSpanId); + await page.goto('/'); + + // Remix injects the meta tag from inside the root loader, so the span it names is the loader span. + const sentryTrace = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + const [traceId, loaderSpanId] = (sentryTrace ?? '').split('-'); + expect(traceId).toMatch(/^[a-f0-9]{32}$/); + expect(loaderSpanId).toMatch(/^[a-f0-9]{16}$/); + + // The client continues the server trace, so its pageload span hangs off the span the meta tag + // names. Selecting it that way, rather than by op, is what makes the trace assertion below mean + // something: a pageload that failed to continue the trace would have no parent at all. + const findPageloadSpan = () => + streamedSpans.find( + span => getSpanOp(span) === 'pageload' && span.is_segment && span.parent_span_id === loaderSpanId, + ); + await expect.poll(findPageloadSpan).toBeDefined(); + expect(findPageloadSpan()!.trace_id).toBe(traceId); + expect(findPageloadSpan()!.name).toBe('routes/_index'); + + const findServerSegmentSpan = () => + streamedSpans.find(span => getSpanOp(span) === 'http.server' && span.is_segment && span.trace_id === traceId); + await expect.poll(findServerSegmentSpan).toBeDefined(); + const serverSegmentSpan = findServerSegmentSpan()!; + + // The index route has no path of its own, so the segment keeps the low-cardinality method-only + // name it starts with. + expect(serverSegmentSpan.name).toBe('GET'); + expect(findPageloadSpan()!.span_id).not.toBe(serverSegmentSpan.span_id); + + const loaderSpan = streamedSpans.find(span => span.span_id === loaderSpanId)!; + expect(loaderSpan.attributes['code.function.name']?.value).toBe('loader'); + expect(loaderSpan.parent_span_id).toBe(serverSegmentSpan.span_id); }); From c2ab737e4d4b7390f39b3f4d0f7985ff38e9b025 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 4 Sep 2026 13:24:56 +0200 Subject: [PATCH 2/2] Poll for the loader span before asserting on it --- .../tests/server-transactions.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts index d8739c223fc6..393d875cb3ba 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts @@ -57,7 +57,8 @@ test('Sends two linked spans (server & client) to Sentry', async ({ page }) => { expect(serverSegmentSpan.name).toBe('GET'); expect(findPageloadSpan()!.span_id).not.toBe(serverSegmentSpan.span_id); - const loaderSpan = streamedSpans.find(span => span.span_id === loaderSpanId)!; - expect(loaderSpan.attributes['code.function.name']?.value).toBe('loader'); - expect(loaderSpan.parent_span_id).toBe(serverSegmentSpan.span_id); + const findLoaderSpan = () => streamedSpans.find(span => span.span_id === loaderSpanId); + await expect.poll(findLoaderSpan).toBeDefined(); + expect(findLoaderSpan()!.attributes['code.function.name']?.value).toBe('loader'); + expect(findLoaderSpan()!.parent_span_id).toBe(serverSegmentSpan.span_id); });