diff --git a/dev-packages/e2e-tests/test-applications/create-react-app/src/index.tsx b/dev-packages/e2e-tests/test-applications/create-react-app/src/index.tsx index 51770b97da5a..3dcb4c4fd07f 100644 --- a/dev-packages/e2e-tests/test-applications/create-react-app/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/create-react-app/src/index.tsx @@ -5,7 +5,6 @@ import App from './App'; import './index.css'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.browserTracingIntegration()], diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/src/main.tsx b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/main.tsx index 3c3d5e729156..352c53c7208b 100644 --- a/dev-packages/e2e-tests/test-applications/lighthouse-react/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/main.tsx @@ -16,7 +16,6 @@ performance.mark('sentry-sdk-init-start', { if (import.meta.env.MODE === 'tracing-replay') { Sentry.init({ - traceLifecycle: 'static', dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined, release: 'lighthouse-fixture', environment: 'qa', @@ -28,7 +27,6 @@ if (import.meta.env.MODE === 'tracing-replay') { } else if (import.meta.env.MODE === 'tracing') { // Tracing + errors, but no replay — isolates the replay integration's cost. Sentry.init({ - traceLifecycle: 'static', dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined, release: 'lighthouse-fixture', environment: 'qa', @@ -40,7 +38,6 @@ if (import.meta.env.MODE === 'tracing-replay') { // (We don't recommend this setup anywhere and neither will it work well. // this is purely for testing if it changes anything about overhead.) Sentry.init({ - traceLifecycle: 'static', dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined, release: 'lighthouse-fixture', environment: 'qa', @@ -53,7 +50,6 @@ if (import.meta.env.MODE === 'tracing-replay') { } else if (import.meta.env.MODE === 'errors-only') { // Default integrations only — errors are always captured, no tracing or replay. Sentry.init({ - traceLifecycle: 'static', dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined, release: 'lighthouse-fixture', environment: 'qa', @@ -61,7 +57,6 @@ if (import.meta.env.MODE === 'tracing-replay') { } else if (import.meta.env.MODE === 'minimal-integrations') { // Minimal integratoins setup only (everything necessary to automatically get errors) Sentry.init({ - traceLifecycle: 'static', dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined, release: 'lighthouse-fixture', environment: 'qa', @@ -79,7 +74,6 @@ if (import.meta.env.MODE === 'tracing-replay') { // DSN set but every integration disabled. Isolates the cost of the enabled // client itself from the default instrumentation that wraps DOM/timer/network APIs. Sentry.init({ - traceLifecycle: 'static', dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined, release: 'lighthouse-fixture', environment: 'qa', @@ -91,7 +85,6 @@ if (import.meta.env.MODE === 'tracing-replay') { // removeEventListener on ~32 prototypes plus setTimeout/setInterval/rAF/XHR. // Isolates that global monkey-patching cost from the rest of the defaults. Sentry.init({ - traceLifecycle: 'static', dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined, release: 'lighthouse-fixture', environment: 'qa', @@ -102,7 +95,6 @@ if (import.meta.env.MODE === 'tracing-replay') { // Default integrations minus Breadcrumbs, which adds a lot of monkey patching to // DOM and Network APIs as well as event targets and listeners Sentry.init({ - traceLifecycle: 'static', dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined, release: 'lighthouse-fixture', environment: 'qa', @@ -112,7 +104,6 @@ if (import.meta.env.MODE === 'tracing-replay') { // Default integrations minus Breadcrumbs, which adds a lot of monkey patching to // DOM and Network APIs as well as event targets and listeners Sentry.init({ - traceLifecycle: 'static', dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined, release: 'lighthouse-fixture', environment: 'qa', @@ -122,7 +113,7 @@ if (import.meta.env.MODE === 'tracing-replay') { } else if (import.meta.env.MODE === 'init-only') { // enabled: false makes the SDK a guaranteed no-op (no transport allocation, // no DSN warning). We're measuring pure SDK-loading + tree-shaking cost. - Sentry.init({ traceLifecycle: 'static', enabled: false }); + Sentry.init({ enabled: false }); } performance.measure('sentry-sdk-init-duration', { diff --git a/dev-packages/e2e-tests/test-applications/react-17/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-17/src/index.tsx index c303ff60fe80..49609a988202 100644 --- a/dev-packages/e2e-tests/test-applications/react-17/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-17/src/index.tsx @@ -16,7 +16,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-17/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-17/tests/errors.test.ts index 7088cd12e3bf..626ac20485f8 100644 --- a/dev-packages/e2e-tests/test-applications/react-17/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-17/tests/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 correct error event', async ({ page }) => { const errorEventPromise = waitForError('react-17', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-17', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-17', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-17', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts similarity index 57% rename from dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts index 2f0d922ea53b..aac9854a292e 100644 --- a/dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts @@ -1,69 +1,51 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-17', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-17', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/`); - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, }); }); -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { +test('sends a navigation span with a parameterized URL', async ({ page }) => { page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-17', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-17', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-17', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + const navigationSpanPromise = waitForStreamedSpan('react-17', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; }); await page.goto(`/`); - await pageloadTxnPromise; + await pageloadSpanPromise; const linkElement = page.locator('id=navigation'); - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-19/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-19/src/index.tsx index c43ee75a22c8..16209793ae07 100644 --- a/dev-packages/e2e-tests/test-applications/react-19/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-19/src/index.tsx @@ -4,7 +4,6 @@ import ReactDOM from 'react-dom/client'; import Index from './pages/Index'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, release: 'e2e-test', diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/globals.d.ts index 4c48f7834434..7e15b6961e45 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/globals.d.ts +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/globals.d.ts @@ -4,8 +4,15 @@ interface RecordedEvent { op?: string; } +/** A streamed segment span, identified by its span id rather than an event id. */ +interface RecordedSpan { + spanId: string; + traceId: string; + op?: string; +} + interface Window { - recordedTransactions?: RecordedEvent[]; + recordedSegmentSpans?: RecordedSpan[]; capturedException?: RecordedEvent; sentryReplayId?: string; } diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/index.tsx index 80cca0f0e55e..137cd74ea4bd 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/index.tsx @@ -16,7 +16,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ @@ -37,6 +36,19 @@ Sentry.init({ // Always capture replays, so we can test this properly replaysSessionSampleRate: 1.0, replaysOnErrorSampleRate: 0.0, + + // Streamed spans never become transaction events, so the pageload and navigation segments are + // recorded here instead of in the event processor below. They are looked up by span id. + beforeSendSpan(span) { + const op = span.attributes['sentry.op']; + + if (span.is_segment && typeof op === 'string' && (op === 'pageload' || op === 'navigation')) { + window.recordedSegmentSpans = window.recordedSegmentSpans || []; + window.recordedSegmentSpans.push({ spanId: span.span_id, traceId: span.trace_id, op }); + } + + return span; + }, }); Object.defineProperty(window, 'sentryReplayId', { @@ -50,16 +62,8 @@ Object.defineProperty(window, 'sentryReplayId', { Sentry.addEventProcessor(event => { const eventId = event.event_id; const traceId = event.contexts?.trace?.trace_id; - const op = event.contexts?.trace?.op; - - if (!eventId || !traceId) { - return event; - } - if (event.type === 'transaction' && (op === 'pageload' || op === 'navigation')) { - window.recordedTransactions = window.recordedTransactions || []; - window.recordedTransactions.push({ eventId, traceId, op }); - } else if (!event.type && event.exception) { + if (eventId && traceId && !event.type && event.exception) { window.capturedException = { eventId, traceId }; } diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/send-to-sentry.test.ts index 4ddd7af0ce29..1041b2d0a303 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/send-to-sentry.test.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; import { ReplayRecordingData } from './fixtures/ReplayRecordingData'; -import { EVENT_POLLING_OPTIONS, findErrorInTrace, findTransactionInTrace } from './utils/sentry-api'; +import { EVENT_POLLING_OPTIONS, findErrorInTrace, findSpanInTrace } from './utils/sentry-api'; const EVENT_POLLING_TIMEOUT = 90_000; @@ -28,52 +28,48 @@ test('Sends an exception to Sentry', async ({ page }) => { await expect.poll(() => findErrorInTrace(traceId, eventId), EVENT_POLLING_OPTIONS).toBeDefined(); }); -test('Sends a pageload transaction to Sentry', async ({ page }) => { +test('Sends a pageload span to Sentry', async ({ page }) => { await page.goto('/'); - const transactionHandle = await page.waitForFunction(() => - window.recordedTransactions?.find(transaction => transaction.op === 'pageload'), + const spanHandle = await page.waitForFunction(() => + window.recordedSegmentSpans?.find(span => span.op === 'pageload'), ); - const pageloadTransaction = await transactionHandle.jsonValue(); + const pageloadSpan = await spanHandle.jsonValue(); - if (pageloadTransaction === undefined) { - throw new Error("Application didn't record a pageload transaction."); + if (pageloadSpan === undefined) { + throw new Error("Application didn't record a pageload span."); } - const { eventId, traceId } = pageloadTransaction; + const { spanId, traceId } = pageloadSpan; - console.log(`Polling for pageload transaction eventId: ${eventId} in trace: ${traceId}`); + console.log(`Polling for pageload spanId: ${spanId} in trace: ${traceId}`); - await expect - .poll(() => findTransactionInTrace(traceId, eventId), EVENT_POLLING_OPTIONS) - .toMatchObject({ op: 'pageload' }); + await expect.poll(() => findSpanInTrace(traceId, 'pageload'), EVENT_POLLING_OPTIONS).toBeDefined(); }); -test('Sends a navigation transaction to Sentry', async ({ page }) => { +test('Sends a navigation span to Sentry', async ({ page }) => { await page.goto('/'); - // Give pageload transaction time to finish + // Give the pageload span time to finish await page.waitForTimeout(4000); const linkElement = page.locator('id=navigation'); await linkElement.click(); - const transactionHandle = await page.waitForFunction(() => - window.recordedTransactions?.find(transaction => transaction.op === 'navigation'), + const spanHandle = await page.waitForFunction(() => + window.recordedSegmentSpans?.find(span => span.op === 'navigation'), ); - const navigationTransaction = await transactionHandle.jsonValue(); + const navigationSpan = await spanHandle.jsonValue(); - if (navigationTransaction === undefined) { - throw new Error("Application didn't record a navigation transaction."); + if (navigationSpan === undefined) { + throw new Error("Application didn't record a navigation span."); } - const { eventId, traceId } = navigationTransaction; + const { spanId, traceId } = navigationSpan; - console.log(`Polling for navigation transaction eventId: ${eventId} in trace: ${traceId}`); + console.log(`Polling for navigation spanId: ${spanId} in trace: ${traceId}`); - await expect - .poll(() => findTransactionInTrace(traceId, eventId), EVENT_POLLING_OPTIONS) - .toMatchObject({ op: 'navigation' }); + await expect.poll(() => findSpanInTrace(traceId, 'navigation'), EVENT_POLLING_OPTIONS).toBeDefined(); }); test('Sends a Replay recording to Sentry', async ({ browser }) => { diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/utils/sentry-api.ts b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/utils/sentry-api.ts index 31e2adbd21c5..943f9a39eb5a 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/utils/sentry-api.ts +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/utils/sentry-api.ts @@ -12,12 +12,10 @@ export const EVENT_POLLING_OPTIONS = { timeout: 180_000, intervals: [5_000] }; * occurrences all share this shape and are discriminated by `event_type`. */ export interface TraceItem { + /** On a span this is the span id. */ event_id?: string; - /** On spans this is the event id of the transaction the span belongs to. */ - transaction_id?: string; event_type?: 'span' | 'error' | 'occurrence' | 'uptime_check'; op?: string; - is_transaction?: boolean; children?: TraceItem[]; errors?: TraceItem[]; occurrences?: TraceItem[]; @@ -67,6 +65,30 @@ export async function findErrorInTrace(traceId: string, eventId: string): Promis return flattenTrace(await fetchTrace(traceId)).find(item => item.event_type === 'error' && item.event_id === eventId); } -export async function findTransactionInTrace(traceId: string, eventId: string): Promise { - return flattenTrace(await fetchTrace(traceId)).find(item => item.is_transaction && item.transaction_id === eventId); +let loggedTraceShape = false; + +/** + * Streamed spans never become transaction events, so the segment is matched by its op rather than by + * the event id of an enclosing transaction. The trace is already unique to the pageload or + * navigation under test, so the op identifies the segment within it. + */ +export async function findSpanInTrace(traceId: string, op: string): Promise { + const items = flattenTrace(await fetchTrace(traceId)); + const match = items.find(item => item.op === op); + + // The trace endpoint's exact span shape is what this lookup depends on, so report it once when a + // non-empty trace does not contain the op we are waiting for. + if (!match && items.length && !loggedTraceShape) { + loggedTraceShape = true; + console.log( + `Trace ${traceId} has no "${op}" item yet. Items so far:`, + JSON.stringify( + items.map(item => ({ event_type: item.event_type, op: item.op, event_id: item.event_id })), + null, + 2, + ), + ); + } + + return match; }