diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/hooks.client.ts index 2b68fff0c5ac..91592e7ab932 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/hooks.client.ts @@ -3,7 +3,6 @@ import * as Sentry from '@sentry/sveltekit'; import * as Spotlight from '@spotlightjs/spotlight'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: env.PUBLIC_E2E_TEST_DSN, debug: !!env.PUBLIC_DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/instrumentation.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/instrumentation.server.ts index c56287af4dc5..136b51a44dee 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/instrumentation.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/src/instrumentation.server.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/sveltekit'; import { E2E_TEST_DSN } from '$env/static/private'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.client.test.ts index be027d84db2c..f17277f4d46b 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.client.test.ts @@ -1,129 +1,83 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; test.describe('client-specific performance events', () => { test('multiple navigations have distinct traces', async ({ page }) => { - const navigationTxn1EventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === '/nav1' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan1Promise = waitForStreamedSpan('sveltekit-2-kit-tracing', span => { + return span.name === '/nav1' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const navigationTxn2EventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === '/' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan2Promise = waitForStreamedSpan('sveltekit-2-kit-tracing', span => { + return span.name === '/' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const navigationTxn3EventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === '/nav2' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan3Promise = waitForStreamedSpan('sveltekit-2-kit-tracing', span => { + return span.name === '/nav2' && getSpanOp(span) === 'navigation' && span.is_segment; }); await waitForInitialPageload(page); await page.getByText('Nav 1').click(); - const navigationTxn1Event = await navigationTxn1EventPromise; + const navigationSpan1 = await navigationSpan1Promise; await page.goBack(); - const navigationTxn2Event = await navigationTxn2EventPromise; + const navigationSpan2 = await navigationSpan2Promise; await page.getByText('Nav 2').click(); - const navigationTxn3Event = await navigationTxn3EventPromise; - - expect(navigationTxn1Event).toMatchObject({ - transaction: '/nav1', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'url.path': '/nav1', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/nav1$/), - 'url.template': '/nav1', - }, + const navigationSpan3 = await navigationSpan3Promise; + + const expectNavigationSpan = (span: typeof navigationSpan1, route: string) => { + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: route, type: 'string' }, + 'url.full': { + value: expect.stringMatching(new RegExp(`^https?:\\/\\/localhost:\\d+${route}$`)), + type: 'string', }, - }, - }); + 'url.template': { value: route, type: 'string' }, + }); + }; - expect(navigationTxn2Event).toMatchObject({ - transaction: '/', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - 'url.template': '/', - }, - }, - }, - }); - - expect(navigationTxn3Event).toMatchObject({ - transaction: '/nav2', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'url.path': '/nav2', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/nav2$/), - 'url.template': '/nav2', - }, - }, - }, - }); + expectNavigationSpan(navigationSpan1, '/nav1'); + expectNavigationSpan(navigationSpan2, '/'); + expectNavigationSpan(navigationSpan3, '/nav2'); // traces should NOT be connected - expect(navigationTxn1Event.contexts?.trace?.trace_id).not.toBe(navigationTxn2Event.contexts?.trace?.trace_id); - expect(navigationTxn2Event.contexts?.trace?.trace_id).not.toBe(navigationTxn3Event.contexts?.trace?.trace_id); - expect(navigationTxn1Event.contexts?.trace?.trace_id).not.toBe(navigationTxn3Event.contexts?.trace?.trace_id); + expect(navigationSpan1.trace_id).not.toBe(navigationSpan2.trace_id); + expect(navigationSpan2.trace_id).not.toBe(navigationSpan3.trace_id); + expect(navigationSpan1.trace_id).not.toBe(navigationSpan3.trace_id); }); test('records manually added component tracking spans', async ({ page }) => { - const componentTxnEventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === '/components'; - }); + const componentTraceSpansPromise = collectStreamedSpans('sveltekit-2-kit-tracing', spansOfTrace => + spansOfTrace.some(span => span.name === '/components' && span.is_segment), + ); await waitForInitialPageload(page); await page.getByText('Component Tracking').click(); - const componentTxnEvent = await componentTxnEventPromise; + const componentTraceSpans = await componentTraceSpansPromise; - expect(componentTxnEvent.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', + const componentSpan = (name: string) => + expect.objectContaining({ + name, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'ui.mount', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.svelte', type: 'string' }, }), + }); + + expect(componentTraceSpans).toEqual( + expect.arrayContaining([ + componentSpan(''), + componentSpan(''), + componentSpan(''), + componentSpan(''), ]), ); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.server.test.ts index c8708815203e..c18a29b688a4 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.server.test.ts @@ -1,187 +1,158 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/sveltekit'; +import { collectSegmentSpans } from '@sentry-internal/test-utils'; test('server pageload request span has nested request span for sub request', async ({ page }) => { - const serverTxnEventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === 'GET /server-load-fetch'; - }); + const serverSegmentPromise = collectSegmentSpans( + 'sveltekit-2-kit-tracing', + segmentSpan => segmentSpan.name === 'GET /server-load-fetch', + ); await page.goto('/server-load-fetch'); - const serverTxnEvent = await serverTxnEventPromise; - const spans = serverTxnEvent.spans; - - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /server-load-fetch', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', - 'http.method': 'GET', - 'http.route': '/server-load-fetch', - 'sveltekit.tracing.original_name': 'sveltekit.handle.root', - }, - }, - }, + const { segmentSpan: serverSpan, childSpans } = await serverSegmentPromise; + + expect(serverSpan.status).toBe('ok'); + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.method': { value: 'GET', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'http.route': { value: '/server-load-fetch', type: 'string' }, + 'sveltekit.tracing.original_name': { value: 'sveltekit.handle.root', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/server-load-fetch', type: 'string' }, + 'http.request.header.accept': { value: expect.any(String), type: 'string' }, + 'http.request.header.user_agent': { value: expect.any(String), type: 'string' }, }); - expect(spans).toHaveLength(6); + expect(childSpans).toHaveLength(6); - expect(spans).toEqual( + expect(childSpans).toEqual( expect.arrayContaining([ // initial resolve span: expect.objectContaining({ - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', - 'http.route': '/server-load-fetch', - }), - op: 'function', - description: 'sveltekit.resolve', - origin: 'auto.http.sveltekit', + name: 'sveltekit.resolve', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'http.route': { value: '/server-load-fetch', type: 'string' }, + }), }), // sequenced handler span: expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.origin': 'auto.function.sveltekit.handle', - 'sentry.op': 'function', - }), - description: 'sveltekit.handle.sequenced.sentryRequestHandler', - op: 'function', - origin: 'auto.function.sveltekit.handle', + name: 'sveltekit.handle.sequenced.sentryRequestHandler', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.handle', type: 'string' }, + }), }), // load span where the server load function initiates the sub request: expect.objectContaining({ - data: expect.objectContaining({ - 'http.route': '/server-load-fetch', - 'sentry.op': 'function', - 'sentry.origin': 'auto.function.sveltekit.load', - 'sveltekit.load.environment': 'server', - 'sveltekit.load.node_id': 'src/routes/server-load-fetch/+page.server.ts', - 'sveltekit.load.node_type': '+page.server', - }), - description: 'sveltekit.load', - op: 'function', - origin: 'auto.function.sveltekit.load', + name: 'sveltekit.load', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.load', type: 'string' }, + 'http.route': { value: '/server-load-fetch', type: 'string' }, + 'sveltekit.load.environment': { value: 'server', type: 'string' }, + 'sveltekit.load.node_id': { value: 'src/routes/server-load-fetch/+page.server.ts', type: 'string' }, + 'sveltekit.load.node_type': { value: '+page.server', type: 'string' }, + }), }), // sub request http.server span: expect.objectContaining({ - data: expect.objectContaining({ - 'http.method': 'GET', - 'http.route': '/api/users', - 'url.full': 'http://localhost:3030/api/users', - 'url.path': '/api/users', - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.sveltekit', - 'sveltekit.is_data_request': false, - 'sveltekit.is_sub_request': true, - 'sveltekit.tracing.original_name': 'sveltekit.handle.root', - }), - description: 'GET /api/users', - op: 'http.server', - origin: 'auto.http.sveltekit', + name: 'GET /api/users', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'http.method': { value: 'GET', type: 'string' }, + 'http.route': { value: '/api/users', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/api/users', type: 'string' }, + 'url.path': { value: '/api/users', type: 'string' }, + 'sveltekit.is_data_request': { value: false, type: 'boolean' }, + 'sveltekit.is_sub_request': { value: true, type: 'boolean' }, + 'sveltekit.tracing.original_name': { value: 'sveltekit.handle.root', type: 'string' }, + }), }), - // sub requestsequenced handler span: + // sub request sequenced handler span: expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.origin': 'auto.function.sveltekit.handle', - 'sentry.op': 'function', - }), - description: 'sveltekit.handle.sequenced.sentryRequestHandler', - op: 'function', - origin: 'auto.function.sveltekit.handle', + name: 'sveltekit.handle.sequenced.sentryRequestHandler', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.handle', type: 'string' }, + }), }), // sub request resolve span: expect.objectContaining({ - data: expect.objectContaining({ - 'http.route': '/api/users', - 'sentry.op': 'function', - 'sentry.origin': 'auto.http.sveltekit', - }), - description: 'sveltekit.resolve', - op: 'function', - origin: 'auto.http.sveltekit', + name: 'sveltekit.resolve', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'http.route': { value: '/api/users', type: 'string' }, + }), }), ]), ); - - expect(serverTxnEvent.request).toEqual({ - cookies: {}, - headers: expect.objectContaining({ - accept: expect.any(String), - 'user-agent': expect.any(String), - }), - method: 'GET', - url: 'http://localhost:3030/server-load-fetch', - }); }); test('server trace includes form action span', async ({ page }) => { - const serverTxnEventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === 'POST /form-action'; - }); + const serverSegmentPromise = collectSegmentSpans( + 'sveltekit-2-kit-tracing', + segmentSpan => segmentSpan.name === 'POST /form-action', + ); await page.goto('/form-action'); await page.locator('#inputName').fill('H4cktor'); await page.locator('#buttonSubmit').click(); - const serverTxnEvent = await serverTxnEventPromise; - - expect(serverTxnEvent).toMatchObject({ - transaction: 'POST /form-action', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + const { segmentSpan: serverSpan, childSpans } = await serverSegmentPromise; + + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); - expect(serverTxnEvent.spans).toHaveLength(3); + expect(childSpans).toHaveLength(3); - expect(serverTxnEvent.spans).toEqual( + expect(childSpans).toEqual( expect.arrayContaining([ // sequenced handler span expect.objectContaining({ - description: 'sveltekit.handle.sequenced.sentryRequestHandler', - op: 'function', - origin: 'auto.function.sveltekit.handle', + name: 'sveltekit.handle.sequenced.sentryRequestHandler', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.handle', type: 'string' }, + }), }), // resolve span expect.objectContaining({ - description: 'sveltekit.resolve', - op: 'function', - origin: 'auto.http.sveltekit', + name: 'sveltekit.resolve', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + }), }), // form action span expect.objectContaining({ - description: 'sveltekit.form_action', - op: 'function', - origin: 'auto.function.sveltekit.action', - data: expect.objectContaining({ - 'sveltekit.form_action.name': 'default', + name: 'sveltekit.form_action', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.action', type: 'string' }, + 'sveltekit.form_action.name': { value: 'default', type: 'string' }, }), }), ]), diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.test.ts index bb74859d9e59..7a7a5706f0bd 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.test.ts @@ -1,76 +1,61 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; test('capture a distributed pageload trace', async ({ page }) => { - const clientTxnEventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === '/users/[id]'; + const traceSpansPromise = collectStreamedSpans('sveltekit-2-kit-tracing', spansOfTrace => { + const hasClientSegment = spansOfTrace.some(span => span.name === '/users/[id]' && span.is_segment); + const hasServerSegment = spansOfTrace.some(span => span.name === 'GET /users/[id]' && span.is_segment); + return hasClientSegment && hasServerSegment; }); - const serverTxnEventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === 'GET /users/[id]'; - }); - - const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + const [_, traceSpans] = await Promise.all([ page.goto('/users/123xyz'), - clientTxnEventPromise, - serverTxnEventPromise, + traceSpansPromise, expect(page.getByText('User id: 123xyz')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.sveltekit', - data: { - 'url.path': '/users/123xyz', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123xyz$/), - 'url.template': '/users/[id]', - }, - }, - }, - }); + const clientSpan = traceSpans.find(span => span.name === '/users/[id]' && span.is_segment)!; + const serverSpan = traceSpans.find(span => span.name === 'GET /users/[id]' && span.is_segment)!; - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/users/123xyz', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123xyz$/), type: 'string' }, + 'url.template': { value: '/users/[id]', type: 'string' }, }); - expect(clientTxnEvent.spans?.length).toBeGreaterThan(5); - - const serverKitResolveSpan = serverTxnEvent.spans?.find(s => s.description === 'sveltekit.resolve'); - expect(serverKitResolveSpan).toMatchObject({ - description: 'sveltekit.resolve', - op: 'function', - origin: 'auto.http.sveltekit', - status: 'ok', + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); + expect(traceSpans.length).toBeGreaterThan(5); + // connected trace - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); + + const serverKitResolveSpan = traceSpans.find(span => span.name === 'sveltekit.resolve'); + expect(serverKitResolveSpan?.status).toBe('ok'); + expect(serverKitResolveSpan?.attributes).toMatchObject({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + }); // Sveltekit resolve span is the parent span of the client span - expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverKitResolveSpan?.span_id); + expect(clientSpan.parent_span_id).toBe(serverKitResolveSpan?.span_id); }); test('capture a distributed navigation trace', async ({ page }) => { - const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === '/users' && txnEvent.contexts?.trace?.op === 'navigation'; + const clientNavigationSpanPromise = waitForStreamedSpan('sveltekit-2-kit-tracing', span => { + return span.name === '/users' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const serverTxnEventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === 'GET /users'; + const serverSpanPromise = waitForStreamedSpan('sveltekit-2-kit-tracing', span => { + return span.name === 'GET /users' && span.is_segment; }); await waitForInitialPageload(page); @@ -78,342 +63,217 @@ test('capture a distributed navigation trace', async ({ page }) => { // navigation to page const clickPromise = page.getByText('Route with Server Load').click(); - const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ - clientNavigationTxnEventPromise, - serverTxnEventPromise, + const [clientSpan, serverSpan, _1, _2] = await Promise.all([ + clientNavigationSpanPromise, + serverSpanPromise, clickPromise, expect(page.getByText('Hi everyone')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'url.path': '/users', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users$/), - 'url.template': '/users', - }, - }, - }, + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/users', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/users$/), type: 'string' }, + 'url.template': { value: '/users', type: 'string' }, }); - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); // trace is connected - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); }); test('record client-side universal load fetch span and trace', async ({ page }) => { await waitForInitialPageload(page); - const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === '/universal-load-fetch' && txnEvent.contexts?.trace?.op === 'navigation'; - }); - - // this transaction should be created because of the fetch call + // the server span should be created because of the fetch call // it should also be part of the trace - const serverTxnEventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.transaction === 'GET /api/users'; + const traceSpansPromise = collectStreamedSpans('sveltekit-2-kit-tracing', spansOfTrace => { + const hasClientSegment = spansOfTrace.some( + span => span.name === '/universal-load-fetch' && getSpanOp(span) === 'navigation' && span.is_segment, + ); + const hasServerSegment = spansOfTrace.some(span => span.name === 'GET /api/users' && span.is_segment); + return hasClientSegment && hasServerSegment; }); // navigation to page const clickPromise = page.getByText('Route with fetch in universal load').click(); - const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ - clientNavigationTxnEventPromise, - serverTxnEventPromise, + const [traceSpans, _1, _2] = await Promise.all([ + traceSpansPromise, clickPromise, expect(page.getByText('alice')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/universal-load-fetch', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'url.path': '/universal-load-fetch', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/universal-load-fetch$/), - 'url.template': '/universal-load-fetch', - }, - }, - }, + const clientSpan = traceSpans.find(span => span.name === '/universal-load-fetch' && span.is_segment)!; + const serverSpan = traceSpans.find(span => span.name === 'GET /api/users' && span.is_segment)!; + + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/universal-load-fetch', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/universal-load-fetch$/), type: 'string' }, + 'url.template': { value: '/universal-load-fetch', type: 'string' }, }); - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /api/users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); // trace is connected - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); - - const clientFetchSpan = clientTxnEvent.spans?.find(s => s.op === 'http.client'); - - expect(clientFetchSpan).toMatchObject({ - description: expect.stringMatching(/^GET.*\/api\/users/), - op: 'http.client', - origin: 'auto.http.browser', - data: { - 'url.full': expect.stringContaining('/api/users'), - type: 'fetch', - 'http.request.method': 'GET', - 'http.response.status_code': 200, - 'network.protocol.version': '1.1', - 'network.protocol.name': 'http', - 'http.request.redirect_start': expect.any(Number), - 'http.request.fetch_start': expect.any(Number), - 'http.request.domain_lookup_start': expect.any(Number), - 'http.request.domain_lookup_end': expect.any(Number), - 'http.request.connect_start': expect.any(Number), - 'http.request.secure_connection_start': expect.any(Number), - 'http.request.connection_end': expect.any(Number), - 'http.request.request_start': expect.any(Number), - 'http.request.response_start': expect.any(Number), - 'http.request.response_end': expect.any(Number), - }, + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); + + const clientFetchSpan = traceSpans.find(span => getSpanOp(span) === 'http.client'); + + expect(clientFetchSpan?.name).toBe('GET localhost'); + expect(clientFetchSpan?.parent_span_id).toBe(clientSpan.span_id); + expect(clientFetchSpan?.attributes).toMatchObject({ + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.browser', type: 'string' }, + 'url.full': { value: expect.stringContaining('/api/users'), type: 'string' }, + type: { value: 'fetch', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'network.protocol.version': { value: '1.1', type: 'string' }, + 'network.protocol.name': { value: 'http', type: 'string' }, + 'http.request.redirect_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.fetch_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.domain_lookup_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.domain_lookup_end': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.connect_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.secure_connection_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.connection_end': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.request_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.response_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.response_end': expect.objectContaining({ value: expect.any(Number) }), }); }); -test('captures a navigation transaction directly after pageload', async ({ page }) => { - const clientPageloadTxnPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'pageload'; +test('captures a navigation span directly after pageload', async ({ page }) => { + const clientPageloadSpanPromise = waitForStreamedSpan('sveltekit-2-kit-tracing', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const clientNavigationTxnPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation'; - }); + const navigationTraceSpansPromise = collectStreamedSpans('sveltekit-2-kit-tracing', spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment), + ); await waitForInitialPageload(page, { route: '/' }); const navigationClickPromise = page.locator('#routeWithParamsLink').click(); - const [pageloadTxnEvent, navigationTxnEvent, _] = await Promise.all([ - clientPageloadTxnPromise, - clientNavigationTxnPromise, + const [pageloadSpan, navigationTraceSpans, _] = await Promise.all([ + clientPageloadSpanPromise, + navigationTraceSpansPromise, navigationClickPromise, ]); - expect(pageloadTxnEvent).toMatchObject({ - transaction: '/', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.sveltekit', - data: { - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - 'url.template': '/', - }, - }, - }, + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + 'url.template': { value: '/', type: 'string' }, }); - expect(navigationTxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - 'url.path': '/users/123abc', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123abc$/), - 'url.template': '/users/[id]', - }, - }, - }, + const navigationSpan = navigationTraceSpans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; + + expect(navigationSpan.name).toBe('/users/[id]'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: '/users/[id]', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, + 'url.path': { value: '/users/123abc', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123abc$/), type: 'string' }, + 'url.template': { value: '/users/[id]', type: 'string' }, }); - const routingSpans = navigationTxnEvent.spans?.filter(s => s.op === 'router'); + const routingSpans = navigationTraceSpans.filter(span => getSpanOp(span) === 'router'); expect(routingSpans).toHaveLength(1); - const routingSpan = routingSpans && routingSpans[0]; - expect(routingSpan).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - }, + const routingSpan = routingSpans[0]!; + expect(routingSpan.name).toBe('Router'); + expect(routingSpan.parent_span_id).toBe(navigationSpan.span_id); + expect(routingSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'router', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.sveltekit', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: '/users/[id]', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, }); }); -test('captures one navigation transaction per redirect', async ({ page }) => { - const clientNavigationRedirect1TxnPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect1'; - }); - - const clientNavigationRedirect2TxnPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect2'; - }); +test('captures one navigation span per redirect', async ({ page }) => { + const collectNavigationTrace = (route: string) => + collectStreamedSpans('sveltekit-2-kit-tracing', spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.name === route && span.is_segment), + ); - const clientNavigationRedirect3TxnPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/users/[id]'; - }); + const redirect1TraceSpansPromise = collectNavigationTrace('/redirect1'); + const redirect2TraceSpansPromise = collectNavigationTrace('/redirect2'); + const redirect3TraceSpansPromise = collectNavigationTrace('/users/[id]'); await waitForInitialPageload(page, { route: '/' }); const navigationClickPromise = page.locator('#redirectLink').click(); - const [redirect1TxnEvent, redirect2TxnEvent, redirect3TxnEvent, _] = await Promise.all([ - clientNavigationRedirect1TxnPromise, - clientNavigationRedirect2TxnPromise, - clientNavigationRedirect3TxnPromise, + const [redirect1TraceSpans, redirect2TraceSpans, redirect3TraceSpans, _] = await Promise.all([ + redirect1TraceSpansPromise, + redirect2TraceSpansPromise, + redirect3TraceSpansPromise, navigationClickPromise, ]); - expect(redirect1TxnEvent).toMatchObject({ - transaction: '/redirect1', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect1', - 'sentry.sample_rate': 1, - 'url.path': '/redirect1', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/redirect1$/), - 'url.template': '/redirect1', - }, - }, - }, - }); - - const redirect1Spans = redirect1TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect1Spans).toHaveLength(1); - - const redirect1Span = redirect1Spans && redirect1Spans[0]; - expect(redirect1Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect1', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); - - expect(redirect2TxnEvent).toMatchObject({ - transaction: '/redirect2', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect2', - 'sentry.sample_rate': 1, - 'url.path': '/redirect2', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/redirect2$/), - 'url.template': '/redirect2', - }, - }, - }, - }); - - const redirect2Spans = redirect2TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect2Spans).toHaveLength(1); - - const redirect2Span = redirect2Spans && redirect2Spans[0]; - expect(redirect2Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect2', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); - - expect(redirect3TxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sample_rate': 1, - 'url.path': '/users/789', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/789$/), - 'url.template': '/users/[id]', - }, - }, - }, - }); - - const redirect3Spans = redirect3TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect3Spans).toHaveLength(1); - - const redirect3Span = redirect3Spans && redirect3Spans[0]; - expect(redirect3Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); + const expectNavigationTrace = ( + traceSpans: typeof redirect1TraceSpans, + { route, path }: { route: string; path: string }, + ) => { + const navigationSpan = traceSpans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; + + expect(navigationSpan.name).toBe(route); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: route, type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'url.path': { value: path, type: 'string' }, + 'url.full': { value: expect.stringMatching(new RegExp(`^https?:\\/\\/localhost:\\d+${path}$`)), type: 'string' }, + 'url.template': { value: route, type: 'string' }, + }); + + const routingSpans = traceSpans.filter(span => getSpanOp(span) === 'router'); + expect(routingSpans).toHaveLength(1); + + const routingSpan = routingSpans[0]!; + expect(routingSpan.name).toBe('Router'); + expect(routingSpan.parent_span_id).toBe(navigationSpan.span_id); + expect(routingSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'router', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.sveltekit', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: route, type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, + }); + }; + + expectNavigationTrace(redirect1TraceSpans, { route: '/redirect1', path: '/redirect1' }); + expectNavigationTrace(redirect2TraceSpans, { route: '/redirect2', path: '/redirect2' }); + expectNavigationTrace(redirect3TraceSpans, { route: '/users/[id]', path: '/users/789' }); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/utils.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/utils.ts index a628f558a4bf..1ec43a3b0f96 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/utils.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/utils.ts @@ -1,5 +1,5 @@ import { Page } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; /** * Helper function that waits for the initial pageload to complete. @@ -7,12 +7,12 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; * This function * - loads the given route ("/" by default) * - waits for SvelteKit's hydration - * - waits for the pageload transaction to be sent (doesn't assert on it though) + * - waits for the pageload span to be sent (doesn't assert on it though) * * Useful for tests that test outcomes of _navigations_ after an initial pageload. - * Waiting on the pageload transaction excludes edge cases where navigations occur - * so quickly that the pageload idle transaction is still active. This might lead - * to cases where the routing span would be attached to the pageload transaction + * Waiting on the pageload span excludes edge cases where navigations occur + * so quickly that the pageload idle span is still active. This might lead + * to cases where the routing span would be attached to the pageload span * and hence eliminates a lot of flakiness. * */ @@ -21,28 +21,28 @@ export async function waitForInitialPageload( opts?: { route?: string; parameterizedRoute?: string; debug?: boolean }, ) { const route = opts?.route ?? '/'; - const txnName = opts?.parameterizedRoute ?? route; + const spanName = opts?.parameterizedRoute ?? route; const debug = opts?.debug ?? false; - const clientPageloadTxnEventPromise = waitForTransaction('sveltekit-2-kit-tracing', txnEvent => { + const clientPageloadSpanPromise = waitForStreamedSpan('sveltekit-2-kit-tracing', span => { debug && console.log({ - txn: txnEvent?.transaction, - op: txnEvent.contexts?.trace?.op, - trace: txnEvent.contexts?.trace?.trace_id, - span: txnEvent.contexts?.trace?.span_id, - parent: txnEvent.contexts?.trace?.parent_span_id, + name: span.name, + op: getSpanOp(span), + trace: span.trace_id, + span: span.span_id, + parent: span.parent_span_id, }); - return txnEvent?.transaction === txnName && txnEvent.contexts?.trace?.op === 'pageload'; + return span.name === spanName && getSpanOp(span) === 'pageload' && span.is_segment; }); await Promise.all([ page.goto(route), // the test app adds the "hydrated" class to the body when hydrating page.waitForSelector('body.hydrated'), - // also waiting for the initial pageload txn so that later navigations don't interfere - clientPageloadTxnEventPromise, + // also waiting for the initial pageload span so that later navigations don't interfere + clientPageloadSpanPromise, ]); debug && console.log('hydrated'); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/.gitignore b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/.gitignore new file mode 100644 index 000000000000..6635cf554275 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/README.md b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/README.md new file mode 100644 index 000000000000..7c0d9fbb26ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/README.md @@ -0,0 +1,41 @@ +# create-svelte + +Everything you need to build a Svelte project, powered by +[`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```bash +# create a new project in the current directory +npm create svelte@latest + +# create a new project in my-app +npm create svelte@latest my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a +development server: + +```bash +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```bash +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target +> environment. diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/docker-compose.yml b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/docker-compose.yml new file mode 100644 index 000000000000..edf0000445fd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/docker-compose.yml @@ -0,0 +1,31 @@ +services: + db: + image: mysql:8.0 + restart: always + container_name: e2e-tests-sveltekit-2-static-mysql + # The `mysql` 2.x driver doesn't speak MySQL 8's default + # `caching_sha2_password` auth, so force the legacy plugin. + command: ['--default-authentication-plugin=mysql_native_password'] + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s + + redis: + image: redis:7 + restart: always + container_name: e2e-tests-sveltekit-2-static-redis + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/global-setup.mjs b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/global-setup.mjs new file mode 100644 index 000000000000..cb48d539c466 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start MySQL + Redis via Docker Compose. `--wait` blocks until the + // healthchecks in docker-compose.yml pass, so the app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json new file mode 100644 index 000000000000..ddc0c67ee4b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json @@ -0,0 +1,40 @@ +{ + "name": "sveltekit-2-static", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "proxy": "node start-event-proxy.mjs", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test:prod": "TEST_ENV=production playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:dev": "TEST_ENV=development playwright test db" + }, + "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", + "dependencies": { + "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz", + "ioredis": "5.10.1", + "mysql": "^2.18.1" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/adapter-node": "^2.0.0", + "@sveltejs/kit": "2.60.1", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.2.8", + "svelte-check": "^3.6.0", + "typescript": "^5.0.0", + "vite": "^5.4.11" + }, + "volta": { + "extends": "../../package.json" + }, + "type": "module" +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/playwright.config.mjs new file mode 100644 index 000000000000..586ead219ff8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/playwright.config.mjs @@ -0,0 +1,17 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const config = getPlaywrightConfig({ + startCommand: testEnv === 'development' ? `pnpm dev --port 3030` : `node build`, +}); + +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/app.html b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/app.html new file mode 100644 index 000000000000..023060c2d49d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/hooks.client.ts new file mode 100644 index 000000000000..2de2a2dd0d1f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/hooks.client.ts @@ -0,0 +1,17 @@ +import { env } from '$env/dynamic/public'; +import * as Sentry from '@sentry/sveltekit'; + +Sentry.init({ + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: env.PUBLIC_E2E_TEST_DSN, + debug: !!env.PUBLIC_DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, +}); + +const myErrorHandler = ({ error, event }: any) => { + console.error('An error occurred on the client side:', error, event); +}; + +export const handleError = Sentry.handleErrorWithSentry(myErrorHandler); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/hooks.server.ts new file mode 100644 index 000000000000..5780bbcb38c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/hooks.server.ts @@ -0,0 +1,16 @@ +import { E2E_TEST_DSN } from '$env/static/private'; +import * as Sentry from '@sentry/sveltekit'; + +Sentry.init({ + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, +}); + +// not logging anything to console to avoid noise in the test output +export const handleError = Sentry.handleErrorWithSentry(() => {}); + +export const handle = Sentry.sentryHandle(); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/+layout.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/+layout.svelte new file mode 100644 index 000000000000..8eb402c9eda6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/+layout.svelte @@ -0,0 +1,17 @@ + + +

Sveltekit E2E Test app

+
+ +
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/+page.svelte new file mode 100644 index 000000000000..0cfae1c54741 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/+page.svelte @@ -0,0 +1,44 @@ +

Welcome to SvelteKit

+

Visit kit.svelte.dev to read the documentation

+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/api/users/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/api/users/+server.ts new file mode 100644 index 000000000000..d0e4371c594b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/api/users/+server.ts @@ -0,0 +1,3 @@ +export const GET = () => { + return new Response(JSON.stringify({ users: ['alice', 'bob', 'carol'] })); +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/building/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/building/+page.server.ts new file mode 100644 index 000000000000..b07376ba97c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/building/+page.server.ts @@ -0,0 +1,5 @@ +import type { PageServerLoad } from './$types'; + +export const load = (async _event => { + return { name: 'building (server)' }; +}) satisfies PageServerLoad; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/building/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/building/+page.svelte new file mode 100644 index 000000000000..b27edb70053d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/building/+page.svelte @@ -0,0 +1,21 @@ + + +

Check Build

+ +

+ This route only exists to check that Typescript definitions + and auto instrumentation are working when the project is built. +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/building/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/building/+page.ts new file mode 100644 index 000000000000..049acdc1fafa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/building/+page.ts @@ -0,0 +1,5 @@ +import type { PageLoad } from './$types'; + +export const load = (async _event => { + return { name: 'building' }; +}) satisfies PageLoad; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/client-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/client-error/+page.svelte new file mode 100644 index 000000000000..ba6b464e9324 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/client-error/+page.svelte @@ -0,0 +1,9 @@ + + +

Client error

+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/+page.svelte new file mode 100644 index 000000000000..3c1052bbbe9c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/+page.svelte @@ -0,0 +1,15 @@ + +

Demonstrating Component Tracking

+ + + + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/Component1.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/Component1.svelte new file mode 100644 index 000000000000..dfcf01de0b07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/Component1.svelte @@ -0,0 +1,10 @@ + +

Howdy, I'm component 1

+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/Component2.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/Component2.svelte new file mode 100644 index 000000000000..1b3ad103b3b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/Component2.svelte @@ -0,0 +1,9 @@ + +

Howdy, I'm component 2

+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/Component3.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/Component3.svelte new file mode 100644 index 000000000000..9b813ff2c744 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/components/Component3.svelte @@ -0,0 +1,6 @@ + + +

Howdy, I'm component 3

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/db-ioredis/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/db-ioredis/+server.ts new file mode 100644 index 000000000000..c7348b20f084 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/db-ioredis/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import Redis from 'ioredis'; + +export const GET = async () => { + const redis = new Redis({ + // Don't keep retrying forever if Redis goes away (e.g. on test teardown) + maxRetriesPerRequest: 1, + retryStrategy: () => null, + }); + + try { + await redis.set('test-key', 'test-value'); + const value = await redis.get('test-key'); + return json({ value }); + } finally { + redis.disconnect(); + } +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/db-mysql/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/db-mysql/+server.ts new file mode 100644 index 000000000000..f6df545e43c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/db-mysql/+server.ts @@ -0,0 +1,20 @@ +import { json } from '@sveltejs/kit'; +import mysql from 'mysql'; + +export const GET = async () => { + const connection = mysql.createConnection({ user: 'root', password: 'docker' }); + try { + await new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', err1 => { + if (err1) return reject(err1); + connection.query('SELECT NOW()', ['1', '2'], err2 => { + if (err2) return reject(err2); + resolve(); + }); + }); + }); + return json({ status: 'ok' }); + } finally { + connection.end(() => {}); + } +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/nav1/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/nav1/+page.svelte new file mode 100644 index 000000000000..31abffc512a2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/nav1/+page.svelte @@ -0,0 +1 @@ +

Navigation 1

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/nav2/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/nav2/+page.svelte new file mode 100644 index 000000000000..20b44bb32da9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/nav2/+page.svelte @@ -0,0 +1 @@ +

Navigation 2

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/redirect1/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/redirect1/+page.ts new file mode 100644 index 000000000000..3f462bf810fd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/redirect1/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + +export const load = async () => { + redirect(301, '/redirect2'); +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/redirect2/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/redirect2/+page.ts new file mode 100644 index 000000000000..99a810761d18 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/redirect2/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + +export const load = async () => { + redirect(301, '/users/789'); +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-error/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-error/+page.server.ts new file mode 100644 index 000000000000..17dd53fb5bbb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-error/+page.server.ts @@ -0,0 +1,6 @@ +export const load = async () => { + throw new Error('Server Load Error'); + return { + msg: 'Hello World', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-error/+page.svelte new file mode 100644 index 000000000000..3a0942971d06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-error/+page.svelte @@ -0,0 +1,9 @@ + + +

Server load error

+ +

+ Message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-fetch/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-fetch/+page.server.ts new file mode 100644 index 000000000000..709e52bcf351 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-fetch/+page.server.ts @@ -0,0 +1,5 @@ +export const load = async ({ fetch }) => { + const res = await fetch('/api/users'); + const data = await res.json(); + return { data }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-fetch/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-fetch/+page.svelte new file mode 100644 index 000000000000..f7f814d31b4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-load-fetch/+page.svelte @@ -0,0 +1,8 @@ + + +
+

Server Load Fetch

+

{JSON.stringify(data, null, 2)}

+
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-route-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-route-error/+page.svelte new file mode 100644 index 000000000000..3d682e7e3462 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-route-error/+page.svelte @@ -0,0 +1,9 @@ + + +

Server Route error

+ +

+ Message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-route-error/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-route-error/+page.ts new file mode 100644 index 000000000000..298240827714 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-route-error/+page.ts @@ -0,0 +1,7 @@ +export const load = async ({ fetch }) => { + const res = await fetch('/server-route-error'); + const data = await res.json(); + return { + msg: data, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-route-error/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-route-error/+server.ts new file mode 100644 index 000000000000..f1a4b94b7706 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/server-route-error/+server.ts @@ -0,0 +1,6 @@ +export const GET = async () => { + throw new Error('Server Route Error'); + return { + msg: 'Hello World', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/type-assertion/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/type-assertion/+page.svelte new file mode 100644 index 000000000000..fcf83b8b2986 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/type-assertion/+page.svelte @@ -0,0 +1,14 @@ + + +

Type Assertion

+ +

+ This route only exists to ensure we don't emit a build error because of the angle bracket type assertion in +page.ts + see https://github.com/getsentry/sentry-javascript/issues/9318 +

+ +

+ Message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/type-assertion/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/type-assertion/+page.ts new file mode 100644 index 000000000000..5e517f53bb90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/type-assertion/+page.ts @@ -0,0 +1,8 @@ +export async function load() { + let x: unknown = 'foo'; + return { + // this angle bracket type assertion threw an auto instrumentation error + // see: https://github.com/getsentry/sentry-javascript/issues/9318 + msg: x, + }; +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-error/+page.svelte new file mode 100644 index 000000000000..dc2d311a0ece --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-error/+page.svelte @@ -0,0 +1,17 @@ + + +

Universal load error

+ +

+ To trigger from client: Load on another route, then navigate to this route. +

+ +

+ To trigger from server: Load on this route +

+ +

+ Message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-error/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-error/+page.ts new file mode 100644 index 000000000000..3d72bf4a890f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-error/+page.ts @@ -0,0 +1,8 @@ +import { browser } from '$app/environment'; + +export const load = async () => { + throw new Error(`Universal Load Error (${browser ? 'browser' : 'server'})`); + return { + msg: 'Hello World', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-fetch/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-fetch/+page.svelte new file mode 100644 index 000000000000..563c51e8c850 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-fetch/+page.svelte @@ -0,0 +1,14 @@ + + +

Fetching in universal load

+ +

Here's a list of a few users:

+ +
    + {#each data.users as user} +
  • {user}
  • + {/each} +
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-fetch/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-fetch/+page.ts new file mode 100644 index 000000000000..63c1ee68e1cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/universal-load-fetch/+page.ts @@ -0,0 +1,5 @@ +export const load = async ({ fetch }) => { + const usersRes = await fetch('/api/users'); + const data = await usersRes.json(); + return { users: data.users }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/+page.server.ts new file mode 100644 index 000000000000..a34c5450f682 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/+page.server.ts @@ -0,0 +1,5 @@ +export const load = async () => { + return { + msg: 'Hi everyone!', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/+page.svelte new file mode 100644 index 000000000000..aa804a4518fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/+page.svelte @@ -0,0 +1,10 @@ + +

+ All Users: +

+ +

+ message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/[id]/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/[id]/+page.server.ts new file mode 100644 index 000000000000..9388f3927018 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/[id]/+page.server.ts @@ -0,0 +1,5 @@ +export const load = async ({ params }) => { + return { + msg: `This is a special message for user ${params.id}`, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/[id]/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/[id]/+page.svelte new file mode 100644 index 000000000000..d348a8c57dad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/users/[id]/+page.svelte @@ -0,0 +1,14 @@ + + +

Route with dynamic params

+ +

+ User id: {$page.params.id} +

+ +

+ Secret message for user: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/wrap-server-route/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/wrap-server-route/+page.svelte new file mode 100644 index 000000000000..adc04d52c0ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/wrap-server-route/+page.svelte @@ -0,0 +1,7 @@ + + +

+ Message from API: {data.myMessage} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/wrap-server-route/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/wrap-server-route/+page.ts new file mode 100644 index 000000000000..3f3f5942366e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/wrap-server-route/+page.ts @@ -0,0 +1,7 @@ +export const load = async ({ fetch }) => { + const res = await fetch('/wrap-server-route/api'); + const myMessage = await res.json(); + return { + myMessage: myMessage.myMessage, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/wrap-server-route/api/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/wrap-server-route/api/+server.ts new file mode 100644 index 000000000000..6ba210690ad5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/wrap-server-route/api/+server.ts @@ -0,0 +1,6 @@ +import { wrapServerRouteWithSentry } from '@sentry/sveltekit'; +import { error } from '@sveltejs/kit'; + +export const GET = wrapServerRouteWithSentry(async () => { + error(500, 'error() error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/start-event-proxy.mjs new file mode 100644 index 000000000000..4b12ae39b423 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'sveltekit-2-static', +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/static/favicon.png b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/static/favicon.png new file mode 100644 index 000000000000..825b9e65af7c Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/static/favicon.png differ diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/svelte.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/svelte.config.js new file mode 100644 index 000000000000..c521eff7de30 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter(), + }, +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/db.test.ts new file mode 100644 index 000000000000..7deff11315f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/db.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Instruments ioredis automatically', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('sveltekit-2-static', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-ioredis'; + }); + + await fetch(`${baseURL}/db-ioredis`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /db-ioredis'); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db.query', + origin: 'auto.db.redis', + description: 'set test-key [1 other arguments]', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'redis', + 'db.operation.name': 'set', + 'db.query.text': 'set test-key [1 other arguments]', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db.query', + origin: 'auto.db.redis', + description: 'get test-key', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'redis', + 'db.operation.name': 'get', + 'db.query.text': 'get test-key', + }), + }), + ); +}); + +test('Instruments mysql automatically', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('sveltekit-2-static', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-mysql'; + }); + + await fetch(`${baseURL}/db-mysql`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.mysql', + description: 'SELECT 1 + 1 AS solution', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'mysql', + 'db.query.text': 'SELECT 1 + 1 AS solution', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'server.address': expect.any(String), + 'server.port': 3306, + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.mysql', + description: 'SELECT NOW()', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'mysql', + 'db.query.text': 'SELECT NOW()', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'server.address': expect.any(String), + 'server.port': 3306, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/errors.client.test.ts new file mode 100644 index 000000000000..85a547c31202 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/errors.client.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { waitForInitialPageload } from './utils'; + +test.describe('client-side errors', () => { + test('captures error thrown on click', async ({ page }) => { + await waitForInitialPageload(page, { route: '/client-error' }); + + const errorEventPromise = waitForError('sveltekit-2-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Click Error'; + }); + + await page.getByText('Throw error').click(); + + await expect(errorEventPromise).resolves.toBeDefined(); + + const errorEvent = await errorEventPromise; + + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + function: expect.stringContaining('HTMLButtonElement'), + lineno: 1, + in_app: true, + }), + ); + + expect(errorEvent.transaction).toEqual('/client-error'); + }); + + test('captures universal load error', async ({ page }) => { + await waitForInitialPageload(page); + await page.reload(); + + const errorEventPromise = waitForError('sveltekit-2-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Universal Load Error (browser)'; + }); + + // navigating triggers the error on the client + await page.getByText('Universal Load error').click(); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + const lastFrame = errorEventFrames?.[errorEventFrames?.length - 1]; + expect(lastFrame).toEqual( + expect.objectContaining({ + lineno: 1, + in_app: true, + }), + ); + + expect(errorEvent.transaction).toEqual('/universal-load-error'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/errors.server.test.ts new file mode 100644 index 000000000000..6d6ef46212ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/errors.server.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('server-side errors', () => { + test('captures universal load error', async ({ page }) => { + const errorEventPromise = waitForError('sveltekit-2-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Universal Load Error (server)'; + }); + + await page.goto('/universal-load-error'); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + function: 'load$1', + in_app: true, + }), + ); + }); + + test('captures server load error', async ({ page }) => { + const errorEventPromise = waitForError('sveltekit-2-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Server Load Error'; + }); + + await page.goto('/server-load-error'); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + function: 'load$1', + in_app: true, + }), + ); + }); + + test('captures server route (GET) error', async ({ page }) => { + const errorEventPromise = waitForError('sveltekit-2-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Server Route Error'; + }); + + await page.goto('/server-route-error'); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + filename: expect.stringContaining('app:///_server.ts'), + function: 'GET', + in_app: true, + }), + ); + + expect(errorEvent.transaction).toEqual('GET /server-route-error'); + }); + + test('captures error() thrown in server route with `wrapServerRouteWithSentry`', async ({ page }) => { + const errorEventPromise = waitForError('sveltekit-2-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === "'HttpError' captured as exception with keys: body, status"; + }); + + await page.goto('/wrap-server-route'); + + expect(await errorEventPromise).toMatchObject({ + exception: { + values: [ + { + value: "'HttpError' captured as exception with keys: body, status", + mechanism: { + handled: false, + type: 'auto.function.sveltekit.server_route', + }, + stacktrace: { frames: expect.any(Array) }, + }, + ], + }, + extra: { + __serialized__: { + body: { + message: 'error() error', + }, + status: 500, + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.client.test.ts new file mode 100644 index 000000000000..1c50a01c0e59 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.client.test.ts @@ -0,0 +1,149 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForInitialPageload } from './utils'; + +test.describe('client-specific performance events', () => { + test('multiple navigations have distinct traces', async ({ page }) => { + const navigationTxn1EventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === '/nav1' && txnEvent.contexts?.trace?.op === 'navigation'; + }); + + const navigationTxn2EventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === '/' && txnEvent.contexts?.trace?.op === 'navigation'; + }); + + const navigationTxn3EventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === '/nav2' && txnEvent.contexts?.trace?.op === 'navigation'; + }); + + await waitForInitialPageload(page); + + const [navigationTxn1Event] = await Promise.all([navigationTxn1EventPromise, page.getByText('Nav 1').click()]); + const [navigationTxn2Event] = await Promise.all([navigationTxn2EventPromise, page.goBack()]); + const [navigationTxn3Event] = await Promise.all([navigationTxn3EventPromise, page.getByText('Nav 2').click()]); + + expect(navigationTxn1Event).toMatchObject({ + transaction: '/nav1', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'url.path': '/nav1', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/nav1$/), + 'url.template': '/nav1', + }, + }, + }, + }); + + expect(navigationTxn2Event).toMatchObject({ + transaction: '/', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + 'url.template': '/', + }, + }, + }, + }); + + expect(navigationTxn3Event).toMatchObject({ + transaction: '/nav2', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'url.path': '/nav2', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/nav2$/), + 'url.template': '/nav2', + }, + }, + }, + }); + + // traces should NOT be connected + expect(navigationTxn1Event.contexts?.trace?.trace_id).not.toBe(navigationTxn2Event.contexts?.trace?.trace_id); + expect(navigationTxn2Event.contexts?.trace?.trace_id).not.toBe(navigationTxn3Event.contexts?.trace?.trace_id); + expect(navigationTxn1Event.contexts?.trace?.trace_id).not.toBe(navigationTxn3Event.contexts?.trace?.trace_id); + }); + + test('records manually added component tracking spans', async ({ page }) => { + const componentTxnEventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === '/components'; + }); + + await waitForInitialPageload(page); + + await page.getByText('Component Tracking').click(); + + const componentTxnEvent = await componentTxnEventPromise; + + expect(componentTxnEvent.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, + description: '', + op: 'ui.mount', + origin: 'auto.ui.svelte', + }), + expect.objectContaining({ + data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, + description: '', + op: 'ui.mount', + origin: 'auto.ui.svelte', + }), + expect.objectContaining({ + data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, + description: '', + op: 'ui.mount', + origin: 'auto.ui.svelte', + }), + expect.objectContaining({ + data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, + description: '', + op: 'ui.mount', + origin: 'auto.ui.svelte', + }), + expect.objectContaining({ + data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, + description: '', + op: 'ui.update', + origin: 'auto.ui.svelte', + }), + expect.objectContaining({ + data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, + description: '', + op: 'ui.update', + origin: 'auto.ui.svelte', + }), + expect.objectContaining({ + data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, + description: '', + op: 'ui.update', + origin: 'auto.ui.svelte', + }), + expect.objectContaining({ + data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, + description: '', + op: 'ui.update', + origin: 'auto.ui.svelte', + }), + ]), + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.server.test.ts new file mode 100644 index 000000000000..6933bb98d6e4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.server.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('server pageload request span has nested request span for sub request', async ({ page }) => { + const serverTxnEventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === 'GET /server-load-fetch'; + }); + + await page.goto('/server-load-fetch'); + + const serverTxnEvent = await serverTxnEventPromise; + const spans = serverTxnEvent.spans; + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /server-load-fetch', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.sveltekit', + }, + }, + }); + + expect(spans).toEqual( + expect.arrayContaining([ + // load span where the server load function initiates the sub request: + expect.objectContaining({ op: 'function', description: '/server-load-fetch' }), + // sub request span: + expect.objectContaining({ op: 'http.server', description: 'GET /api/users' }), + ]), + ); +}); + +test('extracts HTTP request headers as span attributes', async ({ page, baseURL }) => { + const serverTxnEventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === 'GET /api/users'; + }); + + await fetch(`${baseURL}/api/users`, { + headers: { + 'User-Agent': 'Custom-SvelteKit-Agent/1.0', + 'Content-Type': 'application/json', + 'X-Test-Header': 'sveltekit-test-value', + Accept: 'application/json', + 'X-Framework': 'SvelteKit', + 'X-Request-ID': 'sveltekit-123', + }, + }); + + const serverTxnEvent = await serverTxnEventPromise; + + expect(serverTxnEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.request.header.user_agent': 'Custom-SvelteKit-Agent/1.0', + 'http.request.header.content_type': 'application/json', + 'http.request.header.x_test_header': 'sveltekit-test-value', + 'http.request.header.accept': 'application/json', + 'http.request.header.x_framework': 'SvelteKit', + 'http.request.header.x_request_id': 'sveltekit-123', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.test.ts new file mode 100644 index 000000000000..74a68593773a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.test.ts @@ -0,0 +1,413 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForInitialPageload } from './utils'; + +test.describe('performance events', () => { + test('capture a distributed pageload trace', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === '/users/[id]'; + }); + + const serverTxnEventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === 'GET /users/[id]'; + }); + + const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + page.goto('/users/123xyz'), + clientTxnEventPromise, + serverTxnEventPromise, + expect(page.getByText('User id: 123xyz')).toBeVisible(), + ]); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/users/[id]', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.sveltekit', + data: { + 'url.path': '/users/123xyz', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123xyz$/), + 'url.template': '/users/[id]', + }, + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /users/[id]', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.sveltekit', + }, + }, + }); + + expect(clientTxnEvent.spans?.length).toBeGreaterThan(5); + + // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + + // weird but server txn is parent of client txn + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + }); + + test('capture a distributed navigation trace', async ({ page }) => { + const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === '/users' && txnEvent.contexts?.trace?.op === 'navigation'; + }); + + const serverTxnEventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === 'GET /users'; + }); + + await waitForInitialPageload(page); + + // navigation to page + const clickPromise = page.getByText('Route with Server Load').click(); + + const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ + clientNavigationTxnEventPromise, + serverTxnEventPromise, + clickPromise, + expect(page.getByText('Hi everyone')).toBeVisible(), + ]); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/users', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + data: { + 'url.path': '/users', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users$/), + 'url.template': '/users', + }, + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /users', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.sveltekit', + }, + }, + }); + + // trace is connected + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + }); + + test('record client-side universal load fetch span and trace', async ({ page }) => { + await waitForInitialPageload(page); + + const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === '/universal-load-fetch' && txnEvent.contexts?.trace?.op === 'navigation'; + }); + + // this transaction should be created because of the fetch call + // it should also be part of the trace + const serverTxnEventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.transaction === 'GET /api/users'; + }); + + // navigation to page + const clickPromise = page.getByText('Route with fetch in universal load').click(); + + const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ + clientNavigationTxnEventPromise, + serverTxnEventPromise, + clickPromise, + expect(page.getByText('alice')).toBeVisible(), + ]); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/universal-load-fetch', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + data: { + 'url.path': '/universal-load-fetch', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/universal-load-fetch$/), + 'url.template': '/universal-load-fetch', + }, + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /api/users', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.sveltekit', + }, + }, + }); + + // trace is connected + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + + const clientFetchSpan = clientTxnEvent.spans?.find(s => s.op === 'http.client'); + + expect(clientFetchSpan).toMatchObject({ + description: expect.stringMatching(/^GET.*\/api\/users/), + op: 'http.client', + origin: 'auto.http.browser', + data: { + 'url.full': expect.stringContaining('/api/users'), + type: 'fetch', + 'http.request.method': 'GET', + 'http.response.status_code': 200, + 'network.protocol.version': '1.1', + 'network.protocol.name': 'http', + 'http.request.redirect_start': expect.any(Number), + 'http.request.fetch_start': expect.any(Number), + 'http.request.domain_lookup_start': expect.any(Number), + 'http.request.domain_lookup_end': expect.any(Number), + 'http.request.connect_start': expect.any(Number), + 'http.request.secure_connection_start': expect.any(Number), + 'http.request.connection_end': expect.any(Number), + 'http.request.request_start': expect.any(Number), + 'http.request.response_start': expect.any(Number), + 'http.request.response_end': expect.any(Number), + }, + }); + }); + + test('captures a navigation transaction directly after pageload', async ({ page }) => { + const clientPageloadTxnPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.contexts?.trace?.op === 'pageload'; + }); + + const clientNavigationTxnPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.contexts?.trace?.op === 'navigation'; + }); + + await waitForInitialPageload(page, { route: '/' }); + + const navigationClickPromise = page.locator('#routeWithParamsLink').click(); + + const [pageloadTxnEvent, navigationTxnEvent, _] = await Promise.all([ + clientPageloadTxnPromise, + clientNavigationTxnPromise, + navigationClickPromise, + ]); + + expect(pageloadTxnEvent).toMatchObject({ + transaction: '/', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.sveltekit', + data: { + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + 'url.template': '/', + }, + }, + }, + }); + + expect(navigationTxnEvent).toMatchObject({ + transaction: '/users/[id]', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + data: { + 'sentry.sveltekit.navigation.from': '/', + 'sentry.sveltekit.navigation.to': '/users/[id]', + 'sentry.sveltekit.navigation.type': 'link', + 'url.path': '/users/123abc', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123abc$/), + 'url.template': '/users/[id]', + }, + }, + }, + }); + + const routingSpans = navigationTxnEvent.spans?.filter(s => s.op === 'router'); + expect(routingSpans).toHaveLength(1); + + const routingSpan = routingSpans && routingSpans[0]; + expect(routingSpan).toMatchObject({ + op: 'router', + description: 'SvelteKit Route Change', + data: { + 'sentry.op': 'router', + 'sentry.origin': 'auto.ui.sveltekit', + 'sentry.sveltekit.navigation.from': '/', + 'sentry.sveltekit.navigation.to': '/users/[id]', + 'sentry.sveltekit.navigation.type': 'link', + }, + }); + }); + + test('captures one navigation transaction per redirect', async ({ page }) => { + const clientNavigationRedirect1TxnPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect1'; + }); + + const clientNavigationRedirect2TxnPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect2'; + }); + + const clientNavigationRedirect3TxnPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/users/[id]'; + }); + + await waitForInitialPageload(page, { route: '/' }); + + const navigationClickPromise = page.locator('#redirectLink').click(); + + const [redirect1TxnEvent, redirect2TxnEvent, redirect3TxnEvent, _] = await Promise.all([ + clientNavigationRedirect1TxnPromise, + clientNavigationRedirect2TxnPromise, + clientNavigationRedirect3TxnPromise, + navigationClickPromise, + ]); + + expect(redirect1TxnEvent).toMatchObject({ + transaction: '/redirect1', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + data: { + 'sentry.origin': 'auto.navigation.sveltekit', + 'sentry.op': 'navigation', + 'sentry.segment.name.source': 'route', + 'sentry.sveltekit.navigation.type': 'link', + 'sentry.sveltekit.navigation.from': '/', + 'sentry.sveltekit.navigation.to': '/redirect1', + 'sentry.sample_rate': 1, + 'url.path': '/redirect1', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/redirect1$/), + 'url.template': '/redirect1', + }, + }, + }, + }); + + const redirect1Spans = redirect1TxnEvent.spans?.filter(s => s.op === 'router'); + expect(redirect1Spans).toHaveLength(1); + + const redirect1Span = redirect1Spans && redirect1Spans[0]; + expect(redirect1Span).toMatchObject({ + op: 'router', + description: 'SvelteKit Route Change', + data: { + 'sentry.op': 'router', + 'sentry.origin': 'auto.ui.sveltekit', + 'sentry.sveltekit.navigation.from': '/', + 'sentry.sveltekit.navigation.to': '/redirect1', + 'sentry.sveltekit.navigation.type': 'link', + }, + }); + + expect(redirect2TxnEvent).toMatchObject({ + transaction: '/redirect2', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + data: { + 'sentry.origin': 'auto.navigation.sveltekit', + 'sentry.op': 'navigation', + 'sentry.segment.name.source': 'route', + 'sentry.sveltekit.navigation.type': 'link', + 'sentry.sveltekit.navigation.from': '/', + 'sentry.sveltekit.navigation.to': '/redirect2', + 'sentry.sample_rate': 1, + 'url.path': '/redirect2', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/redirect2$/), + 'url.template': '/redirect2', + }, + }, + }, + }); + + const redirect2Spans = redirect2TxnEvent.spans?.filter(s => s.op === 'router'); + expect(redirect2Spans).toHaveLength(1); + + const redirect2Span = redirect2Spans && redirect2Spans[0]; + expect(redirect2Span).toMatchObject({ + op: 'router', + description: 'SvelteKit Route Change', + data: { + 'sentry.op': 'router', + 'sentry.origin': 'auto.ui.sveltekit', + 'sentry.sveltekit.navigation.from': '/', + 'sentry.sveltekit.navigation.to': '/redirect2', + 'sentry.sveltekit.navigation.type': 'link', + }, + }); + + expect(redirect3TxnEvent).toMatchObject({ + transaction: '/users/[id]', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + data: { + 'sentry.origin': 'auto.navigation.sveltekit', + 'sentry.op': 'navigation', + 'sentry.segment.name.source': 'route', + 'sentry.sveltekit.navigation.type': 'link', + 'sentry.sveltekit.navigation.from': '/', + 'sentry.sveltekit.navigation.to': '/users/[id]', + 'sentry.sample_rate': 1, + 'url.path': '/users/789', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/789$/), + 'url.template': '/users/[id]', + }, + }, + }, + }); + + const redirect3Spans = redirect3TxnEvent.spans?.filter(s => s.op === 'router'); + expect(redirect3Spans).toHaveLength(1); + + const redirect3Span = redirect3Spans && redirect3Spans[0]; + expect(redirect3Span).toMatchObject({ + op: 'router', + description: 'SvelteKit Route Change', + data: { + 'sentry.op': 'router', + 'sentry.origin': 'auto.ui.sveltekit', + 'sentry.sveltekit.navigation.from': '/', + 'sentry.sveltekit.navigation.to': '/users/[id]', + 'sentry.sveltekit.navigation.type': 'link', + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/sdk.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/sdk.test.ts new file mode 100644 index 000000000000..8633439eb503 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/sdk.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForInitialPageload } from './utils'; + +test.describe('SDK-internal behavior', () => { + test("Doesn't inject fetch proxy script for SvelteKit>=2.16.0", async ({ page }) => { + await waitForInitialPageload(page, { route: '/' }); + + // @ts-expect-error this is defined + await page.waitForFunction(() => typeof window.__SENTRY__ === 'object'); + + const proxyHandle = await page.evaluate('typeof window._sentryFetchProxy'); + + // fetch proxy script didn't run + expect(proxyHandle).toBe('undefined'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/utils.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/utils.ts new file mode 100644 index 000000000000..c1ea33be689d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/utils.ts @@ -0,0 +1,49 @@ +import { Page } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +/** + * Helper function that waits for the initial pageload to complete. + * + * This function + * - loads the given route ("/" by default) + * - waits for SvelteKit's hydration + * - waits for the pageload transaction to be sent (doesn't assert on it though) + * + * Useful for tests that test outcomes of _navigations_ after an initial pageload. + * Waiting on the pageload transaction excludes edge cases where navigations occur + * so quickly that the pageload idle transaction is still active. This might lead + * to cases where the routing span would be attached to the pageload transaction + * and hence eliminates a lot of flakiness. + * + */ +export async function waitForInitialPageload( + page: Page, + opts?: { route?: string; parameterizedRoute?: string; debug?: boolean }, +) { + const route = opts?.route ?? '/'; + const txnName = opts?.parameterizedRoute ?? route; + const debug = opts?.debug ?? false; + + const clientPageloadTxnEventPromise = waitForTransaction('sveltekit-2-static', txnEvent => { + debug && + console.log({ + txn: txnEvent?.transaction, + op: txnEvent.contexts?.trace?.op, + trace: txnEvent.contexts?.trace?.trace_id, + span: txnEvent.contexts?.trace?.span_id, + parent: txnEvent.contexts?.trace?.parent_span_id, + }); + + return txnEvent?.transaction === txnName && txnEvent.contexts?.trace?.op === 'pageload'; + }); + + await Promise.all([ + page.goto(route), + // the test app adds the "hydrated" class to the body when hydrating + page.waitForSelector('body.hydrated'), + // also waiting for the initial pageload txn so that later navigations don't interfere + clientPageloadTxnEventPromise, + ]); + + debug && console.log('hydrated'); +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tsconfig.json new file mode 100644 index 000000000000..115dd34bec96 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "allowImportingTsExtensions": true + } + // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/vite.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/vite.config.js new file mode 100644 index 000000000000..be8b485d022a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/vite.config.js @@ -0,0 +1,12 @@ +import { sentrySvelteKit } from '@sentry/sveltekit/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + sentrySvelteKit({ + autoUploadSourceMaps: false, + }), + sveltekit(), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/src/hooks.client.ts index 2b68fff0c5ac..91592e7ab932 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/src/hooks.client.ts @@ -3,7 +3,6 @@ import * as Sentry from '@sentry/sveltekit'; import * as Spotlight from '@spotlightjs/spotlight'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: env.PUBLIC_E2E_TEST_DSN, debug: !!env.PUBLIC_DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/src/hooks.server.ts index 27e281bd95a4..99bf4a17aa96 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/src/hooks.server.ts @@ -3,7 +3,6 @@ import * as Sentry from '@sentry/sveltekit'; import { setupSidecar } from '@spotlightjs/spotlight/sidecar'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts index ebf9dd764b21..7e33142449a8 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts @@ -1,129 +1,83 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; test.describe('client-specific performance events', () => { test('multiple navigations have distinct traces', async ({ page }) => { - const navigationTxn1EventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.transaction === '/nav1' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan1Promise = waitForStreamedSpan('sveltekit-2-svelte-5', span => { + return span.name === '/nav1' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const navigationTxn2EventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.transaction === '/' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan2Promise = waitForStreamedSpan('sveltekit-2-svelte-5', span => { + return span.name === '/' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const navigationTxn3EventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.transaction === '/nav2' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan3Promise = waitForStreamedSpan('sveltekit-2-svelte-5', span => { + return span.name === '/nav2' && getSpanOp(span) === 'navigation' && span.is_segment; }); await waitForInitialPageload(page); await page.getByText('Nav 1').click(); - const navigationTxn1Event = await navigationTxn1EventPromise; + const navigationSpan1 = await navigationSpan1Promise; await page.goBack(); - const navigationTxn2Event = await navigationTxn2EventPromise; + const navigationSpan2 = await navigationSpan2Promise; await page.getByText('Nav 2').click(); - const navigationTxn3Event = await navigationTxn3EventPromise; - - expect(navigationTxn1Event).toMatchObject({ - transaction: '/nav1', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'url.path': '/nav1', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/nav1$/), - 'url.template': '/nav1', - }, + const navigationSpan3 = await navigationSpan3Promise; + + const expectNavigationSpan = (span: typeof navigationSpan1, route: string) => { + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: route, type: 'string' }, + 'url.full': { + value: expect.stringMatching(new RegExp(`^https?:\\/\\/localhost:\\d+${route}$`)), + type: 'string', }, - }, - }); + 'url.template': { value: route, type: 'string' }, + }); + }; - expect(navigationTxn2Event).toMatchObject({ - transaction: '/', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - 'url.template': '/', - }, - }, - }, - }); - - expect(navigationTxn3Event).toMatchObject({ - transaction: '/nav2', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'url.path': '/nav2', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/nav2$/), - 'url.template': '/nav2', - }, - }, - }, - }); + expectNavigationSpan(navigationSpan1, '/nav1'); + expectNavigationSpan(navigationSpan2, '/'); + expectNavigationSpan(navigationSpan3, '/nav2'); // traces should NOT be connected - expect(navigationTxn1Event.contexts?.trace?.trace_id).not.toBe(navigationTxn2Event.contexts?.trace?.trace_id); - expect(navigationTxn2Event.contexts?.trace?.trace_id).not.toBe(navigationTxn3Event.contexts?.trace?.trace_id); - expect(navigationTxn1Event.contexts?.trace?.trace_id).not.toBe(navigationTxn3Event.contexts?.trace?.trace_id); + expect(navigationSpan1.trace_id).not.toBe(navigationSpan2.trace_id); + expect(navigationSpan2.trace_id).not.toBe(navigationSpan3.trace_id); + expect(navigationSpan1.trace_id).not.toBe(navigationSpan3.trace_id); }); test('records manually added component tracking spans', async ({ page }) => { - const componentTxnEventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.transaction === '/components'; - }); + const componentTraceSpansPromise = collectStreamedSpans('sveltekit-2-svelte-5', spansOfTrace => + spansOfTrace.some(span => span.name === '/components' && span.is_segment), + ); await waitForInitialPageload(page); await page.getByText('Component Tracking').click(); - const componentTxnEvent = await componentTxnEventPromise; + const componentTraceSpans = await componentTraceSpansPromise; - expect(componentTxnEvent.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', + const componentSpan = (name: string) => + expect.objectContaining({ + name, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'ui.mount', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.svelte', type: 'string' }, }), + }); + + expect(componentTraceSpans).toEqual( + expect.arrayContaining([ + componentSpan(''), + componentSpan(''), + componentSpan(''), + componentSpan(''), ]), ); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.server.test.ts index e98324cd0071..0269cfbab0dd 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.server.test.ts @@ -1,44 +1,40 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; test('server pageload request span has nested request span for sub request', async ({ page }) => { - const serverTxnEventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.transaction === 'GET /server-load-fetch'; - }); + const serverTraceSpansPromise = collectStreamedSpans('sveltekit-2-svelte-5', spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /server-load-fetch' && span.is_segment), + ); await page.goto('/server-load-fetch'); - const serverTxnEvent = await serverTxnEventPromise; - const spans = serverTxnEvent.spans; + const serverTraceSpans = await serverTraceSpansPromise; + const serverSpan = serverTraceSpans.find(span => span.name === 'GET /server-load-fetch' && span.is_segment)!; - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /server-load-fetch', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'url.path': { value: '/server-load-fetch', type: 'string' }, + 'http.request.header.accept': { value: expect.any(String), type: 'string' }, + 'http.request.header.user_agent': { value: expect.any(String), type: 'string' }, }); - expect(spans).toEqual( + expect(serverTraceSpans).toEqual( expect.arrayContaining([ // load span where the server load function initiates the sub request: - expect.objectContaining({ op: 'function', description: '/server-load-fetch' }), + expect.objectContaining({ + name: '/server-load-fetch', + is_segment: false, + attributes: expect.objectContaining({ 'sentry.op': { value: 'function', type: 'string' } }), + }), // sub request span: - expect.objectContaining({ op: 'http.server', description: 'GET /api/users' }), + expect.objectContaining({ + name: 'GET /api/users', + is_segment: false, + attributes: expect.objectContaining({ 'sentry.op': { value: 'http.server', type: 'string' } }), + }), ]), ); - - expect(serverTxnEvent.request).toEqual({ - cookies: {}, - headers: expect.objectContaining({ - accept: expect.any(String), - 'user-agent': expect.any(String), - }), - method: 'GET', - url: 'http://localhost:3030/server-load-fetch', - }); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.test.ts index d61a9d6355f5..26657a10fb8c 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.test.ts @@ -1,69 +1,55 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; test.describe('performance events', () => { test('capture a distributed pageload trace', async ({ page }) => { - const clientTxnEventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.transaction === '/users/[id]'; + const traceSpansPromise = collectStreamedSpans('sveltekit-2-svelte-5', spansOfTrace => { + const hasClientSegment = spansOfTrace.some(span => span.name === '/users/[id]' && span.is_segment); + const hasServerSegment = spansOfTrace.some(span => span.name === 'GET /users/[id]' && span.is_segment); + return hasClientSegment && hasServerSegment; }); - const serverTxnEventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.transaction === 'GET /users/[id]'; - }); - - const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + const [_, traceSpans] = await Promise.all([ page.goto('/users/123xyz'), - clientTxnEventPromise, - serverTxnEventPromise, + traceSpansPromise, expect(page.getByText('User id: 123xyz')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.sveltekit', - data: { - 'url.path': '/users/123xyz', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123xyz$/), - 'url.template': '/users/[id]', - }, - }, - }, + const clientSpan = traceSpans.find(span => span.name === '/users/[id]' && span.is_segment)!; + const serverSpan = traceSpans.find(span => span.name === 'GET /users/[id]' && span.is_segment)!; + + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/users/123xyz', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123xyz$/), type: 'string' }, + 'url.template': { value: '/users/[id]', type: 'string' }, }); - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); - expect(clientTxnEvent.spans?.length).toBeGreaterThan(5); + expect(traceSpans.length).toBeGreaterThan(5); // connected trace - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); - // weird but server txn is parent of client txn - expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + // weird but server span is parent of client span + expect(clientSpan.parent_span_id).toBe(serverSpan.span_id); }); test('capture a distributed navigation trace', async ({ page }) => { - const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.transaction === '/users' && txnEvent.contexts?.trace?.op === 'navigation'; + const clientNavigationSpanPromise = waitForStreamedSpan('sveltekit-2-svelte-5', span => { + return span.name === '/users' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const serverTxnEventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.transaction === 'GET /users'; + const serverSpanPromise = waitForStreamedSpan('sveltekit-2-svelte-5', span => { + return span.name === 'GET /users' && span.is_segment; }); await waitForInitialPageload(page); @@ -71,343 +57,221 @@ test.describe('performance events', () => { // navigation to page const clickPromise = page.getByText('Route with Server Load').click(); - const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ - clientNavigationTxnEventPromise, - serverTxnEventPromise, + const [clientSpan, serverSpan, _1, _2] = await Promise.all([ + clientNavigationSpanPromise, + serverSpanPromise, clickPromise, expect(page.getByText('Hi everyone')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'url.path': '/users', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users$/), - 'url.template': '/users', - }, - }, - }, + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/users', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/users$/), type: 'string' }, + 'url.template': { value: '/users', type: 'string' }, }); - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); // trace is connected - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); }); test('record client-side universal load fetch span and trace', async ({ page }) => { await waitForInitialPageload(page); - const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.transaction === '/universal-load-fetch' && txnEvent.contexts?.trace?.op === 'navigation'; - }); - - // this transaction should be created because of the fetch call + // the server span should be created because of the fetch call // it should also be part of the trace - const serverTxnEventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.transaction === 'GET /api/users'; + const traceSpansPromise = collectStreamedSpans('sveltekit-2-svelte-5', spansOfTrace => { + const hasClientSegment = spansOfTrace.some( + span => span.name === '/universal-load-fetch' && getSpanOp(span) === 'navigation' && span.is_segment, + ); + const hasServerSegment = spansOfTrace.some(span => span.name === 'GET /api/users' && span.is_segment); + return hasClientSegment && hasServerSegment; }); // navigation to page const clickPromise = page.getByText('Route with fetch in universal load').click(); - const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ - clientNavigationTxnEventPromise, - serverTxnEventPromise, + const [traceSpans, _1, _2] = await Promise.all([ + traceSpansPromise, clickPromise, expect(page.getByText('alice')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/universal-load-fetch', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'url.path': '/universal-load-fetch', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/universal-load-fetch$/), - 'url.template': '/universal-load-fetch', - }, - }, - }, + const clientSpan = traceSpans.find(span => span.name === '/universal-load-fetch' && span.is_segment)!; + const serverSpan = traceSpans.find(span => span.name === 'GET /api/users' && span.is_segment)!; + + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/universal-load-fetch', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/universal-load-fetch$/), type: 'string' }, + 'url.template': { value: '/universal-load-fetch', type: 'string' }, }); - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /api/users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); // trace is connected - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); - - const clientFetchSpan = clientTxnEvent.spans?.find(s => s.op === 'http.client'); - - expect(clientFetchSpan).toMatchObject({ - description: expect.stringMatching(/^GET.*\/api\/users/), - op: 'http.client', - origin: 'auto.http.browser', - data: { - 'url.full': expect.stringContaining('/api/users'), - type: 'fetch', - 'http.request.method': 'GET', - 'http.response.status_code': 200, - 'network.protocol.version': '1.1', - 'network.protocol.name': 'http', - 'http.request.redirect_start': expect.any(Number), - 'http.request.fetch_start': expect.any(Number), - 'http.request.domain_lookup_start': expect.any(Number), - 'http.request.domain_lookup_end': expect.any(Number), - 'http.request.connect_start': expect.any(Number), - 'http.request.secure_connection_start': expect.any(Number), - 'http.request.connection_end': expect.any(Number), - 'http.request.request_start': expect.any(Number), - 'http.request.response_start': expect.any(Number), - 'http.request.response_end': expect.any(Number), - }, + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); + + const clientFetchSpan = traceSpans.find(span => getSpanOp(span) === 'http.client'); + + expect(clientFetchSpan?.name).toBe('GET localhost'); + expect(clientFetchSpan?.parent_span_id).toBe(clientSpan.span_id); + expect(clientFetchSpan?.attributes).toMatchObject({ + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.browser', type: 'string' }, + 'url.full': { value: expect.stringContaining('/api/users'), type: 'string' }, + type: { value: 'fetch', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'network.protocol.version': { value: '1.1', type: 'string' }, + 'network.protocol.name': { value: 'http', type: 'string' }, + 'http.request.redirect_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.fetch_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.domain_lookup_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.domain_lookup_end': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.connect_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.secure_connection_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.connection_end': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.request_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.response_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.response_end': expect.objectContaining({ value: expect.any(Number) }), }); }); - test('captures a navigation transaction directly after pageload', async ({ page }) => { - const clientPageloadTxnPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'pageload'; + test('captures a navigation span directly after pageload', async ({ page }) => { + const clientPageloadSpanPromise = waitForStreamedSpan('sveltekit-2-svelte-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const clientNavigationTxnPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation'; - }); + const navigationTraceSpansPromise = collectStreamedSpans('sveltekit-2-svelte-5', spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment), + ); await waitForInitialPageload(page, { route: '/' }); const navigationClickPromise = page.locator('#routeWithParamsLink').click(); - const [pageloadTxnEvent, navigationTxnEvent, _] = await Promise.all([ - clientPageloadTxnPromise, - clientNavigationTxnPromise, + const [pageloadSpan, navigationTraceSpans, _] = await Promise.all([ + clientPageloadSpanPromise, + navigationTraceSpansPromise, navigationClickPromise, ]); - expect(pageloadTxnEvent).toMatchObject({ - transaction: '/', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.sveltekit', - data: { - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - 'url.template': '/', - }, - }, - }, + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + 'url.template': { value: '/', type: 'string' }, }); - expect(navigationTxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - 'url.path': '/users/123abc', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123abc$/), - 'url.template': '/users/[id]', - }, - }, - }, + const navigationSpan = navigationTraceSpans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; + + expect(navigationSpan.name).toBe('/users/[id]'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: '/users/[id]', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, + 'url.path': { value: '/users/123abc', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123abc$/), type: 'string' }, + 'url.template': { value: '/users/[id]', type: 'string' }, }); - const routingSpans = navigationTxnEvent.spans?.filter(s => s.op === 'router'); + const routingSpans = navigationTraceSpans.filter(span => getSpanOp(span) === 'router'); expect(routingSpans).toHaveLength(1); - const routingSpan = routingSpans && routingSpans[0]; - expect(routingSpan).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - }, + const routingSpan = routingSpans[0]!; + expect(routingSpan.name).toBe('Router'); + expect(routingSpan.parent_span_id).toBe(navigationSpan.span_id); + expect(routingSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'router', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.sveltekit', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: '/users/[id]', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, }); }); - test('captures one navigation transaction per redirect', async ({ page }) => { - const clientNavigationRedirect1TxnPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect1'; - }); - - const clientNavigationRedirect2TxnPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect2'; - }); + test('captures one navigation span per redirect', async ({ page }) => { + const collectNavigationTrace = (route: string) => + collectStreamedSpans('sveltekit-2-svelte-5', spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.name === route && span.is_segment), + ); - const clientNavigationRedirect3TxnPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/users/[id]'; - }); + const redirect1TraceSpansPromise = collectNavigationTrace('/redirect1'); + const redirect2TraceSpansPromise = collectNavigationTrace('/redirect2'); + const redirect3TraceSpansPromise = collectNavigationTrace('/users/[id]'); await waitForInitialPageload(page, { route: '/' }); const navigationClickPromise = page.locator('#redirectLink').click(); - const [redirect1TxnEvent, redirect2TxnEvent, redirect3TxnEvent, _] = await Promise.all([ - clientNavigationRedirect1TxnPromise, - clientNavigationRedirect2TxnPromise, - clientNavigationRedirect3TxnPromise, + const [redirect1TraceSpans, redirect2TraceSpans, redirect3TraceSpans, _] = await Promise.all([ + redirect1TraceSpansPromise, + redirect2TraceSpansPromise, + redirect3TraceSpansPromise, navigationClickPromise, ]); - expect(redirect1TxnEvent).toMatchObject({ - transaction: '/redirect1', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect1', - 'sentry.sample_rate': 1, - 'url.path': '/redirect1', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/redirect1$/), - 'url.template': '/redirect1', - }, - }, - }, - }); - - const redirect1Spans = redirect1TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect1Spans).toHaveLength(1); - - const redirect1Span = redirect1Spans && redirect1Spans[0]; - expect(redirect1Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect1', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); - - expect(redirect2TxnEvent).toMatchObject({ - transaction: '/redirect2', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect2', - 'sentry.sample_rate': 1, - 'url.path': '/redirect2', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/redirect2$/), - 'url.template': '/redirect2', - }, + const expectNavigationTrace = ( + traceSpans: typeof redirect1TraceSpans, + { route, path }: { route: string; path: string }, + ) => { + const navigationSpan = traceSpans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; + + expect(navigationSpan.name).toBe(route); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: route, type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'url.path': { value: path, type: 'string' }, + 'url.full': { + value: expect.stringMatching(new RegExp(`^https?:\\/\\/localhost:\\d+${path}$`)), + type: 'string', }, - }, - }); - - const redirect2Spans = redirect2TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect2Spans).toHaveLength(1); - - const redirect2Span = redirect2Spans && redirect2Spans[0]; - expect(redirect2Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect2', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); - - expect(redirect3TxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sample_rate': 1, - 'url.path': '/users/789', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/789$/), - 'url.template': '/users/[id]', - }, - }, - }, - }); - - const redirect3Spans = redirect3TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect3Spans).toHaveLength(1); - - const redirect3Span = redirect3Spans && redirect3Spans[0]; - expect(redirect3Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); + 'url.template': { value: route, type: 'string' }, + }); + + const routingSpans = traceSpans.filter(span => getSpanOp(span) === 'router'); + expect(routingSpans).toHaveLength(1); + + const routingSpan = routingSpans[0]!; + expect(routingSpan.name).toBe('Router'); + expect(routingSpan.parent_span_id).toBe(navigationSpan.span_id); + expect(routingSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'router', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.sveltekit', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: route, type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, + }); + }; + + expectNavigationTrace(redirect1TraceSpans, { route: '/redirect1', path: '/redirect1' }); + expectNavigationTrace(redirect2TraceSpans, { route: '/redirect2', path: '/redirect2' }); + expectNavigationTrace(redirect3TraceSpans, { route: '/users/[id]', path: '/users/789' }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/utils.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/utils.ts index e77e2eb742e2..3e50c890372b 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/utils.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/utils.ts @@ -1,5 +1,5 @@ import { Page } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; /** * Helper function that waits for the initial pageload to complete. @@ -7,12 +7,12 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; * This function * - loads the given route ("/" by default) * - waits for SvelteKit's hydration - * - waits for the pageload transaction to be sent (doesn't assert on it though) + * - waits for the pageload span to be sent (doesn't assert on it though) * * Useful for tests that test outcomes of _navigations_ after an initial pageload. - * Waiting on the pageload transaction excludes edge cases where navigations occur - * so quickly that the pageload idle transaction is still active. This might lead - * to cases where the routing span would be attached to the pageload transaction + * Waiting on the pageload span excludes edge cases where navigations occur + * so quickly that the pageload idle span is still active. This might lead + * to cases where the routing span would be attached to the pageload span * and hence eliminates a lot of flakiness. * */ @@ -21,28 +21,28 @@ export async function waitForInitialPageload( opts?: { route?: string; parameterizedRoute?: string; debug?: boolean }, ) { const route = opts?.route ?? '/'; - const txnName = opts?.parameterizedRoute ?? route; + const spanName = opts?.parameterizedRoute ?? route; const debug = opts?.debug ?? false; - const clientPageloadTxnEventPromise = waitForTransaction('sveltekit-2-svelte-5', txnEvent => { + const clientPageloadSpanPromise = waitForStreamedSpan('sveltekit-2-svelte-5', span => { debug && console.log({ - txn: txnEvent?.transaction, - op: txnEvent.contexts?.trace?.op, - trace: txnEvent.contexts?.trace?.trace_id, - span: txnEvent.contexts?.trace?.span_id, - parent: txnEvent.contexts?.trace?.parent_span_id, + name: span.name, + op: getSpanOp(span), + trace: span.trace_id, + span: span.span_id, + parent: span.parent_span_id, }); - return txnEvent?.transaction === txnName && txnEvent.contexts?.trace?.op === 'pageload'; + return span.name === spanName && getSpanOp(span) === 'pageload' && span.is_segment; }); await Promise.all([ page.goto(route), // the test app adds the "hydrated" class to the body when hydrating page.waitForSelector('body.hydrated'), - // also waiting for the initial pageload txn so that later navigations don't interfere - clientPageloadTxnEventPromise, + // also waiting for the initial pageload span so that later navigations don't interfere + clientPageloadSpanPromise, ]); debug && console.log('hydrated'); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/src/hooks.client.ts index b28018a58771..25cedeee2d07 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/src/hooks.client.ts @@ -2,7 +2,6 @@ import { env } from '$env/dynamic/public'; import * as Sentry from '@sentry/sveltekit'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: env.PUBLIC_E2E_TEST_DSN, release: '1.0.0', diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/src/hooks.server.ts index f94e0cdd4651..e60e51b25968 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2.5.0-twp/src/hooks.server.ts @@ -2,7 +2,6 @@ import { E2E_TEST_DSN } from '$env/static/private'; import * as Sentry from '@sentry/sveltekit'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.client.ts index 2de2a2dd0d1f..b174e9671b8d 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.client.ts @@ -2,7 +2,6 @@ import { env } from '$env/dynamic/public'; import * as Sentry from '@sentry/sveltekit'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: env.PUBLIC_E2E_TEST_DSN, debug: !!env.PUBLIC_DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.server.ts index 5780bbcb38c4..92909c53a24c 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.server.ts @@ -2,7 +2,6 @@ import { E2E_TEST_DSN } from '$env/static/private'; import * as Sentry from '@sentry/sveltekit'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/db.test.ts index 061e5c163960..2dfbf91814ee 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/db.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/db.test.ts @@ -1,89 +1,69 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; test('Instruments ioredis automatically', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('sveltekit-2', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-ioredis'; - }); + const traceSpansPromise = collectStreamedSpans('sveltekit-2', spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'http.server' && span.name === 'GET /db-ioredis' && span.is_segment), + ); await fetch(`${baseURL}/db-ioredis`); - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); - expect(transactionEvent.transaction).toEqual('GET /db-ioredis'); + const traceSpans = await traceSpansPromise; - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( + expect(traceSpans).toContainEqual( expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.redis', - description: 'set test-key [1 other arguments]', + name: 'set localhost:6379', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'redis', - 'db.operation.name': 'set', - 'db.query.text': 'set test-key [1 other arguments]', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'db.query', type: 'string' }, + 'sentry.origin': { value: 'auto.db.redis', type: 'string' }, + 'db.system.name': { value: 'redis', type: 'string' }, + 'db.operation.name': { value: 'set', type: 'string' }, + 'db.query.text': { value: 'set test-key [1 other arguments]', type: 'string' }, }), }), ); - expect(spans).toContainEqual( + expect(traceSpans).toContainEqual( expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.redis', - description: 'get test-key', + name: 'get localhost:6379', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'redis', - 'db.operation.name': 'get', - 'db.query.text': 'get test-key', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'db.query', type: 'string' }, + 'sentry.origin': { value: 'auto.db.redis', type: 'string' }, + 'db.system.name': { value: 'redis', type: 'string' }, + 'db.operation.name': { value: 'get', type: 'string' }, + 'db.query.text': { value: 'get test-key', type: 'string' }, }), }), ); }); test('Instruments mysql automatically', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('sveltekit-2', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-mysql'; - }); + const traceSpansPromise = collectStreamedSpans('sveltekit-2', spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'http.server' && span.name === 'GET /db-mysql' && span.is_segment), + ); await fetch(`${baseURL}/db-mysql`); - const transactionEvent = await transactionEventPromise; + const traceSpans = await traceSpansPromise; - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( + const mysqlSpan = (queryText: string) => expect.objectContaining({ - op: 'db', - origin: 'auto.db.mysql', - description: 'SELECT 1 + 1 AS solution', + name: 'SELECT', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'mysql', - 'db.query.text': 'SELECT 1 + 1 AS solution', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'server.address': expect.any(String), - 'server.port': 3306, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'db', type: 'string' }, + 'sentry.origin': { value: 'auto.db.mysql', type: 'string' }, + 'db.system.name': { value: 'mysql', type: 'string' }, + 'db.query.text': { value: queryText, type: 'string' }, + 'db.query.summary': { value: 'SELECT', type: 'string' }, + 'db.user': { value: 'root', type: 'string' }, + 'db.connection_string': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'server.port': { value: 3306, type: 'integer' }, }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.mysql', - description: 'SELECT NOW()', - status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'mysql', - 'db.query.text': 'SELECT NOW()', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'server.address': expect.any(String), - 'server.port': 3306, - }), - }), - ); + }); + + expect(traceSpans).toContainEqual(mysqlSpan('SELECT 1 + 1 AS solution')); + expect(traceSpans).toContainEqual(mysqlSpan('SELECT NOW()')); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.client.test.ts index 8d3eb63f774c..bb79b01931d8 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.client.test.ts @@ -1,148 +1,82 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; test.describe('client-specific performance events', () => { test('multiple navigations have distinct traces', async ({ page }) => { - const navigationTxn1EventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === '/nav1' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan1Promise = waitForStreamedSpan('sveltekit-2', span => { + return span.name === '/nav1' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const navigationTxn2EventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === '/' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan2Promise = waitForStreamedSpan('sveltekit-2', span => { + return span.name === '/' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const navigationTxn3EventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === '/nav2' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan3Promise = waitForStreamedSpan('sveltekit-2', span => { + return span.name === '/nav2' && getSpanOp(span) === 'navigation' && span.is_segment; }); await waitForInitialPageload(page); - const [navigationTxn1Event] = await Promise.all([navigationTxn1EventPromise, page.getByText('Nav 1').click()]); - const [navigationTxn2Event] = await Promise.all([navigationTxn2EventPromise, page.goBack()]); - const [navigationTxn3Event] = await Promise.all([navigationTxn3EventPromise, page.getByText('Nav 2').click()]); + const [navigationSpan1] = await Promise.all([navigationSpan1Promise, page.getByText('Nav 1').click()]); + const [navigationSpan2] = await Promise.all([navigationSpan2Promise, page.goBack()]); + const [navigationSpan3] = await Promise.all([navigationSpan3Promise, page.getByText('Nav 2').click()]); - expect(navigationTxn1Event).toMatchObject({ - transaction: '/nav1', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'url.path': '/nav1', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/nav1$/), - 'url.template': '/nav1', - }, + const expectNavigationSpan = (span: typeof navigationSpan1, route: string) => { + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: route, type: 'string' }, + 'url.full': { + value: expect.stringMatching(new RegExp(`^https?:\\/\\/localhost:\\d+${route}$`)), + type: 'string', }, - }, - }); + 'url.template': { value: route, type: 'string' }, + }); + }; - expect(navigationTxn2Event).toMatchObject({ - transaction: '/', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - 'url.template': '/', - }, - }, - }, - }); - - expect(navigationTxn3Event).toMatchObject({ - transaction: '/nav2', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'url.path': '/nav2', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/nav2$/), - 'url.template': '/nav2', - }, - }, - }, - }); + expectNavigationSpan(navigationSpan1, '/nav1'); + expectNavigationSpan(navigationSpan2, '/'); + expectNavigationSpan(navigationSpan3, '/nav2'); // traces should NOT be connected - expect(navigationTxn1Event.contexts?.trace?.trace_id).not.toBe(navigationTxn2Event.contexts?.trace?.trace_id); - expect(navigationTxn2Event.contexts?.trace?.trace_id).not.toBe(navigationTxn3Event.contexts?.trace?.trace_id); - expect(navigationTxn1Event.contexts?.trace?.trace_id).not.toBe(navigationTxn3Event.contexts?.trace?.trace_id); + expect(navigationSpan1.trace_id).not.toBe(navigationSpan2.trace_id); + expect(navigationSpan2.trace_id).not.toBe(navigationSpan3.trace_id); + expect(navigationSpan1.trace_id).not.toBe(navigationSpan3.trace_id); }); test('records manually added component tracking spans', async ({ page }) => { - const componentTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === '/components'; - }); + const componentTraceSpansPromise = collectStreamedSpans('sveltekit-2', spansOfTrace => + spansOfTrace.some(span => span.name === '/components' && span.is_segment), + ); await waitForInitialPageload(page); await page.getByText('Component Tracking').click(); - const componentTxnEvent = await componentTxnEventPromise; + const componentTraceSpans = await componentTraceSpansPromise; - expect(componentTxnEvent.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.update', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.update', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.update', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.update', - origin: 'auto.ui.svelte', + const componentSpan = (op: 'ui.mount' | 'ui.update', name: string) => + expect.objectContaining({ + name, + attributes: expect.objectContaining({ + 'sentry.op': { value: op, type: 'string' }, + 'sentry.origin': { value: 'auto.ui.svelte', type: 'string' }, }), + }); + + expect(componentTraceSpans).toEqual( + expect.arrayContaining([ + componentSpan('ui.mount', ''), + componentSpan('ui.mount', ''), + componentSpan('ui.mount', ''), + componentSpan('ui.mount', ''), + componentSpan('ui.update', ''), + componentSpan('ui.update', ''), + componentSpan('ui.update', ''), + componentSpan('ui.update', ''), ]), ); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts index 1195899aa42c..c8e516e1866e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts @@ -1,41 +1,43 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('server pageload request span has nested request span for sub request', async ({ page }) => { - const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === 'GET /server-load-fetch'; - }); + const serverTraceSpansPromise = collectStreamedSpans('sveltekit-2', spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /server-load-fetch' && span.is_segment), + ); await page.goto('/server-load-fetch'); - const serverTxnEvent = await serverTxnEventPromise; - const spans = serverTxnEvent.spans; + const serverTraceSpans = await serverTraceSpansPromise; + const serverSpan = serverTraceSpans.find(span => span.name === 'GET /server-load-fetch' && span.is_segment)!; - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /server-load-fetch', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); - expect(spans).toEqual( + expect(serverTraceSpans).toEqual( expect.arrayContaining([ // load span where the server load function initiates the sub request: - expect.objectContaining({ op: 'function', description: '/server-load-fetch' }), + expect.objectContaining({ + name: '/server-load-fetch', + is_segment: false, + attributes: expect.objectContaining({ 'sentry.op': { value: 'function', type: 'string' } }), + }), // sub request span: - expect.objectContaining({ op: 'http.server', description: 'GET /api/users' }), + expect.objectContaining({ + name: 'GET /api/users', + is_segment: false, + attributes: expect.objectContaining({ 'sentry.op': { value: 'http.server', type: 'string' } }), + }), ]), ); }); -test('extracts HTTP request headers as span attributes', async ({ page, baseURL }) => { - const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === 'GET /api/users'; +test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { + const serverSpanPromise = waitForStreamedSpan('sveltekit-2', span => { + return span.name === 'GET /api/users' && getSpanOp(span) === 'http.server' && span.is_segment; }); await fetch(`${baseURL}/api/users`, { @@ -49,16 +51,14 @@ test('extracts HTTP request headers as span attributes', async ({ page, baseURL }, }); - const serverTxnEvent = await serverTxnEventPromise; + const serverSpan = await serverSpanPromise; - expect(serverTxnEvent.contexts?.trace?.data).toEqual( - expect.objectContaining({ - 'http.request.header.user_agent': 'Custom-SvelteKit-Agent/1.0', - 'http.request.header.content_type': 'application/json', - 'http.request.header.x_test_header': 'sveltekit-test-value', - 'http.request.header.accept': 'application/json', - 'http.request.header.x_framework': 'SvelteKit', - 'http.request.header.x_request_id': 'sveltekit-123', - }), - ); + expect(serverSpan.attributes).toMatchObject({ + 'http.request.header.user_agent': { value: 'Custom-SvelteKit-Agent/1.0', type: 'string' }, + 'http.request.header.content_type': { value: 'application/json', type: 'string' }, + 'http.request.header.x_test_header': { value: 'sveltekit-test-value', type: 'string' }, + 'http.request.header.accept': { value: 'application/json', type: 'string' }, + 'http.request.header.x_framework': { value: 'SvelteKit', type: 'string' }, + 'http.request.header.x_request_id': { value: 'sveltekit-123', type: 'string' }, + }); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.test.ts index 4aacdb936087..878bcebefa55 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.test.ts @@ -1,69 +1,55 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; test.describe('performance events', () => { test('capture a distributed pageload trace', async ({ page }) => { - const clientTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === '/users/[id]'; + const traceSpansPromise = collectStreamedSpans('sveltekit-2', spansOfTrace => { + const hasClientSegment = spansOfTrace.some(span => span.name === '/users/[id]' && span.is_segment); + const hasServerSegment = spansOfTrace.some(span => span.name === 'GET /users/[id]' && span.is_segment); + return hasClientSegment && hasServerSegment; }); - const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === 'GET /users/[id]'; - }); - - const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + const [_, traceSpans] = await Promise.all([ page.goto('/users/123xyz'), - clientTxnEventPromise, - serverTxnEventPromise, + traceSpansPromise, expect(page.getByText('User id: 123xyz')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.sveltekit', - data: { - 'url.path': '/users/123xyz', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123xyz$/), - 'url.template': '/users/[id]', - }, - }, - }, + const clientSpan = traceSpans.find(span => span.name === '/users/[id]' && span.is_segment)!; + const serverSpan = traceSpans.find(span => span.name === 'GET /users/[id]' && span.is_segment)!; + + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/users/123xyz', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123xyz$/), type: 'string' }, + 'url.template': { value: '/users/[id]', type: 'string' }, }); - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); - expect(clientTxnEvent.spans?.length).toBeGreaterThan(5); + expect(traceSpans.length).toBeGreaterThan(5); // connected trace - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); - // weird but server txn is parent of client txn - expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + // weird but server span is parent of client span + expect(clientSpan.parent_span_id).toBe(serverSpan.span_id); }); test('capture a distributed navigation trace', async ({ page }) => { - const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === '/users' && txnEvent.contexts?.trace?.op === 'navigation'; + const clientNavigationSpanPromise = waitForStreamedSpan('sveltekit-2', span => { + return span.name === '/users' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === 'GET /users'; + const serverSpanPromise = waitForStreamedSpan('sveltekit-2', span => { + return span.name === 'GET /users' && span.is_segment; }); await waitForInitialPageload(page); @@ -71,343 +57,221 @@ test.describe('performance events', () => { // navigation to page const clickPromise = page.getByText('Route with Server Load').click(); - const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ - clientNavigationTxnEventPromise, - serverTxnEventPromise, + const [clientSpan, serverSpan, _1, _2] = await Promise.all([ + clientNavigationSpanPromise, + serverSpanPromise, clickPromise, expect(page.getByText('Hi everyone')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'url.path': '/users', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users$/), - 'url.template': '/users', - }, - }, - }, + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/users', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/users$/), type: 'string' }, + 'url.template': { value: '/users', type: 'string' }, }); - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); // trace is connected - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); }); test('record client-side universal load fetch span and trace', async ({ page }) => { await waitForInitialPageload(page); - const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === '/universal-load-fetch' && txnEvent.contexts?.trace?.op === 'navigation'; - }); - - // this transaction should be created because of the fetch call + // the server span should be created because of the fetch call // it should also be part of the trace - const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.transaction === 'GET /api/users'; + const traceSpansPromise = collectStreamedSpans('sveltekit-2', spansOfTrace => { + const hasClientSegment = spansOfTrace.some( + span => span.name === '/universal-load-fetch' && getSpanOp(span) === 'navigation' && span.is_segment, + ); + const hasServerSegment = spansOfTrace.some(span => span.name === 'GET /api/users' && span.is_segment); + return hasClientSegment && hasServerSegment; }); // navigation to page const clickPromise = page.getByText('Route with fetch in universal load').click(); - const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ - clientNavigationTxnEventPromise, - serverTxnEventPromise, + const [traceSpans, _1, _2] = await Promise.all([ + traceSpansPromise, clickPromise, expect(page.getByText('alice')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/universal-load-fetch', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'url.path': '/universal-load-fetch', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/universal-load-fetch$/), - 'url.template': '/universal-load-fetch', - }, - }, - }, + const clientSpan = traceSpans.find(span => span.name === '/universal-load-fetch' && span.is_segment)!; + const serverSpan = traceSpans.find(span => span.name === 'GET /api/users' && span.is_segment)!; + + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/universal-load-fetch', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/universal-load-fetch$/), type: 'string' }, + 'url.template': { value: '/universal-load-fetch', type: 'string' }, }); - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /api/users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); // trace is connected - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); - - const clientFetchSpan = clientTxnEvent.spans?.find(s => s.op === 'http.client'); - - expect(clientFetchSpan).toMatchObject({ - description: expect.stringMatching(/^GET.*\/api\/users/), - op: 'http.client', - origin: 'auto.http.browser', - data: { - 'url.full': expect.stringContaining('/api/users'), - type: 'fetch', - 'http.request.method': 'GET', - 'http.response.status_code': 200, - 'network.protocol.version': '1.1', - 'network.protocol.name': 'http', - 'http.request.redirect_start': expect.any(Number), - 'http.request.fetch_start': expect.any(Number), - 'http.request.domain_lookup_start': expect.any(Number), - 'http.request.domain_lookup_end': expect.any(Number), - 'http.request.connect_start': expect.any(Number), - 'http.request.secure_connection_start': expect.any(Number), - 'http.request.connection_end': expect.any(Number), - 'http.request.request_start': expect.any(Number), - 'http.request.response_start': expect.any(Number), - 'http.request.response_end': expect.any(Number), - }, + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); + + const clientFetchSpan = traceSpans.find(span => getSpanOp(span) === 'http.client'); + + expect(clientFetchSpan?.name).toBe('GET localhost'); + expect(clientFetchSpan?.parent_span_id).toBe(clientSpan.span_id); + expect(clientFetchSpan?.attributes).toMatchObject({ + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.browser', type: 'string' }, + 'url.full': { value: expect.stringContaining('/api/users'), type: 'string' }, + type: { value: 'fetch', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'network.protocol.version': { value: '1.1', type: 'string' }, + 'network.protocol.name': { value: 'http', type: 'string' }, + 'http.request.redirect_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.fetch_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.domain_lookup_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.domain_lookup_end': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.connect_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.secure_connection_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.connection_end': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.request_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.response_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.response_end': expect.objectContaining({ value: expect.any(Number) }), }); }); - test('captures a navigation transaction directly after pageload', async ({ page }) => { - const clientPageloadTxnPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'pageload'; + test('captures a navigation span directly after pageload', async ({ page }) => { + const clientPageloadSpanPromise = waitForStreamedSpan('sveltekit-2', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const clientNavigationTxnPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation'; - }); + const navigationTraceSpansPromise = collectStreamedSpans('sveltekit-2', spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment), + ); await waitForInitialPageload(page, { route: '/' }); const navigationClickPromise = page.locator('#routeWithParamsLink').click(); - const [pageloadTxnEvent, navigationTxnEvent, _] = await Promise.all([ - clientPageloadTxnPromise, - clientNavigationTxnPromise, + const [pageloadSpan, navigationTraceSpans, _] = await Promise.all([ + clientPageloadSpanPromise, + navigationTraceSpansPromise, navigationClickPromise, ]); - expect(pageloadTxnEvent).toMatchObject({ - transaction: '/', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.sveltekit', - data: { - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - 'url.template': '/', - }, - }, - }, + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + 'url.template': { value: '/', type: 'string' }, }); - expect(navigationTxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - 'url.path': '/users/123abc', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123abc$/), - 'url.template': '/users/[id]', - }, - }, - }, + const navigationSpan = navigationTraceSpans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; + + expect(navigationSpan.name).toBe('/users/[id]'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: '/users/[id]', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, + 'url.path': { value: '/users/123abc', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123abc$/), type: 'string' }, + 'url.template': { value: '/users/[id]', type: 'string' }, }); - const routingSpans = navigationTxnEvent.spans?.filter(s => s.op === 'router'); + const routingSpans = navigationTraceSpans.filter(span => getSpanOp(span) === 'router'); expect(routingSpans).toHaveLength(1); - const routingSpan = routingSpans && routingSpans[0]; - expect(routingSpan).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - }, + const routingSpan = routingSpans[0]!; + expect(routingSpan.name).toBe('Router'); + expect(routingSpan.parent_span_id).toBe(navigationSpan.span_id); + expect(routingSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'router', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.sveltekit', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: '/users/[id]', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, }); }); - test('captures one navigation transaction per redirect', async ({ page }) => { - const clientNavigationRedirect1TxnPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect1'; - }); - - const clientNavigationRedirect2TxnPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect2'; - }); + test('captures one navigation span per redirect', async ({ page }) => { + const collectNavigationTrace = (route: string) => + collectStreamedSpans('sveltekit-2', spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.name === route && span.is_segment), + ); - const clientNavigationRedirect3TxnPromise = waitForTransaction('sveltekit-2', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/users/[id]'; - }); + const redirect1TraceSpansPromise = collectNavigationTrace('/redirect1'); + const redirect2TraceSpansPromise = collectNavigationTrace('/redirect2'); + const redirect3TraceSpansPromise = collectNavigationTrace('/users/[id]'); await waitForInitialPageload(page, { route: '/' }); const navigationClickPromise = page.locator('#redirectLink').click(); - const [redirect1TxnEvent, redirect2TxnEvent, redirect3TxnEvent, _] = await Promise.all([ - clientNavigationRedirect1TxnPromise, - clientNavigationRedirect2TxnPromise, - clientNavigationRedirect3TxnPromise, + const [redirect1TraceSpans, redirect2TraceSpans, redirect3TraceSpans, _] = await Promise.all([ + redirect1TraceSpansPromise, + redirect2TraceSpansPromise, + redirect3TraceSpansPromise, navigationClickPromise, ]); - expect(redirect1TxnEvent).toMatchObject({ - transaction: '/redirect1', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect1', - 'sentry.sample_rate': 1, - 'url.path': '/redirect1', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/redirect1$/), - 'url.template': '/redirect1', - }, - }, - }, - }); - - const redirect1Spans = redirect1TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect1Spans).toHaveLength(1); - - const redirect1Span = redirect1Spans && redirect1Spans[0]; - expect(redirect1Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect1', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); - - expect(redirect2TxnEvent).toMatchObject({ - transaction: '/redirect2', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect2', - 'sentry.sample_rate': 1, - 'url.path': '/redirect2', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/redirect2$/), - 'url.template': '/redirect2', - }, + const expectNavigationTrace = ( + traceSpans: typeof redirect1TraceSpans, + { route, path }: { route: string; path: string }, + ) => { + const navigationSpan = traceSpans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; + + expect(navigationSpan.name).toBe(route); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: route, type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'url.path': { value: path, type: 'string' }, + 'url.full': { + value: expect.stringMatching(new RegExp(`^https?:\\/\\/localhost:\\d+${path}$`)), + type: 'string', }, - }, - }); - - const redirect2Spans = redirect2TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect2Spans).toHaveLength(1); - - const redirect2Span = redirect2Spans && redirect2Spans[0]; - expect(redirect2Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect2', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); - - expect(redirect3TxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sample_rate': 1, - 'url.path': '/users/789', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/789$/), - 'url.template': '/users/[id]', - }, - }, - }, - }); - - const redirect3Spans = redirect3TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect3Spans).toHaveLength(1); - - const redirect3Span = redirect3Spans && redirect3Spans[0]; - expect(redirect3Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); + 'url.template': { value: route, type: 'string' }, + }); + + const routingSpans = traceSpans.filter(span => getSpanOp(span) === 'router'); + expect(routingSpans).toHaveLength(1); + + const routingSpan = routingSpans[0]!; + expect(routingSpan.name).toBe('Router'); + expect(routingSpan.parent_span_id).toBe(navigationSpan.span_id); + expect(routingSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'router', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.sveltekit', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: route, type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, + }); + }; + + expectNavigationTrace(redirect1TraceSpans, { route: '/redirect1', path: '/redirect1' }); + expectNavigationTrace(redirect2TraceSpans, { route: '/redirect2', path: '/redirect2' }); + expectNavigationTrace(redirect3TraceSpans, { route: '/users/[id]', path: '/users/789' }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/utils.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/utils.ts index 84a7e5605a1d..dd0b15c08bd5 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/utils.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/utils.ts @@ -1,5 +1,5 @@ import { Page } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; /** * Helper function that waits for the initial pageload to complete. @@ -7,12 +7,12 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; * This function * - loads the given route ("/" by default) * - waits for SvelteKit's hydration - * - waits for the pageload transaction to be sent (doesn't assert on it though) + * - waits for the pageload span to be sent (doesn't assert on it though) * * Useful for tests that test outcomes of _navigations_ after an initial pageload. - * Waiting on the pageload transaction excludes edge cases where navigations occur - * so quickly that the pageload idle transaction is still active. This might lead - * to cases where the routing span would be attached to the pageload transaction + * Waiting on the pageload span excludes edge cases where navigations occur + * so quickly that the pageload idle span is still active. This might lead + * to cases where the routing span would be attached to the pageload span * and hence eliminates a lot of flakiness. * */ @@ -21,28 +21,28 @@ export async function waitForInitialPageload( opts?: { route?: string; parameterizedRoute?: string; debug?: boolean }, ) { const route = opts?.route ?? '/'; - const txnName = opts?.parameterizedRoute ?? route; + const spanName = opts?.parameterizedRoute ?? route; const debug = opts?.debug ?? false; - const clientPageloadTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { + const clientPageloadSpanPromise = waitForStreamedSpan('sveltekit-2', span => { debug && console.log({ - txn: txnEvent?.transaction, - op: txnEvent.contexts?.trace?.op, - trace: txnEvent.contexts?.trace?.trace_id, - span: txnEvent.contexts?.trace?.span_id, - parent: txnEvent.contexts?.trace?.parent_span_id, + name: span.name, + op: getSpanOp(span), + trace: span.trace_id, + span: span.span_id, + parent: span.parent_span_id, }); - return txnEvent?.transaction === txnName && txnEvent.contexts?.trace?.op === 'pageload'; + return span.name === spanName && getSpanOp(span) === 'pageload' && span.is_segment; }); await Promise.all([ page.goto(route), // the test app adds the "hydrated" class to the body when hydrating page.waitForSelector('body.hydrated'), - // also waiting for the initial pageload txn so that later navigations don't interfere - clientPageloadTxnEventPromise, + // also waiting for the initial pageload span so that later navigations don't interfere + clientPageloadSpanPromise, ]); debug && console.log('hydrated'); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts index 050a7171d7ef..4d4723a527d3 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts @@ -2,7 +2,6 @@ import { PUBLIC_E2E_TEST_DSN } from '$app/env/public'; import * as Sentry from '@sentry/sveltekit'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: PUBLIC_E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts index 5b232f5e2dfe..18c0b65b6db7 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts @@ -4,7 +4,6 @@ import * as Sentry from '@sentry/sveltekit'; // With SvelteKit 3 native instrumentation enabled (`experimental.instrumentation.server`), // `Sentry.init` runs here instead of in `hooks.server.ts`. Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts index 33a7804f37c2..9162364c6e12 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts @@ -1,114 +1,78 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; test.describe('client-specific performance events', () => { test('multiple navigations have distinct traces', async ({ page }) => { - const navigationTxn1EventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === '/nav1' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan1Promise = waitForStreamedSpan('sveltekit-3', span => { + return span.name === '/nav1' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const navigationTxn2EventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === '/' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan2Promise = waitForStreamedSpan('sveltekit-3', span => { + return span.name === '/' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const navigationTxn3EventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === '/nav2' && txnEvent.contexts?.trace?.op === 'navigation'; + const navigationSpan3Promise = waitForStreamedSpan('sveltekit-3', span => { + return span.name === '/nav2' && getSpanOp(span) === 'navigation' && span.is_segment; }); await waitForInitialPageload(page); await page.getByText('Nav 1').click(); - const navigationTxn1Event = await navigationTxn1EventPromise; + const navigationSpan1 = await navigationSpan1Promise; await page.goBack(); - const navigationTxn2Event = await navigationTxn2EventPromise; + const navigationSpan2 = await navigationSpan2Promise; await page.getByText('Nav 2').click(); - const navigationTxn3Event = await navigationTxn3EventPromise; - - expect(navigationTxn1Event).toMatchObject({ - transaction: '/nav1', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }, - }); - - expect(navigationTxn2Event).toMatchObject({ - transaction: '/', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }, - }); - - expect(navigationTxn3Event).toMatchObject({ - transaction: '/nav2', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }, - }); + const navigationSpan3 = await navigationSpan3Promise; + + const expectNavigationSpan = (span: typeof navigationSpan1, route: string) => { + expect(span.name).toBe(route); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + }); + }; + + expectNavigationSpan(navigationSpan1, '/nav1'); + expectNavigationSpan(navigationSpan2, '/'); + expectNavigationSpan(navigationSpan3, '/nav2'); // traces should NOT be connected - expect(navigationTxn1Event.contexts?.trace?.trace_id).not.toBe(navigationTxn2Event.contexts?.trace?.trace_id); - expect(navigationTxn2Event.contexts?.trace?.trace_id).not.toBe(navigationTxn3Event.contexts?.trace?.trace_id); - expect(navigationTxn1Event.contexts?.trace?.trace_id).not.toBe(navigationTxn3Event.contexts?.trace?.trace_id); + expect(navigationSpan1.trace_id).not.toBe(navigationSpan2.trace_id); + expect(navigationSpan2.trace_id).not.toBe(navigationSpan3.trace_id); + expect(navigationSpan1.trace_id).not.toBe(navigationSpan3.trace_id); }); test('records manually added component tracking spans', async ({ page }) => { - const componentTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === '/components'; - }); + const componentTraceSpansPromise = collectStreamedSpans('sveltekit-3', spansOfTrace => + spansOfTrace.some(span => span.name === '/components' && span.is_segment), + ); await waitForInitialPageload(page); await page.getByText('Component Tracking').click(); - const componentTxnEvent = await componentTxnEventPromise; + const componentTraceSpans = await componentTraceSpansPromise; - expect(componentTxnEvent.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.mount', - origin: 'auto.ui.svelte', + const componentSpan = (name: string) => + expect.objectContaining({ + name, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'ui.mount', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.svelte', type: 'string' }, }), + }); + + expect(componentTraceSpans).toEqual( + expect.arrayContaining([ + componentSpan(''), + componentSpan(''), + componentSpan(''), + componentSpan(''), ]), ); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts index 8c6bfe1c3fc1..7760715a7ad4 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts @@ -1,190 +1,162 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/sveltekit'; +import { collectSegmentSpans } from '@sentry-internal/test-utils'; test('server pageload request span has nested request span for sub request', async ({ page }) => { - const serverTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === 'GET /server-load-fetch'; - }); + const serverSegmentPromise = collectSegmentSpans( + 'sveltekit-3', + segmentSpan => segmentSpan.name === 'GET /server-load-fetch', + ); await page.goto('/server-load-fetch'); - const serverTxnEvent = await serverTxnEventPromise; - const spans = serverTxnEvent.spans; - - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /server-load-fetch', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', - 'http.method': 'GET', - 'http.route': '/server-load-fetch', - 'sveltekit.tracing.original_name': 'sveltekit.handle.root', - }, - }, - }, + const { segmentSpan: serverSpan, childSpans } = await serverSegmentPromise; + + expect(serverSpan.status).toBe('ok'); + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.method': { value: 'GET', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'http.route': { value: '/server-load-fetch', type: 'string' }, + 'sveltekit.tracing.original_name': { value: 'sveltekit.handle.root', type: 'string' }, + 'url.full': { value: 'https://localhost:3030/server-load-fetch', type: 'string' }, + 'http.request.header.accept': { value: expect.any(String), type: 'string' }, + 'http.request.header.user_agent': { value: expect.any(String), type: 'string' }, }); - expect(spans).toHaveLength(6); + expect(childSpans).toHaveLength(6); - expect(spans).toEqual( + expect(childSpans).toEqual( expect.arrayContaining([ // initial resolve span: expect.objectContaining({ - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', - 'http.route': '/server-load-fetch', - }), - op: 'function', - description: 'sveltekit.resolve', - origin: 'auto.http.sveltekit', + name: 'sveltekit.resolve', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'http.route': { value: '/server-load-fetch', type: 'string' }, + }), }), // sequenced handler span: expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.origin': 'auto.function.sveltekit.handle', - 'sentry.op': 'function', - }), - description: 'sveltekit.handle.sequenced.sentryRequestHandler', - op: 'function', - origin: 'auto.function.sveltekit.handle', + name: 'sveltekit.handle.sequenced.sentryRequestHandler', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.handle', type: 'string' }, + }), }), // load span where the server load function initiates the sub request: expect.objectContaining({ - data: expect.objectContaining({ - 'http.route': '/server-load-fetch', - 'sentry.op': 'function', - 'sentry.origin': 'auto.function.sveltekit.load', - 'sveltekit.load.environment': 'server', - 'sveltekit.load.node_id': 'src/routes/server-load-fetch/+page.server.ts', - 'sveltekit.load.node_type': '+page.server', - }), - description: 'sveltekit.load', - op: 'function', - origin: 'auto.function.sveltekit.load', + name: 'sveltekit.load', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.load', type: 'string' }, + 'http.route': { value: '/server-load-fetch', type: 'string' }, + 'sveltekit.load.environment': { value: 'server', type: 'string' }, + 'sveltekit.load.node_id': { value: 'src/routes/server-load-fetch/+page.server.ts', type: 'string' }, + 'sveltekit.load.node_type': { value: '+page.server', type: 'string' }, + }), }), // sub request http.server span: expect.objectContaining({ - data: expect.objectContaining({ - 'http.method': 'GET', - 'http.route': '/api/users', - 'url.full': 'https://localhost:3030/api/users', - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.sveltekit', - 'sveltekit.is_data_request': false, - 'sveltekit.is_sub_request': true, - 'sveltekit.tracing.original_name': 'sveltekit.handle.root', - }), - description: 'GET /api/users', - op: 'http.server', - origin: 'auto.http.sveltekit', + name: 'GET /api/users', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'http.method': { value: 'GET', type: 'string' }, + 'http.route': { value: '/api/users', type: 'string' }, + 'url.full': { value: 'https://localhost:3030/api/users', type: 'string' }, + 'url.path': { value: '/api/users', type: 'string' }, + 'sveltekit.is_data_request': { value: false, type: 'boolean' }, + 'sveltekit.is_sub_request': { value: true, type: 'boolean' }, + 'sveltekit.tracing.original_name': { value: 'sveltekit.handle.root', type: 'string' }, + }), }), - // sub requestsequenced handler span: + // sub request sequenced handler span: expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.origin': 'auto.function.sveltekit.handle', - 'sentry.op': 'function', - }), - description: 'sveltekit.handle.sequenced.sentryRequestHandler', - op: 'function', - origin: 'auto.function.sveltekit.handle', + name: 'sveltekit.handle.sequenced.sentryRequestHandler', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.handle', type: 'string' }, + }), }), // sub request resolve span: expect.objectContaining({ - data: expect.objectContaining({ - 'http.route': '/api/users', - 'sentry.op': 'function', - 'sentry.origin': 'auto.http.sveltekit', - }), - description: 'sveltekit.resolve', - op: 'function', - origin: 'auto.http.sveltekit', + name: 'sveltekit.resolve', status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'http.route': { value: '/api/users', type: 'string' }, + }), }), ]), ); - - expect(serverTxnEvent.request).toEqual({ - cookies: {}, - headers: expect.objectContaining({ - accept: expect.any(String), - 'user-agent': expect.any(String), - }), - method: 'GET', - url: 'http://localhost:3030/server-load-fetch', - }); }); -// FIXME(sveltekit-3): the `POST /form-action` server transaction never arrives under Kit 3 (no POST +// FIXME(sveltekit-3): the `POST /form-action` server span never arrives under Kit 3 (no POST // root span is created server-side; `handleUnknownRoutes` does not help). The `use:enhance` POST // either doesn't reach the traced handle or isn't traced under Kit 3 — needs isolation. Unskip once -// form-action requests produce a server transaction again. +// form-action requests produce a server segment span again. test.skip('server trace includes form action span', async ({ page }) => { - const serverTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === 'POST /form-action'; - }); + const serverSegmentPromise = collectSegmentSpans( + 'sveltekit-3', + segmentSpan => segmentSpan.name === 'POST /form-action', + ); await page.goto('/form-action'); await page.locator('#inputName').fill('H4cktor'); await page.locator('#buttonSubmit').click(); - const serverTxnEvent = await serverTxnEventPromise; - - expect(serverTxnEvent).toMatchObject({ - transaction: 'POST /form-action', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + const { segmentSpan: serverSpan, childSpans } = await serverSegmentPromise; + + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); - expect(serverTxnEvent.spans).toHaveLength(3); + expect(childSpans).toHaveLength(3); - expect(serverTxnEvent.spans).toEqual( + expect(childSpans).toEqual( expect.arrayContaining([ // sequenced handler span expect.objectContaining({ - description: 'sveltekit.handle.sequenced.sentryRequestHandler', - op: 'function', - origin: 'auto.function.sveltekit.handle', + name: 'sveltekit.handle.sequenced.sentryRequestHandler', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.handle', type: 'string' }, + }), }), // resolve span expect.objectContaining({ - description: 'sveltekit.resolve', - op: 'function', - origin: 'auto.http.sveltekit', + name: 'sveltekit.resolve', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + }), }), // form action span expect.objectContaining({ - description: 'sveltekit.form_action', - op: 'function', - origin: 'auto.function.sveltekit.action', - data: expect.objectContaining({ - 'sveltekit.form_action.name': 'default', + name: 'sveltekit.form_action', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.action', type: 'string' }, + 'sveltekit.form_action.name': { value: 'default', type: 'string' }, }), }), ]), @@ -192,9 +164,10 @@ test.skip('server trace includes form action span', async ({ page }) => { }); test('server trace for a `QUERY` server route includes the wrapped route handler span', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === 'QUERY /query-server-route'; - }); + const serverSegmentPromise = collectSegmentSpans( + 'sveltekit-3', + segmentSpan => segmentSpan.name === 'QUERY /query-server-route', + ); const response = await request.fetch('/query-server-route', { method: 'QUERY', @@ -205,35 +178,24 @@ test('server trace for a `QUERY` server route includes the wrapped route handler expect(response.status()).toBe(200); expect(await response.json()).toEqual({ term: 'sentry', results: ['alice', 'bob'] }); - const serverTxnEvent = await serverTxnEventPromise; - - expect(serverTxnEvent).toMatchObject({ - transaction: 'QUERY /query-server-route', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', - 'http.method': 'QUERY', - 'http.route': '/query-server-route', - }, - }, - }, + const { segmentSpan: serverSpan, childSpans } = await serverSegmentPromise; + + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.method': { value: 'QUERY', type: 'string' }, + 'http.route': { value: '/query-server-route', type: 'string' }, }); - expect(serverTxnEvent.spans).toEqual( + expect(childSpans).toEqual( expect.arrayContaining([ expect.objectContaining({ - description: 'QUERY /query-server-route', - origin: 'auto.function.sveltekit', - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', - 'code.function.name': 'QUERY', - 'http.request.method': 'QUERY', + name: 'QUERY /query-server-route', + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.function.sveltekit', type: 'string' }, + 'code.function.name': { value: 'QUERY', type: 'string' }, + 'http.request.method': { value: 'QUERY', type: 'string' }, }), }), ]), diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts index b3cf48adce31..0531ed4e4b14 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts @@ -1,71 +1,58 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; test('capture a distributed pageload trace', async ({ page }) => { - const clientTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === '/users/[id]'; + const traceSpansPromise = collectStreamedSpans('sveltekit-3', spansOfTrace => { + const hasClientSegment = spansOfTrace.some(span => span.name === '/users/[id]' && span.is_segment); + const hasServerSegment = spansOfTrace.some(span => span.name === 'GET /users/[id]' && span.is_segment); + return hasClientSegment && hasServerSegment; }); - const serverTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === 'GET /users/[id]'; - }); - - const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + const [_, traceSpans] = await Promise.all([ page.goto('/users/123xyz'), - clientTxnEventPromise, - serverTxnEventPromise, + traceSpansPromise, expect(page.getByText('User id: 123xyz')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.sveltekit', - }, - }, - }); + const clientSpan = traceSpans.find(span => span.name === '/users/[id]' && span.is_segment)!; + const serverSpan = traceSpans.find(span => span.name === 'GET /users/[id]' && span.is_segment)!; - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); - expect(clientTxnEvent.spans?.length).toBeGreaterThan(5); - - const serverKitResolveSpan = serverTxnEvent.spans?.find(s => s.description === 'sveltekit.resolve'); - expect(serverKitResolveSpan).toMatchObject({ - description: 'sveltekit.resolve', - op: 'function', - origin: 'auto.http.sveltekit', - status: 'ok', + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); + expect(traceSpans.length).toBeGreaterThan(5); + // connected trace - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); + + const serverKitResolveSpan = traceSpans.find(span => span.name === 'sveltekit.resolve'); + expect(serverKitResolveSpan?.status).toBe('ok'); + expect(serverKitResolveSpan?.attributes).toMatchObject({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + }); // Sveltekit resolve span is the parent span of the client span - expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverKitResolveSpan?.span_id); + expect(clientSpan.parent_span_id).toBe(serverKitResolveSpan?.span_id); }); test('capture a distributed navigation trace', async ({ page }) => { - const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === '/users' && txnEvent.contexts?.trace?.op === 'navigation'; + const clientNavigationSpanPromise = waitForStreamedSpan('sveltekit-3', span => { + return span.name === '/users' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const serverTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === 'GET /users'; + const serverSpanPromise = waitForStreamedSpan('sveltekit-3', span => { + return span.name === 'GET /users' && span.is_segment; }); await waitForInitialPageload(page); @@ -73,315 +60,198 @@ test('capture a distributed navigation trace', async ({ page }) => { // navigation to page const clickPromise = page.getByText('Route with Server Load').click(); - const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ - clientNavigationTxnEventPromise, - serverTxnEventPromise, + const [clientSpan, serverSpan, _1, _2] = await Promise.all([ + clientNavigationSpanPromise, + serverSpanPromise, clickPromise, expect(page.getByText('Hi everyone')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - }, - }, + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); // trace is connected - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); }); test('record client-side universal load fetch span and trace', async ({ page }) => { await waitForInitialPageload(page); - const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === '/universal-load-fetch' && txnEvent.contexts?.trace?.op === 'navigation'; - }); - - // this transaction should be created because of the fetch call + // the server span should be created because of the fetch call // it should also be part of the trace - const serverTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.transaction === 'GET /api/users'; + const traceSpansPromise = collectStreamedSpans('sveltekit-3', spansOfTrace => { + const hasClientSegment = spansOfTrace.some( + span => span.name === '/universal-load-fetch' && getSpanOp(span) === 'navigation' && span.is_segment, + ); + const hasServerSegment = spansOfTrace.some(span => span.name === 'GET /api/users' && span.is_segment); + return hasClientSegment && hasServerSegment; }); // navigation to page const clickPromise = page.getByText('Route with fetch in universal load').click(); - const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ - clientNavigationTxnEventPromise, - serverTxnEventPromise, + const [traceSpans, _1, _2] = await Promise.all([ + traceSpansPromise, clickPromise, expect(page.getByText('alice')).toBeVisible(), ]); - expect(clientTxnEvent).toMatchObject({ - transaction: '/universal-load-fetch', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - }, - }, + const clientSpan = traceSpans.find(span => span.name === '/universal-load-fetch' && span.is_segment)!; + const serverSpan = traceSpans.find(span => span.name === 'GET /api/users' && span.is_segment)!; + + expect(clientSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); - expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /api/users', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.sveltekit', - }, - }, + expect(serverSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); // trace is connected - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); - - const clientFetchSpan = clientTxnEvent.spans?.find(s => s.op === 'http.client'); - - expect(clientFetchSpan).toMatchObject({ - description: expect.stringMatching(/^GET.*\/api\/users/), - op: 'http.client', - origin: 'auto.http.browser', - data: { - 'url.full': expect.stringContaining('/api/users'), - type: 'fetch', - 'http.request.method': 'GET', - 'http.response.status_code': 200, - 'network.protocol.version': '1.1', - 'network.protocol.name': 'http', - 'http.request.redirect_start': expect.any(Number), - 'http.request.fetch_start': expect.any(Number), - 'http.request.domain_lookup_start': expect.any(Number), - 'http.request.domain_lookup_end': expect.any(Number), - 'http.request.connect_start': expect.any(Number), - 'http.request.secure_connection_start': expect.any(Number), - 'http.request.connection_end': expect.any(Number), - 'http.request.request_start': expect.any(Number), - 'http.request.response_start': expect.any(Number), - 'http.request.response_end': expect.any(Number), - }, + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); + + const clientFetchSpan = traceSpans.find(span => getSpanOp(span) === 'http.client'); + + expect(clientFetchSpan?.name).toBe('GET localhost'); + expect(clientFetchSpan?.parent_span_id).toBe(clientSpan.span_id); + expect(clientFetchSpan?.attributes).toMatchObject({ + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.browser', type: 'string' }, + type: { value: 'fetch', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'network.protocol.version': { value: '1.1', type: 'string' }, + 'network.protocol.name': { value: 'http', type: 'string' }, + 'http.request.redirect_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.fetch_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.domain_lookup_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.domain_lookup_end': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.connect_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.secure_connection_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.connection_end': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.request_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.response_start': expect.objectContaining({ value: expect.any(Number) }), + 'http.request.response_end': expect.objectContaining({ value: expect.any(Number) }), }); }); -test('captures a navigation transaction directly after pageload', async ({ page }) => { - const clientPageloadTxnPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'pageload'; +test('captures a navigation span directly after pageload', async ({ page }) => { + const clientPageloadSpanPromise = waitForStreamedSpan('sveltekit-3', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const clientNavigationTxnPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation'; - }); + const navigationTraceSpansPromise = collectStreamedSpans('sveltekit-3', spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment), + ); await waitForInitialPageload(page, { route: '/' }); const navigationClickPromise = page.locator('#routeWithParamsLink').click(); - const [pageloadTxnEvent, navigationTxnEvent, _] = await Promise.all([ - clientPageloadTxnPromise, - clientNavigationTxnPromise, + const [pageloadSpan, navigationTraceSpans, _] = await Promise.all([ + clientPageloadSpanPromise, + navigationTraceSpansPromise, navigationClickPromise, ]); - expect(pageloadTxnEvent).toMatchObject({ - transaction: '/', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.sveltekit', - }, - }, + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); - expect(navigationTxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - }, - }, - }, + const navigationSpan = navigationTraceSpans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; + + expect(navigationSpan.name).toBe('/users/[id]'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: '/users/[id]', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, }); - const routingSpans = navigationTxnEvent.spans?.filter(s => s.op === 'router'); + const routingSpans = navigationTraceSpans.filter(span => getSpanOp(span) === 'router'); expect(routingSpans).toHaveLength(1); - const routingSpan = routingSpans && routingSpans[0]; - expect(routingSpan).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - }, + const routingSpan = routingSpans[0]!; + expect(routingSpan.name).toBe('Router'); + expect(routingSpan.parent_span_id).toBe(navigationSpan.span_id); + expect(routingSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'router', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.sveltekit', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: '/users/[id]', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, }); }); -test('captures one navigation transaction per redirect', async ({ page }) => { - const clientNavigationRedirect1TxnPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect1'; - }); +test('captures one navigation span per redirect', async ({ page }) => { + const collectNavigationTrace = (route: string) => + collectStreamedSpans('sveltekit-3', spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.name === route && span.is_segment), + ); - const clientNavigationRedirect2TxnPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect2'; - }); - - const clientNavigationRedirect3TxnPromise = waitForTransaction('sveltekit-3', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/users/[id]'; - }); + const redirect1TraceSpansPromise = collectNavigationTrace('/redirect1'); + const redirect2TraceSpansPromise = collectNavigationTrace('/redirect2'); + const redirect3TraceSpansPromise = collectNavigationTrace('/users/[id]'); await waitForInitialPageload(page, { route: '/' }); const navigationClickPromise = page.locator('#redirectLink').click(); - const [redirect1TxnEvent, redirect2TxnEvent, redirect3TxnEvent, _] = await Promise.all([ - clientNavigationRedirect1TxnPromise, - clientNavigationRedirect2TxnPromise, - clientNavigationRedirect3TxnPromise, + const [redirect1TraceSpans, redirect2TraceSpans, redirect3TraceSpans, _] = await Promise.all([ + redirect1TraceSpansPromise, + redirect2TraceSpansPromise, + redirect3TraceSpansPromise, navigationClickPromise, ]); - expect(redirect1TxnEvent).toMatchObject({ - transaction: '/redirect1', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect1', - 'sentry.sample_rate': 1, - }, - }, - }, - }); - - const redirect1Spans = redirect1TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect1Spans).toHaveLength(1); - - const redirect1Span = redirect1Spans && redirect1Spans[0]; - expect(redirect1Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect1', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); - - expect(redirect2TxnEvent).toMatchObject({ - transaction: '/redirect2', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect2', - 'sentry.sample_rate': 1, - }, - }, - }, - }); - - const redirect2Spans = redirect2TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect2Spans).toHaveLength(1); - - const redirect2Span = redirect2Spans && redirect2Spans[0]; - expect(redirect2Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/redirect2', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); - - expect(redirect3TxnEvent).toMatchObject({ - transaction: '/users/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.sveltekit', - data: { - 'sentry.origin': 'auto.navigation.sveltekit', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'sentry.sveltekit.navigation.type': 'link', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sample_rate': 1, - }, - }, - }, - }); - - const redirect3Spans = redirect3TxnEvent.spans?.filter(s => s.op === 'router'); - expect(redirect3Spans).toHaveLength(1); - - const redirect3Span = redirect3Spans && redirect3Spans[0]; - expect(redirect3Span).toMatchObject({ - op: 'router', - description: 'SvelteKit Route Change', - data: { - 'sentry.op': 'router', - 'sentry.origin': 'auto.ui.sveltekit', - 'sentry.sveltekit.navigation.from': '/', - 'sentry.sveltekit.navigation.to': '/users/[id]', - 'sentry.sveltekit.navigation.type': 'link', - }, - }); + const expectNavigationTrace = (traceSpans: typeof redirect1TraceSpans, route: string) => { + const navigationSpan = traceSpans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; + + expect(navigationSpan.name).toBe(route); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.navigation.sveltekit', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: route, type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + }); + + const routingSpans = traceSpans.filter(span => getSpanOp(span) === 'router'); + expect(routingSpans).toHaveLength(1); + + const routingSpan = routingSpans[0]!; + expect(routingSpan.name).toBe('Router'); + expect(routingSpan.parent_span_id).toBe(navigationSpan.span_id); + expect(routingSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'router', type: 'string' }, + 'sentry.origin': { value: 'auto.ui.sveltekit', type: 'string' }, + 'sentry.sveltekit.navigation.from': { value: '/', type: 'string' }, + 'sentry.sveltekit.navigation.to': { value: route, type: 'string' }, + 'sentry.sveltekit.navigation.type': { value: 'link', type: 'string' }, + }); + }; + + expectNavigationTrace(redirect1TraceSpans, '/redirect1'); + expectNavigationTrace(redirect2TraceSpans, '/redirect2'); + expectNavigationTrace(redirect3TraceSpans, '/users/[id]'); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/utils.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/utils.ts index c3677dd863db..9bc720e717c8 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/utils.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/utils.ts @@ -1,5 +1,5 @@ import { Page } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; /** * Helper function that waits for the initial pageload to complete. @@ -7,12 +7,12 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; * This function * - loads the given route ("/" by default) * - waits for SvelteKit's hydration - * - waits for the pageload transaction to be sent (doesn't assert on it though) + * - waits for the pageload span to be sent (doesn't assert on it though) * * Useful for tests that test outcomes of _navigations_ after an initial pageload. - * Waiting on the pageload transaction excludes edge cases where navigations occur - * so quickly that the pageload idle transaction is still active. This might lead - * to cases where the routing span would be attached to the pageload transaction + * Waiting on the pageload span excludes edge cases where navigations occur + * so quickly that the pageload idle span is still active. This might lead + * to cases where the routing span would be attached to the pageload span * and hence eliminates a lot of flakiness. * */ @@ -21,28 +21,28 @@ export async function waitForInitialPageload( opts?: { route?: string; parameterizedRoute?: string; debug?: boolean }, ) { const route = opts?.route ?? '/'; - const txnName = opts?.parameterizedRoute ?? route; + const spanName = opts?.parameterizedRoute ?? route; const debug = opts?.debug ?? false; - const clientPageloadTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { + const clientPageloadSpanPromise = waitForStreamedSpan('sveltekit-3', span => { debug && console.log({ - txn: txnEvent?.transaction, - op: txnEvent.contexts?.trace?.op, - trace: txnEvent.contexts?.trace?.trace_id, - span: txnEvent.contexts?.trace?.span_id, - parent: txnEvent.contexts?.trace?.parent_span_id, + name: span.name, + op: getSpanOp(span), + trace: span.trace_id, + span: span.span_id, + parent: span.parent_span_id, }); - return txnEvent?.transaction === txnName && txnEvent.contexts?.trace?.op === 'pageload'; + return span.name === spanName && getSpanOp(span) === 'pageload' && span.is_segment; }); await Promise.all([ page.goto(route), // the test app adds the "hydrated" class to the body when hydrating page.waitForSelector('body.hydrated'), - // also waiting for the initial pageload txn so that later navigations don't interfere - clientPageloadTxnEventPromise, + // also waiting for the initial pageload span so that later navigations don't interfere + clientPageloadSpanPromise, ]); debug && console.log('hydrated'); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.client.ts index 0cfa76d7a622..4dc12acebc45 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.client.ts @@ -2,7 +2,6 @@ import { env } from '$env/dynamic/public'; import * as Sentry from '@sentry/sveltekit'; Sentry.init({ - traceLifecycle: 'static', dsn: env.PUBLIC_E2E_TEST_DSN, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts index 6c3f59ee5f68..4ba4904744e6 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts @@ -6,7 +6,6 @@ export const handleError = handleErrorWithSentry(); export const handle = sequence( initCloudflareSentryHandle({ - traceLifecycle: 'static', dsn: E2E_TEST_DSN, tunnel: 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tests/db.test.ts index 29779ff2a045..458f1212de44 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tests/db.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tests/db.test.ts @@ -1,52 +1,57 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ baseURL }) => { // The `orchestrion:mysql:query` channel is injected into the bundled `mysql` package at // build time by `@sentry/sveltekit`, which — because this app uses the Cloudflare adapter — // also registers the subscriber factory on the global marker that `@sentry/cloudflare` reads // in `wrapRequestHandler`. The query below therefore produces a `db` span on the request's - // http.server transaction, with no OTel require-hook (which wouldn't work in workerd). - const transactionPromise = waitForTransaction('sveltekit-cloudflare-pages', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - (transactionEvent.spans?.some(span => span.op === 'db') ?? false) - ); + // http.server trace, with no OTel require-hook (which wouldn't work in workerd). + const traceSpansPromise = collectStreamedSpans('sveltekit-cloudflare-pages', spansOfTrace => { + const hasServerSegment = spansOfTrace.some(span => getSpanOp(span) === 'http.server' && span.is_segment); + return hasServerSegment && spansOfTrace.some(span => getSpanOp(span) === 'db'); }); const res = await fetch(`${baseURL}/db-mysql`); expect(res.status).toBe(200); - const transactionEvent = await transactionPromise; - const dbSpans = transactionEvent.spans!.filter(span => span.op === 'db'); + const traceSpans = await traceSpansPromise; + const dbSpans = traceSpans.filter(span => getSpanOp(span) === 'db'); - const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution'); + const firstQuery = dbSpans.find(span => span.attributes['db.query.text']?.value === 'SELECT 1 + 1 AS solution'); expect(firstQuery).toBeDefined(); - expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql'); - expect(firstQuery!.data?.['db.system.name']).toBe('mysql'); - expect(firstQuery!.data?.['db.query.text']).toBe('SELECT 1 + 1 AS solution'); - expect(firstQuery!.data?.['server.address']).toBe('127.0.0.1'); - expect(firstQuery!.data?.['server.port']).toBe(3306); - expect(firstQuery!.data?.['db.user']).toBe('root'); + expect(firstQuery!.name).toBe('SELECT'); + expect(firstQuery!.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.db.mysql', type: 'string' }, + 'db.system.name': { value: 'mysql', type: 'string' }, + 'db.query.text': { value: 'SELECT 1 + 1 AS solution', type: 'string' }, + 'db.query.summary': { value: 'SELECT', type: 'string' }, + 'server.address': { value: '127.0.0.1', type: 'string' }, + 'server.port': { value: 3306, type: 'integer' }, + 'db.user': { value: 'root', type: 'string' }, + }); }); -test('a nested query lands on the same transaction (async context restored)', async ({ baseURL }) => { +test('a nested query lands on the same trace (async context restored)', async ({ baseURL }) => { // The second query runs inside the first query's callback — i.e. across mysql's async - // socket-callback dispatch. Both spans appearing on the SAME http.server transaction proves - // the channel subscriber restored the parent span across that async boundary (otherwise the - // nested query would start its own trace and never join this transaction). - const transactionPromise = waitForTransaction('sveltekit-cloudflare-pages', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - (transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 - ); + // socket-callback dispatch. Both spans appearing on the SAME http.server trace, under the same + // parent, proves the channel subscriber restored the parent span across that async boundary + // (otherwise the nested query would start its own trace and never join this one). + const traceSpansPromise = collectStreamedSpans('sveltekit-cloudflare-pages', spansOfTrace => { + const hasServerSegment = spansOfTrace.some(span => getSpanOp(span) === 'http.server' && span.is_segment); + return hasServerSegment && spansOfTrace.filter(span => getSpanOp(span) === 'db').length >= 2; }); const res = await fetch(`${baseURL}/db-mysql`); expect(res.status).toBe(200); - const transactionEvent = await transactionPromise; - const descriptions = transactionEvent.spans!.filter(span => span.op === 'db').map(span => span.description); - expect(descriptions).toContain('SELECT 1 + 1 AS solution'); - expect(descriptions).toContain('SELECT NOW()'); + const traceSpans = await traceSpansPromise; + const dbSpans = traceSpans.filter(span => getSpanOp(span) === 'db'); + + const queryTexts = dbSpans.map(span => span.attributes['db.query.text']?.value); + expect(queryTexts).toContain('SELECT 1 + 1 AS solution'); + expect(queryTexts).toContain('SELECT NOW()'); + + const parentSpanIds = new Set(dbSpans.map(span => span.parent_span_id)); + expect(parentSpanIds.size).toBe(1); }); diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 7de1582b2b10..2582b5c65c9a 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -659,6 +659,71 @@ export function collectStreamedSpans( }).then(() => matched ?? []); } +/** + * Accumulate streamed Span V2 spans across envelopes, grouped by segment, and resolve with the first + * segment whose segment span satisfies `isTargetSegment`: that segment span plus its own descendants, + * and nothing else. + * + * This differs from {@link collectStreamedSpans} in scope. Under distributed tracing a trace holds + * more than one segment (the browser's pageload segment is a child of a server span, so both share a + * trace), and assertions on a server request's child spans must not pick up browser spans. Grouping + * by the `sentry.segment.id` attribute every streamed span carries sidesteps both the trace and any + * parent-chain walk, which would break when a child arrives in an envelope before its segment. + * + * Resolves once the matching segment span itself has arrived. A segment ends after its children, so + * children that flushed earlier have been accumulated by then. + * + * @example + * ```ts + * const { segmentSpan, childSpans } = await collectSegmentSpans(PROXY_SERVER_NAME, segment => + * segment.name === 'GET /server-load-fetch', + * ); + * expect(segmentSpan.status).toBe('ok'); + * expect(childSpans).toHaveLength(6); + * ``` + */ +export function collectSegmentSpans( + proxyServerName: string, + isTargetSegment: (segmentSpan: SerializedStreamedSpan) => boolean, +): Promise<{ segmentSpan: SerializedStreamedSpan; childSpans: SerializedStreamedSpan[] }> { + const spansBySegment = new Map(); + + return new Promise((resolve, reject) => { + waitForStreamedSpans(proxyServerName, spans => { + for (const span of spans) { + const segmentId = getSegmentId(span); + if (!segmentId) { + continue; + } + const spansOfSegment = spansBySegment.get(segmentId); + if (spansOfSegment) { + spansOfSegment.push(span); + } else { + spansBySegment.set(segmentId, [span]); + } + } + + for (const spansOfSegment of spansBySegment.values()) { + const segmentSpan = spansOfSegment.find(span => span.is_segment); + if (segmentSpan && isTargetSegment(segmentSpan)) { + resolve({ segmentSpan, childSpans: spansOfSegment.filter(span => span !== segmentSpan) }); + return true; + } + } + + return false; + }).catch(reject); + }); +} + +function getSegmentId(span: SerializedStreamedSpan): string | undefined { + const attribute = span.attributes['sentry.segment.id']; + if (attribute?.type === 'string') { + return attribute.value; + } + return span.is_segment ? span.span_id : undefined; +} + /** * Helper to get the span operation from a Span V2 JSON object. * diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index ebb89fd08e5f..3f048468390f 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -12,6 +12,7 @@ export { waitForStreamedSpans, waitForStreamedSpanEnvelope, collectStreamedSpans, + collectSegmentSpans, getSpanOp, } from './event-proxy-server'; export type { SerializedStreamedSpan } from '@sentry/core';