From f84f70ba3b0f1a89706d71da878c9b09b823dad9 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 4 Sep 2026 10:34:36 +0200 Subject: [PATCH 1/2] test(e2e): Port the create-remix-app-v2 E2E app to span streaming Drops the `traceLifecycle: 'static'` pin and rewrites the specs to assert on streamed spans. --- .../create-remix-app-v2/app/entry.client.tsx | 1 - .../create-remix-app-v2/instrument.server.cjs | 1 - .../tests/client-transactions.test.ts | 48 ++++---- .../create-remix-app-v2/tests/db.test.ts | 115 ++++++++---------- .../tests/server-transactions.test.ts | 86 +++++++------ 5 files changed, 114 insertions(+), 137 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.client.tsx index 49ae52f933e9..c6c86ac7f1f4 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/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/instrument.server.cjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs index c231a4742dfb..6d211cac4592 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/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/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/client-transactions.test.ts index b84a44128200..69993066aba7 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/client-transactions.test.ts @@ -1,29 +1,26 @@ 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', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/'; +test('Sends a pageload span to Sentry', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-v2', 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.segment.name.source': 'route', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - 'url.path': '/', - 'url.template': '/', - }), - ); + expect(span.attributes).toMatchObject({ + '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' }, + }); }); -test('Sends a navigation transaction to Sentry', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-v2', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/user/:id'; +test('Sends a navigation span to Sentry', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-v2', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/user/:id'; }); await page.goto('/'); @@ -31,17 +28,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': '/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-v2/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts index 28d38d56db88..52d3c69d8f61 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts @@ -1,94 +1,81 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +function isSegmentFor(path: string): (span: SerializedStreamedSpan) => boolean { + return span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path; +} // Orchestrion force-bundles + transforms mysql/ioredis at build time, and the databases // are booted via docker-compose in the Playwright global setup. test.describe('orchestrion DB instrumentation', () => { test('Instruments ioredis automatically via orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('create-remix-app-v2', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-ioredis') - ); - }); + const spansPromise = collectStreamedSpans( + 'create-remix-app-v2', + spans => + spans.some(isSegmentFor('/db-ioredis')) && spans.filter(span => getSpanOp(span) === 'db.query').length >= 2, + ); await fetch(`${baseURL}/db-ioredis`); - const transactionEvent = await transactionEventPromise; + const spans = await spansPromise; + const redisSpans = spans.filter(span => getSpanOp(span) === 'db.query'); - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( + // With span streaming `db.query.text` carries the key, so the command span is named + // `{db.operation.name} {server.address}:{server.port}` instead. + expect(redisSpans).toContainEqual( expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.redis', - description: 'set test-key [1 other arguments]', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'redis', - 'db.operation.name': 'set', - 'db.query.text': 'set test-key [1 other arguments]', + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.db.redis', type: 'string' }, + 'db.system.name': { value: 'redis', type: 'string' }, + 'db.operation.name': { value: 'set', type: 'string' }, + 'db.query.text': { value: 'set test-key [1 other arguments]', type: 'string' }, }), }), ); - expect(spans).toContainEqual( + expect(redisSpans).toContainEqual( expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.redis', - description: 'get test-key', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'redis', - 'db.operation.name': 'get', - 'db.query.text': 'get test-key', + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.db.redis', type: 'string' }, + 'db.system.name': { value: 'redis', type: 'string' }, + 'db.operation.name': { value: 'get', type: 'string' }, + 'db.query.text': { value: 'get test-key', type: 'string' }, }), }), ); }); test('Instruments mysql automatically via orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('create-remix-app-v2', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-mysql') - ); - }); + const spansPromise = collectStreamedSpans( + 'create-remix-app-v2', + spans => spans.some(isSegmentFor('/db-mysql')) && spans.filter(span => getSpanOp(span) === 'db').length >= 2, + ); await fetch(`${baseURL}/db-mysql`); - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; + const spans = await spansPromise; + const mysqlSpans = spans.filter(span => getSpanOp(span) === 'db'); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.mysql', - description: 'SELECT 1 + 1 AS solution', - status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'mysql', - 'db.query.text': 'SELECT 1 + 1 AS solution', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'server.address': expect.any(String), - 'server.port': 3306, + // With span streaming the span name is the low-cardinality query summary; the statement + // stays in `db.query.text`. + for (const query of ['SELECT 1 + 1 AS solution', 'SELECT NOW()']) { + expect(mysqlSpans).toContainEqual( + expect.objectContaining({ + name: 'SELECT', + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.db.mysql', type: 'string' }, + 'db.system.name': { value: 'mysql', type: 'string' }, + 'db.query.text': { value: query, type: 'string' }, + 'db.user': { value: 'root', type: 'string' }, + 'db.connection_string': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'server.port': { value: 3306, type: 'integer' }, + }), }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.mysql', - description: 'SELECT NOW()', - status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'mysql', - 'db.query.text': 'SELECT NOW()', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'server.address': expect.any(String), - 'server.port': 3306, - }), - }), - ); + ); + } }); }); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts index 915f813db060..c489ceee5cdd 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts @@ -1,58 +1,56 @@ 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'; test.describe.configure({ mode: 'serial' }); -test('Sends parameterized transaction name to Sentry', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-v2', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server'; +test('Sends a parameterized span name to Sentry', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-v2', 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', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.tags?.['sentry_test'] === testTag; - }); - - const pageLoadTransactionPromise = waitForTransaction('create-remix-app-v2', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag; +test('Links the server and client spans of one page load', 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('create-remix-app-v2', 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(); + expect(findPageloadSpan()!.span_id).not.toBe(findServerSegmentSpan()!.span_id); + + const loaderSpan = streamedSpans.find(span => span.span_id === loaderSpanId); + expect(loaderSpan).toBeDefined(); + expect(loaderSpan!.attributes['code.function.name']?.value).toBe('loader'); }); From f54c39917671fb9cad6ce01ef24e55e7ac57ab51 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 4 Sep 2026 12:03:19 +0200 Subject: [PATCH 2/2] Remove the now unused sentry_test tag setting --- .../create-remix-app-v2/app/routes/_index.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/_index.tsx index 40de0390d6ac..cbd57201976d 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/_index.tsx +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/_index.tsx @@ -1,13 +1,6 @@ -import { Link, useSearchParams } from '@remix-run/react'; -import * as Sentry from '@sentry/remix'; +import { Link } from '@remix-run/react'; export default function Index() { - const [searchParams] = useSearchParams(); - - if (searchParams.get('tag')) { - Sentry.setTag('sentry_test', searchParams.get('tag')); - } - return (