From 08285e60f23aa85b032c47191e3a22f826e82cef Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 4 Sep 2026 10:43:09 +0200 Subject: [PATCH 1/2] test(e2e): Port the create-remix-app-express-vite-dev E2E app to span streaming Drops the `traceLifecycle: 'static'` pin and rewrites the specs to assert on streamed spans. --- .../app/entry.client.tsx | 1 - .../instrument.mjs | 1 - .../tests/client-transactions.test.ts | 72 +++++---------- .../tests/server-transactions.test.ts | 92 ++++++++++--------- 4 files changed, 69 insertions(+), 97 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/app/entry.client.tsx index 1dfd96fe5da3..d77a64882a30 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/app/entry.client.tsx @@ -14,7 +14,6 @@ declare global { } 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-express-vite-dev/instrument.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/instrument.mjs index e51f229bce5b..b52053445456 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/instrument.mjs @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/remix'; import * as process from 'process'; 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-express-vite-dev/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts index 5082f1a07bdc..fe4f6cd146b4 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts @@ -1,53 +1,27 @@ 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-express-vite-dev', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/'; +test('Sends a pageload span to Sentry', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-express-vite-dev', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/'; }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const span = await spanPromise; - expect(transactionEvent).toBeDefined(); - expect(transactionEvent.contexts?.trace?.data).toEqual( - expect.objectContaining({ - 'sentry.origin': 'auto.pageload.remix', - 'sentry.segment.name.source': 'route', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - 'url.path': '/', - 'url.template': '/', - }), - ); -}); - -test('Sends a navigation transaction to Sentry', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express-vite-dev', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/user/:id'; + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.pageload.remix', type: 'string' }, + '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: '/', type: 'string' }, }); - - await page.goto('/'); - - const linkElement = page.locator('id=navigation'); - await linkElement.click(); - - const transactionEvent = await transactionPromise; - - 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': '/user/:id', - }), - ); }); -test('Sends a navigation transaction with parameterized route to Sentry', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express-vite-dev', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation'; +test('Sends a navigation span to Sentry', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-express-vite-dev', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/user/:id'; }); await page.goto('/'); @@ -55,18 +29,14 @@ test('Sends a navigation transaction with parameterized route to Sentry', async const linkElement = page.locator('id=navigation'); await linkElement.click(); - const transactionEvent = await transactionPromise; + const span = await spanPromise; - expect(transactionEvent).toBeDefined(); - expect(transactionEvent.transaction).toBe('/user/:id'); - 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': '/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: '/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-express-vite-dev/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts index 1b839666a325..80db1b936b30 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts @@ -1,59 +1,63 @@ import { expect, test } from '@playwright/test'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +const APP_NAME = 'create-remix-app-express-vite-dev'; test.describe.configure({ mode: 'serial' }); -test('Sends parameterized transaction name to Sentry', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express-vite-dev', 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-express-vite-dev', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.tags?.['sentry_test'] === testTag; - }); - - const pageLoadTransactionPromise = waitForTransaction('create-remix-app-express-vite-dev', 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('/'); - - 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('/'); + + 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 d7249c09537d937594e98193d77d6a59355ba91f Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 4 Sep 2026 13:24:04 +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-express-vite-dev/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts index 80db1b936b30..f15593d3e003 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/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); });