diff --git a/.size-limit.js b/.size-limit.js index 955b2eed9aa0..44e701b3466b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -124,7 +124,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'logger'), gzip: true, - limit: '27 KB', + limit: '28 KB', }, { name: '@sentry/browser (incl. Metrics & Logs)', @@ -148,7 +148,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '45 KB', + limit: '46 KB', }, // Vue SDK (ESM) { @@ -262,7 +262,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '209 KB', + limit: '210 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/init.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/init.js new file mode 100644 index 000000000000..aaafd3396f14 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1.0, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js new file mode 100644 index 000000000000..7e4395e06708 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js @@ -0,0 +1,13 @@ +Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { + Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => { + // noop + }); + + const inactiveSpan = Sentry.startInactiveSpan({ name: 'test-inactive-span' }); + inactiveSpan.end(); + + Sentry.startSpanManual({ name: 'test-manual-span' }, span => { + // noop + span.end(); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts new file mode 100644 index 000000000000..b5f8f41ab4b4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts @@ -0,0 +1,217 @@ +import { expect } from '@playwright/test'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +sentryTest( + 'sends a streamed span envelope if spanStreamingIntegration is enabled', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const spanEnvelopePromise = waitForStreamedSpanEnvelope(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const spanEnvelope = await spanEnvelopePromise; + + const envelopeHeader = spanEnvelope[0]; + const envelopeItem = spanEnvelope[1]; + const spans = envelopeItem[0][1].items; + + expect(envelopeHeader).toEqual({ + sdk: { + name: 'sentry.javascript.browser', + version: SDK_VERSION, + }, + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rand: expect.any(String), + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + transaction: 'test-span', + }, + }); + + const numericSampleRand = parseFloat(envelopeHeader.trace!.sample_rand!); + const traceId = envelopeHeader.trace!.trace_id; + + expect(Number.isNaN(numericSampleRand)).toBe(false); + + expect(envelopeItem).toEqual([ + [ + { content_type: 'application/vnd.sentry.items.span.v2+json', item_count: 4, type: 'span' }, + { + items: expect.any(Array), + }, + ], + ]); + + const segmentSpanId = spans.find(s => !!s.is_segment)?.span_id; + expect(segmentSpanId).toBeDefined(); + + expect(spans).toEqual([ + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'test-child', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: segmentSpanId, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: 'test-span', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'test-child-span', + parent_span_id: segmentSpanId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: traceId, + }, + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: segmentSpanId, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: 'test-span', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'test-inactive-span', + parent_span_id: segmentSpanId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: traceId, + }, + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: segmentSpanId, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: 'test-span', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'test-manual-span', + parent_span_id: segmentSpanId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: traceId, + }, + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'test', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: segmentSpanId, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: 'test-span', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'custom', + }, + 'sentry.span.source': { + type: 'string', + value: 'custom', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + name: 'test-span', + span_id: segmentSpanId, + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: traceId, + }, + ]); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/init.js new file mode 100644 index 000000000000..749560a5c459 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/subject.js new file mode 100644 index 000000000000..b657f38ac009 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/subject.js @@ -0,0 +1,8 @@ +document.getElementById('go-background').addEventListener('click', () => { + setTimeout(() => { + Object.defineProperty(document, 'hidden', { value: true, writable: true }); + const ev = document.createEvent('Event'); + ev.initEvent('visibilitychange'); + document.dispatchEvent(ev); + }, 250); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/template.html new file mode 100644 index 000000000000..8083ddc80694 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/test.ts new file mode 100644 index 000000000000..10e58acb81ad --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/test.ts @@ -0,0 +1,18 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest('finishes streamed pageload span when the page goes background', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + const url = await getLocalTestUrl({ testDir: __dirname }); + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + await page.locator('#go-background').click(); + const pageloadSpan = await pageloadSpanPromise; + + // TODO: Is this what we want? + expect(pageloadSpan.status).toBe('ok'); + expect(pageloadSpan.attributes?.['sentry.cancellation_reason']?.value).toBe('document.hidden'); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js new file mode 100644 index 000000000000..7eff1a54e9ff --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + _experiments: { + enableHTTPTimings: true, + }, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, + traceLifecycle: 'stream', + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/subject.js new file mode 100644 index 000000000000..e19cc07e28f5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/subject.js @@ -0,0 +1,3 @@ +fetch('http://sentry-test-site.example/0').then( + fetch('http://sentry-test-site.example/1').then(fetch('http://sentry-test-site.example/2')), +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts new file mode 100644 index 000000000000..ffa63937bf32 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts @@ -0,0 +1,71 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +/** + * This test details a limitation of span streaming in comparison to transaction-based tracing: + * We can no longer attach http PerformanceResourceTiming attributes to http.client spans in + * span streaming mode. The reason is that we track `http.client` spans in real time but only + * get the detailed timing information after the span already ended. + * We can probably fix this (somehat at least) but will do so in a follow-up PR. + * @see https://github.com/getsentry/sentry-javascript/issues/19613 + */ +sentryTest( + "[limitation] doesn't add http timing to http.client spans in span streaming mode", + async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + sentryTest.skip(shouldSkipTracingTest() || !supportedBrowsers.includes(browserName) || testingCdnBundle()); + + await page.route('http://sentry-test-site.example/*', async route => { + const request = route.request(); + const postData = await request.postDataJSON(); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Object.assign({ id: 1 }, postData)), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'http.client')); + await page.goto(url); + + const requestSpans = (await spansPromise).filter(s => getSpanOp(s) === 'http.client'); + const pageloadSpan = (await spansPromise).find(s => getSpanOp(s) === 'pageload'); + + expect(pageloadSpan).toBeDefined(); + expect(requestSpans).toHaveLength(3); + + requestSpans?.forEach((span, index) => + expect(span).toMatchObject({ + name: `GET http://sentry-test-site.example/${index}`, + parent_span_id: pageloadSpan?.span_id, + span_id: expect.stringMatching(/[a-f\d]{16}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + trace_id: pageloadSpan?.trace_id, + status: 'ok', + attributes: expect.not.objectContaining({ + 'http.request.redirect_start': expect.any(Object), + 'http.request.redirect_end': expect.any(Object), + 'http.request.worker_start': expect.any(Object), + 'http.request.fetch_start': expect.any(Object), + 'http.request.domain_lookup_start': expect.any(Object), + 'http.request.domain_lookup_end': expect.any(Object), + 'http.request.connect_start': expect.any(Object), + 'http.request.secure_connection_start': expect.any(Object), + 'http.request.connection_end': expect.any(Object), + 'http.request.request_start': expect.any(Object), + 'http.request.response_start': expect.any(Object), + 'http.request.response_end': expect.any(Object), + 'http.request.time_to_first_byte': expect.any(Object), + 'network.protocol.version': expect.any(Object), + }), + }), + ); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js new file mode 100644 index 000000000000..385e9ed6b6cf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + enableLongTask: false, + _experiments: { + enableInteractions: true, + }, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js new file mode 100644 index 000000000000..ff9057926396 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js @@ -0,0 +1,16 @@ +const blockUI = e => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 70) { + // + } + + e.target.classList.add('clicked'); +}; + +document.querySelector('[data-test-id=interaction-button]').addEventListener('click', blockUI); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html new file mode 100644 index 000000000000..64e944054632 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html @@ -0,0 +1,14 @@ + + + + + + +
Rendered Before Long Task
+ + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts new file mode 100644 index 000000000000..fd384d0d3ff9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts @@ -0,0 +1,134 @@ +import { expect } from '@playwright/test'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('captures streamed interaction span tree. @firefox', async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + sentryTest.skip(shouldSkipTracingTest() || !supportedBrowsers.includes(browserName) || testingCdnBundle()); + const url = await getLocalTestUrl({ testDir: __dirname }); + + const interactionSpansPromise = waitForStreamedSpans(page, spans => + spans.some(span => getSpanOp(span) === 'ui.action.click'), + ); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + // wait for pageload span to finish before clicking the interaction button + const pageloadSpan = await pageloadSpanPromise; + + await page.locator('[data-test-id=interaction-button]').click(); + await page.locator('.clicked[data-test-id=interaction-button]').isVisible(); + + const interactionSpanTree = await interactionSpansPromise; + + const interactionSegmentSpan = interactionSpanTree.find(span => !!span.is_segment); + + expect(interactionSegmentSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { + type: 'string', + value: 'idleTimeout', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'ui.action.click', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', // TODO: This is incorrect but not from span streaming. + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: interactionSegmentSpan!.span_id, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: '/index.html', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'url', + }, + 'sentry.span.source': { + type: 'string', + value: 'url', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + name: '/index.html', + span_id: interactionSegmentSpan!.span_id, + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: pageloadSpan.trace_id, // same trace id as pageload + }); + + const loAFSpans = interactionSpanTree.filter(span => getSpanOp(span)?.startsWith('ui.long-animation-frame')); + expect(loAFSpans).toHaveLength(1); + + const interactionSpan = interactionSpanTree.find(span => getSpanOp(span) === 'ui.interaction.click'); + expect(interactionSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'ui.interaction.click', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.ui.browser.metrics', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: interactionSegmentSpan!.span_id, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: '/index.html', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'body > button.clicked', + parent_span_id: interactionSegmentSpan!.span_id, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: pageloadSpan.trace_id, // same trace id as pageload + }); + + const interactionSpanDuration = (interactionSpan!.end_timestamp - interactionSpan!.start_timestamp) * 1000; + expect(interactionSpanDuration).toBeGreaterThan(65); + expect(interactionSpanDuration).toBeLessThan(200); + expect(interactionSpan?.status).toBe('ok'); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js new file mode 100644 index 000000000000..63afee65329a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracePropagationTargets: ['sentry-test-external.io'], + tracesSampler: ctx => { + if (ctx.attributes && ctx.attributes['sentry.origin'] === 'auto.pageload.browser') { + return 1; + } + return ctx.inheritOrSampleWith(0); + }, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js new file mode 100644 index 000000000000..de60904fab3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('http://sentry-test-external.io'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html new file mode 100644 index 000000000000..f26a602c7c6f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts new file mode 100644 index 000000000000..a97e13a4890a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts @@ -0,0 +1,153 @@ +import { expect } from '@playwright/test'; +import { extractTraceparentData, parseBaggageHeader, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle, waitForTracingHeadersOnUrl } from '../../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils'; + +sentryTest.describe('When `consistentTraceSampling` is `true`', () => { + sentryTest('continues sampling decision from initial pageload span', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const { pageloadSpan, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => { + const pageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + await page.goto(url); + + const envelope = await pageloadEnvelopePromise; + const pageloadSampleRand = Number(envelope[0].trace?.sample_rand); + const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(pageloadSpan.attributes?.['sentry.sample_rate']?.value).toBe(1); + expect(Number.isNaN(pageloadSampleRand)).toBe(false); + expect(pageloadSampleRand).toBeGreaterThanOrEqual(0); + expect(pageloadSampleRand).toBeLessThanOrEqual(1); + + return { pageloadSpan, pageloadSampleRand }; + }); + + const customTraceSpan = await sentryTest.step('Custom trace', async () => { + const customEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'), + ); + await page.locator('#btn1').click(); + const envelope = await customEnvelopePromise; + const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!; + + expect(span.trace_id).not.toEqual(pageloadSpan.trace_id); + // although we "continue the trace" from pageload, this is actually a root span, + // so there must not be a parent span id + expect(span.parent_span_id).toBeUndefined(); + + expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand); + + return span; + }); + + await sentryTest.step('Navigation', async () => { + const navigationEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + await page.goto(`${url}#foo`); + const envelope = await navigationEnvelopePromise; + const navSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!; + + expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id); + expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + + expect(navSpan.links).toEqual([ + { + trace_id: customTraceSpan.trace_id, + span_id: customTraceSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + expect(navSpan.parent_span_id).toBeUndefined(); + + expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand); + }); + }); + + sentryTest('Propagates continued sampling decision to outgoing requests', async ({ page, getLocalTestUrl }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const { pageloadSpan, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => { + const pageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + await page.goto(url); + + const envelope = await pageloadEnvelopePromise; + const pageloadSampleRand = Number(envelope[0].trace?.sample_rand); + + expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand); + expect(pageloadSampleRand).toBeGreaterThanOrEqual(0); + expect(pageloadSampleRand).toBeLessThanOrEqual(1); + expect(Number.isNaN(pageloadSampleRand)).toBe(false); + + const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(pageloadSpan.attributes?.['sentry.sample_rate']?.value).toBe(1); + + return { pageloadSpan, pageloadSampleRand }; + }); + + await sentryTest.step('Make fetch request', async () => { + const fetchEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'), + ); + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io'); + + await page.locator('#btn2').click(); + + const { baggage, sentryTrace } = await tracingHeadersPromise; + const fetchEnvelope = await fetchEnvelopePromise; + + const fetchTraceSampleRand = Number(fetchEnvelope[0].trace?.sample_rand); + const fetchTraceSpans = fetchEnvelope[1][0][1].items; + const fetchTraceSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'custom')!; + const httpClientSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'http.client'); + + expect(fetchTraceSampleRand).toBe(pageloadSampleRand); + + expect(fetchTraceSpan.attributes?.['sentry.sample_rate']?.value).toEqual( + pageloadSpan.attributes?.['sentry.sample_rate']?.value, + ); + expect(fetchTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: fetchTraceSpan.trace_id, + parentSpanId: httpClientSpan?.span_id, + parentSampled: true, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${pageloadSampleRand}`, + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + 'sentry-trace_id': fetchTraceSpan.trace_id, + 'sentry-transaction': 'custom root span 2', + }); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js new file mode 100644 index 000000000000..d570ac45144c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + }), + Sentry.spanStreamingIntegration(), + ], + traceLifecycle: 'stream', + tracePropagationTargets: ['sentry-test-external.io'], + tracesSampleRate: 1, + debug: true, + sendClientReports: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js new file mode 100644 index 000000000000..de60904fab3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('http://sentry-test-external.io'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html new file mode 100644 index 000000000000..6347fa37fc00 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts new file mode 100644 index 000000000000..73b4bea99e22 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts @@ -0,0 +1,97 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { extractTraceparentData, parseBaggageHeader } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core/src'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + testingCdnBundle, + waitForClientReportRequest, + waitForTracingHeadersOnUrl, +} from '../../../../../../utils/helpers'; +import { observeStreamedSpan } from '../../../../../../utils/spanUtils'; + +const metaTagSampleRand = 0.9; +const metaTagSampleRate = 0.2; +const metaTagTraceId = '12345678901234567890123456789012'; + +sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => { + sentryTest( + 'Continues negative sampling decision from meta tag across all traces and downstream propagations', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansReceived: SerializedStreamedSpan[] = []; + observeStreamedSpan(page, span => { + spansReceived.push(span); + return false; + }); + + const clientReportPromise = waitForClientReportRequest(page); + + await sentryTest.step('Initial pageload', async () => { + await page.goto(url); + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Custom instrumented button click', async () => { + await page.locator('#btn1').click(); + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Navigation', async () => { + await page.goto(`${url}#foo`); + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Make fetch request', async () => { + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io'); + + await page.locator('#btn2').click(); + const { baggage, sentryTrace } = await tracingHeadersPromise; + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: expect.not.stringContaining(metaTagTraceId), + parentSpanId: expect.stringMatching(/^[\da-f]{16}$/), + parentSampled: false, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${metaTagSampleRand}`, + 'sentry-sample_rate': `${metaTagSampleRate}`, + 'sentry-sampled': 'false', + 'sentry-trace_id': expect.not.stringContaining(metaTagTraceId), + 'sentry-transaction': 'custom root span 2', + }); + + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Client report', async () => { + await hidePage(page); + const clientReport = envelopeRequestParser(await clientReportPromise); + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 4, + reason: 'sample_rate', + }, + ], + }); + }); + + expect(spansReceived).toHaveLength(0); + }, + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js new file mode 100644 index 000000000000..177fe4c4aeaf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'session-storage', + consistentTraceSampling: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracePropagationTargets: ['sentry-test-external.io'], + tracesSampler: ({ inheritOrSampleWith }) => { + return inheritOrSampleWith(0); + }, + debug: true, + sendClientReports: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html new file mode 100644 index 000000000000..9a0719b7e505 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html @@ -0,0 +1,15 @@ + + + + + + + + +

Another Page

+ Go To the next page + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html new file mode 100644 index 000000000000..27cd47bba7c1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html @@ -0,0 +1,10 @@ + + + + + + +

Another Page

+ Go To the next page + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js new file mode 100644 index 000000000000..ec0264fa49ef --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1?.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2?.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('http://sentry-test-external.io'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html new file mode 100644 index 000000000000..eab1fecca6c4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html @@ -0,0 +1,14 @@ + + + + + + + + Go To another page + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts new file mode 100644 index 000000000000..4cafe023b57d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts @@ -0,0 +1,116 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { extractTraceparentData, parseBaggageHeader } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + testingCdnBundle, + waitForClientReportRequest, + waitForTracingHeadersOnUrl, +} from '../../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils'; + +const metaTagSampleRand = 0.9; +const metaTagSampleRate = 0.2; +const metaTagTraceIdIndex = '12345678901234567890123456789012'; +const metaTagTraceIdPage1 = 'a2345678901234567890123456789012'; + +sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => { + sentryTest( + 'meta tag decision has precedence over sampling decision from previous trace in session storage', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const clientReportPromise = waitForClientReportRequest(page); + + await sentryTest.step('Initial pageload', async () => { + // negative sampling decision -> no pageload span + await page.goto(url); + }); + + await sentryTest.step('Make fetch request', async () => { + // The fetch requests starts a new trace on purpose. So we only want the + // sampling decision and rand to be the same as from the meta tag but not the trace id or DSC + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io'); + + await page.locator('#btn2').click(); + + const { baggage, sentryTrace } = await tracingHeadersPromise; + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: expect.not.stringContaining(metaTagTraceIdIndex), + parentSpanId: expect.stringMatching(/^[\da-f]{16}$/), + parentSampled: false, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${metaTagSampleRand}`, + 'sentry-sample_rate': `${metaTagSampleRate}`, + 'sentry-sampled': 'false', + 'sentry-trace_id': expect.not.stringContaining(metaTagTraceIdIndex), + 'sentry-transaction': 'custom root span 2', + }); + }); + + await sentryTest.step('Client report', async () => { + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 2, + reason: 'sample_rate', + }, + ], + }); + }); + + await sentryTest.step('Navigate to another page with meta tags', async () => { + const page1PageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload' && s.trace_id === metaTagTraceIdPage1), + ); + await page.locator('a').click(); + + const envelope = await page1PageloadEnvelopePromise; + const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(Number(envelope[0].trace?.sample_rand)).toBe(0.12); + expect(Number(envelope[0].trace?.sample_rate)).toBe(0.2); + expect(pageloadSpan.trace_id).toEqual(metaTagTraceIdPage1); + }); + + await sentryTest.step('Navigate to another page without meta tags', async () => { + const page2PageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => + !!env[1][0][1].items.find( + s => + getSpanOp(s) === 'pageload' && s.trace_id !== metaTagTraceIdPage1 && s.trace_id !== metaTagTraceIdIndex, + ), + ); + await page.locator('a').click(); + + const envelope = await page2PageloadEnvelopePromise; + const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(Number(envelope[0].trace?.sample_rand)).toBe(0.12); + expect(Number(envelope[0].trace?.sample_rate)).toBe(0.2); + expect(pageloadSpan.trace_id).not.toEqual(metaTagTraceIdPage1); + expect(pageloadSpan.trace_id).not.toEqual(metaTagTraceIdIndex); + }); + }, + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js new file mode 100644 index 000000000000..a1ddc5465950 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracePropagationTargets: ['sentry-test-external.io'], + // only take into account sampling from meta tag; otherwise sample negatively + tracesSampleRate: 0, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js new file mode 100644 index 000000000000..de60904fab3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('http://sentry-test-external.io'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html new file mode 100644 index 000000000000..7ceca6fec2a3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts new file mode 100644 index 000000000000..08cee9111b8a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts @@ -0,0 +1,171 @@ +import { expect } from '@playwright/test'; +import { + extractTraceparentData, + parseBaggageHeader, + SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE, +} from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle, waitForTracingHeadersOnUrl } from '../../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils'; + +const metaTagSampleRand = 0.051121; +const metaTagSampleRate = 0.2; + +sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => { + sentryTest('Continues sampling decision across all traces from meta tag', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpan = await sentryTest.step('Initial pageload', async () => { + const pageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + + await page.goto(url); + + const envelope = await pageloadEnvelopePromise; + const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand); + expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(span.attributes?.['sentry.sample_rate']).toBeUndefined(); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined(); + + return span; + }); + + const customTraceSpan = await sentryTest.step('Custom trace', async () => { + const customEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'), + ); + + await page.locator('#btn1').click(); + + const envelope = await customEnvelopePromise; + const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!; + + expect(span.trace_id).not.toEqual(pageloadSpan.trace_id); + expect(span.parent_span_id).toBeUndefined(); + + expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand); + expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate); + expect(envelope[0].trace?.sampled).toBe('true'); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(span.attributes?.['sentry.sample_rate']).toBeUndefined(); + + // but we need to set this attribute to still be able to correctly add the sample rate to the DSC (checked above in trace header) + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe(metaTagSampleRate); + + return span; + }); + + await sentryTest.step('Navigation', async () => { + const navigationEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + + await page.goto(`${url}#foo`); + + const envelope = await navigationEnvelopePromise; + const navSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!; + + expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id); + + expect(navSpan.parent_span_id).toBeUndefined(); + + expect(Number(envelope[0].trace?.sample_rand)).toEqual(metaTagSampleRand); + expect(Number(envelope[0].trace?.sample_rate)).toEqual(metaTagSampleRate); + expect(envelope[0].trace?.sampled).toEqual('true'); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(navSpan.attributes?.['sentry.sample_rate']).toBeUndefined(); + + // but we need to set this attribute to still be able to correctly add the sample rate to the DSC (checked above in trace header) + expect(navSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe(metaTagSampleRate); + }); + }); + + sentryTest( + 'Propagates continued tag sampling decision to outgoing requests', + async ({ page, getLocalTestUrl }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpan = await sentryTest.step('Initial pageload', async () => { + const pageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + + await page.goto(url); + + const envelope = await pageloadEnvelopePromise; + const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand); + expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(span.attributes?.['sentry.sample_rate']).toBeUndefined(); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined(); + + return span; + }); + + await sentryTest.step('Make fetch request', async () => { + const fetchEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'), + ); + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io'); + + await page.locator('#btn2').click(); + + const { baggage, sentryTrace } = await tracingHeadersPromise; + const fetchEnvelope = await fetchEnvelopePromise; + + const fetchTraceSampleRand = Number(fetchEnvelope[0].trace?.sample_rand); + const fetchTraceSpans = fetchEnvelope[1][0][1].items; + const fetchTraceSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'custom')!; + const httpClientSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'http.client'); + + expect(fetchTraceSampleRand).toEqual(metaTagSampleRand); + + expect(fetchTraceSpan.attributes?.['sentry.sample_rate']).toBeUndefined(); + expect(fetchTraceSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe( + metaTagSampleRate, + ); + + expect(fetchTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: fetchTraceSpan.trace_id, + parentSpanId: httpClientSpan?.span_id, + parentSampled: true, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${metaTagSampleRand}`, + 'sentry-sample_rate': `${metaTagSampleRate}`, + 'sentry-sampled': 'true', + 'sentry-trace_id': fetchTraceSpan.trace_id, + 'sentry-transaction': 'custom root span 2', + }); + }); + }, + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js new file mode 100644 index 000000000000..623db0ecc028 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + enableInp: false, + }), + Sentry.spanStreamingIntegration(), + ], + tracePropagationTargets: ['sentry-test-external.io'], + tracesSampler: ctx => { + if (ctx.attributes && ctx.attributes['sentry.origin'] === 'auto.pageload.browser') { + return 1; + } + if (ctx.name === 'custom root span 1') { + return 0; + } + if (ctx.name === 'custom root span 2') { + return 1; + } + return ctx.inheritOrSampleWith(0); + }, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js new file mode 100644 index 000000000000..de60904fab3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('http://sentry-test-external.io'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html new file mode 100644 index 000000000000..f26a602c7c6f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts new file mode 100644 index 000000000000..46805496a676 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts @@ -0,0 +1,152 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/browser'; +import type { ClientReport } from '@sentry/core'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + testingCdnBundle, + waitForClientReportRequest, +} from '../../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils'; + +/** + * This test demonstrates that: + * - explicit sampling decisions in `tracesSampler` has precedence over consistent sampling + * - despite consistentTraceSampling being activated, there are still a lot of cases where the trace chain can break + */ +sentryTest.describe('When `consistentTraceSampling` is `true`', () => { + sentryTest('explicit sampling decisions in `tracesSampler` have precedence', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const { pageloadSpan } = await sentryTest.step('Initial pageload', async () => { + const pageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + await page.goto(url); + + const envelope = await pageloadEnvelopePromise; + const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(pageloadSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1); + expect(Number(envelope[0].trace?.sample_rand)).toBeGreaterThanOrEqual(0); + + return { pageloadSpan }; + }); + + await sentryTest.step('Custom trace is sampled negatively (explicitly in tracesSampler)', async () => { + const clientReportPromise = waitForClientReportRequest(page); + + await page.locator('#btn1').click(); + + await page.waitForTimeout(500); + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 1, + reason: 'sample_rate', + }, + ], + }); + }); + + await sentryTest.step('Subsequent navigation trace is also sampled negatively', async () => { + const clientReportPromise = waitForClientReportRequest(page); + + await page.goto(`${url}#foo`); + + await page.waitForTimeout(500); + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 1, + reason: 'sample_rate', + }, + ], + }); + }); + + const { customTrace2Span } = await sentryTest.step( + 'Custom trace 2 is sampled positively (explicitly in tracesSampler)', + async () => { + const customEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'), + ); + + await page.locator('#btn2').click(); + + const envelope = await customEnvelopePromise; + const customTrace2Span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!; + + expect(customTrace2Span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1); + expect(customTrace2Span.trace_id).not.toEqual(pageloadSpan.trace_id); + expect(customTrace2Span.parent_span_id).toBeUndefined(); + + expect(customTrace2Span.links).toEqual([ + { + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + sampled: false, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }, + ]); + + return { customTrace2Span }; + }, + ); + + await sentryTest.step('Navigation trace is sampled positively (inherited from previous trace)', async () => { + const navigationEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => env[0].trace?.sampled === 'true' && !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + + await page.goto(`${url}#bar`); + + const envelope = await navigationEnvelopePromise; + const navigationSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!; + + expect(navigationSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1); + expect(navigationSpan.trace_id).not.toEqual(customTrace2Span.trace_id); + expect(navigationSpan.parent_span_id).toBeUndefined(); + + expect(navigationSpan.links).toEqual([ + { + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + sampled: true, + span_id: customTrace2Span.span_id, + trace_id: customTrace2Span.trace_id, + }, + ]); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js new file mode 100644 index 000000000000..2a929a7e5083 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js @@ -0,0 +1,14 @@ +const btn1 = document.getElementById('btn1'); +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, () => {}); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html new file mode 100644 index 000000000000..f26a602c7c6f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts new file mode 100644 index 000000000000..d6e45901f959 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts @@ -0,0 +1,63 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest('manually started custom traces are linked correctly in the chain', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpan = await sentryTest.step('Initial pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + return pageloadSpanPromise; + }); + + const customTraceSpan = await sentryTest.step('Custom trace', async () => { + const customSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'custom'); + await page.locator('#btn1').click(); + const span = await customSpanPromise; + + expect(span.trace_id).not.toEqual(pageloadSpan.trace_id); + expect(span.links).toEqual([ + { + trace_id: pageloadSpan.trace_id, + span_id: pageloadSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + return span; + }); + + await sentryTest.step('Navigation', async () => { + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + const navSpan = await navigationSpanPromise; + + expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id); + expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + + expect(navSpan.links).toEqual([ + { + trace_id: customTraceSpan.trace_id, + span_id: customTraceSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts new file mode 100644 index 000000000000..80e500437f79 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts @@ -0,0 +1,95 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest("navigation spans link back to previous trace's root span", async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const pageloadSpan = await pageloadSpanPromise; + + const navigation1SpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + const navigation1Span = await navigation1SpanPromise; + + const navigation2SpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#bar`); + const navigation2Span = await navigation2SpanPromise; + + const pageloadTraceId = pageloadSpan.trace_id; + const navigation1TraceId = navigation1Span.trace_id; + const navigation2TraceId = navigation2Span.trace_id; + + expect(pageloadSpan.links).toBeUndefined(); + + expect(navigation1Span.links).toEqual([ + { + trace_id: pageloadTraceId, + span_id: pageloadSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(navigation1Span.attributes?.['sentry.previous_trace']).toEqual({ + type: 'string', + value: `${pageloadTraceId}-${pageloadSpan.span_id}-1`, + }); + + expect(navigation2Span.links).toEqual([ + { + trace_id: navigation1TraceId, + span_id: navigation1Span.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(navigation2Span.attributes?.['sentry.previous_trace']).toEqual({ + type: 'string', + value: `${navigation1TraceId}-${navigation1Span.span_id}-1`, + }); + + expect(pageloadTraceId).not.toEqual(navigation1TraceId); + expect(navigation1TraceId).not.toEqual(navigation2TraceId); + expect(pageloadTraceId).not.toEqual(navigation2TraceId); +}); + +sentryTest("doesn't link between hard page reloads by default", async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await sentryTest.step('First pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const pageload1Span = await pageloadSpanPromise; + + expect(pageload1Span).toBeDefined(); + expect(pageload1Span.links).toBeUndefined(); + }); + + await sentryTest.step('Second pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.reload(); + const pageload2Span = await pageloadSpanPromise; + + expect(pageload2Span).toBeDefined(); + expect(pageload2Span.links).toBeUndefined(); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js new file mode 100644 index 000000000000..749560a5c459 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js new file mode 100644 index 000000000000..f07f76ecd692 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + integrations: [ + Sentry.browserTracingIntegration({ _experiments: { enableInteractions: true } }), + Sentry.spanStreamingIntegration(), + ], +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html new file mode 100644 index 000000000000..7f6845239468 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts new file mode 100644 index 000000000000..c34aba99dbdd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts @@ -0,0 +1,79 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +/* + This is quite peculiar behavior but it's a result of the route-based trace lifetime. + Once we shortened trace lifetime, this whole scenario will change as the interaction + spans will be their own trace. So most likely, we can replace this test with a new one + that covers the new default behavior. +*/ +sentryTest( + 'only the first root spans in the trace link back to the previous trace', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpan = await sentryTest.step('Initial pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const span = await pageloadSpanPromise; + + expect(span).toBeDefined(); + expect(span.links).toBeUndefined(); + + return span; + }); + + await sentryTest.step('Click Before navigation', async () => { + const interactionSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.action.click'); + await page.click('#btn'); + const interactionSpan = await interactionSpanPromise; + + // sanity check: route-based trace lifetime means the trace_id should be the same + expect(interactionSpan.trace_id).toBe(pageloadSpan.trace_id); + + // no links yet as previous root span belonged to same trace + expect(interactionSpan.links).toBeUndefined(); + }); + + const navigationSpan = await sentryTest.step('Navigation', async () => { + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + const span = await navigationSpanPromise; + + expect(getSpanOp(span)).toBe('navigation'); + expect(span.links).toEqual([ + { + trace_id: pageloadSpan.trace_id, + span_id: pageloadSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(span.trace_id).not.toEqual(span.links![0].trace_id); + return span; + }); + + await sentryTest.step('Click After navigation', async () => { + const interactionSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.action.click'); + await page.click('#btn'); + const interactionSpan = await interactionSpanPromise; + + // sanity check: route-based trace lifetime means the trace_id should be the same + expect(interactionSpan.trace_id).toBe(navigationSpan.trace_id); + + // since this is the second root span in the trace, it doesn't link back to the previous trace + expect(interactionSpan.links).toBeUndefined(); + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html new file mode 100644 index 000000000000..2221bd0fee1d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts new file mode 100644 index 000000000000..cbcc231593ea --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts @@ -0,0 +1,50 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest( + "links back to previous trace's local root span if continued from meta tags", + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const metaTagTraceId = '12345678901234567890123456789012'; + + const pageloadSpan = await sentryTest.step('Initial pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const span = await pageloadSpanPromise; + + // sanity check + expect(span.trace_id).toBe(metaTagTraceId); + expect(span.links).toBeUndefined(); + + return span; + }); + + const navigationSpan = await sentryTest.step('Navigation', async () => { + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + return navigationSpanPromise; + }); + + expect(navigationSpan.links).toEqual([ + { + trace_id: metaTagTraceId, + span_id: pageloadSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(navigationSpan.trace_id).not.toEqual(metaTagTraceId); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js new file mode 100644 index 000000000000..778092cf026b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + // We want to ignore redirects for this test + integrations: [Sentry.browserTracingIntegration({ detectRedirects: false }), Sentry.spanStreamingIntegration()], + tracesSampler: ctx => { + if (ctx.attributes['sentry.origin'] === 'auto.pageload.browser') { + return 0; + } + return 1; + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts new file mode 100644 index 000000000000..06366eb9921a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts @@ -0,0 +1,44 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest('includes a span link to a previously negatively sampled span', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await sentryTest.step('Initial pageload', async () => { + // No span envelope expected here because this pageload span is sampled negatively! + await page.goto(url); + }); + + await sentryTest.step('Navigation', async () => { + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + const navigationSpan = await navigationSpanPromise; + + expect(getSpanOp(navigationSpan)).toBe('navigation'); + expect(navigationSpan.links).toEqual([ + { + trace_id: expect.stringMatching(/[a-f\d]{32}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), + sampled: false, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(navigationSpan.attributes?.['sentry.previous_trace']).toEqual({ + type: 'string', + value: expect.stringMatching(/[a-f\d]{32}-[a-f\d]{16}-0/), + }); + + expect(navigationSpan.trace_id).not.toEqual(navigationSpan.links![0].trace_id); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js new file mode 100644 index 000000000000..e51af56c2a9d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ linkPreviousTrace: 'session-storage' }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts new file mode 100644 index 000000000000..96a5bbeacc6d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts @@ -0,0 +1,42 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest('adds link between hard page reloads when opting into sessionStorage', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageload1Span = await sentryTest.step('First pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const span = await pageloadSpanPromise; + expect(span).toBeDefined(); + expect(span.links).toBeUndefined(); + return span; + }); + + const pageload2Span = await sentryTest.step('Hard page reload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.reload(); + return pageloadSpanPromise; + }); + + expect(pageload2Span.links).toEqual([ + { + trace_id: pageload1Span.trace_id, + span_id: pageload1Span.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(pageload1Span.trace_id).not.toEqual(pageload2Span.trace_id); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js new file mode 100644 index 000000000000..ee197adaa33c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 2000, + enableLongTask: false, + enableLongAnimationFrame: true, + instrumentPageLoad: false, + enableInp: false, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js new file mode 100644 index 000000000000..b02ed6efa33b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js @@ -0,0 +1,18 @@ +function getElapsed(startTime) { + const time = Date.now(); + return time - startTime; +} + +function handleClick() { + const startTime = Date.now(); + while (getElapsed(startTime) < 105) { + // + } + window.history.pushState({}, '', `#myHeading`); +} + +const button = document.getElementById('clickme'); + +console.log('button', button); + +button.addEventListener('click', handleClick); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html new file mode 100644 index 000000000000..6a6a89752f20 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html @@ -0,0 +1,11 @@ + + + + + + + + +

My Heading

+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts new file mode 100644 index 000000000000..3054c1c84bcb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts @@ -0,0 +1,25 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + "doesn't capture long animation frame that starts before a navigation.", + async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const navigationSpansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'navigation')); + + await page.goto(url); + + await page.locator('#clickme').click(); + + const spans = await navigationSpansPromise; + + const loafSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame')); + expect(loafSpans).toHaveLength(0); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js new file mode 100644 index 000000000000..195a094070be --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js @@ -0,0 +1,12 @@ +(() => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 101) { + // + } +})(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js new file mode 100644 index 000000000000..965613d5464e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html new file mode 100644 index 000000000000..62aed26413f8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html @@ -0,0 +1,10 @@ + + + + + + +
Rendered Before Long Animation Frame
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts new file mode 100644 index 000000000000..7ba1dddd0c90 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts @@ -0,0 +1,28 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'does not capture long animation frame when flag is disabled.', + async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => + route.fulfill({ path: `${__dirname}/assets/script.js` }), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui')); + + expect(uiSpans.length).toBe(0); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js new file mode 100644 index 000000000000..10552eeb5bd5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js @@ -0,0 +1,25 @@ +function getElapsed(startTime) { + const time = Date.now(); + return time - startTime; +} + +function handleClick() { + const startTime = Date.now(); + while (getElapsed(startTime) < 105) { + // + } +} + +function start() { + const startTime = Date.now(); + while (getElapsed(startTime) < 105) { + // + } +} + +// trigger 2 long-animation-frame events +// one from the top-level and the other from an event-listener +start(); + +const button = document.getElementById('clickme'); +button.addEventListener('click', handleClick); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js new file mode 100644 index 000000000000..1f6cc0a8f463 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 2000, + enableLongTask: false, + enableLongAnimationFrame: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html new file mode 100644 index 000000000000..c157aa80cb8d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html @@ -0,0 +1,11 @@ + + + + + + +
Rendered Before Long Animation Frame
+ + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts new file mode 100644 index 000000000000..c1e7efa5e8d8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts @@ -0,0 +1,109 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/browser'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'captures long animation frame span for top-level script.', + async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => + route.fulfill({ path: `${__dirname}/assets/script.js` }), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!; + + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame')); + + expect(uiSpans.length).toBeGreaterThanOrEqual(1); + + const topLevelUISpan = uiSpans.find( + s => s.attributes?.['browser.script.invoker']?.value === 'https://sentry-test-site.example/path/to/script.js', + )!; + + expect(topLevelUISpan).toEqual( + expect.objectContaining({ + name: 'Main UI thread blocked', + parent_span_id: pageloadSpan.span_id, + attributes: expect.objectContaining({ + 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' }, + 'browser.script.source_char_position': expect.objectContaining({ value: 0 }), + 'browser.script.invoker': { + type: 'string', + value: 'https://sentry-test-site.example/path/to/script.js', + }, + 'browser.script.invoker_type': { type: 'string', value: 'classic-script' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' }, + }), + }), + ); + + const start = topLevelUISpan.start_timestamp ?? 0; + const end = topLevelUISpan.end_timestamp ?? 0; + const duration = end - start; + + expect(duration).toBeGreaterThanOrEqual(0.1); + expect(duration).toBeLessThanOrEqual(0.15); + }, +); + +sentryTest('captures long animation frame span for event listener.', async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + // trigger long animation frame function + await page.getByRole('button').click(); + + const spans = await spansPromise; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!; + + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame')); + + expect(uiSpans.length).toBeGreaterThanOrEqual(2); + + const eventListenerUISpan = uiSpans.find( + s => s.attributes?.['browser.script.invoker']?.value === 'BUTTON#clickme.onclick', + )!; + + expect(eventListenerUISpan).toEqual( + expect.objectContaining({ + name: 'Main UI thread blocked', + parent_span_id: pageloadSpan.span_id, + attributes: expect.objectContaining({ + 'browser.script.invoker': { type: 'string', value: 'BUTTON#clickme.onclick' }, + 'browser.script.invoker_type': { type: 'string', value: 'event-listener' }, + 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' }, + }), + }), + ); + + const start = eventListenerUISpan.start_timestamp ?? 0; + const end = eventListenerUISpan.end_timestamp ?? 0; + const duration = end - start; + + expect(duration).toBeGreaterThanOrEqual(0.1); + expect(duration).toBeLessThanOrEqual(0.15); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js new file mode 100644 index 000000000000..10552eeb5bd5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js @@ -0,0 +1,25 @@ +function getElapsed(startTime) { + const time = Date.now(); + return time - startTime; +} + +function handleClick() { + const startTime = Date.now(); + while (getElapsed(startTime) < 105) { + // + } +} + +function start() { + const startTime = Date.now(); + while (getElapsed(startTime) < 105) { + // + } +} + +// trigger 2 long-animation-frame events +// one from the top-level and the other from an event-listener +start(); + +const button = document.getElementById('clickme'); +button.addEventListener('click', handleClick); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js new file mode 100644 index 000000000000..3e3eedaf49b7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 2000, + enableLongTask: true, + enableLongAnimationFrame: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html new file mode 100644 index 000000000000..c157aa80cb8d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html @@ -0,0 +1,11 @@ + + + + + + +
Rendered Before Long Animation Frame
+ + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts new file mode 100644 index 000000000000..4f9207fa1e34 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts @@ -0,0 +1,111 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/browser'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'captures long animation frame span for top-level script.', + async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + // Long animation frame should take priority over long tasks + + await page.route('**/path/to/script.js', (route: Route) => + route.fulfill({ path: `${__dirname}/assets/script.js` }), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!; + + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame')); + + expect(uiSpans.length).toBeGreaterThanOrEqual(1); + + const topLevelUISpan = uiSpans.find( + s => s.attributes?.['browser.script.invoker']?.value === 'https://sentry-test-site.example/path/to/script.js', + )!; + + expect(topLevelUISpan).toEqual( + expect.objectContaining({ + name: 'Main UI thread blocked', + parent_span_id: pageloadSpan.span_id, + attributes: expect.objectContaining({ + 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' }, + 'browser.script.source_char_position': expect.objectContaining({ value: 0 }), + 'browser.script.invoker': { + type: 'string', + value: 'https://sentry-test-site.example/path/to/script.js', + }, + 'browser.script.invoker_type': { type: 'string', value: 'classic-script' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' }, + }), + }), + ); + + const start = topLevelUISpan.start_timestamp ?? 0; + const end = topLevelUISpan.end_timestamp ?? 0; + const duration = end - start; + + expect(duration).toBeGreaterThanOrEqual(0.1); + expect(duration).toBeLessThanOrEqual(0.15); + }, +); + +sentryTest('captures long animation frame span for event listener.', async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + // trigger long animation frame function + await page.getByRole('button').click(); + + const spans = await spansPromise; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!; + + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame')); + + expect(uiSpans.length).toBeGreaterThanOrEqual(2); + + const eventListenerUISpan = uiSpans.find( + s => s.attributes?.['browser.script.invoker']?.value === 'BUTTON#clickme.onclick', + )!; + + expect(eventListenerUISpan).toEqual( + expect.objectContaining({ + name: 'Main UI thread blocked', + parent_span_id: pageloadSpan.span_id, + attributes: expect.objectContaining({ + 'browser.script.invoker': { type: 'string', value: 'BUTTON#clickme.onclick' }, + 'browser.script.invoker_type': { type: 'string', value: 'event-listener' }, + 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' }, + }), + }), + ); + + const start = eventListenerUISpan.start_timestamp ?? 0; + const end = eventListenerUISpan.end_timestamp ?? 0; + const duration = end - start; + + expect(duration).toBeGreaterThanOrEqual(0.1); + expect(duration).toBeLessThanOrEqual(0.15); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js new file mode 100644 index 000000000000..f6e5ce777e06 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 2000, + enableLongAnimationFrame: false, + instrumentPageLoad: false, + instrumentNavigation: true, + enableInp: false, + enableLongTask: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js new file mode 100644 index 000000000000..d814f8875715 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js @@ -0,0 +1,17 @@ +const longTaskButton = document.getElementById('myButton'); + +longTaskButton?.addEventListener('click', () => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 500) { + // + } + + // trigger a navigation in the same event loop tick + window.history.pushState({}, '', '#myHeading'); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html new file mode 100644 index 000000000000..c2cb2a8129fe --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html @@ -0,0 +1,12 @@ + + + + + + +
Rendered Before Long Task
+ + +

Heading

+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts new file mode 100644 index 000000000000..74ce32706584 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts @@ -0,0 +1,29 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + "doesn't capture long task spans starting before a navigation in the navigation transaction", + async ({ browserName, getLocalTestUrl, page }) => { + // Long tasks only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/path/to/script.js', route => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const navigationSpansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'navigation')); + + await page.goto(url); + + await page.locator('#myButton').click(); + + const spans = await navigationSpansPromise; + + const navigationSpan = spans.find(s => getSpanOp(s) === 'navigation'); + expect(navigationSpan).toBeDefined(); + + const longTaskSpans = spans.filter(s => getSpanOp(s) === 'ui.long-task'); + expect(longTaskSpans).toHaveLength(0); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js new file mode 100644 index 000000000000..195a094070be --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js @@ -0,0 +1,12 @@ +(() => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 101) { + // + } +})(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js new file mode 100644 index 000000000000..965613d5464e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html new file mode 100644 index 000000000000..b03231da2c65 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html @@ -0,0 +1,10 @@ + + + + + + +
Rendered Before Long Task
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts new file mode 100644 index 000000000000..83600f5d4a6a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts @@ -0,0 +1,23 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest("doesn't capture long task spans when flag is disabled.", async ({ browserName, getLocalTestUrl, page }) => { + // Long tasks only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui')); + + expect(uiSpans.length).toBe(0); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js new file mode 100644 index 000000000000..b61592e05943 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js @@ -0,0 +1,12 @@ +(() => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 105) { + // + } +})(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js new file mode 100644 index 000000000000..484350c14fcf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 2000, + enableLongAnimationFrame: false, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html new file mode 100644 index 000000000000..b03231da2c65 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html @@ -0,0 +1,10 @@ + + + + + + +
Rendered Before Long Task
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts new file mode 100644 index 000000000000..8b73aa91dff6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts @@ -0,0 +1,42 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('captures long task.', async ({ browserName, getLocalTestUrl, page }) => { + // Long tasks only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!; + + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui')); + expect(uiSpans.length).toBeGreaterThan(0); + + const [firstUISpan] = uiSpans; + expect(firstUISpan).toEqual( + expect.objectContaining({ + name: 'Main UI thread blocked', + parent_span_id: pageloadSpan.span_id, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'ui.long-task' }, + }), + }), + ); + + const start = firstUISpan.start_timestamp ?? 0; + const end = firstUISpan.end_timestamp ?? 0; + const duration = end - start; + + expect(duration).toBeGreaterThanOrEqual(0.1); + expect(duration).toBeLessThanOrEqual(0.15); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js new file mode 100644 index 000000000000..a93fc742bafb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts new file mode 100644 index 000000000000..7128d2d5ecce --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts @@ -0,0 +1,219 @@ +import { expect } from '@playwright/test'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { + getSpanOp, + getSpansFromEnvelope, + waitForStreamedSpan, + waitForStreamedSpanEnvelope, +} from '../../../../utils/spanUtils'; + +sentryTest('starts a streamed navigation span on page navigation', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'navigation'), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + // simulate navigation + page.goto(`${url}#foo`); + + const navigationSpanEnvelope = await navigationSpanEnvelopePromise; + + const navigationSpanEnvelopeHeader = navigationSpanEnvelope[0]; + const navigationSpanEnvelopeItem = navigationSpanEnvelope[1]; + const navigationSpans = navigationSpanEnvelopeItem[0][1].items; + const navigationSpan = navigationSpans.find(s => getSpanOp(s) === 'navigation')!; + + expect(navigationSpanEnvelopeHeader).toEqual({ + sent_at: expect.any(String), + trace: { + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + environment: 'production', + public_key: 'public', + sample_rand: expect.any(String), + sample_rate: '1', + sampled: 'true', + }, + sdk: { + name: 'sentry.javascript.browser', + version: SDK_VERSION, + }, + }); + + const numericSampleRand = parseFloat(navigationSpanEnvelopeHeader.trace!.sample_rand!); + expect(Number.isNaN(numericSampleRand)).toBe(false); + + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; + + expect(pageloadTraceId).toBeDefined(); + expect(navigationTraceId).toBeDefined(); + expect(pageloadTraceId).not.toEqual(navigationTraceId); + + expect(pageloadSpan.name).toEqual('/index.html'); + + expect(navigationSpan).toEqual({ + attributes: { + effectiveConnectionType: { + type: 'string', + value: expect.any(String), + }, + hardwareConcurrency: { + type: 'string', + value: expect.any(String), + }, + 'sentry.idle_span_finish_reason': { + type: 'string', + value: 'idleTimeout', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'navigation', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.navigation.browser', + }, + 'sentry.previous_trace': { + type: 'string', + value: `${pageloadTraceId}-${pageloadSpan.span_id}-1`, + }, + 'sentry.sample_rate': { + type: 'integer', + value: 1, + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: SDK_VERSION, + }, + 'sentry.segment.id': { + type: 'string', + value: navigationSpan.span_id, + }, + 'sentry.segment.name': { + type: 'string', + value: '/index.html', + }, + 'sentry.source': { + type: 'string', + value: 'url', + }, + 'sentry.span.source': { + type: 'string', + value: 'url', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + links: [ + { + attributes: { + 'sentry.link.type': { + type: 'string', + value: 'previous_trace', + }, + }, + sampled: true, + span_id: pageloadSpan.span_id, + trace_id: pageloadTraceId, + }, + ], + name: '/index.html', + span_id: navigationSpan.span_id, + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: navigationTraceId, + }); +}); + +sentryTest('handles pushState with full URL', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const navigationSpan1Promise = waitForStreamedSpan( + page, + span => getSpanOp(span) === 'navigation' && span.name === '/sub-page', + ); + const navigationSpan2Promise = waitForStreamedSpan( + page, + span => getSpanOp(span) === 'navigation' && span.name === '/sub-page-2', + ); + + await page.goto(url); + await pageloadSpanPromise; + + await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page`);"); + + const navigationSpan1 = await navigationSpan1Promise; + + expect(navigationSpan1.name).toEqual('/sub-page'); + + expect(navigationSpan1.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.navigation.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'url', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'navigation', + }, + }); + + await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page-2`);"); + + const navigationSpan2 = await navigationSpan2Promise; + + expect(navigationSpan2.name).toEqual('/sub-page-2'); + + expect(navigationSpan2.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.navigation.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'url', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'navigation', + }, + ['sentry.idle_span_finish_reason']: { + type: 'string', + value: 'idleTimeout', + }, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js new file mode 100644 index 000000000000..bd3b6ed17872 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts new file mode 100644 index 000000000000..47d9e00d4307 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts @@ -0,0 +1,131 @@ +import { expect } from '@playwright/test'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +sentryTest( + 'creates a pageload streamed span envelope with url as pageload span name source', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const spanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'pageload'), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const spanEnvelope = await spanEnvelopePromise; + const envelopeHeader = spanEnvelope[0]; + const envelopeItem = spanEnvelope[1]; + const spans = envelopeItem[0][1].items; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload'); + + const timeOrigin = await page.evaluate('window._testBaseTimestamp'); + + expect(envelopeHeader).toEqual({ + sdk: { + name: 'sentry.javascript.browser', + version: SDK_VERSION, + }, + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rand: expect.any(String), + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }, + }); + + const numericSampleRand = parseFloat(envelopeHeader.trace!.sample_rand!); + const traceId = envelopeHeader.trace!.trace_id; + + expect(Number.isNaN(numericSampleRand)).toBe(false); + + expect(envelopeItem[0][0].item_count).toBeGreaterThan(1); + + expect(pageloadSpan?.start_timestamp).toBeCloseTo(timeOrigin, 1); + + expect(pageloadSpan).toEqual({ + attributes: { + effectiveConnectionType: { + type: 'string', + value: expect.any(String), + }, + hardwareConcurrency: { + type: 'string', + value: expect.any(String), + }, + 'performance.activationStart': { + type: 'integer', + value: expect.any(Number), + }, + 'performance.timeOrigin': { + type: 'double', + value: expect.any(Number), + }, + 'sentry.idle_span_finish_reason': { + type: 'string', + value: 'idleTimeout', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'pageload', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.pageload.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: pageloadSpan?.span_id, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: '/index.html', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'url', + }, + 'sentry.span.source': { + type: 'string', + value: 'url', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + name: '/index.html', + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: traceId, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js new file mode 100644 index 000000000000..ded3ca204b6b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ enableReportPageLoaded: true }), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + debug: true, +}); + +setTimeout(() => { + Sentry.reportPageLoaded(); +}, 2500); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts new file mode 100644 index 000000000000..fb6fa3ab2393 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts @@ -0,0 +1,41 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest( + 'waits for Sentry.reportPageLoaded() to be called when `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp; + + expect(pageloadSpan.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' }, + [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'reportPageLoaded' }, + }); + + // We wait for 2.5 seconds before calling Sentry.reportPageLoaded() + // the margins are to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeGreaterThan(2); + expect(spanDurationSeconds).toBeLessThan(3); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js new file mode 100644 index 000000000000..b1c19f779713 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ enableReportPageLoaded: true, finalTimeout: 3000 }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, + debug: true, +}); + +// not calling Sentry.reportPageLoaded() on purpose! diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts new file mode 100644 index 000000000000..79df6a902e45 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts @@ -0,0 +1,40 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest( + 'final timeout cancels the pageload span even if `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp; + + expect(pageloadSpan.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' }, + 'sentry.idle_span_finish_reason': { type: 'string', value: 'finalTimeout' }, + }); + + // We wait for 3 seconds before calling Sentry.reportPageLoaded() + // the margins are to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeGreaterThan(2.5); + expect(spanDurationSeconds).toBeLessThan(3.5); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js new file mode 100644 index 000000000000..ac42880742a3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ enableReportPageLoaded: true, instrumentNavigation: false }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, + debug: true, +}); + +setTimeout(() => { + Sentry.startBrowserTracingNavigationSpan(Sentry.getClient(), { name: 'custom_navigation' }); +}, 1000); + +setTimeout(() => { + Sentry.reportPageLoaded(); +}, 2500); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts new file mode 100644 index 000000000000..77f138f34053 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts @@ -0,0 +1,38 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest( + 'starting a navigation span cancels the pageload span even if `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp; + + expect(pageloadSpan.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' }, + 'sentry.idle_span_finish_reason': { type: 'string', value: 'cancelled' }, + }); + + // ending span after 1s but adding a margin of 0.5s to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeLessThan(1.5); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js new file mode 100644 index 000000000000..9afcee48dc4a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js new file mode 100644 index 000000000000..510fb07540ad --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js @@ -0,0 +1,28 @@ +// REGULAR --- +const rootSpan1 = Sentry.startInactiveSpan({ name: 'rootSpan1' }); +rootSpan1.end(); + +Sentry.startSpan({ name: 'rootSpan2' }, rootSpan2 => { + rootSpan2.addLink({ + context: rootSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); +}); + +// NESTED --- +Sentry.startSpan({ name: 'rootSpan3' }, async rootSpan3 => { + Sentry.startSpan({ name: 'childSpan3.1' }, async childSpan1 => { + childSpan1.addLink({ + context: rootSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); + + childSpan1.end(); + }); + + Sentry.startSpan({ name: 'childSpan3.2' }, async childSpan2 => { + childSpan2.addLink({ context: rootSpan3.spanContext() }); + + childSpan2.end(); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts new file mode 100644 index 000000000000..dc35f0c8fcf1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts @@ -0,0 +1,66 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../utils/helpers'; +import { waitForStreamedSpan, waitForStreamedSpans } from '../../../utils/spanUtils'; + +sentryTest('links spans with addLink() in trace context', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const rootSpan1Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan1' && !!s.is_segment); + const rootSpan2Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan2' && !!s.is_segment); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const rootSpan1 = await rootSpan1Promise; + const rootSpan2 = await rootSpan2Promise; + + expect(rootSpan1.name).toBe('rootSpan1'); + expect(rootSpan1.links).toBeUndefined(); + + expect(rootSpan2.name).toBe('rootSpan2'); + expect(rootSpan2.links).toHaveLength(1); + expect(rootSpan2.links?.[0]).toMatchObject({ + attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, + sampled: true, + span_id: rootSpan1.span_id, + trace_id: rootSpan1.trace_id, + }); +}); + +sentryTest('links spans with addLink() in nested startSpan() calls', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const rootSpan1Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan1' && !!s.is_segment); + const rootSpan3SpansPromise = waitForStreamedSpans(page, spans => + spans.some(s => s.name === 'rootSpan3' && s.is_segment), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const rootSpan1 = await rootSpan1Promise; + const rootSpan3Spans = await rootSpan3SpansPromise; + + const rootSpan3 = rootSpan3Spans.find(s => s.name === 'rootSpan3')!; + const childSpan1 = rootSpan3Spans.find(s => s.name === 'childSpan3.1')!; + const childSpan2 = rootSpan3Spans.find(s => s.name === 'childSpan3.2')!; + + expect(rootSpan3.name).toBe('rootSpan3'); + + expect(childSpan1.name).toBe('childSpan3.1'); + expect(childSpan1.links).toHaveLength(1); + expect(childSpan1.links?.[0]).toMatchObject({ + attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, + sampled: true, + span_id: rootSpan1.span_id, + trace_id: rootSpan1.trace_id, + }); + + expect(childSpan2.name).toBe('childSpan3.2'); + expect(childSpan2.links?.[0]).toMatchObject({ + sampled: true, + span_id: rootSpan3.span_id, + trace_id: rootSpan3.trace_id, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js new file mode 100644 index 000000000000..c4c8791cf32c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js new file mode 100644 index 000000000000..482a738009c2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js @@ -0,0 +1,5 @@ +fetch('http://sentry-test-site.example/0').then( + fetch('http://sentry-test-site.example/1', { headers: { 'X-Test-Header': 'existing-header' } }).then( + fetch('http://sentry-test-site.example/2'), + ), +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts new file mode 100644 index 000000000000..201c3e4979f2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('creates spans for fetch requests', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans( + page, + spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3, + ); + + await page.goto(url); + + const allSpans = await spansPromise; + const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload'); + const requestSpans = allSpans.filter(s => getSpanOp(s) === 'http.client'); + + expect(requestSpans).toHaveLength(3); + + requestSpans.forEach((span, index) => + expect(span).toMatchObject({ + name: `GET http://sentry-test-site.example/${index}`, + parent_span_id: pageloadSpan?.span_id, + span_id: expect.stringMatching(/[a-f\d]{16}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + trace_id: pageloadSpan?.trace_id, + attributes: expect.objectContaining({ + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: `http://sentry-test-site.example/${index}` }, + url: { type: 'string', value: `http://sentry-test-site.example/${index}` }, + 'server.address': { type: 'string', value: 'sentry-test-site.example' }, + type: { type: 'string', value: 'fetch' }, + }), + }), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js new file mode 100644 index 000000000000..c4c8791cf32c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js new file mode 100644 index 000000000000..9c584bf743cb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js @@ -0,0 +1,12 @@ +const xhr_1 = new XMLHttpRequest(); +xhr_1.open('GET', 'http://sentry-test-site.example/0'); +xhr_1.send(); + +const xhr_2 = new XMLHttpRequest(); +xhr_2.open('GET', 'http://sentry-test-site.example/1'); +xhr_2.setRequestHeader('X-Test-Header', 'existing-header'); +xhr_2.send(); + +const xhr_3 = new XMLHttpRequest(); +xhr_3.open('GET', 'http://sentry-test-site.example/2'); +xhr_3.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts new file mode 100644 index 000000000000..d3f20fd36453 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('creates spans for XHR requests', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans( + page, + spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3, + ); + + await page.goto(url); + + const allSpans = await spansPromise; + const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload'); + const requestSpans = allSpans.filter(s => getSpanOp(s) === 'http.client'); + + expect(requestSpans).toHaveLength(3); + + requestSpans.forEach((span, index) => + expect(span).toMatchObject({ + name: `GET http://sentry-test-site.example/${index}`, + parent_span_id: pageloadSpan?.span_id, + span_id: expect.stringMatching(/[a-f\d]{16}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + trace_id: pageloadSpan?.trace_id, + attributes: expect.objectContaining({ + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: `http://sentry-test-site.example/${index}` }, + url: { type: 'string', value: `http://sentry-test-site.example/${index}` }, + 'server.address': { type: 'string', value: 'sentry-test-site.example' }, + type: { type: 'string', value: 'xhr' }, + }), + }), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js new file mode 100644 index 000000000000..9afcee48dc4a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js new file mode 100644 index 000000000000..0ce39588eb1b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js @@ -0,0 +1,14 @@ +const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); +Sentry.setActiveSpanInBrowser(checkoutSpan); + +Sentry.startSpan({ name: 'checkout-step-1' }, () => { + Sentry.startSpan({ name: 'checkout-step-1-1' }, () => { + // ... ` + }); +}); + +Sentry.startSpan({ name: 'checkout-step-2' }, () => { + // ... ` +}); + +checkoutSpan.end(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts new file mode 100644 index 000000000000..a144e171a93a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('sets an inactive span active and adds child spans to it', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => s.name === 'checkout-flow' && s.is_segment)); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const spans = await spansPromise; + const checkoutSpan = spans.find(s => s.name === 'checkout-flow'); + const checkoutSpanId = checkoutSpan?.span_id; + expect(checkoutSpanId).toMatch(/[a-f\d]{16}/); + + expect(spans.filter(s => !s.is_segment)).toHaveLength(3); + + const checkoutStep1 = spans.find(s => s.name === 'checkout-step-1'); + const checkoutStep11 = spans.find(s => s.name === 'checkout-step-1-1'); + const checkoutStep2 = spans.find(s => s.name === 'checkout-step-2'); + + expect(checkoutStep1).toBeDefined(); + expect(checkoutStep11).toBeDefined(); + expect(checkoutStep2).toBeDefined(); + + expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); + + // despite 1-1 being called within 1, it's still parented to the root span + // due to this being default behaviour in browser environments + expect(checkoutStep11?.parent_span_id).toBe(checkoutSpanId); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js new file mode 100644 index 000000000000..5b4cff73e95d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + parentSpanIsAlwaysRootSpan: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js new file mode 100644 index 000000000000..dc601cbf4d30 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js @@ -0,0 +1,22 @@ +const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); +Sentry.setActiveSpanInBrowser(checkoutSpan); + +Sentry.startSpan({ name: 'checkout-step-1' }, () => {}); + +const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' }); +Sentry.setActiveSpanInBrowser(checkoutStep2); + +Sentry.startSpan({ name: 'checkout-step-2-1' }, () => { + // ... ` +}); +checkoutStep2.end(); + +Sentry.startSpan({ name: 'checkout-step-3' }, () => {}); + +checkoutSpan.end(); + +Sentry.startSpan({ name: 'post-checkout' }, () => { + Sentry.startSpan({ name: 'post-checkout-1' }, () => { + // ... ` + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts new file mode 100644 index 000000000000..58728bba07f9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts @@ -0,0 +1,63 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'nested calls to setActiveSpanInBrowser with parentSpanIsAlwaysRootSpan=false result in correct parenting', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const checkoutSpansPromise = waitForStreamedSpans(page, spans => + spans.some(s => s.name === 'checkout-flow' && s.is_segment), + ); + const postCheckoutSpansPromise = waitForStreamedSpans(page, spans => + spans.some(s => s.name === 'post-checkout' && s.is_segment), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const checkoutSpans = await checkoutSpansPromise; + const postCheckoutSpans = await postCheckoutSpansPromise; + + const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow'); + const postCheckoutSpan = postCheckoutSpans.find(s => s.name === 'post-checkout'); + + const checkoutSpanId = checkoutSpan?.span_id; + const postCheckoutSpanId = postCheckoutSpan?.span_id; + + expect(checkoutSpanId).toMatch(/[a-f\d]{16}/); + expect(postCheckoutSpanId).toMatch(/[a-f\d]{16}/); + + expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(4); + expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1); + + const checkoutStep1 = checkoutSpans.find(s => s.name === 'checkout-step-1'); + const checkoutStep2 = checkoutSpans.find(s => s.name === 'checkout-step-2'); + const checkoutStep21 = checkoutSpans.find(s => s.name === 'checkout-step-2-1'); + const checkoutStep3 = checkoutSpans.find(s => s.name === 'checkout-step-3'); + + expect(checkoutStep1).toBeDefined(); + expect(checkoutStep2).toBeDefined(); + expect(checkoutStep21).toBeDefined(); + expect(checkoutStep3).toBeDefined(); + + expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); + + // with parentSpanIsAlwaysRootSpan=false, 2-1 is parented to 2 because + // 2 was the active span when 2-1 was started + expect(checkoutStep21?.parent_span_id).toBe(checkoutStep2?.span_id); + + // since the parent of three is `checkoutSpan`, we correctly reset + // the active span to `checkoutSpan` after 2 ended + expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId); + + // post-checkout trace is started as a new trace because ending checkoutSpan removes the active + // span on the scope + const postCheckoutStep1 = postCheckoutSpans.find(s => s.name === 'post-checkout-1'); + expect(postCheckoutStep1).toBeDefined(); + expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/init.js new file mode 100644 index 000000000000..9afcee48dc4a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/subject.js new file mode 100644 index 000000000000..dc601cbf4d30 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/subject.js @@ -0,0 +1,22 @@ +const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); +Sentry.setActiveSpanInBrowser(checkoutSpan); + +Sentry.startSpan({ name: 'checkout-step-1' }, () => {}); + +const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' }); +Sentry.setActiveSpanInBrowser(checkoutStep2); + +Sentry.startSpan({ name: 'checkout-step-2-1' }, () => { + // ... ` +}); +checkoutStep2.end(); + +Sentry.startSpan({ name: 'checkout-step-3' }, () => {}); + +checkoutSpan.end(); + +Sentry.startSpan({ name: 'post-checkout' }, () => { + Sentry.startSpan({ name: 'post-checkout-1' }, () => { + // ... ` + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts new file mode 100644 index 000000000000..4d11a36982b7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'nested calls to setActiveSpanInBrowser still parent to root span by default', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const checkoutSpansPromise = waitForStreamedSpans(page, spans => + spans.some(s => s.name === 'checkout-flow' && s.is_segment), + ); + const postCheckoutSpansPromise = waitForStreamedSpans(page, spans => + spans.some(s => s.name === 'post-checkout' && s.is_segment), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const checkoutSpans = await checkoutSpansPromise; + const postCheckoutSpans = await postCheckoutSpansPromise; + + const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow'); + const postCheckoutSpan = postCheckoutSpans.find(s => s.name === 'post-checkout'); + + const checkoutSpanId = checkoutSpan?.span_id; + const postCheckoutSpanId = postCheckoutSpan?.span_id; + + expect(checkoutSpanId).toMatch(/[a-f\d]{16}/); + expect(postCheckoutSpanId).toMatch(/[a-f\d]{16}/); + + expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(4); + expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1); + + const checkoutStep1 = checkoutSpans.find(s => s.name === 'checkout-step-1'); + const checkoutStep2 = checkoutSpans.find(s => s.name === 'checkout-step-2'); + const checkoutStep21 = checkoutSpans.find(s => s.name === 'checkout-step-2-1'); + const checkoutStep3 = checkoutSpans.find(s => s.name === 'checkout-step-3'); + + expect(checkoutStep1).toBeDefined(); + expect(checkoutStep2).toBeDefined(); + expect(checkoutStep21).toBeDefined(); + expect(checkoutStep3).toBeDefined(); + + expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId); + + // despite 2-1 being called within 2 AND setting 2 as active span, it's still parented to the + // root span due to this being default behaviour in browser environments + expect(checkoutStep21?.parent_span_id).toBe(checkoutSpanId); + + const postCheckoutStep1 = postCheckoutSpans.find(s => s.name === 'post-checkout-1'); + expect(postCheckoutStep1).toBeDefined(); + expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js new file mode 100644 index 000000000000..3dd77207e103 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; +// Import this separately so that generatePlugin can handle it for CDN scenarios +import { feedbackIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), feedbackIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts new file mode 100644 index 000000000000..28f3e5039910 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts @@ -0,0 +1,318 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import type { EventAndTraceHeader } from '../../../../utils/helpers'; +import { + eventAndTraceHeaderRequestParser, + getFirstSentryEnvelopeRequest, + shouldSkipFeedbackTest, + shouldSkipTracingTest, + testingCdnBundle, +} from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +sentryTest('creates a new trace and sample_rand on each navigation', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Wait for and skip the initial pageload span + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigation1SpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + await page.goto(`${url}#foo`); + const navigation1SpanEnvelope = await navigation1SpanEnvelopePromise; + + const navigation2SpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + await page.goto(`${url}#bar`); + const navigation2SpanEnvelope = await navigation2SpanEnvelopePromise; + + const navigation1TraceId = navigation1SpanEnvelope[0].trace?.trace_id; + const navigation1SampleRand = navigation1SpanEnvelope[0].trace?.sample_rand; + const navigation2TraceId = navigation2SpanEnvelope[0].trace?.trace_id; + const navigation2SampleRand = navigation2SpanEnvelope[0].trace?.sample_rand; + + const navigation1Span = navigation1SpanEnvelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!; + const navigation2Span = navigation2SpanEnvelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!; + + expect(getSpanOp(navigation1Span)).toEqual('navigation'); + expect(navigation1TraceId).toMatch(/^[\da-f]{32}$/); + expect(navigation1Span.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigation1Span.parent_span_id).toBeUndefined(); + + expect(navigation1SpanEnvelope[0].trace).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: navigation1TraceId, + sample_rand: expect.any(String), + }); + + expect(getSpanOp(navigation2Span)).toEqual('navigation'); + expect(navigation2TraceId).toMatch(/^[\da-f]{32}$/); + expect(navigation2Span.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigation2Span.parent_span_id).toBeUndefined(); + + expect(navigation2SpanEnvelope[0].trace).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: navigation2TraceId, + sample_rand: expect.any(String), + }); + + expect(navigation1TraceId).not.toEqual(navigation2TraceId); + expect(navigation1SampleRand).not.toEqual(navigation2SampleRand); +}); + +sentryTest('error after navigation has navigation traceId', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // ensure pageload span is finished + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + await page.goto(`${url}#foo`); + const [navigationSpan, navigationSpanEnvelope] = await Promise.all([ + navigationSpanPromise, + navigationSpanEnvelopePromise, + ]); + + const navigationTraceId = navigationSpan.trace_id; + + expect(getSpanOp(navigationSpan)).toEqual('navigation'); + expect(navigationTraceId).toMatch(/^[\da-f]{32}$/); + expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigationSpan.parent_span_id).toBeUndefined(); + + expect(navigationSpanEnvelope[0].trace).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: navigationTraceId, + sample_rand: expect.any(String), + }); + + const errorEventPromise = getFirstSentryEnvelopeRequest( + page, + undefined, + eventAndTraceHeaderRequestParser, + ); + await page.locator('#errorBtn').click(); + const [errorEvent, errorTraceHeader] = await errorEventPromise; + + expect(errorEvent.type).toEqual(undefined); + + const errorTraceContext = errorEvent.contexts?.trace; + expect(errorTraceContext).toEqual({ + trace_id: navigationTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + expect(errorTraceHeader).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: navigationTraceId, + sample_rand: expect.any(String), + }); +}); + +sentryTest('error during navigation has new navigation traceId', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // ensure pageload span is finished + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + const errorEventPromise = getFirstSentryEnvelopeRequest( + page, + undefined, + eventAndTraceHeaderRequestParser, + ); + + await page.goto(`${url}#foo`); + await page.locator('#errorBtn').click(); + const [navigationSpan, [errorEvent, errorTraceHeader]] = await Promise.all([ + navigationSpanPromise, + errorEventPromise, + ]); + + expect(getSpanOp(navigationSpan)).toEqual('navigation'); + expect(errorEvent.type).toEqual(undefined); + + const navigationTraceId = navigationSpan.trace_id; + expect(navigationTraceId).toMatch(/^[\da-f]{32}$/); + expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigationSpan.parent_span_id).toBeUndefined(); + + const errorTraceContext = errorEvent?.contexts?.trace; + expect(errorTraceContext).toEqual({ + trace_id: navigationTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + + expect(errorTraceHeader).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: navigationTraceId, + sample_rand: expect.any(String), + }); +}); + +sentryTest( + 'outgoing fetch request during navigation has navigation traceId in headers', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + // ensure pageload span is finished + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + const requestPromise = page.waitForRequest('http://sentry-test-site.example/*'); + await page.goto(`${url}#foo`); + await page.locator('#fetchBtn').click(); + const [navigationSpanEnvelope, request] = await Promise.all([navigationSpanEnvelopePromise, requestPromise]); + + const navigationTraceId = navigationSpanEnvelope[0].trace?.trace_id; + const sampleRand = navigationSpanEnvelope[0].trace?.sample_rand; + + expect(navigationTraceId).toMatch(/^[\da-f]{32}$/); + + const headers = request.headers(); + + // sampling decision is propagated from active span sampling decision + expect(headers['sentry-trace']).toMatch(new RegExp(`^${navigationTraceId}-[0-9a-f]{16}-1$`)); + expect(headers['baggage']).toEqual( + `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${navigationTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`, + ); + }, +); + +sentryTest( + 'outgoing XHR request during navigation has navigation traceId in headers', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + // ensure navigation span is finished + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + const requestPromise = page.waitForRequest('http://sentry-test-site.example/*'); + await page.goto(`${url}#foo`); + await page.locator('#xhrBtn').click(); + const [navigationSpanEnvelope, request] = await Promise.all([navigationSpanEnvelopePromise, requestPromise]); + + const navigationTraceId = navigationSpanEnvelope[0].trace?.trace_id; + const sampleRand = navigationSpanEnvelope[0].trace?.sample_rand; + + expect(navigationTraceId).toMatch(/^[\da-f]{32}$/); + + const headers = request.headers(); + + // sampling decision is propagated from active span sampling decision + expect(headers['sentry-trace']).toMatch(new RegExp(`^${navigationTraceId}-[0-9a-f]{16}-1$`)); + expect(headers['baggage']).toEqual( + `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${navigationTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`, + ); + }, +); + +sentryTest( + 'user feedback event after navigation has navigation traceId in headers', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || shouldSkipFeedbackTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname, handleLazyLoadedFeedback: true }); + + // ensure pageload span is finished + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + const navigationSpan = await navigationSpanPromise; + + const navigationTraceId = navigationSpan.trace_id; + expect(getSpanOp(navigationSpan)).toEqual('navigation'); + expect(navigationTraceId).toMatch(/^[\da-f]{32}$/); + expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigationSpan.parent_span_id).toBeUndefined(); + + const feedbackEventPromise = getFirstSentryEnvelopeRequest(page); + + await page.getByText('Report a Bug').click(); + expect(await page.locator(':visible:text-is("Report a Bug")').count()).toEqual(1); + await page.locator('[name="name"]').fill('Jane Doe'); + await page.locator('[name="email"]').fill('janedoe@example.org'); + await page.locator('[name="message"]').fill('my example feedback'); + await page.locator('[data-sentry-feedback] .btn--primary').click(); + + const feedbackEvent = await feedbackEventPromise; + + expect(feedbackEvent.type).toEqual('feedback'); + + const feedbackTraceContext = feedbackEvent.contexts?.trace; + + expect(feedbackTraceContext).toMatchObject({ + trace_id: navigationTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js new file mode 100644 index 000000000000..3dd77207e103 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; +// Import this separately so that generatePlugin can handle it for CDN scenarios +import { feedbackIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), feedbackIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts new file mode 100644 index 000000000000..1b4458991559 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts @@ -0,0 +1,238 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import type { EventAndTraceHeader } from '../../../../utils/helpers'; +import { + eventAndTraceHeaderRequestParser, + getFirstSentryEnvelopeRequest, + shouldSkipFeedbackTest, + shouldSkipTracingTest, + testingCdnBundle, +} from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +sentryTest('creates a new trace for a navigation after the initial pageload', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + page.goto(`${url}#foo`); + + const navigationSpan = await navigationSpanPromise; + + expect(getSpanOp(pageloadSpan)).toEqual('pageload'); + expect(pageloadSpan.trace_id).toMatch(/^[\da-f]{32}$/); + expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(pageloadSpan.parent_span_id).toBeUndefined(); + + expect(getSpanOp(navigationSpan)).toEqual('navigation'); + expect(navigationSpan.trace_id).toMatch(/^[\da-f]{32}$/); + expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigationSpan.parent_span_id).toBeUndefined(); + + expect(pageloadSpan.span_id).not.toEqual(navigationSpan.span_id); + expect(pageloadSpan.trace_id).not.toEqual(navigationSpan.trace_id); +}); + +sentryTest('error after pageload has pageload traceId', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + const pageloadTraceId = pageloadSpan.trace_id; + + expect(getSpanOp(pageloadSpan)).toEqual('pageload'); + expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/); + expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(pageloadSpan.parent_span_id).toBeUndefined(); + + const errorEventPromise = getFirstSentryEnvelopeRequest( + page, + undefined, + eventAndTraceHeaderRequestParser, + ); + await page.locator('#errorBtn').click(); + const [errorEvent, errorTraceHeader] = await errorEventPromise; + + const errorTraceContext = errorEvent.contexts?.trace; + expect(errorEvent.type).toEqual(undefined); + + expect(errorTraceContext).toEqual({ + trace_id: pageloadTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + + expect(errorTraceHeader).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: pageloadTraceId, + sample_rand: expect.any(String), + }); +}); + +sentryTest('error during pageload has pageload traceId', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const errorEventPromise = getFirstSentryEnvelopeRequest( + page, + undefined, + eventAndTraceHeaderRequestParser, + ); + + await page.goto(url); + await page.locator('#errorBtn').click(); + const [pageloadSpan, [errorEvent, errorTraceHeader]] = await Promise.all([pageloadSpanPromise, errorEventPromise]); + + const pageloadTraceId = pageloadSpan.trace_id; + + expect(getSpanOp(pageloadSpan)).toEqual('pageload'); + expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/); + expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(pageloadSpan.parent_span_id).toBeUndefined(); + + const errorTraceContext = errorEvent?.contexts?.trace; + expect(errorEvent.type).toEqual(undefined); + + expect(errorTraceContext).toEqual({ + trace_id: pageloadTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + + expect(errorTraceHeader).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: pageloadTraceId, + sample_rand: expect.any(String), + }); +}); + +sentryTest( + 'outgoing fetch request during pageload has pageload traceId in headers', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const pageloadSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + const requestPromise = page.waitForRequest('http://sentry-test-site.example/*'); + await page.goto(url); + await page.locator('#fetchBtn').click(); + const [pageloadSpanEnvelope, request] = await Promise.all([pageloadSpanEnvelopePromise, requestPromise]); + + const pageloadTraceId = pageloadSpanEnvelope[0].trace?.trace_id; + const sampleRand = pageloadSpanEnvelope[0].trace?.sample_rand; + + expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/); + + const headers = request.headers(); + + // sampling decision is propagated from active span sampling decision + expect(headers['sentry-trace']).toMatch(new RegExp(`^${pageloadTraceId}-[0-9a-f]{16}-1$`)); + expect(headers['baggage']).toBe( + `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${pageloadTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`, + ); + }, +); + +sentryTest( + 'outgoing XHR request during pageload has pageload traceId in headers', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const pageloadSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + const requestPromise = page.waitForRequest('http://sentry-test-site.example/*'); + await page.goto(url); + await page.locator('#xhrBtn').click(); + const [pageloadSpanEnvelope, request] = await Promise.all([pageloadSpanEnvelopePromise, requestPromise]); + + const pageloadTraceId = pageloadSpanEnvelope[0].trace?.trace_id; + const sampleRand = pageloadSpanEnvelope[0].trace?.sample_rand; + + expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/); + + const headers = request.headers(); + + // sampling decision is propagated from active span sampling decision + expect(headers['sentry-trace']).toMatch(new RegExp(`^${pageloadTraceId}-[0-9a-f]{16}-1$`)); + expect(headers['baggage']).toBe( + `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${pageloadTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`, + ); + }, +); + +sentryTest('user feedback event after pageload has pageload traceId in headers', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || shouldSkipFeedbackTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname, handleLazyLoadedFeedback: true }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const pageloadSpan = await pageloadSpanPromise; + const pageloadTraceId = pageloadSpan.trace_id; + + expect(getSpanOp(pageloadSpan)).toEqual('pageload'); + expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/); + expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(pageloadSpan.parent_span_id).toBeUndefined(); + + const feedbackEventPromise = getFirstSentryEnvelopeRequest(page); + + await page.getByText('Report a Bug').click(); + expect(await page.locator(':visible:text-is("Report a Bug")').count()).toEqual(1); + await page.locator('[name="name"]').fill('Jane Doe'); + await page.locator('[name="email"]').fill('janedoe@example.org'); + await page.locator('[name="message"]').fill('my example feedback'); + await page.locator('[data-sentry-feedback] .btn--primary').click(); + + const feedbackEvent = await feedbackEventPromise; + + expect(feedbackEvent.type).toEqual('feedback'); + + const feedbackTraceContext = feedbackEvent.contexts?.trace; + + expect(feedbackTraceContext).toMatchObject({ + trace_id: pageloadTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js new file mode 100644 index 000000000000..187e07624fdf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js new file mode 100644 index 000000000000..3bb1e489ccb6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js @@ -0,0 +1,15 @@ +const newTraceBtn = document.getElementById('newTrace'); +newTraceBtn.addEventListener('click', async () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ op: 'ui.interaction.click', name: 'new-trace' }, async () => { + await fetch('http://sentry-test-site.example'); + }); + }); +}); + +const oldTraceBtn = document.getElementById('oldTrace'); +oldTraceBtn.addEventListener('click', async () => { + Sentry.startSpan({ op: 'ui.interaction.click', name: 'old-trace' }, async () => { + await fetch('http://sentry-test-site.example'); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html new file mode 100644 index 000000000000..f78960343dd0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts new file mode 100644 index 000000000000..d294efcd2e3b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts @@ -0,0 +1,44 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest( + 'creates a new trace if `startNewTrace` is called and leaves old trace valid outside the callback', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const pageloadSpan = await pageloadSpanPromise; + + const newTraceSpanPromise = waitForStreamedSpan(page, span => span.name === 'new-trace'); + const oldTraceSpanPromise = waitForStreamedSpan(page, span => span.name === 'old-trace'); + + await page.locator('#newTrace').click(); + await page.locator('#oldTrace').click(); + + const [newTraceSpan, oldTraceSpan] = await Promise.all([newTraceSpanPromise, oldTraceSpanPromise]); + + expect(getSpanOp(newTraceSpan)).toEqual('ui.interaction.click'); + expect(newTraceSpan.trace_id).toMatch(/^[\da-f]{32}$/); + expect(newTraceSpan.span_id).toMatch(/^[\da-f]{16}$/); + + expect(getSpanOp(oldTraceSpan)).toEqual('ui.interaction.click'); + expect(oldTraceSpan.trace_id).toMatch(/^[\da-f]{32}$/); + expect(oldTraceSpan.span_id).toMatch(/^[\da-f]{16}$/); + + expect(oldTraceSpan.trace_id).toEqual(pageloadSpan.trace_id); + expect(newTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + }, +); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 50150c6bee20..5dade230e1e4 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -62,7 +62,7 @@ export const eventAndTraceHeaderRequestParser = (request: Request | null): Event return getEventAndTraceHeader(envelope); }; -const properFullEnvelopeParser = (request: Request | null): T => { +export const properFullEnvelopeParser = (request: Request | null): T => { // https://develop.sentry.dev/sdk/envelopes/ const envelope = request?.postData() || ''; diff --git a/dev-packages/browser-integration-tests/utils/spanUtils.ts b/dev-packages/browser-integration-tests/utils/spanUtils.ts new file mode 100644 index 000000000000..67b5798b66f1 --- /dev/null +++ b/dev-packages/browser-integration-tests/utils/spanUtils.ts @@ -0,0 +1,133 @@ +import type { Page } from '@playwright/test'; +import type { SerializedStreamedSpan, StreamedSpanEnvelope } from '@sentry/core'; +import { properFullEnvelopeParser } from './helpers'; + +/** + * Wait for a full span v2 envelope + * Useful for testing the entire envelope shape + */ +export async function waitForStreamedSpanEnvelope( + page: Page, + callback?: (spanEnvelope: StreamedSpanEnvelope) => boolean, +): Promise { + const req = await page.waitForRequest(req => { + const postData = req.postData(); + if (!postData) { + return false; + } + + try { + const spanEnvelope = properFullEnvelopeParser(req); + + const envelopeItemHeader = spanEnvelope[1][0][0]; + + if ( + envelopeItemHeader?.type !== 'span' || + envelopeItemHeader?.content_type !== 'application/vnd.sentry.items.span.v2+json' + ) { + return false; + } + + if (callback) { + return callback(spanEnvelope); + } + + return true; + } catch { + return false; + } + }); + + return properFullEnvelopeParser(req); +} + +/** + * Wait for v2 spans sent in one envelope. + * Useful for testing multiple spans in one envelope. + * @param page + * @param callback - Callback being called with all spans + */ +export async function waitForStreamedSpans( + page: Page, + callback?: (spans: SerializedStreamedSpan[]) => boolean, +): Promise { + const spanEnvelope = await waitForStreamedSpanEnvelope(page, envelope => { + if (callback) { + return callback(envelope[1][0][1].items); + } + return true; + }); + return spanEnvelope[1][0][1].items; +} + +export async function waitForStreamedSpan( + page: Page, + callback: (span: SerializedStreamedSpan) => boolean, +): Promise { + const spanEnvelope = await waitForStreamedSpanEnvelope(page, envelope => { + if (callback) { + const spans = envelope[1][0][1].items; + return spans.some(span => callback(span)); + } + return true; + }); + const firstMatchingSpan = spanEnvelope[1][0][1].items.find(span => callback(span)); + if (!firstMatchingSpan) { + throw new Error( + 'No matching span found but envelope search matched previously. Something is likely off with this function. Debug me.', + ); + } + return firstMatchingSpan; +} + +/** + * Observes outgoing requests and looks for sentry envelope requests. If an envelope request is found, it applies + * @param callback to check for a matching span. + * + * Important: This function only observes requests and does not block the test when it ends. Use this primarily to + * throw errors if you encounter unwanted spans. You most likely want to use {@link waitForStreamedSpan} or {@link waitForStreamedSpans} instead! + */ +export async function observeStreamedSpan( + page: Page, + callback: (span: SerializedStreamedSpan) => boolean, +): Promise { + page.on('request', request => { + const postData = request.postData(); + if (!postData) { + return; + } + + try { + const spanEnvelope = properFullEnvelopeParser(request); + + const envelopeItemHeader = spanEnvelope[1][0][0]; + + if ( + envelopeItemHeader?.type !== 'span' || + envelopeItemHeader?.content_type !== 'application/vnd.sentry.items.span.v2+json' + ) { + return false; + } + + const spans = spanEnvelope[1][0][1].items; + + for (const span of spans) { + if (callback(span)) { + return true; + } + } + + return false; + } catch { + return false; + } + }); +} + +export function getSpanOp(span: SerializedStreamedSpan): string | undefined { + return span.attributes?.['sentry.op']?.type === 'string' ? span.attributes?.['sentry.op']?.value : undefined; +} + +export function getSpansFromEnvelope(envelope: StreamedSpanEnvelope): SerializedStreamedSpan[] { + return envelope[1][0][1].items; +} diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index e8e3f8bcd542..f741e2746885 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -6,6 +6,7 @@ import { hasSpanStreamingEnabled, isStreamedBeforeSendSpanCallback, SpanBuffer, + spanIsSampled, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; @@ -44,7 +45,15 @@ export const spanStreamingIntegration = defineIntegration(() => { const buffer = new SpanBuffer(client); - client.on('afterSpanEnd', span => buffer.add(captureSpan(span, client))); + client.on('afterSpanEnd', span => { + // Negatively sampled spans must not be captured. + // This happens because OTel and we create non-recording spans for negatively sampled spans + // that go through the same life cycle as recording spans. + if (!spanIsSampled(span)) { + return; + } + buffer.add(captureSpan(span, client)); + }); // In addition to capturing the span, we also flush the trace when the segment // span ends to ensure things are sent timely. We never know when the browser diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts index 6993e494f9ce..63e07738570c 100644 --- a/packages/browser/test/integrations/spanstreaming.test.ts +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -1,6 +1,6 @@ import * as SentryCore from '@sentry/core'; import { debug } from '@sentry/core'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient, spanStreamingIntegration } from '../../src'; import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; @@ -24,6 +24,10 @@ vi.mock('@sentry/core', async () => { }); describe('spanStreamingIntegration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('has the correct hooks', () => { const integration = spanStreamingIntegration(); expect(integration.name).toBe('SpanStreaming'); @@ -106,12 +110,13 @@ describe('spanStreamingIntegration', () => { ...getDefaultBrowserClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], + tracesSampleRate: 1, }); SentryCore.setCurrentClient(client); client.init(); - const span = new SentryCore.SentrySpan({ name: 'test' }); + const span = new SentryCore.SentrySpan({ name: 'test', sampled: true }); client.emit('afterSpanEnd', span); expect(mockSpanBufferInstance.add).toHaveBeenCalledWith({ @@ -148,6 +153,24 @@ describe('spanStreamingIntegration', () => { }); }); + it('does not enqueue a span into the buffer when the span is not sampled', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + tracesSampleRate: 1, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test', sampled: false }); + client.emit('afterSpanEnd', span); + + expect(mockSpanBufferInstance.add).not.toHaveBeenCalled(); + expect(mockSpanBufferInstance.flush).not.toHaveBeenCalled(); + }); + it('flushes the trace when the segment span ends', () => { const client = new BrowserClient({ ...getDefaultBrowserClientOptions(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d5d926f51769..a2af055232a1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -467,6 +467,7 @@ export type { SpanJSON, SpanContextData, TraceFlag, + SerializedStreamedSpan, StreamedSpanJSON, } from './types-hoist/span'; export type { SpanStatus } from './types-hoist/spanStatus'; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 2168530a9c91..d22905670efc 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -332,7 +332,12 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef * Convert the various statuses to the simple onces expected by Sentry for steamed spans ('ok' is default). */ export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | 'error' { - return !status || status.code === SPAN_STATUS_OK || status.code === SPAN_STATUS_UNSET ? 'ok' : 'error'; + return !status || + status.code === SPAN_STATUS_OK || + status.code === SPAN_STATUS_UNSET || + status.message === 'cancelled' + ? 'ok' + : 'error'; } const CHILD_SPANS_FIELD = '_sentryChildSpans';