diff --git a/.size-limit.js b/.size-limit.js index fcc455808948..1e6e8d951464 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -15,7 +15,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '24.5 KB', + limit: '25 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -103,7 +103,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'sendFeedback'), gzip: true, - limit: '31 KB', + limit: '32 KB', }, { name: '@sentry/browser (incl. FeedbackAsync)', @@ -117,7 +117,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'metrics'), gzip: true, - limit: '27 KB', + limit: '28 KB', }, { name: '@sentry/browser (incl. Logs)', @@ -148,7 +148,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '45.1 KB', + limit: '46 KB', }, // Vue SDK (ESM) { @@ -220,13 +220,13 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '86 KB', + limit: '87 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '87 KB', + limit: '88 KB', }, // browser CDN bundles (non-gzipped) { @@ -241,7 +241,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '129 KB', + limit: '130 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', @@ -255,7 +255,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '132 KB', + limit: '134 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed', @@ -269,7 +269,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '246 KB', + limit: '247 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', @@ -317,7 +317,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '57 KB', + limit: '59 KB', }, // Node SDK (ESM) { @@ -326,14 +326,14 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '176 KB', + limit: '177 KB', }, { name: '@sentry/node - without tracing', path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '98 KB', + limit: '100 KB', ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -356,7 +356,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '114 KB', + limit: '117 KB', }, ]; 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..25d4ac497992 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts @@ -0,0 +1,63 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'adds 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.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..ea50f09f2361 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts @@ -0,0 +1,99 @@ +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: 'span', + quantity: expect.any(Number), + reason: 'sample_rate', + }, + ], + }); + // exact number depends on performance observer emissions + expect(clientReport.discarded_events[0].quantity).toBeGreaterThanOrEqual(10); + }); + + 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..367b48e70eda --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts @@ -0,0 +1,118 @@ +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: 'span', + quantity: expect.any(Number), + reason: 'sample_rate', + }, + ], + }); + // exact number depends on performance observer emissions + expect(clientReport.discarded_events[0].quantity).toBeGreaterThanOrEqual(3); + }); + + 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..d661a4548e94 --- /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: 'span', + 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: 'span', + 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/ignoreSpans-streamed/child/init.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/init.js new file mode 100644 index 000000000000..086383ccdd54 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/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.spanStreamingIntegration()], + ignoreSpans: [/ignore/, { op: 'ignored-op' }], + parentSpanIsAlwaysRootSpan: false, + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js new file mode 100644 index 000000000000..47c336e973cd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js @@ -0,0 +1,31 @@ +Sentry.startSpan({ name: 'parent-span' }, () => { + Sentry.startSpan({ name: 'keep-me' }, () => {}); + + // This child matches ignoreSpans —> dropped + Sentry.startSpan({ name: 'ignore-child' }, () => { + // dropped + Sentry.startSpan({ name: 'ignore-grandchild-1' }, () => { + // kept + Sentry.startSpan({ name: 'great-grandchild-1' }, () => { + // dropped + Sentry.startSpan({ name: 'ignore-great-great-grandchild-1' }, () => { + // kept + Sentry.startSpan({ name: 'great-great-great-grandchild-1' }, () => {}); + }); + }); + }); + // Grandchild is reparented to 'parent-span' —> kept + Sentry.startSpan({ name: 'grandchild-2' }, () => {}); + }); + + // both dropped + Sentry.startSpan({ name: 'name-passes-but-op-not-span-1', op: 'ignored-op' }, () => {}); + Sentry.startSpan( + // sentry.op attribute has precedence over top op argument + { name: 'name-passes-but-op-not-span-2', op: 'keep', attributes: { 'sentry.op': 'ignored-op' } }, + () => {}, + ); + + // kept + Sentry.startSpan({ name: 'another-keeper' }, () => {}); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts new file mode 100644 index 000000000000..967ef101092d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts @@ -0,0 +1,59 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + testingCdnBundle, + waitForClientReportRequest, +} from '../../../../utils/helpers'; +import { waitForStreamedSpans } from '../../../../utils/spanUtils'; +import type { ClientReport } from '@sentry/core'; + +sentryTest('ignored child spans are dropped and their children are reparented', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'parent-span')); + + const clientReportPromise = waitForClientReportRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const spans = await spansPromise; + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + const segmentSpanId = spans.find(s => s.name === 'parent-span')?.span_id; + + expect(spans.length).toBe(6); + + expect(spans.some(s => s.name === 'keep-me')).toBe(true); + expect(spans.some(s => s.name === 'another-keeper')).toBe(true); + + expect(spans.some(s => s.name?.includes('ignore'))).toBe(false); + + const greatGrandChild1 = spans.find(s => s.name === 'great-grandchild-1'); + const grandchild2 = spans.find(s => s.name === 'grandchild-2'); + const greatGreatGreatGrandChild1 = spans.find(s => s.name === 'great-great-great-grandchild-1'); + + expect(greatGrandChild1).toBeDefined(); + expect(grandchild2).toBeDefined(); + expect(greatGreatGreatGrandChild1).toBeDefined(); + + expect(greatGrandChild1?.parent_span_id).toBe(segmentSpanId); + expect(grandchild2?.parent_span_id).toBe(segmentSpanId); + expect(greatGreatGreatGrandChild1?.parent_span_id).toBe(greatGrandChild1?.span_id); + + expect(spans.every(s => s.name === 'parent-span' || !s.is_segment)).toBe(true); + + expect(clientReport.discarded_events).toEqual([ + { + category: 'span', + quantity: 5, // 5 ignored child spans + reason: 'ignored', + }, + ]); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/init.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/init.js new file mode 100644 index 000000000000..3a710324f0e1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/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.spanStreamingIntegration()], + ignoreSpans: [/ignore/], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js new file mode 100644 index 000000000000..645668376b36 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js @@ -0,0 +1,11 @@ +// This segment span matches ignoreSpans — should NOT produce a transaction +Sentry.startSpan({ name: 'ignore-segment' }, () => { + Sentry.startSpan({ name: 'child-of-ignored-segment' }, () => {}); +}); + +setTimeout(() => { + // This segment span does NOT match — should produce a transaction + Sentry.startSpan({ name: 'normal-segment' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); +}, 1000); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts new file mode 100644 index 000000000000..93042cc5469e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts @@ -0,0 +1,44 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + testingCdnBundle, + waitForClientReportRequest, +} from '../../../../utils/helpers'; +import { observeStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils'; +import type { ClientReport } from '@sentry/core'; + +sentryTest('ignored segment span drops entire trace', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + observeStreamedSpan(page, span => { + if (span.name === 'ignore-segment' || span.name === 'child-of-ignored-segment') { + throw new Error('Ignored span found'); + } + return false; // means we keep on looking for unwanted spans + }); + + const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'normal-segment')); + + const clientReportPromise = waitForClientReportRequest(page); + + await page.goto(url); + + expect((await spansPromise)?.length).toBe(2); + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + expect(clientReport.discarded_events).toEqual([ + { + category: 'span', + quantity: 2, // segment + child span + reason: 'ignored', + }, + ]); +}); 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/metrics/web-vitals-cls-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js new file mode 100644 index 000000000000..0c1792f0bd3f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/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({ idleTimeout: 9000 }), Sentry.spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js new file mode 100644 index 000000000000..9742a4a5cc29 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js @@ -0,0 +1,17 @@ +import { simulateCLS } from '../../../../utils/web-vitals/cls.ts'; + +// Simulate Layout shift right at the beginning of the page load, depending on the URL hash +// don't run if expected CLS is NaN +const expectedCLS = Number(location.hash.slice(1)); +if (expectedCLS && expectedCLS >= 0) { + simulateCLS(expectedCLS).then(() => window.dispatchEvent(new Event('cls-done'))); +} + +// Simulate layout shift whenever the trigger-cls event is dispatched +// Cannot trigger via a button click because expected layout shift after +// an interaction doesn't contribute to CLS. +window.addEventListener('trigger-cls', () => { + simulateCLS(0.1).then(() => { + window.dispatchEvent(new Event('cls-done')); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html new file mode 100644 index 000000000000..10e2e22f7d6a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html @@ -0,0 +1,10 @@ + + + + + + +
+

Some content

+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts new file mode 100644 index 000000000000..cf995f7a912d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts @@ -0,0 +1,77 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest.beforeEach(async ({ browserName, page }) => { + if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.setViewportSize({ width: 800, height: 1200 }); +}); + +function waitForLayoutShift(page: Page): Promise { + return page.evaluate(() => { + return new Promise(resolve => { + window.addEventListener('cls-done', () => resolve()); + }); + }); +} + +function hidePage(page: Page): Promise { + return page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); +} + +sentryTest('captures CLS as a streamed span with source attributes', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls'); + + await page.goto(`${url}#0.15`); + await waitForLayoutShift(page); + await hidePage(page); + + const clsSpan = await clsSpanPromise; + + expect(clsSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.cls' }); + expect(clsSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.cls' }); + expect(clsSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); + expect(clsSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome')); + + // Check browser.web_vital.cls.source attributes + expect(clsSpan.attributes?.['browser.web_vital.cls.source.1']?.value).toEqual( + expect.stringContaining('body > div#content > p'), + ); + + // Check pageload span id is present + expect(clsSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/); + + // CLS is a point-in-time metric + expect(clsSpan.start_timestamp).toEqual(clsSpan.end_timestamp); + + expect(clsSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(clsSpan.trace_id).toMatch(/^[\da-f]{32}$/); +}); + +sentryTest('CLS streamed span has web vital value attribute', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls'); + + await page.goto(`${url}#0.1`); + await waitForLayoutShift(page); + await hidePage(page); + + const clsSpan = await clsSpanPromise; + + // The CLS value should be set as a browser.web_vital.cls.value attribute + expect(clsSpan.attributes?.['browser.web_vital.cls.value']?.type).toBe('double'); + // Flakey value dependent on timings -> we check for a range + const clsValue = clsSpan.attributes?.['browser.web_vital.cls.value']?.value as number; + expect(clsValue).toBeGreaterThan(0.05); + expect(clsValue).toBeLessThan(0.15); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png new file mode 100644 index 000000000000..353b7233d6bf Binary files /dev/null and b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png differ diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js new file mode 100644 index 000000000000..0c1792f0bd3f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/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({ idleTimeout: 9000 }), Sentry.spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html new file mode 100644 index 000000000000..b613a556aca4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts new file mode 100644 index 000000000000..c91ebd2bbd51 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts @@ -0,0 +1,66 @@ +import type { Page, Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest.beforeEach(async ({ browserName, page }) => { + if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.setViewportSize({ width: 800, height: 1200 }); +}); + +function hidePage(page: Page): Promise { + return page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); +} + +sentryTest('captures LCP as a streamed span with element attributes', async ({ getLocalTestUrl, page }) => { + page.route('**', route => route.continue()); + page.route('**/my/image.png', async (route: Route) => { + return route.fulfill({ + path: `${__dirname}/assets/sentry-logo-600x179.png`, + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const lcpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.lcp'); + + await page.goto(url); + + // Wait for LCP to be captured + await page.waitForTimeout(1000); + + await hidePage(page); + + const lcpSpan = await lcpSpanPromise; + + expect(lcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.lcp' }); + expect(lcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.lcp' }); + expect(lcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); + expect(lcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome')); + + // Check browser.web_vital.lcp.* attributes + expect(lcpSpan.attributes?.['browser.web_vital.lcp.element']?.value).toEqual(expect.stringContaining('body > img')); + expect(lcpSpan.attributes?.['browser.web_vital.lcp.url']?.value).toBe( + 'https://sentry-test-site.example/my/image.png', + ); + expect(lcpSpan.attributes?.['browser.web_vital.lcp.size']?.value).toEqual(expect.any(Number)); + + // Check web vital value attribute + expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.type).toBe('double'); + expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.value).toBeGreaterThan(0); + + // Check pageload span id is present + expect(lcpSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/); + + // Span should have meaningful duration (navigation start -> LCP event) + expect(lcpSpan.end_timestamp).toBeGreaterThan(lcpSpan.start_timestamp); + + expect(lcpSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(lcpSpan.trace_id).toMatch(/^[\da-f]{32}$/); +}); 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..8f5e54e1fba0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/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 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 url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const checkoutSpans = await checkoutSpansPromise; + + const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow'); + const postCheckoutSpan = checkoutSpans.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(5); + + 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 = checkoutSpans.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..1b04553090bc --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts @@ -0,0 +1,53 @@ +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 url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const checkoutSpans = await checkoutSpansPromise; + + const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow'); + const postCheckoutSpan = checkoutSpans.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(5); + + 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 = checkoutSpans.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 879e672b6c87..ff0398d6b209 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -64,7 +64,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/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts new file mode 100644 index 000000000000..cf8c1be967f4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + integrations: [Sentry.spanStreamingIntegration()], + transport: loggingTransport, + release: '1.0.0', +}); + +setupOtel(client); + +Sentry.startSpan({ name: 'test-span', op: 'test' }, segmentSpan => { + Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => { + // noop + }); + + const inactiveSpan = Sentry.startInactiveSpan({ name: 'test-inactive-span' }); + inactiveSpan.addLink({ context: segmentSpan.spanContext(), attributes: { 'sentry.link.type': 'some_relation' } }); + inactiveSpan.end(); + + Sentry.startSpanManual({ name: 'test-manual-span' }, span => { + span.end(); + }); +}); + +void Sentry.flush(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts new file mode 100644 index 000000000000..3184aae69d64 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -0,0 +1,148 @@ +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + 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, +} from '@sentry/core'; +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('sends a streamed span envelope with correct envelope header', async () => { + await createRunner(__dirname, 'scenario.ts') + .expectHeader({ + span: { + sent_at: expect.any(String), + sdk: { + name: 'sentry.javascript.node-core', + version: SDK_VERSION, + }, + trace: expect.objectContaining({ + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + transaction: 'test-span', + }), + }, + }) + .start() + .completed(); +}); + +test('sends a streamed span envelope with correct spans for a manually started span with children', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + span: container => { + const spans = container.items; + expect(spans.length).toBe(4); + + const segmentSpan = spans.find(s => !!s.is_segment); + expect(segmentSpan).toBeDefined(); + + const segmentSpanId = segmentSpan!.span_id; + const traceId = segmentSpan!.trace_id; + + const childSpan = spans.find(s => s.name === 'test-child-span'); + expect(childSpan).toBeDefined(); + expect(childSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'test-child', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' }, + [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_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + name: 'test-child-span', + is_segment: false, + parent_span_id: segmentSpanId, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const inactiveSpan = spans.find(s => s.name === 'test-inactive-span'); + expect(inactiveSpan).toBeDefined(); + expect(inactiveSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' }, + [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_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + links: [ + { + attributes: { + 'sentry.link.type': { + type: 'string', + value: 'some_relation', + }, + }, + sampled: true, + span_id: segmentSpanId, + trace_id: traceId, + }, + ], + name: 'test-inactive-span', + is_segment: false, + parent_span_id: segmentSpanId, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const manualSpan = spans.find(s => s.name === 'test-manual-span'); + expect(manualSpan).toBeDefined(); + expect(manualSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' }, + [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_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + name: 'test-manual-span', + is_segment: false, + parent_span_id: segmentSpanId, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + expect(segmentSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' }, + [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_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + name: 'test-span', + is_segment: true, + trace_id: traceId, + span_id: segmentSpanId, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts new file mode 100644 index 000000000000..048b06f29178 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.getCurrentScope().setPropagationContext({ + parentSpanId: '1234567890123456', + traceId: '12345678901234567890123456789012', + sampleRand: Math.random(), +}); + +const spanIdTraceId = Sentry.startSpan( + { + name: 'test_span_1', + }, + span1 => span1.spanContext().traceId, +); + +Sentry.startSpan( + { + name: 'test_span_2', + attributes: { spanIdTraceId }, + }, + () => undefined, +); + +Sentry.flush(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts new file mode 100644 index 000000000000..f332715bbc42 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts @@ -0,0 +1,32 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('sends manually started streamed parallel root spans in root context', async () => { + expect.assertions(7); + + await createRunner(__dirname, 'scenario.ts') + .expect({ span: { items: [{ name: 'test_span_1' }] } }) + .expect({ + span: spanContainer => { + expect(spanContainer).toBeDefined(); + const traceId = spanContainer.items[0]!.trace_id; + expect(traceId).toMatch(/^[0-9a-f]{32}$/); + + // It ignores propagation context of the root context + expect(traceId).not.toBe('12345678901234567890123456789012'); + expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); + + // Different trace ID than the first span + const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value; + expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + + expect(trace1Id).not.toBe(traceId); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts new file mode 100644 index 000000000000..fd283637b0b6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.withScope(() => { + const spanIdTraceId = Sentry.startSpan( + { + name: 'test_span_1', + }, + span1 => span1.spanContext().traceId, + ); + + Sentry.startSpan( + { + name: 'test_span_2', + attributes: { spanIdTraceId }, + }, + () => undefined, + ); +}); + +Sentry.flush(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts new file mode 100644 index 000000000000..02456c9e93b2 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts @@ -0,0 +1,29 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('sends manually started streamed parallel root spans outside of root context', async () => { + expect.assertions(6); + + await createRunner(__dirname, 'scenario.ts') + .expect({ span: { items: [{ name: 'test_span_1' }] } }) + .expect({ + span: spanContainer => { + expect(spanContainer).toBeDefined(); + const traceId = spanContainer.items[0]!.trace_id; + expect(traceId).toMatch(/^[0-9a-f]{32}$/); + expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); + + const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value; + expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + + // Different trace ID as the first span + expect(trace1Id).not.toBe(traceId); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts new file mode 100644 index 000000000000..367ce6eda6fa --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts @@ -0,0 +1,38 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.withScope(scope => { + scope.setPropagationContext({ + parentSpanId: '1234567890123456', + traceId: '12345678901234567890123456789012', + sampleRand: Math.random(), + }); + + const spanIdTraceId = Sentry.startSpan( + { + name: 'test_span_1', + }, + span1 => span1.spanContext().traceId, + ); + + Sentry.startSpan( + { + name: 'test_span_2', + attributes: { spanIdTraceId }, + }, + () => undefined, + ); +}); + +Sentry.flush(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts new file mode 100644 index 000000000000..325f0f0d5d42 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts @@ -0,0 +1,29 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('sends manually started streamed parallel root spans outside of root context with parentSpanId', async () => { + expect.assertions(6); + + await createRunner(__dirname, 'scenario.ts') + .expect({ span: { items: [{ name: 'test_span_1' }] } }) + .expect({ + span: spanContainer => { + expect(spanContainer).toBeDefined(); + const traceId = spanContainer.items[0]!.trace_id; + expect(traceId).toMatch(/^[0-9a-f]{32}$/); + expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); + + const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value; + expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + + // Different trace ID as the first span + expect(trace1Id).not.toBe(traceId); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts new file mode 100644 index 000000000000..8512fb954b8e --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan( + { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, + (span: Sentry.Span) => { + span.updateName('new name'); + }, +); + +void Sentry.flush(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts new file mode 100644 index 000000000000..09cc140278ed --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts @@ -0,0 +1,26 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node-core'; +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('updates the span name when calling `span.updateName` (streamed)', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + span: { + items: [ + { + name: 'new name', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + }, + }, + ], + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts new file mode 100644 index 000000000000..34892b20d692 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan( + { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, + (span: Sentry.Span) => { + Sentry.updateSpanName(span, 'new name'); + }, +); + +void Sentry.flush(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts new file mode 100644 index 000000000000..8ff4b71ed5e6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts @@ -0,0 +1,26 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node-core'; +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('updates the span name and source when calling `updateSpanName` (streamed)', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + span: { + items: [ + { + name: 'new name', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, + }, + }, + ], + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/utils/assertions.ts b/dev-packages/node-core-integration-tests/utils/assertions.ts index 00c6019fab0c..4f08141d9f93 100644 --- a/dev-packages/node-core-integration-tests/utils/assertions.ts +++ b/dev-packages/node-core-integration-tests/utils/assertions.ts @@ -6,12 +6,19 @@ import type { SerializedLogContainer, SerializedMetricContainer, SerializedSession, + SerializedStreamedSpanContainer, SessionAggregates, TransactionEvent, } from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { expect } from 'vitest'; +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + /** * Asserts against a Sentry Event ignoring non-deterministic properties * @@ -86,6 +93,16 @@ export function assertSentryMetricContainer( }); } +export function assertSentrySpanContainer( + actual: SerializedStreamedSpanContainer, + expected: DeepPartial, +): void { + expect(actual).toMatchObject({ + items: expect.any(Array), + ...expected, + }); +} + export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void { expect(actual).toEqual({ event_id: expect.any(String), @@ -97,3 +114,14 @@ export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void { + expect(actual).toEqual({ + sent_at: expect.any(String), + sdk: { + name: 'sentry.javascript.node-core', + version: SDK_VERSION, + }, + ...expected, + }); +} diff --git a/dev-packages/node-core-integration-tests/utils/runner.ts b/dev-packages/node-core-integration-tests/utils/runner.ts index 416d55d803a1..d27c65fc81be 100644 --- a/dev-packages/node-core-integration-tests/utils/runner.ts +++ b/dev-packages/node-core-integration-tests/utils/runner.ts @@ -9,6 +9,7 @@ import type { SerializedLogContainer, SerializedMetricContainer, SerializedSession, + SerializedStreamedSpanContainer, SessionAggregates, TransactionEvent, } from '@sentry/core'; @@ -17,6 +18,7 @@ import { execSync, spawn, spawnSync } from 'child_process'; import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; import { join } from 'path'; import { afterAll, beforeAll, describe, test } from 'vitest'; +import type { DeepPartial } from './assertions'; import { assertEnvelopeHeader, assertSentryCheckIn, @@ -26,7 +28,9 @@ import { assertSentryMetricContainer, assertSentrySession, assertSentrySessions, + assertSentrySpanContainer, assertSentryTransaction, + assertSpanEnvelopeHeader, } from './assertions'; import { createBasicSentryServer } from './server'; @@ -125,6 +129,9 @@ type ExpectedCheckIn = Partial | ((event: SerializedCheckIn) type ExpectedClientReport = Partial | ((event: ClientReport) => void); type ExpectedLogContainer = Partial | ((event: SerializedLogContainer) => void); type ExpectedMetricContainer = Partial | ((event: SerializedMetricContainer) => void); +type ExpectedSpanContainer = + | DeepPartial + | ((container: SerializedStreamedSpanContainer) => void); type Expected = | { @@ -150,6 +157,9 @@ type Expected = } | { trace_metric: ExpectedMetricContainer; + } + | { + span: ExpectedSpanContainer; }; type ExpectedEnvelopeHeader = @@ -157,7 +167,8 @@ type ExpectedEnvelopeHeader = | { transaction: Partial } | { session: Partial } | { sessions: Partial } - | { log: Partial }; + | { log: Partial } + | { span: Partial }; type StartResult = { completed(): Promise; @@ -360,7 +371,11 @@ export function createRunner(...paths: string[]) { return; } - assertEnvelopeHeader(header, expected); + if (envelopeItemType === 'span') { + assertSpanEnvelopeHeader(header, expected); + } else { + assertEnvelopeHeader(header, expected); + } expectCallbackCalled(); } catch (e) { @@ -412,6 +427,9 @@ export function createRunner(...paths: string[]) { } else if ('trace_metric' in expected) { expectMetric(item[1] as SerializedMetricContainer, expected.trace_metric); expectCallbackCalled(); + } else if ('span' in expected) { + expectSpanContainer(item[1] as SerializedStreamedSpanContainer, expected.span); + expectCallbackCalled(); } else { throw new Error( `Unhandled expected envelope item type: ${JSON.stringify(expected)}\nItem: ${JSON.stringify(item)}`, @@ -666,6 +684,14 @@ function expectMetric(item: SerializedMetricContainer, expected: ExpectedMetricC } } +function expectSpanContainer(item: SerializedStreamedSpanContainer, expected: ExpectedSpanContainer): void { + if (typeof expected === 'function') { + expected(item); + } else { + assertSentrySpanContainer(item, expected); + } +} + /** * Converts ESM import statements to CommonJS require statements * @param content The content of an ESM file diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts new file mode 100644 index 000000000000..ce3f914d51b8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + integrations: [Sentry.spanStreamingIntegration()], + transport: loggingTransport, + release: '1.0.0', +}); + +Sentry.startSpan({ name: 'test-span', op: 'test' }, segmentSpan => { + Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => { + // noop + }); + + const inactiveSpan = Sentry.startInactiveSpan({ + name: 'test-inactive-span', + links: [{ context: segmentSpan.spanContext(), attributes: { 'sentry.link.type': 'some_relation' } }], + }); + inactiveSpan.end(); + + Sentry.startSpanManual({ name: 'test-manual-span' }, span => { + span.end(); + }); +}); + +void Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts new file mode 100644 index 000000000000..b31ca320df53 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -0,0 +1,148 @@ +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + 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, +} from '@sentry/core'; +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('sends a streamed span envelope with correct envelope header', async () => { + await createRunner(__dirname, 'scenario.ts') + .expectHeader({ + span: { + sent_at: expect.any(String), + sdk: { + name: 'sentry.javascript.node', + version: SDK_VERSION, + }, + trace: expect.objectContaining({ + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + transaction: 'test-span', + }), + }, + }) + .start() + .completed(); +}); + +test('sends a streamed span envelope with correct spans for a manually started span with children', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + span: container => { + const spans = container.items; + expect(spans.length).toBe(4); + + const segmentSpan = spans.find(s => !!s.is_segment); + expect(segmentSpan).toBeDefined(); + + const segmentSpanId = segmentSpan!.span_id; + const traceId = segmentSpan!.trace_id; + + const childSpan = spans.find(s => s.name === 'test-child-span'); + expect(childSpan).toBeDefined(); + expect(childSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'test-child', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [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_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + name: 'test-child-span', + is_segment: false, + parent_span_id: segmentSpanId, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const inactiveSpan = spans.find(s => s.name === 'test-inactive-span'); + expect(inactiveSpan).toBeDefined(); + expect(inactiveSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [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_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + links: [ + { + attributes: { + 'sentry.link.type': { + type: 'string', + value: 'some_relation', + }, + }, + sampled: true, + span_id: segmentSpanId, + trace_id: traceId, + }, + ], + name: 'test-inactive-span', + is_segment: false, + parent_span_id: segmentSpanId, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const manualSpan = spans.find(s => s.name === 'test-manual-span'); + expect(manualSpan).toBeDefined(); + expect(manualSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [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_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + name: 'test-manual-span', + is_segment: false, + parent_span_id: segmentSpanId, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + expect(segmentSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [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_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + name: 'test-span', + is_segment: true, + trace_id: traceId, + span_id: segmentSpanId, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts new file mode 100644 index 000000000000..5e8aedc0458a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +Sentry.getCurrentScope().setPropagationContext({ + parentSpanId: '1234567890123456', + traceId: '12345678901234567890123456789012', + sampleRand: Math.random(), +}); + +const spanIdTraceId = Sentry.startSpan( + { + name: 'test_span_1', + }, + span1 => span1.spanContext().traceId, +); + +Sentry.startSpan( + { + name: 'test_span_2', + attributes: { spanIdTraceId }, + }, + () => undefined, +); + +Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts new file mode 100644 index 000000000000..f332715bbc42 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts @@ -0,0 +1,32 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('sends manually started streamed parallel root spans in root context', async () => { + expect.assertions(7); + + await createRunner(__dirname, 'scenario.ts') + .expect({ span: { items: [{ name: 'test_span_1' }] } }) + .expect({ + span: spanContainer => { + expect(spanContainer).toBeDefined(); + const traceId = spanContainer.items[0]!.trace_id; + expect(traceId).toMatch(/^[0-9a-f]{32}$/); + + // It ignores propagation context of the root context + expect(traceId).not.toBe('12345678901234567890123456789012'); + expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); + + // Different trace ID than the first span + const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value; + expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + + expect(trace1Id).not.toBe(traceId); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts new file mode 100644 index 000000000000..d688d3c3adf7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +Sentry.withScope(() => { + const spanIdTraceId = Sentry.startSpan( + { + name: 'test_span_1', + }, + span1 => span1.spanContext().traceId, + ); + + Sentry.startSpan( + { + name: 'test_span_2', + attributes: { spanIdTraceId }, + }, + () => undefined, + ); +}); + +Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts new file mode 100644 index 000000000000..02456c9e93b2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts @@ -0,0 +1,29 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('sends manually started streamed parallel root spans outside of root context', async () => { + expect.assertions(6); + + await createRunner(__dirname, 'scenario.ts') + .expect({ span: { items: [{ name: 'test_span_1' }] } }) + .expect({ + span: spanContainer => { + expect(spanContainer).toBeDefined(); + const traceId = spanContainer.items[0]!.trace_id; + expect(traceId).toMatch(/^[0-9a-f]{32}$/); + expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); + + const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value; + expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + + // Different trace ID as the first span + expect(trace1Id).not.toBe(traceId); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts new file mode 100644 index 000000000000..64f194549572 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +Sentry.withScope(scope => { + scope.setPropagationContext({ + parentSpanId: '1234567890123456', + traceId: '12345678901234567890123456789012', + sampleRand: Math.random(), + }); + + const spanIdTraceId = Sentry.startSpan( + { + name: 'test_span_1', + }, + span1 => span1.spanContext().traceId, + ); + + Sentry.startSpan( + { + name: 'test_span_2', + attributes: { spanIdTraceId }, + }, + () => undefined, + ); +}); + +Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts new file mode 100644 index 000000000000..325f0f0d5d42 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts @@ -0,0 +1,29 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('sends manually started streamed parallel root spans outside of root context with parentSpanId', async () => { + expect.assertions(6); + + await createRunner(__dirname, 'scenario.ts') + .expect({ span: { items: [{ name: 'test_span_1' }] } }) + .expect({ + span: spanContainer => { + expect(spanContainer).toBeDefined(); + const traceId = spanContainer.items[0]!.trace_id; + expect(traceId).toMatch(/^[0-9a-f]{32}$/); + expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); + + const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value; + expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + + // Different trace ID as the first span + expect(trace1Id).not.toBe(traceId); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts new file mode 100644 index 000000000000..2cbbaef888ae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +Sentry.startSpan( + { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, + (span: Sentry.Span) => { + span.updateName('new name'); + }, +); + +void Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts new file mode 100644 index 000000000000..f9d15cf60e30 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts @@ -0,0 +1,26 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node'; +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('updates the span name when calling `span.updateName` (streamed)', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + span: { + items: [ + { + name: 'new name', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + }, + }, + ], + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts new file mode 100644 index 000000000000..93d653d107aa --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +Sentry.startSpan( + { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, + (span: Sentry.Span) => { + Sentry.updateSpanName(span, 'new name'); + }, +); + +void Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts new file mode 100644 index 000000000000..ace2f0ca0d76 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts @@ -0,0 +1,26 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node'; +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('updates the span name and source when calling `updateSpanName` (streamed)', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + span: { + items: [ + { + name: 'new name', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, + }, + }, + ], + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/instrument.mjs new file mode 100644 index 000000000000..04f3908530c0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/instrument.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + ignoreSpans: ['middleware - expressInit', /custom-to-drop/, { op: 'ignored-op' }], + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/server.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/server.mjs new file mode 100644 index 000000000000..a79ef373ec11 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/server.mjs @@ -0,0 +1,45 @@ +import express from 'express'; +import cors from 'cors'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + Sentry.startSpan( + { + name: 'custom-to-drop', + op: 'custom', + }, + () => { + Sentry.startSpan( + { + name: 'custom', + op: 'custom', + }, + () => { + Sentry.startSpan({ name: 'custom-grandchild', op: 'custom' }, () => { + Sentry.startSpan({ name: 'custom-to-drop-grand-grandchild', op: 'custom' }, () => { + Sentry.startSpan({ name: 'custom-grand-grand-grandchild', op: 'custom' }, () => {}); + }); + }); + Sentry.startSpan({ name: 'custom-grandchild-2', op: 'custom' }, () => {}); + }, + ); + }, + ); + + Sentry.startSpan({ name: 'name-passes-but-op-not-span-1', op: 'ignored-op' }, () => {}); + Sentry.startSpan( + // sentry.op attribute has precedence over top op argument + { name: 'name-passes-but-op-not-span-2', op: 'keep', attributes: { 'sentry.op': 'ignored-op' } }, + () => {}, + ); + res.send({ response: 'response 1' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/test.ts new file mode 100644 index 000000000000..263f8f74c1c3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/test.ts @@ -0,0 +1,69 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; + +describe('filtering child spans with ignoreSpans (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('child spans are dropped and remaining spans correctly parented', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'span', + quantity: 5, + reason: 'ignored', + }, + ], + }, + }) + .expect({ + span: container => { + // 5 spans: 1 root, 2 middleware, 1 request handler, 1 custom + // Would be 7 if we didn't ignore the 'middleware - expressInit' and 'custom-to-drop' spans + expect(container.items).toHaveLength(8); + const getSpan = (name: string, op: string) => + container.items.find( + item => item.name === name && item.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value === op, + ); + const queryMiddlewareSpan = getSpan('query', 'middleware.express'); + const corsMiddlewareSpan = getSpan('corsMiddleware', 'middleware.express'); + const requestHandlerSpan = getSpan('/test/express', 'request_handler.express'); + const httpServerSpan = getSpan('GET /test/express', 'http.server'); + const customSpan = getSpan('custom', 'custom'); + const customGrandchildSpan = getSpan('custom-grandchild', 'custom'); + const customGrandchild2Span = getSpan('custom-grandchild-2', 'custom'); + const customGrandGrandGrandChildSpan = getSpan('custom-grand-grand-grandchild', 'custom'); + + expect(queryMiddlewareSpan).toBeDefined(); + expect(corsMiddlewareSpan).toBeDefined(); + expect(requestHandlerSpan).toBeDefined(); + expect(httpServerSpan).toBeDefined(); + expect(customSpan).toBeDefined(); + expect(customGrandchildSpan).toBeDefined(); + expect(customGrandchild2Span).toBeDefined(); + expect(customGrandGrandGrandChildSpan).toBeDefined(); + + expect(customGrandchildSpan?.parent_span_id).toBe(customSpan?.span_id); + expect(customGrandchild2Span?.parent_span_id).toBe(customSpan?.span_id); + expect(customGrandGrandGrandChildSpan?.parent_span_id).toBe(customGrandchildSpan?.span_id); + expect(customSpan?.parent_span_id).toBe(requestHandlerSpan?.span_id); + expect(requestHandlerSpan?.parent_span_id).toBe(httpServerSpan?.span_id); + expect(queryMiddlewareSpan?.parent_span_id).toBe(httpServerSpan?.span_id); + expect(corsMiddlewareSpan?.parent_span_id).toBe(httpServerSpan?.span_id); + expect(httpServerSpan?.parent_span_id).toBeUndefined(); + }, + }) + .start(); + + runner.makeRequest('get', '/test/express'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/instrument.mjs new file mode 100644 index 000000000000..4d14e615745b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/instrument.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + ignoreSpans: [/\/health/], + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/server.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/server.mjs new file mode 100644 index 000000000000..f9c7f136aef2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/server.mjs @@ -0,0 +1,20 @@ +import express from 'express'; +import cors from 'cors'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +app.use(cors()); + +app.get('/health', (_req, res) => { + res.send({ status: 'ok-health' }); +}); + +app.get('/ok', (_req, res) => { + res.send({ status: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/test.ts new file mode 100644 index 000000000000..538c9c77a532 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/test.ts @@ -0,0 +1,45 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('filtering segment spans with ignoreSpans (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('segment spans matching ignoreSpans are dropped including all children', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'span', + quantity: 5, // 1 segment ignored + 4 child spans (implicitly ignored) + reason: 'ignored', + }, + ], + }, + }) + .expect({ + span: container => { + expect(container.items).toHaveLength(5); + const segmentSpan = container.items.find(s => s.name === 'GET /ok' && !!s.is_segment); + + expect(segmentSpan).toBeDefined(); + expect(container.items.every(s => s.trace_id === segmentSpan!.trace_id)).toBe(true); + }, + }) + + .start(); + + const res = await runner.makeRequest('get', '/health'); + expect((res as { status: string }).status).toBe('ok-health'); + + const res2 = await runner.makeRequest('get', '/ok'); // contains all spans + expect((res2 as { status: string }).status).toBe('ok'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-static/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-static/instrument.mjs new file mode 100644 index 000000000000..cbf0bbf2e2fc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-static/instrument.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampler: ({ inheritOrSampleWith, name }) => { + if (name === 'GET /health') { + return inheritOrSampleWith(0); + } + return inheritOrSampleWith(1); + }, + transport: loggingTransport, + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-static/server.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-static/server.mjs new file mode 100644 index 000000000000..f9c7f136aef2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-static/server.mjs @@ -0,0 +1,20 @@ +import express from 'express'; +import cors from 'cors'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +app.use(cors()); + +app.get('/health', (_req, res) => { + res.send({ status: 'ok-health' }); +}); + +app.get('/ok', (_req, res) => { + res.send({ status: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts b/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts new file mode 100644 index 000000000000..8eb57ccdd54e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts @@ -0,0 +1,40 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('negative sampling (static)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('records sample_rate outcome for root span/transaction', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + transaction: { + transaction: 'GET /ok', + }, + }) + .expect({ + client_report: { + discarded_events: [ + { + category: 'transaction', + quantity: 1, + reason: 'sample_rate', + }, + ], + }, + }) + .start(); + + const res = await runner.makeRequest('get', '/health'); + expect((res as { status: string }).status).toBe('ok-health'); + + const res2 = await runner.makeRequest('get', '/ok'); // contains all spans + expect((res2 as { status: string }).status).toBe('ok'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/instrument.mjs new file mode 100644 index 000000000000..713a676ede3d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/instrument.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampler: ({ inheritOrSampleWith, name }) => { + if (name === 'GET /health') { + return inheritOrSampleWith(0); + } + return inheritOrSampleWith(1); + }, + transport: loggingTransport, + traceLifecycle: 'stream', + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/server.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/server.mjs new file mode 100644 index 000000000000..f9c7f136aef2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/server.mjs @@ -0,0 +1,20 @@ +import express from 'express'; +import cors from 'cors'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +app.use(cors()); + +app.get('/health', (_req, res) => { + res.send({ status: 'ok-health' }); +}); + +app.get('/ok', (_req, res) => { + res.send({ status: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/test.ts new file mode 100644 index 000000000000..d98d2f82afb0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/test.ts @@ -0,0 +1,44 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('negative sampling (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('records sample_rate outcome for segment and child spans of negatively sampled segment', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'span', + quantity: 5, // 1 segment ignored + 4 child spans (implicitly ignored) + reason: 'sample_rate', + }, + ], + }, + }) + .expect({ + span: container => { + expect(container.items).toHaveLength(5); + const segmentSpan = container.items.find(s => s.name === 'GET /ok' && !!s.is_segment); + + expect(segmentSpan).toBeDefined(); + expect(container.items.every(s => s.trace_id === segmentSpan!.trace_id)).toBe(true); + }, + }) + .start(); + + const res = await runner.makeRequest('get', '/health'); + expect((res as { status: string }).status).toBe('ok-health'); + + const res2 = await runner.makeRequest('get', '/ok'); // contains all spans + expect((res2 as { status: string }).status).toBe('ok'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/utils/assertions.ts b/dev-packages/node-integration-tests/utils/assertions.ts index 8d9fb5f2251f..b6c45ebc043c 100644 --- a/dev-packages/node-integration-tests/utils/assertions.ts +++ b/dev-packages/node-integration-tests/utils/assertions.ts @@ -6,12 +6,19 @@ import type { SerializedLogContainer, SerializedMetricContainer, SerializedSession, + SerializedStreamedSpanContainer, SessionAggregates, TransactionEvent, } from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { expect } from 'vitest'; +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + /** * Asserts against a Sentry Event ignoring non-deterministic properties * @@ -86,6 +93,16 @@ export function assertSentryMetricContainer( }); } +export function assertSentrySpanContainer( + actual: SerializedStreamedSpanContainer, + expected: DeepPartial, +): void { + expect(actual).toMatchObject({ + items: expect.any(Array), + ...expected, + }); +} + export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void { expect(actual).toEqual({ event_id: expect.any(String), @@ -97,3 +114,14 @@ export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void { + expect(actual).toEqual({ + sent_at: expect.any(String), + sdk: { + name: 'sentry.javascript.node', + version: SDK_VERSION, + }, + ...expected, + }); +} diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index ee2fae0bc06b..7690fa40ee8b 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -9,6 +9,7 @@ import type { SerializedLogContainer, SerializedMetricContainer, SerializedSession, + SerializedStreamedSpanContainer, SessionAggregates, TransactionEvent, } from '@sentry/core'; @@ -20,6 +21,7 @@ import { cp, mkdir, readFile, rm, writeFile } from 'fs/promises'; import { basename, join } from 'path'; import { inspect, promisify } from 'util'; import { afterAll, beforeAll, describe, test } from 'vitest'; +import type { DeepPartial } from './assertions'; import { assertEnvelopeHeader, assertSentryCheckIn, @@ -29,7 +31,9 @@ import { assertSentryMetricContainer, assertSentrySession, assertSentrySessions, + assertSentrySpanContainer, assertSentryTransaction, + assertSpanEnvelopeHeader, } from './assertions'; const execPromise = promisify(exec); @@ -135,6 +139,9 @@ type ExpectedCheckIn = Partial | ((event: SerializedCheckIn) type ExpectedClientReport = Partial | ((event: ClientReport) => void); type ExpectedLogContainer = Partial | ((event: SerializedLogContainer) => void); type ExpectedMetricContainer = Partial | ((event: SerializedMetricContainer) => void); +type ExpectedSpanContainer = + | DeepPartial + | ((container: SerializedStreamedSpanContainer) => void); type Expected = | { @@ -160,6 +167,9 @@ type Expected = } | { trace_metric: ExpectedMetricContainer; + } + | { + span: ExpectedSpanContainer; }; type ExpectedEnvelopeHeader = @@ -167,7 +177,8 @@ type ExpectedEnvelopeHeader = | { transaction: Partial } | { session: Partial } | { sessions: Partial } - | { log: Partial }; + | { log: Partial } + | { span: Partial }; type StartResult = { completed(): Promise; @@ -479,7 +490,11 @@ export function createRunner(...paths: string[]) { return; } - assertEnvelopeHeader(header, expected); + if (envelopeItemType === 'span') { + assertSpanEnvelopeHeader(header, expected); + } else { + assertEnvelopeHeader(header, expected); + } expectCallbackCalled(); } catch (e) { @@ -531,6 +546,9 @@ export function createRunner(...paths: string[]) { } else if ('trace_metric' in expected) { expectMetric(item[1] as SerializedMetricContainer, expected.trace_metric); expectCallbackCalled(); + } else if ('span' in expected) { + expectSpanContainer(item[1] as SerializedStreamedSpanContainer, expected.span); + expectCallbackCalled(); } else { throw new Error( `Unhandled expected envelope item type: ${JSON.stringify(expected)}\nItem: ${JSON.stringify(item)}`, @@ -797,6 +815,14 @@ function expectMetric(item: SerializedMetricContainer, expected: ExpectedMetricC } } +function expectSpanContainer(item: SerializedStreamedSpanContainer, expected: ExpectedSpanContainer): void { + if (typeof expected === 'function') { + expected(item); + } else { + assertSentrySpanContainer(item, expected); + } +} + /** * Converts ESM import statements to CommonJS require statements * @param content The content of an ESM file diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 1b893f885e87..fb03d5fc57af 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -173,6 +173,7 @@ export { statsigIntegration, unleashIntegration, growthbookIntegration, + spanStreamingIntegration, metrics, } from '@sentry/node'; diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts index 2e51ab1b0f1e..8dfffdcbd852 100644 --- a/packages/astro/src/index.types.ts +++ b/packages/astro/src/index.types.ts @@ -20,6 +20,7 @@ export declare function init(options: Options | clientSdk.BrowserOptions | NodeO export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; +export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 8cf0a93d01bd..a5044bc6b29e 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -160,6 +160,7 @@ export { unleashIntegration, growthbookIntegration, metrics, + spanStreamingIntegration, } from '@sentry/node'; export { diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 2b2d4b7f9397..888524ed7c21 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -20,6 +20,8 @@ export { elementTimingIntegration, startTrackingElementTiming } from './metrics/ export { extractNetworkProtocol } from './metrics/utils'; +export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './metrics/webVitalSpans'; + export { addClickKeypressInstrumentationHandler } from './instrument/dom'; export { addHistoryInstrumentationHandler } from './instrument/history'; diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 28d1f2bfaec8..1e6c974b543d 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -75,8 +75,18 @@ let _lcpEntry: LargestContentfulPaint | undefined; let _clsEntry: LayoutShift | undefined; interface StartTrackingWebVitalsOptions { - recordClsStandaloneSpans: boolean; - recordLcpStandaloneSpans: boolean; + /** + * When `true`, CLS is tracked as a standalone span. When `false`, CLS is + * recorded as a measurement on the pageload span. When `undefined`, CLS + * tracking is skipped entirely (e.g. because span streaming handles it). + */ + recordClsStandaloneSpans: boolean | undefined; + /** + * When `true`, LCP is tracked as a standalone span. When `false`, LCP is + * recorded as a measurement on the pageload span. When `undefined`, LCP + * tracking is skipped entirely (e.g. because span streaming handles it). + */ + recordLcpStandaloneSpans: boolean | undefined; client: Client; } @@ -97,9 +107,22 @@ export function startTrackingWebVitals({ if (performance.mark) { WINDOW.performance.mark('sentry-tracing-init'); } - const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : _trackLCP(); + + const lcpCleanupCallback = + recordLcpStandaloneSpans === true + ? trackLcpAsStandaloneSpan(client) + : recordLcpStandaloneSpans === false + ? _trackLCP() + : undefined; + + const clsCleanupCallback = + recordClsStandaloneSpans === true + ? trackClsAsStandaloneSpan(client) + : recordClsStandaloneSpans === false + ? _trackCLS() + : undefined; + const ttfbCleanupCallback = _trackTtfb(); - const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : _trackCLS(); return (): void => { lcpCleanupCallback?.(); diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index 831565f07408..b348d8195c84 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -37,7 +37,7 @@ const ELEMENT_NAME_TIMESTAMP_MAP = new Map(); * 60 seconds is the maximum for a plausible INP value * (source: Me) */ -const MAX_PLAUSIBLE_INP_DURATION = 60; +export const MAX_PLAUSIBLE_INP_DURATION = 60; /** * Start tracking INP webvital events. */ @@ -54,7 +54,7 @@ export function startTrackingINP(): () => void { return () => undefined; } -const INP_ENTRY_MAP: Record = { +export const INP_ENTRY_MAP: Record = { click: 'click', pointerdown: 'click', pointerup: 'click', diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 4c461ec6776c..5dc9d78f4ce8 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -114,7 +114,6 @@ let _previousCls: Metric | undefined; let _previousLcp: Metric | undefined; let _previousTtfb: Metric | undefined; let _previousInp: Metric | undefined; - /** * Add a callback that will be triggered when a CLS metric is available. * Returns a cleanup callback which can be called to remove the instrumentation handler. diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts new file mode 100644 index 000000000000..deeec8ede191 --- /dev/null +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -0,0 +1,266 @@ +import type { Client, SpanAttributes } from '@sentry/core'; +import { + browserPerformanceTimeOrigin, + debug, + getActiveSpan, + getCurrentScope, + getRootSpan, + htmlTreeAsString, + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, + startInactiveSpan, + timestampInSeconds, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../types'; +import { INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp'; +import type { InstrumentationHandlerCallback } from './instrument'; +import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler } from './instrument'; +import { listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils'; + +interface WebVitalSpanOptions { + name: string; + op: string; + origin: string; + metricName: string; + value: number; + unit: string; + attributes?: SpanAttributes; + pageloadSpanId?: string; + startTime: number; + endTime?: number; +} + +/** + * Emits a web vital span that flows through the span streaming pipeline. + */ +export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { + const { + name, + op, + origin, + metricName, + value, + unit, + attributes: passedAttributes, + pageloadSpanId, + startTime, + endTime, + } = options; + + const routeName = getCurrentScope().getScopeData().transactionName; + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, + [`browser.web_vital.${metricName}.value`]: value, + transaction: routeName, + // Web vital score calculation relies on the user agent + 'user_agent.original': WINDOW.navigator?.userAgent, + ...passedAttributes, + }; + + if (pageloadSpanId) { + attributes['sentry.pageload.span_id'] = pageloadSpanId; + } + + const span = startInactiveSpan({ + name, + attributes, + startTime, + }); + + if (span) { + span.addEvent(metricName, { + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: unit, + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: value, + }); + + span.end(endTime ?? startTime); + } +} + +/** + * Tracks LCP as a streamed span. + */ +export function trackLcpAsSpan(client: Client): void { + let lcpValue = 0; + let lcpEntry: LargestContentfulPaint | undefined; + + if (!supportsWebVital('largest-contentful-paint')) { + return; + } + + const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; + if (!entry) { + return; + } + lcpValue = metric.value; + lcpEntry = entry; + }, true); + + listenForWebVitalReportEvents(client, (_reportEvent, pageloadSpanId) => { + _sendLcpSpan(lcpValue, lcpEntry, pageloadSpanId); + cleanupLcpHandler(); + }); +} + +/** + * Exported only for testing. + */ +export function _sendLcpSpan( + lcpValue: number, + entry: LargestContentfulPaint | undefined, + pageloadSpanId: string, +): void { + DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`); + + const performanceTimeOrigin = browserPerformanceTimeOrigin() || 0; + const timeOrigin = msToSec(performanceTimeOrigin); + const endTime = msToSec(performanceTimeOrigin + (entry?.startTime || 0)); + const name = entry ? htmlTreeAsString(entry.element) : 'Largest contentful paint'; + + const attributes: SpanAttributes = {}; + + if (entry) { + entry.element && (attributes['browser.web_vital.lcp.element'] = htmlTreeAsString(entry.element)); + entry.id && (attributes['browser.web_vital.lcp.id'] = entry.id); + entry.url && (attributes['browser.web_vital.lcp.url'] = entry.url); + entry.loadTime != null && (attributes['browser.web_vital.lcp.load_time'] = entry.loadTime); + entry.renderTime != null && (attributes['browser.web_vital.lcp.render_time'] = entry.renderTime); + entry.size != null && (attributes['browser.web_vital.lcp.size'] = entry.size); + } + + _emitWebVitalSpan({ + name, + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', + value: lcpValue, + unit: 'millisecond', + attributes, + pageloadSpanId, + startTime: timeOrigin, + endTime, + }); +} + +/** + * Tracks CLS as a streamed span. + */ +export function trackClsAsSpan(client: Client): void { + let clsValue = 0; + let clsEntry: LayoutShift | undefined; + + if (!supportsWebVital('layout-shift')) { + return; + } + + const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; + if (!entry) { + return; + } + clsValue = metric.value; + clsEntry = entry; + }, true); + + listenForWebVitalReportEvents(client, (_reportEvent, pageloadSpanId) => { + _sendClsSpan(clsValue, clsEntry, pageloadSpanId); + cleanupClsHandler(); + }); +} + +/** + * Exported only for testing. + */ +export function _sendClsSpan(clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string): void { + DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`); + + const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds(); + const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift'; + + const attributes: SpanAttributes = {}; + + if (entry?.sources) { + entry.sources.forEach((source, index) => { + attributes[`browser.web_vital.cls.source.${index + 1}`] = htmlTreeAsString(source.node); + }); + } + + _emitWebVitalSpan({ + name, + op: 'ui.webvital.cls', + origin: 'auto.http.browser.cls', + metricName: 'cls', + value: clsValue, + unit: '', + attributes, + pageloadSpanId, + startTime, + }); +} + +/** + * Tracks INP as a streamed span. + */ +export function trackInpAsSpan(_client: Client): void { + const onInp: InstrumentationHandlerCallback = ({ metric }) => { + if (metric.value == null) { + return; + } + + // Guard against unrealistically long INP values (matching standalone INP handler) + if (msToSec(metric.value) > MAX_PLAUSIBLE_INP_DURATION) { + return; + } + + const entry = metric.entries.find(e => e.duration === metric.value && INP_ENTRY_MAP[e.name]); + + if (!entry) { + return; + } + + _sendInpSpan(metric.value, entry); + }; + + addInpInstrumentationHandler(onInp); +} + +/** + * Exported only for testing. + */ +export function _sendInpSpan( + inpValue: number, + entry: { name: string; startTime: number; duration: number; target?: unknown | null }, +): void { + DEBUG_BUILD && debug.log(`Sending INP span (${inpValue})`); + + const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime); + const interactionType = INP_ENTRY_MAP[entry.name]; + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + const routeName = rootSpan ? spanToJSON(rootSpan).description : getCurrentScope().getScopeData().transactionName; + const name = htmlTreeAsString(entry.target); + + _emitWebVitalSpan({ + name, + op: `ui.interaction.${interactionType}`, + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: inpValue, + unit: 'millisecond', + attributes: { + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration, + transaction: routeName, + }, + startTime, + endTime: startTime + msToSec(entry.duration), + }); +} diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts new file mode 100644 index 000000000000..44f91a779b64 --- /dev/null +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -0,0 +1,377 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan } from '../../src/metrics/webVitalSpans'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + browserPerformanceTimeOrigin: vi.fn(), + timestampInSeconds: vi.fn(), + getCurrentScope: vi.fn(), + htmlTreeAsString: vi.fn(), + startInactiveSpan: vi.fn(), + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + spanToJSON: vi.fn(), + }; +}); + +// Mock WINDOW +vi.mock('../../src/types', () => ({ + WINDOW: { + navigator: { userAgent: 'test-user-agent' }, + performance: { + getEntriesByType: vi.fn().mockReturnValue([]), + }, + }, +})); + +describe('_emitWebVitalSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-transaction', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('creates a non-standalone span with correct attributes', () => { + _emitWebVitalSpan({ + name: 'Test Vital', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 100, + unit: 'millisecond', + startTime: 1.5, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({ + name: 'Test Vital', + attributes: { + 'sentry.origin': 'auto.http.browser.test', + 'sentry.op': 'ui.webvital.test', + 'sentry.exclusive_time': 0, + 'browser.web_vital.test.value': 100, + transaction: 'test-transaction', + 'user_agent.original': 'test-user-agent', + }, + startTime: 1.5, + }); + + // No standalone flag + expect(SentryCore.startInactiveSpan).not.toHaveBeenCalledWith( + expect.objectContaining({ experimental: expect.anything() }), + ); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('test', { + 'sentry.measurement_unit': 'millisecond', + 'sentry.measurement_value': 100, + }); + + expect(mockSpan.end).toHaveBeenCalledWith(1.5); + }); + + it('includes pageloadSpanId when provided', () => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 50, + unit: 'millisecond', + pageloadSpanId: 'abc123', + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.pageload.span_id': 'abc123', + }), + }), + ); + }); + + it('merges additional attributes', () => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 50, + unit: 'millisecond', + attributes: { 'custom.attr': 'value' }, + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'custom.attr': 'value', + }), + }), + ); + }); + + it('handles when startInactiveSpan returns undefined', () => { + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(undefined as any); + + expect(() => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 50, + unit: 'millisecond', + startTime: 1.0, + }); + }).not.toThrow(); + }); +}); + +describe('_sendLcpSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sends a streamed LCP span with entry data', () => { + const mockEntry = { + element: { tagName: 'img' } as Element, + id: 'hero', + url: 'https://example.com/hero.jpg', + loadTime: 100, + renderTime: 150, + size: 50000, + startTime: 200, + } as LargestContentfulPaint; + + _sendLcpSpan(250, mockEntry, 'pageload-123'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.op': 'ui.webvital.lcp', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': 'pageload-123', + 'browser.web_vital.lcp.element': '', + 'browser.web_vital.lcp.id': 'hero', + 'browser.web_vital.lcp.url': 'https://example.com/hero.jpg', + 'browser.web_vital.lcp.load_time': 100, + 'browser.web_vital.lcp.render_time': 150, + 'browser.web_vital.lcp.size': 50000, + }), + startTime: 1, // timeOrigin: 1000 / 1000 + }), + ); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('lcp', { + 'sentry.measurement_unit': 'millisecond', + 'sentry.measurement_value': 250, + }); + + // endTime = timeOrigin + entry.startTime = (1000 + 200) / 1000 = 1.2 + expect(mockSpan.end).toHaveBeenCalledWith(1.2); + }); + + it('sends a streamed LCP span without entry data', () => { + _sendLcpSpan(0, undefined, 'pageload-456'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Largest contentful paint', + startTime: 1, // timeOrigin: 1000 / 1000 + }), + ); + }); +}); + +describe('_sendClsSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); + vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sends a streamedCLS span with entry data and sources', () => { + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 100, + duration: 0, + value: 0.1, + hadRecentInput: false, + sources: [ + // @ts-expect-error - other properties are irrelevant + { node: { tagName: 'div' } as Element }, + // @ts-expect-error - other properties are irrelevant + { node: { tagName: 'span' } as Element }, + ], + toJSON: vi.fn(), + }; + + vi.mocked(SentryCore.htmlTreeAsString) + .mockReturnValueOnce('
') // for the name + .mockReturnValueOnce('
') // for source 1 + .mockReturnValueOnce(''); // for source 2 + + _sendClsSpan(0.1, mockEntry, 'pageload-789'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '
', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.pageload.span_id': 'pageload-789', + 'browser.web_vital.cls.source.1': '
', + 'browser.web_vital.cls.source.2': '', + }), + }), + ); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', { + 'sentry.measurement_unit': '', + 'sentry.measurement_value': 0.1, + }); + }); + + it('sends a streamedCLS span without entry data', () => { + _sendClsSpan(0, undefined, 'pageload-000'); + + expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Layout shift', + startTime: 1.5, + }), + ); + }); +}); + +describe('_sendInpSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.htmlTreeAsString).mockReturnValue('