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..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,7 +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); + Sentry.setAttribute(`tag${id}`, 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..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 @@ -1,26 +1,38 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { + collectStreamedSpansUntilSegment, + getSpanOp, + 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 = collectStreamedSpansUntilSegment(APP_NAME, 'POST action-formdata'); await page.goto('/action-formdata'); @@ -33,178 +45,151 @@ 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; - }); + // 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, + span => getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/', + ); 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; - }); - - 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}`); +test('Propagates the trace when the ErrorBoundary is triggered', async ({ page }) => { + // 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 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 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 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), + ); - const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id; - const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id; - const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id; + await page.goto(path); + await expect(page.locator('#event-id')).not.toBeEmpty(); - expect(httpServerTransaction.transaction).toBe('GET client-error'); - expect(pageloadTransaction.transaction).toBe('/client-error'); + const serverSegmentSpan = await serverSegmentSpanPromise; + const loaderSpan = await loaderSpanPromise; + const pageloadSpan = await pageloadSpanPromise; - expect(httpServerTraceId).toBeDefined(); - expect(httpServerSpanId).toBeDefined(); + expect(serverSegmentSpan.name).toBe('GET error-boundary-capture/:id'); + expect(pageloadSpan.name).toBe('/error-boundary-capture/:id'); - expect(pageLoadTraceId).toEqual(httpServerTraceId); - expect(pageLoadParentSpanId).toEqual(loaderSpanId); - expect(pageLoadSpanId).not.toEqual(httpServerSpanId); + // The client continues the server trace, so its pageload span hangs off the root loader span. + 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); }); 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 = collectStreamedSpansUntilSegment(APP_NAME, 'GET users/:userId/posts/:postId'); 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 = collectStreamedSpansUntilSegment(APP_NAME, 'POST action-json-response/:id'); 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(); + expect(spans.some(isDataFunction('loader', 'root'))).toBe(true); + expect(spans.some(isDataFunction('loader', routeId))).toBe(true); - 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 = collectStreamedSpansUntilSegment(APP_NAME, 'GET loader-defer-response/:id'); 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'); + expect(spans.some(isDataFunction('loader', routeId))).toBe(true); }); 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,16 +199,56 @@ 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('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); - }), +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 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 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([ @@ -233,61 +258,13 @@ test('Does not bleed scope tags between concurrent requests', async ({ request } request.get('/scope-bleed/4'), ]); - const transactions = await Promise.all(txnPromises); - - transactions.forEach(txn => { - const tags = txn.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); - }); -}); - -test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => { - // We use this to identify the transactions - const testTag = crypto.randomUUID(); + const spans = await Promise.all(spanPromises); - const httpServerTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.tags?.['sentry_test'] === testTag; - }); + spans.forEach(span => { + const customKeys = Object.keys(span.attributes).filter(key => key.startsWith('tag')); + expect(customKeys).toHaveLength(1); - const pageLoadTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag; + const key = customKeys[0]!; + expect(span.attributes[key]?.value).toBe(key[key.length - 1]); }); - - 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); });