From 93cf80aecaef0fe99514e243cdd3d8ca938612c8 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 4 Sep 2026 10:41:58 +0200 Subject: [PATCH 1/5] test(e2e): Port the create-remix-app-express 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/routes/scope-bleed.$id.tsx | 3 + .../create-remix-app-express/instrument.mjs | 1 - .../tests/client-transactions.test.ts | 120 +++--- .../tests/server-errors.test.ts | 34 +- .../tests/server-transactions.test.ts | 347 +++++++++--------- 6 files changed, 238 insertions(+), 268 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/entry.client.tsx index a033ae9b5b78..5207852d10de 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/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/app/routes/scope-bleed.$id.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/scope-bleed.$id.tsx index 943bb9ba772d..a1880fc6a09e 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/scope-bleed.$id.tsx +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/scope-bleed.$id.tsx @@ -5,6 +5,9 @@ export const loader: LoaderFunction = async ({ params: { id } }) => { const timeTil = parseInt(id || '', 10) * 1000; await new Promise(resolve => setTimeout(resolve, 3000 - timeTil)); Sentry.setTag(`tag${id}`, id); + // Scope tags only reach Sentry on events, so the request emits one to make the tags of its own + // isolation scope observable. + Sentry.captureMessage(`scope-bleed-${id}`); return json({ test: 'test' }); }; diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/instrument.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express/instrument.mjs index 7078a725d4eb..9af4c474e984 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/instrument.mjs @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/remix'; import 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/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts index 15ac9f26f1ac..23afa41ee30b 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts @@ -1,30 +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', 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', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/'; }); await page.goto('/'); - const transactionEvent = await transactionPromise; - - 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': '/', - }), - ); + const span = await spanPromise; + + 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' }, + }); }); -test('Sends a navigation transaction to Sentry', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', 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-express', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/user/:id'; }); await page.goto('/'); @@ -32,17 +29,14 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => { 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', - }), - ); + const span = await spanPromise; + + 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 }) => { @@ -133,78 +127,66 @@ test('Does not inject sentry-trace and baggage when throwing an external redirec expect(baggage).toBeFalsy(); }); -test('Pageload transaction is parameterized for a dynamic route', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/error-boundary-capture/:id' - ); +test('Pageload span is parameterized for a dynamic route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-express', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/error-boundary-capture/:id'; }); await page.goto('/error-boundary-capture/123'); - const transactionEvent = await transactionPromise; + const span = await spanPromise; - expect(transactionEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Pageload transaction is parameterized for a 2-level nested route', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/users/:userId/posts/:postId' - ); +test('Pageload span is parameterized for a 2-level nested route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-express', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/users/:userId/posts/:postId'; }); await page.goto('/users/user123/posts/post456'); - const transactionEvent = await transactionPromise; + const span = await spanPromise; - expect(transactionEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Pageload transaction is parameterized for a deeply nested route', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/deeply/:nested/:structure/:id' - ); +test('Pageload span is parameterized for a deeply nested route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-express', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/deeply/:nested/:structure/:id'; }); await page.goto('/deeply/level1/level2/level3'); - const transactionEvent = await transactionPromise; + const span = await spanPromise; - expect(transactionEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Pageload transaction is parameterized for a flat dot-notation route', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/products/:productId/reviews/:reviewId' - ); +test('Pageload span is parameterized for a flat dot-notation route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-express', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/products/:productId/reviews/:reviewId'; }); await page.goto('/products/prod789/reviews/rev101'); - const transactionEvent = await transactionPromise; + const span = await spanPromise; - expect(transactionEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Reports a manually created transaction', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent.transaction === 'test_transaction_1'; +test('Reports a manually created span', async ({ page }) => { + const spanPromise = waitForStreamedSpan('create-remix-app-express', span => { + return span.name === 'test_transaction_1'; }); await page.goto('/manual-tracing/0'); - const transactionEvent = await transactionPromise; + const span = await spanPromise; - expect(transactionEvent.sdk?.name).toBe('sentry.javascript.remix'); - expect(transactionEvent.start_timestamp).toBeDefined(); - expect(transactionEvent.timestamp).toBeDefined(); + expect(span.attributes['sentry.sdk.name']?.value).toBe('sentry.javascript.remix'); + expect(span.start_timestamp).toBeDefined(); + expect(span.end_timestamp).toBeDefined(); }); test('Renders data from a deferred loader response', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-errors.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-errors.test.ts index 40030e276515..5a5b124bdd69 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends a loader error to Sentry', async ({ page }) => { const loaderErrorPromise = waitForError('create-remix-app-express', errorEvent => { @@ -14,8 +14,8 @@ test('Sends a loader error to Sentry', async ({ page }) => { }); test('Reports an error thrown from a loader with handleError mechanism', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', txn => { - return txn.transaction === 'GET loader-json-response/:id' && txn.contexts?.trace?.status === 'internal_error'; + const spanPromise = waitForStreamedSpan('create-remix-app-express', span => { + return getSpanOp(span) === 'http.server' && span.name === 'GET loader-json-response/:id' && span.status === 'error'; }); const errorPromise = waitForError('create-remix-app-express', errorEvent => { return ( @@ -26,10 +26,10 @@ test('Reports an error thrown from a loader with handleError mechanism', async ( await page.goto('/loader-json-response/-2').catch(() => {}); - const transaction = await transactionPromise; + const span = await spanPromise; const errorEvent = await errorPromise; - expect(transaction.contexts?.trace?.data?.['http.response.status_code']).toBe(500); + expect(span.attributes['http.response.status_code']?.value).toBe(500); expect(errorEvent.exception?.values?.[0]?.mechanism).toMatchObject({ handled: false, type: 'auto.function.remix.server', @@ -76,8 +76,10 @@ test('Reports an error in the redirection target loader', async ({ page }) => { }); test('Reports an error thrown from an action', async ({ request }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', txn => { - return txn.transaction === 'POST action-json-response/:id' && txn.contexts?.trace?.status === 'internal_error'; + const spanPromise = waitForStreamedSpan('create-remix-app-express', span => { + return ( + getSpanOp(span) === 'http.server' && span.name === 'POST action-json-response/:id' && span.status === 'error' + ); }); const errorPromise = waitForError('create-remix-app-express', errorEvent => { return ( @@ -90,10 +92,10 @@ test('Reports an error thrown from an action', async ({ request }) => { await request.post('/action-json-response/-1').catch(() => {}); - const transaction = await transactionPromise; + const span = await spanPromise; const errorEvent = await errorPromise; - expect(transaction.contexts?.trace?.data?.['http.response.status_code']).toBe(500); + expect(span.attributes['http.response.status_code']?.value).toBe(500); expect(errorEvent.exception?.values?.[0]?.mechanism).toMatchObject({ handled: false, type: 'auto.function.remix.server', @@ -177,12 +179,8 @@ test('Reports a thrown plain object from an action', async ({ request }) => { }); test('Reports an SSR error and applies tags from wrapHandleErrorWithSentry', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', txn => { - return ( - txn.transaction === 'GET ssr-error' && - txn.contexts?.trace?.status === 'internal_error' && - txn.tags?.['remix-test-tag'] === 'remix-test-value' - ); + const spanPromise = waitForStreamedSpan('create-remix-app-express', span => { + return getSpanOp(span) === 'http.server' && span.name === 'GET ssr-error' && span.status === 'error'; }); const errorPromise = waitForError('create-remix-app-express', errorEvent => { return errorEvent.exception?.values?.[0]?.value === 'Sentry SSR Test Error'; @@ -190,10 +188,12 @@ test('Reports an SSR error and applies tags from wrapHandleErrorWithSentry', asy await page.goto('/ssr-error').catch(() => {}); - const transaction = await transactionPromise; + const span = await spanPromise; const errorEvent = await errorPromise; - expect(transaction.contexts?.trace?.data?.['http.response.status_code']).toBe(500); + expect(span.attributes['http.response.status_code']?.value).toBe(500); + // `wrapHandleErrorWithSentry` sets the tag on the scope, which only events carry. + expect(errorEvent.tags?.['remix-test-tag']).toBe('remix-test-value'); expect(errorEvent.exception?.values?.[0]?.mechanism).toMatchObject({ handled: false, type: 'auto.function.remix.server', diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts index 9c1ca00e5a32..e75a2313f244 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts @@ -1,26 +1,42 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { + collectStreamedSpans, + getSpanOp, + waitForError, + waitForStreamedSpan, + waitForStreamedSpans, +} from '@sentry-internal/test-utils'; + +const APP_NAME = 'create-remix-app-express'; test.describe.configure({ mode: 'serial' }); -test('Sends parameterized transaction name to Sentry', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server'; - }); +function isSegmentNamed(name: string): (span: SerializedStreamedSpan) => boolean { + return span => getSpanOp(span) === 'http.server' && span.is_segment && span.name === name; +} + +function isDataFunction(name: 'action' | 'loader', routeId?: string): (span: SerializedStreamedSpan) => boolean { + return span => + span.attributes['code.function.name']?.value === name && + (routeId === undefined || span.attributes['match.route.id']?.value === routeId); +} + +test('Sends a parameterized span name to Sentry', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, isSegmentNamed('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(transaction.contexts?.trace?.data?.['http.route']).toBe('user/:id'); + expect(span.attributes['http.route']?.value).toBe('user/:id'); }); -test('Sends form data with action span', async ({ page }) => { - const formdataActionTransaction = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent?.spans?.some(span => span.data && span.data['code.function.name'] === 'action') || false; - }); +test('Sends form data with the action span', async ({ page }) => { + const spansPromise = collectStreamedSpans( + APP_NAME, + spans => spans.some(isSegmentNamed('POST action-formdata')) && spans.some(isDataFunction('action')), + ); await page.goto('/action-formdata'); @@ -33,178 +49,160 @@ test('Sends form data with action span', async ({ page }) => { await page.locator('button[type=submit]').click(); - const actionSpan = (await formdataActionTransaction)?.spans?.find( - span => span.data && span.data['code.function.name'] === 'action', - ); + const spans = await spansPromise; + const actionSpan = spans.find(isDataFunction('action')); expect(actionSpan).toBeDefined(); - expect(actionSpan?.op).toBe('function'); - expect(actionSpan?.data?.['code.function.name']).toBe('action'); - expect(actionSpan?.data).toMatchObject({ - 'remix.action_form_data.text': 'test', - 'remix.action_form_data.file': 'file.txt', + expect(getSpanOp(actionSpan!)).toBe('function'); + expect(actionSpan!.attributes).toMatchObject({ + 'remix.action_form_data.text': { value: 'test', type: 'string' }, + 'remix.action_form_data.file': { value: 'file.txt', type: 'string' }, }); }); test('Sends a loader span to Sentry', async ({ page }) => { - const loaderTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent?.spans?.some(span => span.data && span.data['code.function.name'] === 'loader') || false; - }); + const spansPromise = collectStreamedSpans( + APP_NAME, + spans => spans.some(isSegmentNamed('GET')) && spans.some(isDataFunction('loader')), + ); await page.goto('/'); - const loaderSpan = (await loaderTransactionPromise)?.spans?.find( - span => span.data && span.data['code.function.name'] === 'loader', - ); + const spans = await spansPromise; + const loaderSpan = spans.find(isDataFunction('loader')); expect(loaderSpan).toBeDefined(); - expect(loaderSpan?.op).toBe('function'); - expect(loaderSpan?.data?.['code.function.name']).toBe('loader'); + expect(getSpanOp(loaderSpan!)).toBe('function'); }); -test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => { - // We use this to identify the transactions - const testTag = crypto.randomUUID(); - - const httpServerTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.tags?.['sentry_test'] === testTag; +test('Propagates the trace when the ErrorBoundary is triggered', async ({ page }) => { + // Streamed spans are buffered before they flush, so spans from an earlier page load can still be + // arriving here. + const streamedSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans(APP_NAME, spans => { + streamedSpans.push(...spans); + return false; }); - const pageLoadTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag; - }); - - page.goto(`/client-error?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 client-error'); - expect(pageloadTransaction.transaction).toBe('/client-error'); - - expect(httpServerTraceId).toBeDefined(); - expect(httpServerSpanId).toBeDefined(); - - expect(pageLoadTraceId).toEqual(httpServerTraceId); - expect(pageLoadParentSpanId).toEqual(loaderSpanId); - expect(pageLoadSpanId).not.toEqual(httpServerSpanId); + // The ErrorBoundary replaces the document, so there is no `sentry-trace` meta tag to read this + // page load's trace off. A unique path identifies its server segment instead. + const id = crypto.randomUUID(); + await page.goto(`/error-boundary-capture/${id}`); + await expect(page.locator('#event-id')).not.toBeEmpty(); + + const findServerSegmentSpan = () => + streamedSpans.find( + span => + getSpanOp(span) === 'http.server' && + span.is_segment && + span.attributes['url.path']?.value === `/error-boundary-capture/${id}`, + ); + await expect.poll(findServerSegmentSpan).toBeDefined(); + const serverSegmentSpan = findServerSegmentSpan()!; + expect(serverSegmentSpan.name).toBe('GET error-boundary-capture/:id'); + + // The client continues the server trace, so its pageload span hangs off the root loader span. + const findPageloadSpan = () => + streamedSpans.find( + span => getSpanOp(span) === 'pageload' && span.is_segment && span.trace_id === serverSegmentSpan.trace_id, + ); + await expect.poll(findPageloadSpan).toBeDefined(); + const pageloadSpan = findPageloadSpan()!; + expect(pageloadSpan.name).toBe('/error-boundary-capture/:id'); + expect(pageloadSpan.span_id).not.toBe(serverSegmentSpan.span_id); + + const loaderSpan = streamedSpans.find(span => span.span_id === pageloadSpan.parent_span_id); + expect(loaderSpan).toBeDefined(); + expect(loaderSpan!.attributes['code.function.name']?.value).toBe('loader'); }); test('Parameterizes a 2-level nested route on the server', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', txn => { - return txn.contexts?.trace?.op === 'http.server' && txn.transaction === 'GET users/:userId/posts/:postId'; - }); + const spansPromise = collectStreamedSpans( + APP_NAME, + spans => spans.some(isSegmentNamed('GET users/:userId/posts/:postId')) && spans.some(isDataFunction('loader')), + ); await page.goto('/users/user123/posts/post456'); - const transaction = await transactionPromise; + const spans = await spansPromise; + const segment = spans.find(isSegmentNamed('GET users/:userId/posts/:postId'))!; - expect(transaction.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(transaction.spans?.some(s => s.data?.['code.function.name'] === 'loader' && s.op === 'function')).toBe(true); + expect(segment.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(spans.some(span => isDataFunction('loader')(span) && getSpanOp(span) === 'function')).toBe(true); }); test('Parameterizes a 3-level nested API route on the server', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', txn => { - return txn.contexts?.trace?.op === 'http.server' && txn.transaction === 'GET api/v1/data/:id'; - }); + const spanPromise = waitForStreamedSpan(APP_NAME, isSegmentNamed('GET api/v1/data/:id')); await page.goto('/api/v1/data/abc123'); - const transaction = await transactionPromise; + const span = await spanPromise; - expect(transaction.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); }); test('Parameterizes a deeply nested route on the server', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', txn => { - return txn.contexts?.trace?.op === 'http.server' && txn.transaction === 'GET deeply/:nested/:structure/:id'; - }); + const spanPromise = waitForStreamedSpan(APP_NAME, isSegmentNamed('GET deeply/:nested/:structure/:id')); await page.goto('/deeply/level1/level2/level3'); - const transaction = await transactionPromise; + const span = await spanPromise; - expect(transaction.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); }); test('Parameterizes a flat dot-notation route on the server', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', txn => { - return txn.contexts?.trace?.op === 'http.server' && txn.transaction === 'GET products/:productId/reviews/:reviewId'; - }); + const spanPromise = waitForStreamedSpan(APP_NAME, isSegmentNamed('GET products/:productId/reviews/:reviewId')); await page.goto('/products/prod789/reviews/rev101'); - const transaction = await transactionPromise; + const span = await spanPromise; - expect(transaction.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); }); test('Records action and loader spans on a parameterized action route', async ({ request }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', txn => { - return txn.transaction === 'POST action-json-response/:id'; - }); + const routeId = 'routes/action-json-response.$id'; + const spansPromise = collectStreamedSpans( + APP_NAME, + spans => + spans.some(isSegmentNamed('POST action-json-response/:id')) && + spans.some(isDataFunction('action', routeId)) && + spans.some(isDataFunction('loader', 'root')) && + spans.some(isDataFunction('loader', routeId)), + ); await request.post('/action-json-response/123123'); - const transaction = await transactionPromise; + const spans = await spansPromise; + const actionSpan = spans.find(isDataFunction('action', routeId))!; - const actionSpan = transaction.spans?.find( - s => - s.data?.['code.function.name'] === 'action' && s.data?.['match.route.id'] === 'routes/action-json-response.$id', - ); - expect(actionSpan).toBeDefined(); - expect(actionSpan?.op).toBe('function'); - expect(actionSpan?.data?.['match.params.id']).toBe('123123'); + expect(getSpanOp(actionSpan)).toBe('function'); + expect(actionSpan.attributes['match.params.id']?.value).toBe('123123'); - const rootLoaderSpan = transaction.spans?.find( - s => s.data?.['code.function.name'] === 'loader' && s.data?.['match.route.id'] === 'root', - ); - expect(rootLoaderSpan).toBeDefined(); - - const routeLoaderSpan = transaction.spans?.find( - s => - s.data?.['code.function.name'] === 'loader' && s.data?.['match.route.id'] === 'routes/action-json-response.$id', - ); - expect(routeLoaderSpan).toBeDefined(); - - expect(transaction.request?.method).toBe('POST'); + const segment = spans.find(isSegmentNamed('POST action-json-response/:id'))!; + expect(segment.attributes['http.request.method']?.value).toBe('POST'); }); test('Records loader spans on a deferred loader response', async ({ page }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', txn => { - return txn.transaction === 'GET loader-defer-response/:id'; - }); + const routeId = 'routes/loader-defer-response.$id'; + const spansPromise = collectStreamedSpans( + APP_NAME, + spans => + spans.some(isSegmentNamed('GET loader-defer-response/:id')) && spans.some(isDataFunction('loader', routeId)), + ); await page.goto('/loader-defer-response/123123'); - const transaction = await transactionPromise; + const spans = await spansPromise; + const segment = spans.find(isSegmentNamed('GET loader-defer-response/:id'))!; - expect(transaction.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect( - transaction.spans?.some( - s => - s.data?.['code.function.name'] === 'loader' && - s.data?.['match.route.id'] === 'routes/loader-defer-response.$id', - ), - ).toBe(true); + expect(segment.attributes['sentry.segment.name.source']?.value).toBe('route'); }); test('Continues a trace from incoming sentry-trace and baggage headers', async ({ request }) => { - const transactionPromise = waitForTransaction('create-remix-app-express', txn => { - return txn.contexts?.trace?.trace_id === '12312012123120121231201212312012'; + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.trace_id === '12312012123120121231201212312012' && span.is_segment; }); await request.get('/loader-json-response/3', { @@ -214,17 +212,51 @@ test('Continues a trace from incoming sentry-trace and baggage headers', async ( }, }); - const transaction = await transactionPromise; + const span = await spanPromise; - expect(transaction.contexts?.trace?.parent_span_id).toBe('1121201211212012'); + expect(span.parent_span_id).toBe('1121201211212012'); +}); + +test('Sends two linked spans (server & client) to Sentry', async ({ page }) => { + const streamedSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans(APP_NAME, spans => { + streamedSpans.push(...spans); + return false; + }); + + await page.goto('/'); + + 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}$/); + + 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 and never gets an `http.route`. + expect(serverSegmentSpan.name).toBe('GET'); + expect(serverSegmentSpan.attributes['http.route']).toBeUndefined(); + 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); }); test('Does not bleed scope tags between concurrent requests', async ({ request }) => { - const txnPromises = [1, 2, 3, 4].map(i => - waitForTransaction('create-remix-app-express', txn => { - return txn.transaction === 'GET scope-bleed/:id' && txn.tags?.[`tag${i}`] === String(i); - }), - ); + const eventPromises = [1, 2, 3, 4].map(i => waitForError(APP_NAME, event => event.message === `scope-bleed-${i}`)); await Promise.all([ request.get('/scope-bleed/1'), @@ -233,61 +265,16 @@ test('Does not bleed scope tags between concurrent requests', async ({ request } request.get('/scope-bleed/4'), ]); - const transactions = await Promise.all(txnPromises); + const events = await Promise.all(eventPromises); - transactions.forEach(txn => { - const tags = txn.tags ?? {}; + events.forEach(event => { + const tags = event.tags ?? {}; const customTags = Object.keys(tags).filter(t => t.startsWith('tag')); expect(customTags).toHaveLength(1); const key = customTags[0]!; const value = key[key.length - 1]; expect(tags[key]).toBe(value); + expect(event.message).toBe(`scope-bleed-${value}`); }); }); - -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', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.tags?.['sentry_test'] === testTag; - }); - - const pageLoadTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag; - }); - - 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 loaderSpan = httpServerTransaction?.spans?.find( - span => span.data && span.data['code.function.name'] === 'loader', - ); - const loaderSpanId = loaderSpan?.span_id; - const loaderParentSpanId = loaderSpan?.parent_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(httpServerTransaction.contexts?.trace?.data?.['http.route']).toBeUndefined(); - expect(pageloadTransaction.transaction).toBe('/'); - - expect(httpServerTraceId).toBeDefined(); - expect(httpServerSpanId).toBeDefined(); - - expect(loaderParentSpanId).toEqual(httpServerSpanId); - expect(pageLoadTraceId).toEqual(httpServerTraceId); - expect(pageLoadParentSpanId).toEqual(loaderSpanId); - expect(pageLoadSpanId).not.toEqual(httpServerSpanId); -}); From 0553f069f07ab2fafa30453c8fbfc5258f3fdec4 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 4 Sep 2026 13:27:36 +0200 Subject: [PATCH 2/5] Poll for the loader span before asserting on it --- .../tests/server-transactions.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts index e75a2313f244..971ded0f754d 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts @@ -111,9 +111,9 @@ test('Propagates the trace when the ErrorBoundary is triggered', async ({ page } expect(pageloadSpan.name).toBe('/error-boundary-capture/:id'); expect(pageloadSpan.span_id).not.toBe(serverSegmentSpan.span_id); - const loaderSpan = streamedSpans.find(span => span.span_id === pageloadSpan.parent_span_id); - expect(loaderSpan).toBeDefined(); - expect(loaderSpan!.attributes['code.function.name']?.value).toBe('loader'); + const findLoaderSpan = () => streamedSpans.find(span => span.span_id === pageloadSpan.parent_span_id); + await expect.poll(findLoaderSpan).toBeDefined(); + expect(findLoaderSpan()!.attributes['code.function.name']?.value).toBe('loader'); }); test('Parameterizes a 2-level nested route on the server', async ({ page }) => { @@ -250,9 +250,10 @@ test('Sends two linked spans (server & client) to Sentry', async ({ page }) => { expect(serverSegmentSpan.attributes['http.route']).toBeUndefined(); 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); }); test('Does not bleed scope tags between concurrent requests', async ({ request }) => { From 7f70cc488ed99bf61145be1588e287b91ddc26bd Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 4 Sep 2026 14:06:51 +0200 Subject: [PATCH 3/5] Use collectStreamedSpansUntilSegment --- .../tests/server-transactions.test.ts | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts index 971ded0f754d..75241459d669 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; import { - collectStreamedSpans, + collectStreamedSpansUntilSegment, getSpanOp, waitForError, waitForStreamedSpan, @@ -33,10 +33,7 @@ test('Sends a parameterized span name to Sentry', async ({ page }) => { }); test('Sends form data with the action span', async ({ page }) => { - const spansPromise = collectStreamedSpans( - APP_NAME, - spans => spans.some(isSegmentNamed('POST action-formdata')) && spans.some(isDataFunction('action')), - ); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'POST action-formdata'); await page.goto('/action-formdata'); @@ -61,9 +58,11 @@ test('Sends form data with the action span', async ({ page }) => { }); test('Sends a loader span to Sentry', async ({ page }) => { - const spansPromise = collectStreamedSpans( + // The index route has no path of its own, so the segment is named after the method alone; its + // `url.path` is what tells this request apart. + const spansPromise = collectStreamedSpansUntilSegment( APP_NAME, - spans => spans.some(isSegmentNamed('GET')) && spans.some(isDataFunction('loader')), + span => getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/', ); await page.goto('/'); @@ -117,10 +116,7 @@ test('Propagates the trace when the ErrorBoundary is triggered', async ({ page } }); test('Parameterizes a 2-level nested route on the server', async ({ page }) => { - const spansPromise = collectStreamedSpans( - APP_NAME, - spans => spans.some(isSegmentNamed('GET users/:userId/posts/:postId')) && spans.some(isDataFunction('loader')), - ); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET users/:userId/posts/:postId'); await page.goto('/users/user123/posts/post456'); @@ -163,34 +159,27 @@ test('Parameterizes a flat dot-notation route on the server', async ({ page }) = test('Records action and loader spans on a parameterized action route', async ({ request }) => { const routeId = 'routes/action-json-response.$id'; - const spansPromise = collectStreamedSpans( - APP_NAME, - spans => - spans.some(isSegmentNamed('POST action-json-response/:id')) && - spans.some(isDataFunction('action', routeId)) && - spans.some(isDataFunction('loader', 'root')) && - spans.some(isDataFunction('loader', routeId)), - ); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'POST action-json-response/:id'); await request.post('/action-json-response/123123'); const spans = await spansPromise; const actionSpan = spans.find(isDataFunction('action', routeId))!; + expect(actionSpan).toBeDefined(); expect(getSpanOp(actionSpan)).toBe('function'); expect(actionSpan.attributes['match.params.id']?.value).toBe('123123'); + expect(spans.some(isDataFunction('loader', 'root'))).toBe(true); + expect(spans.some(isDataFunction('loader', routeId))).toBe(true); + const segment = spans.find(isSegmentNamed('POST action-json-response/:id'))!; expect(segment.attributes['http.request.method']?.value).toBe('POST'); }); test('Records loader spans on a deferred loader response', async ({ page }) => { const routeId = 'routes/loader-defer-response.$id'; - const spansPromise = collectStreamedSpans( - APP_NAME, - spans => - spans.some(isSegmentNamed('GET loader-defer-response/:id')) && spans.some(isDataFunction('loader', routeId)), - ); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET loader-defer-response/:id'); await page.goto('/loader-defer-response/123123'); @@ -198,6 +187,7 @@ test('Records loader spans on a deferred loader response', async ({ page }) => { const segment = spans.find(isSegmentNamed('GET loader-defer-response/:id'))!; expect(segment.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(spans.some(isDataFunction('loader', routeId))).toBe(true); }); test('Continues a trace from incoming sentry-trace and baggage headers', async ({ request }) => { From e0e8e7d00fd5bf8b96b7d1dcf055750136740bfd Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 4 Sep 2026 14:22:53 +0200 Subject: [PATCH 4/5] Await the ErrorBoundary trace spans individually --- .../tests/server-transactions.test.ts | 60 +++++++++---------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts index 75241459d669..e233f823d9ef 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts @@ -75,44 +75,42 @@ test('Sends a loader span to Sentry', async ({ page }) => { }); test('Propagates the trace when the ErrorBoundary is triggered', async ({ page }) => { - // Streamed spans are buffered before they flush, so spans from an earlier page load can still be - // arriving here. - const streamedSpans: SerializedStreamedSpan[] = []; - void waitForStreamedSpans(APP_NAME, spans => { - streamedSpans.push(...spans); - return false; - }); - // The ErrorBoundary replaces the document, so there is no `sentry-trace` meta tag to read this - // page load's trace off. A unique path identifies its server segment instead. - const id = crypto.randomUUID(); - await page.goto(`/error-boundary-capture/${id}`); + // page load's trace off. A unique path identifies all three of its spans instead, which lets each + // one be awaited on its own rather than selected out of an accumulated trace. + const path = `/error-boundary-capture/${crypto.randomUUID()}`; + const hasPath = (span: SerializedStreamedSpan): boolean => span.attributes['url.path']?.value === path; + + const serverSegmentSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && span.is_segment && hasPath(span), + ); + // Remix renders the document from inside the root loader, so that is the span the client + // continues the trace from. + const loaderSpanPromise = waitForStreamedSpan( + APP_NAME, + span => isDataFunction('loader', 'root')(span) && hasPath(span), + ); + const pageloadSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'pageload' && span.is_segment && hasPath(span), + ); + + await page.goto(path); await expect(page.locator('#event-id')).not.toBeEmpty(); - const findServerSegmentSpan = () => - streamedSpans.find( - span => - getSpanOp(span) === 'http.server' && - span.is_segment && - span.attributes['url.path']?.value === `/error-boundary-capture/${id}`, - ); - await expect.poll(findServerSegmentSpan).toBeDefined(); - const serverSegmentSpan = findServerSegmentSpan()!; + const serverSegmentSpan = await serverSegmentSpanPromise; + const loaderSpan = await loaderSpanPromise; + const pageloadSpan = await pageloadSpanPromise; + expect(serverSegmentSpan.name).toBe('GET error-boundary-capture/:id'); + expect(pageloadSpan.name).toBe('/error-boundary-capture/:id'); // The client continues the server trace, so its pageload span hangs off the root loader span. - const findPageloadSpan = () => - streamedSpans.find( - span => getSpanOp(span) === 'pageload' && span.is_segment && span.trace_id === serverSegmentSpan.trace_id, - ); - await expect.poll(findPageloadSpan).toBeDefined(); - const pageloadSpan = findPageloadSpan()!; - expect(pageloadSpan.name).toBe('/error-boundary-capture/:id'); + expect(loaderSpan.parent_span_id).toBe(serverSegmentSpan.span_id); + expect(pageloadSpan.parent_span_id).toBe(loaderSpan.span_id); + expect(pageloadSpan.trace_id).toBe(serverSegmentSpan.trace_id); expect(pageloadSpan.span_id).not.toBe(serverSegmentSpan.span_id); - - const findLoaderSpan = () => streamedSpans.find(span => span.span_id === pageloadSpan.parent_span_id); - await expect.poll(findLoaderSpan).toBeDefined(); - expect(findLoaderSpan()!.attributes['code.function.name']?.value).toBe('loader'); }); test('Parameterizes a 2-level nested route on the server', async ({ page }) => { From 9c09f40343e8c773e90610e7352edc855def4d97 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 4 Sep 2026 14:38:58 +0200 Subject: [PATCH 5/5] Use setAttribute for the scope-bleed check --- .../app/routes/scope-bleed.$id.tsx | 5 +--- .../tests/server-transactions.test.ts | 25 ++++++++++--------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/scope-bleed.$id.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/scope-bleed.$id.tsx index a1880fc6a09e..946253cc7d29 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/scope-bleed.$id.tsx +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/scope-bleed.$id.tsx @@ -4,10 +4,7 @@ import * as Sentry from '@sentry/remix'; export const loader: LoaderFunction = async ({ params: { id } }) => { const timeTil = parseInt(id || '', 10) * 1000; await new Promise(resolve => setTimeout(resolve, 3000 - timeTil)); - Sentry.setTag(`tag${id}`, id); - // Scope tags only reach Sentry on events, so the request emits one to make the tags of its own - // isolation scope observable. - Sentry.captureMessage(`scope-bleed-${id}`); + Sentry.setAttribute(`tag${id}`, id); return json({ test: 'test' }); }; diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts index e233f823d9ef..a4ffb5c41ee4 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts @@ -3,7 +3,6 @@ import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; import { collectStreamedSpansUntilSegment, getSpanOp, - waitForError, waitForStreamedSpan, waitForStreamedSpans, } from '@sentry-internal/test-utils'; @@ -244,8 +243,13 @@ test('Sends two linked spans (server & client) to Sentry', async ({ page }) => { expect(findLoaderSpan()!.parent_span_id).toBe(serverSegmentSpan.span_id); }); -test('Does not bleed scope tags between concurrent requests', async ({ request }) => { - const eventPromises = [1, 2, 3, 4].map(i => waitForError(APP_NAME, event => event.message === `scope-bleed-${i}`)); +test('Does not bleed scope attributes between concurrent requests', async ({ request }) => { + const spanPromises = [1, 2, 3, 4].map(i => + waitForStreamedSpan( + APP_NAME, + span => isSegmentNamed('GET scope-bleed/:id')(span) && span.attributes[`tag${i}`]?.value === String(i), + ), + ); await Promise.all([ request.get('/scope-bleed/1'), @@ -254,16 +258,13 @@ test('Does not bleed scope tags between concurrent requests', async ({ request } request.get('/scope-bleed/4'), ]); - const events = await Promise.all(eventPromises); + const spans = await Promise.all(spanPromises); - events.forEach(event => { - const tags = event.tags ?? {}; - const customTags = Object.keys(tags).filter(t => t.startsWith('tag')); - expect(customTags).toHaveLength(1); + spans.forEach(span => { + const customKeys = Object.keys(span.attributes).filter(key => key.startsWith('tag')); + expect(customKeys).toHaveLength(1); - const key = customTags[0]!; - const value = key[key.length - 1]; - expect(tags[key]).toBe(value); - expect(event.message).toBe(`scope-bleed-${value}`); + const key = customKeys[0]!; + expect(span.attributes[key]?.value).toBe(key[key.length - 1]); }); });