diff --git a/.size-limit.js b/.size-limit.js index baded21f5200..aef521dc83f6 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 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: '133 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: '53 KB', + limit: '55 KB', }, // Node SDK (ESM) { @@ -326,14 +326,14 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '175 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: '116 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..73b4bea99e22 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts @@ -0,0 +1,97 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { extractTraceparentData, parseBaggageHeader } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core/src'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + testingCdnBundle, + waitForClientReportRequest, + waitForTracingHeadersOnUrl, +} from '../../../../../../utils/helpers'; +import { observeStreamedSpan } from '../../../../../../utils/spanUtils'; + +const metaTagSampleRand = 0.9; +const metaTagSampleRate = 0.2; +const metaTagTraceId = '12345678901234567890123456789012'; + +sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => { + sentryTest( + 'Continues negative sampling decision from meta tag across all traces and downstream propagations', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansReceived: SerializedStreamedSpan[] = []; + observeStreamedSpan(page, span => { + spansReceived.push(span); + return false; + }); + + const clientReportPromise = waitForClientReportRequest(page); + + await sentryTest.step('Initial pageload', async () => { + await page.goto(url); + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Custom instrumented button click', async () => { + await page.locator('#btn1').click(); + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Navigation', async () => { + await page.goto(`${url}#foo`); + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Make fetch request', async () => { + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io'); + + await page.locator('#btn2').click(); + const { baggage, sentryTrace } = await tracingHeadersPromise; + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: expect.not.stringContaining(metaTagTraceId), + parentSpanId: expect.stringMatching(/^[\da-f]{16}$/), + parentSampled: false, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${metaTagSampleRand}`, + 'sentry-sample_rate': `${metaTagSampleRate}`, + 'sentry-sampled': 'false', + 'sentry-trace_id': expect.not.stringContaining(metaTagTraceId), + 'sentry-transaction': 'custom root span 2', + }); + + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Client report', async () => { + await hidePage(page); + const clientReport = envelopeRequestParser(await clientReportPromise); + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 4, + reason: 'sample_rate', + }, + ], + }); + }); + + expect(spansReceived).toHaveLength(0); + }, + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js new file mode 100644 index 000000000000..177fe4c4aeaf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'session-storage', + consistentTraceSampling: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracePropagationTargets: ['sentry-test-external.io'], + tracesSampler: ({ inheritOrSampleWith }) => { + return inheritOrSampleWith(0); + }, + debug: true, + sendClientReports: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html new file mode 100644 index 000000000000..9a0719b7e505 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html @@ -0,0 +1,15 @@ + + + + + + + + +

Another Page

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

Another Page

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

My Heading

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

Heading

+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts new file mode 100644 index 000000000000..74ce32706584 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts @@ -0,0 +1,29 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + "doesn't capture long task spans starting before a navigation in the navigation transaction", + async ({ browserName, getLocalTestUrl, page }) => { + // Long tasks only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/path/to/script.js', route => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const navigationSpansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'navigation')); + + await page.goto(url); + + await page.locator('#myButton').click(); + + const spans = await navigationSpansPromise; + + const navigationSpan = spans.find(s => getSpanOp(s) === 'navigation'); + expect(navigationSpan).toBeDefined(); + + const longTaskSpans = spans.filter(s => getSpanOp(s) === 'ui.long-task'); + expect(longTaskSpans).toHaveLength(0); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js new file mode 100644 index 000000000000..195a094070be --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js @@ -0,0 +1,12 @@ +(() => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 101) { + // + } +})(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js new file mode 100644 index 000000000000..965613d5464e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html new file mode 100644 index 000000000000..b03231da2c65 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html @@ -0,0 +1,10 @@ + + + + + + +
Rendered Before Long Task
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts new file mode 100644 index 000000000000..83600f5d4a6a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts @@ -0,0 +1,23 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest("doesn't capture long task spans when flag is disabled.", async ({ browserName, getLocalTestUrl, page }) => { + // Long tasks only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui')); + + expect(uiSpans.length).toBe(0); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js new file mode 100644 index 000000000000..b61592e05943 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js @@ -0,0 +1,12 @@ +(() => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 105) { + // + } +})(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js new file mode 100644 index 000000000000..484350c14fcf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 2000, + enableLongAnimationFrame: false, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html new file mode 100644 index 000000000000..b03231da2c65 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html @@ -0,0 +1,10 @@ + + + + + + +
Rendered Before Long Task
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts new file mode 100644 index 000000000000..8b73aa91dff6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts @@ -0,0 +1,42 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('captures long task.', async ({ browserName, getLocalTestUrl, page }) => { + // Long tasks only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!; + + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui')); + expect(uiSpans.length).toBeGreaterThan(0); + + const [firstUISpan] = uiSpans; + expect(firstUISpan).toEqual( + expect.objectContaining({ + name: 'Main UI thread blocked', + parent_span_id: pageloadSpan.span_id, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'ui.long-task' }, + }), + }), + ); + + const start = firstUISpan.start_timestamp ?? 0; + const end = firstUISpan.end_timestamp ?? 0; + const duration = end - start; + + expect(duration).toBeGreaterThanOrEqual(0.1); + expect(duration).toBeLessThanOrEqual(0.15); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js new file mode 100644 index 000000000000..a93fc742bafb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts new file mode 100644 index 000000000000..7128d2d5ecce --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts @@ -0,0 +1,219 @@ +import { expect } from '@playwright/test'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { + getSpanOp, + getSpansFromEnvelope, + waitForStreamedSpan, + waitForStreamedSpanEnvelope, +} from '../../../../utils/spanUtils'; + +sentryTest('starts a streamed navigation span on page navigation', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'navigation'), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + // simulate navigation + page.goto(`${url}#foo`); + + const navigationSpanEnvelope = await navigationSpanEnvelopePromise; + + const navigationSpanEnvelopeHeader = navigationSpanEnvelope[0]; + const navigationSpanEnvelopeItem = navigationSpanEnvelope[1]; + const navigationSpans = navigationSpanEnvelopeItem[0][1].items; + const navigationSpan = navigationSpans.find(s => getSpanOp(s) === 'navigation')!; + + expect(navigationSpanEnvelopeHeader).toEqual({ + sent_at: expect.any(String), + trace: { + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + environment: 'production', + public_key: 'public', + sample_rand: expect.any(String), + sample_rate: '1', + sampled: 'true', + }, + sdk: { + name: 'sentry.javascript.browser', + version: SDK_VERSION, + }, + }); + + const numericSampleRand = parseFloat(navigationSpanEnvelopeHeader.trace!.sample_rand!); + expect(Number.isNaN(numericSampleRand)).toBe(false); + + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; + + expect(pageloadTraceId).toBeDefined(); + expect(navigationTraceId).toBeDefined(); + expect(pageloadTraceId).not.toEqual(navigationTraceId); + + expect(pageloadSpan.name).toEqual('/index.html'); + + expect(navigationSpan).toEqual({ + attributes: { + effectiveConnectionType: { + type: 'string', + value: expect.any(String), + }, + hardwareConcurrency: { + type: 'string', + value: expect.any(String), + }, + 'sentry.idle_span_finish_reason': { + type: 'string', + value: 'idleTimeout', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'navigation', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.navigation.browser', + }, + 'sentry.previous_trace': { + type: 'string', + value: `${pageloadTraceId}-${pageloadSpan.span_id}-1`, + }, + 'sentry.sample_rate': { + type: 'integer', + value: 1, + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: SDK_VERSION, + }, + 'sentry.segment.id': { + type: 'string', + value: navigationSpan.span_id, + }, + 'sentry.segment.name': { + type: 'string', + value: '/index.html', + }, + 'sentry.source': { + type: 'string', + value: 'url', + }, + 'sentry.span.source': { + type: 'string', + value: 'url', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + links: [ + { + attributes: { + 'sentry.link.type': { + type: 'string', + value: 'previous_trace', + }, + }, + sampled: true, + span_id: pageloadSpan.span_id, + trace_id: pageloadTraceId, + }, + ], + name: '/index.html', + span_id: navigationSpan.span_id, + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: navigationTraceId, + }); +}); + +sentryTest('handles pushState with full URL', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const navigationSpan1Promise = waitForStreamedSpan( + page, + span => getSpanOp(span) === 'navigation' && span.name === '/sub-page', + ); + const navigationSpan2Promise = waitForStreamedSpan( + page, + span => getSpanOp(span) === 'navigation' && span.name === '/sub-page-2', + ); + + await page.goto(url); + await pageloadSpanPromise; + + await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page`);"); + + const navigationSpan1 = await navigationSpan1Promise; + + expect(navigationSpan1.name).toEqual('/sub-page'); + + expect(navigationSpan1.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.navigation.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'url', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'navigation', + }, + }); + + await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page-2`);"); + + const navigationSpan2 = await navigationSpan2Promise; + + expect(navigationSpan2.name).toEqual('/sub-page-2'); + + expect(navigationSpan2.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.navigation.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'url', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'navigation', + }, + ['sentry.idle_span_finish_reason']: { + type: 'string', + value: 'idleTimeout', + }, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js new file mode 100644 index 000000000000..bd3b6ed17872 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts new file mode 100644 index 000000000000..47d9e00d4307 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts @@ -0,0 +1,131 @@ +import { expect } from '@playwright/test'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +sentryTest( + 'creates a pageload streamed span envelope with url as pageload span name source', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const spanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'pageload'), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const spanEnvelope = await spanEnvelopePromise; + const envelopeHeader = spanEnvelope[0]; + const envelopeItem = spanEnvelope[1]; + const spans = envelopeItem[0][1].items; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload'); + + const timeOrigin = await page.evaluate('window._testBaseTimestamp'); + + expect(envelopeHeader).toEqual({ + sdk: { + name: 'sentry.javascript.browser', + version: SDK_VERSION, + }, + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rand: expect.any(String), + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }, + }); + + const numericSampleRand = parseFloat(envelopeHeader.trace!.sample_rand!); + const traceId = envelopeHeader.trace!.trace_id; + + expect(Number.isNaN(numericSampleRand)).toBe(false); + + expect(envelopeItem[0][0].item_count).toBeGreaterThan(1); + + expect(pageloadSpan?.start_timestamp).toBeCloseTo(timeOrigin, 1); + + expect(pageloadSpan).toEqual({ + attributes: { + effectiveConnectionType: { + type: 'string', + value: expect.any(String), + }, + hardwareConcurrency: { + type: 'string', + value: expect.any(String), + }, + 'performance.activationStart': { + type: 'integer', + value: expect.any(Number), + }, + 'performance.timeOrigin': { + type: 'double', + value: expect.any(Number), + }, + 'sentry.idle_span_finish_reason': { + type: 'string', + value: 'idleTimeout', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'pageload', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.pageload.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: pageloadSpan?.span_id, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: '/index.html', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'url', + }, + 'sentry.span.source': { + type: 'string', + value: 'url', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + name: '/index.html', + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: traceId, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js new file mode 100644 index 000000000000..ded3ca204b6b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ enableReportPageLoaded: true }), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + debug: true, +}); + +setTimeout(() => { + Sentry.reportPageLoaded(); +}, 2500); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts new file mode 100644 index 000000000000..fb6fa3ab2393 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts @@ -0,0 +1,41 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest( + 'waits for Sentry.reportPageLoaded() to be called when `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp; + + expect(pageloadSpan.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' }, + [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'reportPageLoaded' }, + }); + + // We wait for 2.5 seconds before calling Sentry.reportPageLoaded() + // the margins are to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeGreaterThan(2); + expect(spanDurationSeconds).toBeLessThan(3); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js new file mode 100644 index 000000000000..b1c19f779713 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ enableReportPageLoaded: true, finalTimeout: 3000 }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, + debug: true, +}); + +// not calling Sentry.reportPageLoaded() on purpose! diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts new file mode 100644 index 000000000000..79df6a902e45 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts @@ -0,0 +1,40 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest( + 'final timeout cancels the pageload span even if `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp; + + expect(pageloadSpan.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' }, + 'sentry.idle_span_finish_reason': { type: 'string', value: 'finalTimeout' }, + }); + + // We wait for 3 seconds before calling Sentry.reportPageLoaded() + // the margins are to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeGreaterThan(2.5); + expect(spanDurationSeconds).toBeLessThan(3.5); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js new file mode 100644 index 000000000000..ac42880742a3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ enableReportPageLoaded: true, instrumentNavigation: false }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, + debug: true, +}); + +setTimeout(() => { + Sentry.startBrowserTracingNavigationSpan(Sentry.getClient(), { name: 'custom_navigation' }); +}, 1000); + +setTimeout(() => { + Sentry.reportPageLoaded(); +}, 2500); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts new file mode 100644 index 000000000000..77f138f34053 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts @@ -0,0 +1,38 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest( + 'starting a navigation span cancels the pageload span even if `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp; + + expect(pageloadSpan.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' }, + 'sentry.idle_span_finish_reason': { type: 'string', value: 'cancelled' }, + }); + + // ending span after 1s but adding a margin of 0.5s to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeLessThan(1.5); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js new file mode 100644 index 000000000000..9afcee48dc4a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js new file mode 100644 index 000000000000..510fb07540ad --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js @@ -0,0 +1,28 @@ +// REGULAR --- +const rootSpan1 = Sentry.startInactiveSpan({ name: 'rootSpan1' }); +rootSpan1.end(); + +Sentry.startSpan({ name: 'rootSpan2' }, rootSpan2 => { + rootSpan2.addLink({ + context: rootSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); +}); + +// NESTED --- +Sentry.startSpan({ name: 'rootSpan3' }, async rootSpan3 => { + Sentry.startSpan({ name: 'childSpan3.1' }, async childSpan1 => { + childSpan1.addLink({ + context: rootSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); + + childSpan1.end(); + }); + + Sentry.startSpan({ name: 'childSpan3.2' }, async childSpan2 => { + childSpan2.addLink({ context: rootSpan3.spanContext() }); + + childSpan2.end(); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts new file mode 100644 index 000000000000..dc35f0c8fcf1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts @@ -0,0 +1,66 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../utils/helpers'; +import { waitForStreamedSpan, waitForStreamedSpans } from '../../../utils/spanUtils'; + +sentryTest('links spans with addLink() in trace context', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const rootSpan1Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan1' && !!s.is_segment); + const rootSpan2Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan2' && !!s.is_segment); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const rootSpan1 = await rootSpan1Promise; + const rootSpan2 = await rootSpan2Promise; + + expect(rootSpan1.name).toBe('rootSpan1'); + expect(rootSpan1.links).toBeUndefined(); + + expect(rootSpan2.name).toBe('rootSpan2'); + expect(rootSpan2.links).toHaveLength(1); + expect(rootSpan2.links?.[0]).toMatchObject({ + attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, + sampled: true, + span_id: rootSpan1.span_id, + trace_id: rootSpan1.trace_id, + }); +}); + +sentryTest('links spans with addLink() in nested startSpan() calls', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const rootSpan1Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan1' && !!s.is_segment); + const rootSpan3SpansPromise = waitForStreamedSpans(page, spans => + spans.some(s => s.name === 'rootSpan3' && s.is_segment), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const rootSpan1 = await rootSpan1Promise; + const rootSpan3Spans = await rootSpan3SpansPromise; + + const rootSpan3 = rootSpan3Spans.find(s => s.name === 'rootSpan3')!; + const childSpan1 = rootSpan3Spans.find(s => s.name === 'childSpan3.1')!; + const childSpan2 = rootSpan3Spans.find(s => s.name === 'childSpan3.2')!; + + expect(rootSpan3.name).toBe('rootSpan3'); + + expect(childSpan1.name).toBe('childSpan3.1'); + expect(childSpan1.links).toHaveLength(1); + expect(childSpan1.links?.[0]).toMatchObject({ + attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, + sampled: true, + span_id: rootSpan1.span_id, + trace_id: rootSpan1.trace_id, + }); + + expect(childSpan2.name).toBe('childSpan3.2'); + expect(childSpan2.links?.[0]).toMatchObject({ + sampled: true, + span_id: rootSpan3.span_id, + trace_id: rootSpan3.trace_id, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js new file mode 100644 index 000000000000..c4c8791cf32c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js new file mode 100644 index 000000000000..482a738009c2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js @@ -0,0 +1,5 @@ +fetch('http://sentry-test-site.example/0').then( + fetch('http://sentry-test-site.example/1', { headers: { 'X-Test-Header': 'existing-header' } }).then( + fetch('http://sentry-test-site.example/2'), + ), +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts new file mode 100644 index 000000000000..201c3e4979f2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('creates spans for fetch requests', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans( + page, + spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3, + ); + + await page.goto(url); + + const allSpans = await spansPromise; + const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload'); + const requestSpans = allSpans.filter(s => getSpanOp(s) === 'http.client'); + + expect(requestSpans).toHaveLength(3); + + requestSpans.forEach((span, index) => + expect(span).toMatchObject({ + name: `GET http://sentry-test-site.example/${index}`, + parent_span_id: pageloadSpan?.span_id, + span_id: expect.stringMatching(/[a-f\d]{16}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + trace_id: pageloadSpan?.trace_id, + attributes: expect.objectContaining({ + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: `http://sentry-test-site.example/${index}` }, + url: { type: 'string', value: `http://sentry-test-site.example/${index}` }, + 'server.address': { type: 'string', value: 'sentry-test-site.example' }, + type: { type: 'string', value: 'fetch' }, + }), + }), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js new file mode 100644 index 000000000000..c4c8791cf32c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js new file mode 100644 index 000000000000..9c584bf743cb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js @@ -0,0 +1,12 @@ +const xhr_1 = new XMLHttpRequest(); +xhr_1.open('GET', 'http://sentry-test-site.example/0'); +xhr_1.send(); + +const xhr_2 = new XMLHttpRequest(); +xhr_2.open('GET', 'http://sentry-test-site.example/1'); +xhr_2.setRequestHeader('X-Test-Header', 'existing-header'); +xhr_2.send(); + +const xhr_3 = new XMLHttpRequest(); +xhr_3.open('GET', 'http://sentry-test-site.example/2'); +xhr_3.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts new file mode 100644 index 000000000000..d3f20fd36453 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('creates spans for XHR requests', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans( + page, + spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3, + ); + + await page.goto(url); + + const allSpans = await spansPromise; + const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload'); + const requestSpans = allSpans.filter(s => getSpanOp(s) === 'http.client'); + + expect(requestSpans).toHaveLength(3); + + requestSpans.forEach((span, index) => + expect(span).toMatchObject({ + name: `GET http://sentry-test-site.example/${index}`, + parent_span_id: pageloadSpan?.span_id, + span_id: expect.stringMatching(/[a-f\d]{16}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + trace_id: pageloadSpan?.trace_id, + attributes: expect.objectContaining({ + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: `http://sentry-test-site.example/${index}` }, + url: { type: 'string', value: `http://sentry-test-site.example/${index}` }, + 'server.address': { type: 'string', value: 'sentry-test-site.example' }, + type: { type: 'string', value: 'xhr' }, + }), + }), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js new file mode 100644 index 000000000000..9afcee48dc4a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js new file mode 100644 index 000000000000..0ce39588eb1b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js @@ -0,0 +1,14 @@ +const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); +Sentry.setActiveSpanInBrowser(checkoutSpan); + +Sentry.startSpan({ name: 'checkout-step-1' }, () => { + Sentry.startSpan({ name: 'checkout-step-1-1' }, () => { + // ... ` + }); +}); + +Sentry.startSpan({ name: 'checkout-step-2' }, () => { + // ... ` +}); + +checkoutSpan.end(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts new file mode 100644 index 000000000000..a144e171a93a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('sets an inactive span active and adds child spans to it', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => s.name === 'checkout-flow' && s.is_segment)); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const spans = await spansPromise; + const checkoutSpan = spans.find(s => s.name === 'checkout-flow'); + const checkoutSpanId = checkoutSpan?.span_id; + expect(checkoutSpanId).toMatch(/[a-f\d]{16}/); + + expect(spans.filter(s => !s.is_segment)).toHaveLength(3); + + const checkoutStep1 = spans.find(s => s.name === 'checkout-step-1'); + const checkoutStep11 = spans.find(s => s.name === 'checkout-step-1-1'); + const checkoutStep2 = spans.find(s => s.name === 'checkout-step-2'); + + expect(checkoutStep1).toBeDefined(); + expect(checkoutStep11).toBeDefined(); + expect(checkoutStep2).toBeDefined(); + + expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); + + // despite 1-1 being called within 1, it's still parented to the root span + // due to this being default behaviour in browser environments + expect(checkoutStep11?.parent_span_id).toBe(checkoutSpanId); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js new file mode 100644 index 000000000000..5b4cff73e95d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + parentSpanIsAlwaysRootSpan: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js new file mode 100644 index 000000000000..dc601cbf4d30 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js @@ -0,0 +1,22 @@ +const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); +Sentry.setActiveSpanInBrowser(checkoutSpan); + +Sentry.startSpan({ name: 'checkout-step-1' }, () => {}); + +const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' }); +Sentry.setActiveSpanInBrowser(checkoutStep2); + +Sentry.startSpan({ name: 'checkout-step-2-1' }, () => { + // ... ` +}); +checkoutStep2.end(); + +Sentry.startSpan({ name: 'checkout-step-3' }, () => {}); + +checkoutSpan.end(); + +Sentry.startSpan({ name: 'post-checkout' }, () => { + Sentry.startSpan({ name: 'post-checkout-1' }, () => { + // ... ` + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts new file mode 100644 index 000000000000..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 50150c6bee20..5dade230e1e4 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -62,7 +62,7 @@ export const eventAndTraceHeaderRequestParser = (request: Request | null): Event return getEventAndTraceHeader(envelope); }; -const properFullEnvelopeParser = (request: Request | null): T => { +export const properFullEnvelopeParser = (request: Request | null): T => { // https://develop.sentry.dev/sdk/envelopes/ const envelope = request?.postData() || ''; diff --git a/dev-packages/browser-integration-tests/utils/spanUtils.ts b/dev-packages/browser-integration-tests/utils/spanUtils.ts new file mode 100644 index 000000000000..67b5798b66f1 --- /dev/null +++ b/dev-packages/browser-integration-tests/utils/spanUtils.ts @@ -0,0 +1,133 @@ +import type { Page } from '@playwright/test'; +import type { SerializedStreamedSpan, StreamedSpanEnvelope } from '@sentry/core'; +import { properFullEnvelopeParser } from './helpers'; + +/** + * Wait for a full span v2 envelope + * Useful for testing the entire envelope shape + */ +export async function waitForStreamedSpanEnvelope( + page: Page, + callback?: (spanEnvelope: StreamedSpanEnvelope) => boolean, +): Promise { + const req = await page.waitForRequest(req => { + const postData = req.postData(); + if (!postData) { + return false; + } + + try { + const spanEnvelope = properFullEnvelopeParser(req); + + const envelopeItemHeader = spanEnvelope[1][0][0]; + + if ( + envelopeItemHeader?.type !== 'span' || + envelopeItemHeader?.content_type !== 'application/vnd.sentry.items.span.v2+json' + ) { + return false; + } + + if (callback) { + return callback(spanEnvelope); + } + + return true; + } catch { + return false; + } + }); + + return properFullEnvelopeParser(req); +} + +/** + * Wait for v2 spans sent in one envelope. + * Useful for testing multiple spans in one envelope. + * @param page + * @param callback - Callback being called with all spans + */ +export async function waitForStreamedSpans( + page: Page, + callback?: (spans: SerializedStreamedSpan[]) => boolean, +): Promise { + const spanEnvelope = await waitForStreamedSpanEnvelope(page, envelope => { + if (callback) { + return callback(envelope[1][0][1].items); + } + return true; + }); + return spanEnvelope[1][0][1].items; +} + +export async function waitForStreamedSpan( + page: Page, + callback: (span: SerializedStreamedSpan) => boolean, +): Promise { + const spanEnvelope = await waitForStreamedSpanEnvelope(page, envelope => { + if (callback) { + const spans = envelope[1][0][1].items; + return spans.some(span => callback(span)); + } + return true; + }); + const firstMatchingSpan = spanEnvelope[1][0][1].items.find(span => callback(span)); + if (!firstMatchingSpan) { + throw new Error( + 'No matching span found but envelope search matched previously. Something is likely off with this function. Debug me.', + ); + } + return firstMatchingSpan; +} + +/** + * Observes outgoing requests and looks for sentry envelope requests. If an envelope request is found, it applies + * @param callback to check for a matching span. + * + * Important: This function only observes requests and does not block the test when it ends. Use this primarily to + * throw errors if you encounter unwanted spans. You most likely want to use {@link waitForStreamedSpan} or {@link waitForStreamedSpans} instead! + */ +export async function observeStreamedSpan( + page: Page, + callback: (span: SerializedStreamedSpan) => boolean, +): Promise { + page.on('request', request => { + const postData = request.postData(); + if (!postData) { + return; + } + + try { + const spanEnvelope = properFullEnvelopeParser(request); + + const envelopeItemHeader = spanEnvelope[1][0][0]; + + if ( + envelopeItemHeader?.type !== 'span' || + envelopeItemHeader?.content_type !== 'application/vnd.sentry.items.span.v2+json' + ) { + return false; + } + + const spans = spanEnvelope[1][0][1].items; + + for (const span of spans) { + if (callback(span)) { + return true; + } + } + + return false; + } catch { + return false; + } + }); +} + +export function getSpanOp(span: SerializedStreamedSpan): string | undefined { + return span.attributes?.['sentry.op']?.type === 'string' ? span.attributes?.['sentry.op']?.value : undefined; +} + +export function getSpansFromEnvelope(envelope: StreamedSpanEnvelope): SerializedStreamedSpan[] { + return envelope[1][0][1].items; +} diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index ac01ff0647a7..cf4c6dc6e9bb 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -170,6 +170,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 1c980e4cae2d..b49b214b65d7 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -157,6 +157,7 @@ export { unleashIntegration, growthbookIntegration, metrics, + spanStreamingIntegration, } from '@sentry/node'; export { diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 70a6595d07d9..071f38f72ca9 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -41,6 +41,7 @@ export { } from './tracing/browserTracingIntegration'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; +export { spanStreamingIntegration } from './integrations/spanstreaming'; export type { RequestInstrumentationOptions } from './tracing/request'; export { diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts new file mode 100644 index 000000000000..9d1a296b5e57 --- /dev/null +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -0,0 +1,71 @@ +import type { IntegrationFn } from '@sentry/core'; +import { + captureSpan, + debug, + defineIntegration, + hasSpanStreamingEnabled, + isStreamedBeforeSendSpanCallback, + SpanBuffer, + spanIsSampled, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +export const spanStreamingIntegration = defineIntegration(() => { + return { + name: 'SpanStreaming', + + beforeSetup(client) { + // If users only set spanStreamingIntegration, without traceLifecycle, we set it to "stream" for them. + // This avoids the classic double-opt-in problem we'd otherwise have in the browser SDK. + const clientOptions = client.getOptions(); + if (!clientOptions.traceLifecycle) { + DEBUG_BUILD && debug.warn('[SpanStreaming] set `traceLifecycle` to "stream"'); + clientOptions.traceLifecycle = 'stream'; + } + }, + + setup(client) { + const initialMessage = 'SpanStreaming integration requires'; + const fallbackMsg = 'Falling back to static trace lifecycle.'; + const clientOptions = client.getOptions(); + + if (!hasSpanStreamingEnabled(client)) { + clientOptions.traceLifecycle = 'static'; + DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); + return; + } + + const beforeSendSpan = clientOptions.beforeSendSpan; + // If users misconfigure their SDK by opting into span streaming but + // using an incompatible beforeSendSpan callback, we fall back to the static trace lifecycle. + if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) { + clientOptions.traceLifecycle = 'static'; + DEBUG_BUILD && + debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`); + return; + } + + const buffer = new SpanBuffer(client); + + client.on('afterSpanEnd', span => { + // Negatively sampled spans must not be captured. + // This happens because OTel and we create non-recording spans for negatively sampled spans + // that go through the same life cycle as recording spans. + if (!spanIsSampled(span)) { + return; + } + buffer.add(captureSpan(span, client)); + }); + + // In addition to capturing the span, we also flush the trace when the segment + // span ends to ensure things are sent timely. We never know when the browser + // is closed, users navigate away, etc. + client.on('afterSegmentSpanEnd', segmentSpan => { + const traceId = segmentSpan.spanContext().traceId; + setTimeout(() => { + buffer.flush(traceId); + }, 500); + }); + }, + }; +}) satisfies IntegrationFn; diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 85faf0871a55..6211cf72947a 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import type { Client, HandlerDataXhr, @@ -5,6 +6,7 @@ import type { ResponseHookInfo, SentryWrappedXMLHttpRequest, Span, + SpanTimeInput, } from '@sentry/core'; import { addFetchEndInstrumentationHandler, @@ -14,6 +16,7 @@ import { getLocationHref, getTraceData, hasSpansEnabled, + hasSpanStreamingEnabled, instrumentFetchRequest, parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -25,6 +28,7 @@ import { stringMatchesSomePattern, stripDataUrlContent, stripUrlQueryAndFragment, + timestampInSeconds, } from '@sentry/core'; import type { XhrHint } from '@sentry-internal/browser-utils'; import { @@ -205,7 +209,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { + // Clean up the performance observer and other resources + // We have to wait here because otherwise this cleans itself up before it is fully done. + // Default (non-streaming): just deregister the observer. + let onEntryFound = (): void => void setTimeout(unsubscribePerformanceObsever); + + // For streamed spans, we have to artificially delay the ending of the span until we + // either receive the timing data, or HTTP_TIMING_WAIT_MS elapses. + if (hasSpanStreamingEnabled(client)) { + const originalEnd = span.end.bind(span); + + span.end = (endTimestamp?: SpanTimeInput) => { + const capturedEndTimestamp = endTimestamp ?? timestampInSeconds(); + let isEnded = false; + + const endSpanAndCleanup = (): void => { + if (isEnded) { + return; + } + isEnded = true; + setTimeout(unsubscribePerformanceObsever); + originalEnd(capturedEndTimestamp); + clearTimeout(fallbackTimeout); + }; + + onEntryFound = endSpanAndCleanup; + + // Fallback: always end the span after HTTP_TIMING_WAIT_MS even if no + // PerformanceResourceTiming entry arrives (e.g. cross-origin without + // Timing-Allow-Origin, or the browser didn't fire the observer in time). + const fallbackTimeout = setTimeout(endSpanAndCleanup, HTTP_TIMING_WAIT_MS); + }; + } + + const unsubscribePerformanceObsever = addPerformanceInstrumentationHandler('resource', ({ entries }) => { entries.forEach(entry => { if (isPerformanceResourceTiming(entry) && entry.name.endsWith(url)) { span.setAttributes(resourceTimingToSpanAttributes(entry)); - // In the next tick, clean this handler up - // We have to wait here because otherwise this cleans itself up before it is fully done - setTimeout(cleanup); + onEntryFound(); } }); }); diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts new file mode 100644 index 000000000000..1d5d587290a3 --- /dev/null +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -0,0 +1,199 @@ +import * as SentryCore from '@sentry/core'; +import { debug } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrowserClient, spanStreamingIntegration } from '../../src'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; + +// Mock SpanBuffer as a class that can be instantiated +const mockSpanBufferInstance = vi.hoisted(() => ({ + flush: vi.fn(), + add: vi.fn(), + drain: vi.fn(), +})); + +const MockSpanBuffer = vi.hoisted(() => { + return vi.fn(() => mockSpanBufferInstance); +}); + +vi.mock('@sentry/core', async () => { + const original = await vi.importActual('@sentry/core'); + return { + ...original, + SpanBuffer: MockSpanBuffer, + }; +}); + +describe('spanStreamingIntegration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('has the correct hooks', () => { + const integration = spanStreamingIntegration(); + expect(integration.name).toBe('SpanStreaming'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(integration.beforeSetup).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(integration.setup).toBeDefined(); + }); + + it('sets traceLifecycle to "stream" if not set', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); + + it.each(['static', 'somethingElse'])( + 'logs a warning if traceLifecycle is not set to "stream" but to %s', + traceLifecycle => { + const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + // @ts-expect-error - we want to test the warning for invalid traceLifecycle values + traceLifecycle, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(debugSpy).toHaveBeenCalledWith( + 'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.', + ); + debugSpy.mockRestore(); + + expect(client.getOptions().traceLifecycle).toBe('static'); + }, + ); + + it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => { + const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + beforeSendSpan: (span: Span) => span, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(debugSpy).toHaveBeenCalledWith( + 'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.', + ); + debugSpy.mockRestore(); + + expect(client.getOptions().traceLifecycle).toBe('static'); + }); + + it('does nothing if traceLifecycle set to "stream"', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); + + it('enqueues a span into the buffer when the span ends', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + tracesSampleRate: 1, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test', sampled: true }); + client.emit('afterSpanEnd', span); + + expect(mockSpanBufferInstance.add).toHaveBeenCalledWith({ + _segmentSpan: span, + trace_id: span.spanContext().traceId, + span_id: span.spanContext().spanId, + end_timestamp: expect.any(Number), + is_segment: true, + name: 'test', + start_timestamp: expect.any(Number), + status: 'ok', + attributes: { + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: span.spanContext().spanId, + }, + 'sentry.segment.name': { + type: 'string', + value: 'test', + }, + }, + }); + }); + + it('does not enqueue a span into the buffer when the span is not sampled', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + tracesSampleRate: 1, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test', sampled: false }); + client.emit('afterSpanEnd', span); + + expect(mockSpanBufferInstance.add).not.toHaveBeenCalled(); + expect(mockSpanBufferInstance.flush).not.toHaveBeenCalled(); + }); + + it('flushes the trace when the segment span ends after a delay for close to finished child spans', () => { + vi.useFakeTimers(); + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test' }); + client.emit('afterSegmentSpanEnd', span); + + vi.advanceTimersByTime(500); + + expect(mockSpanBufferInstance.flush).toHaveBeenCalledWith(span.spanContext().traceId); + + vi.useRealTimers(); + }); +}); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c2990e6262a7..6f52a92ab6da 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -177,6 +177,7 @@ export { statsigIntegration, unleashIntegration, metrics, + spanStreamingIntegration, } from '@sentry/node'; export { diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index d3255d76b0e9..1f4a6638f577 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -1,4 +1,6 @@ import type { DurationUnit, FractionUnit, InformationUnit } from './types-hoist/measurement'; +import type { Primitive } from './types-hoist/misc'; +import { isPrimitive } from './utils/is'; export type RawAttributes = T & ValidatedAttributes; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -127,6 +129,46 @@ export function serializeAttributes( return serializedAttributes; } +/** + * Estimates the serialized byte size of {@link Attributes}, + * with a couple of heuristics for performance. + */ +export function estimateTypedAttributesSizeInBytes(attributes: Attributes | undefined): number { + if (!attributes) { + return 0; + } + let weight = 0; + for (const [key, attr] of Object.entries(attributes)) { + weight += key.length * 2; + weight += attr.type.length * 2; + weight += (attr.unit?.length ?? 0) * 2; + const val = attr.value; + + if (Array.isArray(val)) { + // Assumption: Individual array items have the same type and roughly the same size + // probably not always true but allows us to cut down on runtime + weight += estimatePrimitiveSizeInBytes(val[0]) * val.length; + } else if (isPrimitive(val)) { + weight += estimatePrimitiveSizeInBytes(val); + } else { + // default fallback for anything else (objects) + weight += 100; + } + } + return weight; +} + +function estimatePrimitiveSizeInBytes(value: Primitive): number { + if (typeof value === 'string') { + return value.length * 2; + } else if (typeof value === 'boolean') { + return 4; + } else if (typeof value === 'number') { + return 8; + } + return 0; +} + /** * NOTE: We intentionally do not return anything for non-primitive values: * - array support will come in the future but if we stringify arrays now, diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 8d69411aacfd..6c3ca949f38e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -11,6 +11,7 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal'; import type { Scope } from './scope'; import { updateSession } from './session'; import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext'; +import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base'; import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types-hoist/breadcrumb'; import type { CheckIn, MonitorConfig } from './types-hoist/checkin'; @@ -31,7 +32,7 @@ import type { RequestEventData } from './types-hoist/request'; import type { SdkMetadata } from './types-hoist/sdkmetadata'; import type { Session, SessionAggregates } from './types-hoist/session'; import type { SeverityLevel } from './types-hoist/severity'; -import type { Span, SpanAttributes, SpanContextData, SpanJSON } from './types-hoist/span'; +import type { Span, SpanAttributes, SpanContextData, SpanJSON, StreamedSpanJSON } from './types-hoist/span'; import type { StartSpanOptions } from './types-hoist/startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport'; import { createClientReportEnvelope } from './utils/clientreport'; @@ -503,6 +504,10 @@ export abstract class Client { public addIntegration(integration: Integration): void { const isAlreadyInstalled = this._integrations[integration.name]; + if (!isAlreadyInstalled && integration.beforeSetup) { + integration.beforeSetup(this); + } + // This hook takes care of only installing if not already installed setupIntegration(this, integration, this._integrations); // Here we need to check manually to make sure to not run this multiple times @@ -613,6 +618,28 @@ export abstract class Client { */ public on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + /** + * Register a callback for after a span is ended and the `spanEnd` hook has run. + * NOTE: The span cannot be mutated anymore in this callback. + */ + public on(hook: 'afterSpanEnd', callback: (immutableSegmentSpan: Readonly) => void): () => void; + + /** + * Register a callback for after a segment span is ended and the `segmentSpanEnd` hook has run. + * NOTE: The segment span cannot be mutated anymore in this callback. + */ + public on(hook: 'afterSegmentSpanEnd', callback: (immutableSegmentSpan: Readonly) => void): () => void; + + /** + * Register a callback for when a span JSON is processed, to add some data to the span JSON. + */ + public on(hook: 'processSpan', callback: (streamedSpanJSON: StreamedSpanJSON) => void): () => void; + + /** + * Register a callback for when a segment span JSON is processed, to add some data to the segment span JSON. + */ + public on(hook: 'processSegmentSpan', callback: (streamedSpanJSON: StreamedSpanJSON) => void): () => void; + /** * Register a callback for when an idle span is allowed to auto-finish. * @returns {() => void} A function that, when executed, removes the registered callback. @@ -885,6 +912,26 @@ export abstract class Client { /** Fire a hook whenever a span ends. */ public emit(hook: 'spanEnd', span: Span): void; + /** + * Fire a hook event after a span ends and the `spanEnd` hook has run. + */ + public emit(hook: 'afterSpanEnd', immutableSpan: Readonly): void; + + /** + * Fire a hook event after a segment span ends and the `spanEnd` hook has run. + */ + public emit(hook: 'afterSegmentSpanEnd', immutableSegmentSpan: Readonly): void; + + /** + * Fire a hook event when a span JSON is processed, to add some data to the span JSON. + */ + public emit(hook: 'processSpan', streamedSpanJSON: StreamedSpanJSON): void; + + /** + * Fire a hook event for when a segment span JSON is processed, to add some data to the segment span JSON. + */ + public emit(hook: 'processSegmentSpan', streamedSpanJSON: StreamedSpanJSON): void; + /** * Fire a hook indicating that an idle span is allowed to auto finish. */ @@ -1513,7 +1560,9 @@ function processBeforeSend( event: Event, hint: EventHint, ): PromiseLike | Event | null { - const { beforeSend, beforeSendTransaction, beforeSendSpan, ignoreSpans } = options; + const { beforeSend, beforeSendTransaction, ignoreSpans } = options; + const beforeSendSpan = !isStreamedBeforeSendSpanCallback(options.beforeSendSpan) && options.beforeSendSpan; + let processedEvent = event; if (isErrorEvent(processedEvent) && beforeSend) { diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 875056890e0e..dd91d077f45c 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -1,6 +1,7 @@ import type { Client } from './client'; import { getDynamicSamplingContextFromSpan } from './tracing/dynamicSamplingContext'; import type { SentrySpan } from './tracing/sentrySpan'; +import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; import type { LegacyCSPReport } from './types-hoist/csp'; import type { DsnComponents } from './types-hoist/dsn'; import type { @@ -152,7 +153,7 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? const convertToSpanJSON = beforeSendSpan ? (span: SentrySpan) => { const spanJson = spanToJSON(span); - const processedSpan = beforeSendSpan(spanJson); + const processedSpan = !isStreamedBeforeSendSpanCallback(beforeSendSpan) ? beforeSendSpan(spanJson) : spanJson; if (!processedSpan) { showSpanDropWarning(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 61865ea7ba3c..539405f09adc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,6 +68,8 @@ export { prepareEvent } from './utils/prepareEvent'; export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; +export { withStreamedSpan } from './tracing/spans/beforeSendSpan'; +export { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize, fmt } from './utils/parameterize'; @@ -81,11 +83,13 @@ export { convertSpanLinksForEnvelope, spanToTraceHeader, spanToJSON, + spanToStreamedSpanJSON, spanIsSampled, spanToTraceContext, getSpanDescendants, getStatusMessage, getRootSpan, + INTERNAL_getSegmentSpan, getActiveSpan, addChildSpanToSpan, spanTimeInputToSeconds, @@ -177,6 +181,11 @@ export type { GoogleGenAIOptions, GoogleGenAIIstrumentedMethod, } from './tracing/google-genai/types'; + +export { SpanBuffer } from './tracing/spans/spanBuffer'; +export { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled'; +export { spanStreamingIntegration } from './integrations/spanStreaming'; + export type { FeatureFlag } from './utils/featureFlags'; export { @@ -391,6 +400,7 @@ export type { ProfileChunkEnvelope, ProfileChunkItem, SpanEnvelope, + StreamedSpanEnvelope, SpanItem, LogEnvelope, MetricEnvelope, @@ -458,6 +468,8 @@ export type { SpanJSON, SpanContextData, TraceFlag, + SerializedStreamedSpan, + StreamedSpanJSON, } from './types-hoist/span'; export type { SpanStatus } from './types-hoist/spanStatus'; export type { Log, LogSeverityLevel } from './types-hoist/log'; diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 892228476824..b8e7240cf748 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -76,6 +76,12 @@ export function getIntegrationsToSetup( export function setupIntegrations(client: Client, integrations: Integration[]): IntegrationIndex { const integrationIndex: IntegrationIndex = {}; + integrations.forEach((integration: Integration | undefined) => { + if (integration?.beforeSetup) { + integration.beforeSetup(client); + } + }); + integrations.forEach((integration: Integration | undefined) => { // guard against empty provided integrations if (integration) { diff --git a/packages/core/src/integrations/spanStreaming.ts b/packages/core/src/integrations/spanStreaming.ts new file mode 100644 index 000000000000..541be6b7f5e9 --- /dev/null +++ b/packages/core/src/integrations/spanStreaming.ts @@ -0,0 +1,44 @@ +import type { IntegrationFn } from '../types-hoist/integration'; +import { DEBUG_BUILD } from '../debug-build'; +import { defineIntegration } from '../integration'; +import { isStreamedBeforeSendSpanCallback } from '../tracing/spans/beforeSendSpan'; +import { captureSpan } from '../tracing/spans/captureSpan'; +import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled'; +import { SpanBuffer } from '../tracing/spans/spanBuffer'; +import { debug } from '../utils/debug-logger'; +import { spanIsSampled } from '../utils/spanUtils'; + +export const spanStreamingIntegration = defineIntegration(() => { + return { + name: 'SpanStreaming', + + setup(client) { + const initialMessage = 'SpanStreaming integration requires'; + const fallbackMsg = 'Falling back to static trace lifecycle.'; + const clientOptions = client.getOptions(); + + if (!hasSpanStreamingEnabled(client)) { + clientOptions.traceLifecycle = 'static'; + DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); + return; + } + + const beforeSendSpan = clientOptions.beforeSendSpan; + if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) { + clientOptions.traceLifecycle = 'static'; + DEBUG_BUILD && + debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`); + return; + } + + const buffer = new SpanBuffer(client); + + client.on('afterSpanEnd', span => { + if (!spanIsSampled(span)) { + return; + } + buffer.add(captureSpan(span, client)); + }); + }, + }; +}) satisfies IntegrationFn; diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 88b0f470dfa3..02b6a4ec08a6 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -1,7 +1,7 @@ /** - * Use this attribute to represent the source of a span. - * Should be one of: custom, url, route, view, component, task, unknown - * + * Use this attribute to represent the source of a span name. + * Must be one of: custom, url, route, view, component, task + * TODO(v11): rename this to sentry.span.source' */ export const SEMANTIC_ATTRIBUTE_SENTRY_SOURCE = 'sentry.source'; @@ -40,6 +40,28 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT = 'sentry.measurement_un /** The value of a measurement, which may be stored as a TimedEvent. */ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE = 'sentry.measurement_value'; +/** The release version of the application */ +export const SEMANTIC_ATTRIBUTE_SENTRY_RELEASE = 'sentry.release'; +/** The environment name (e.g., "production", "staging", "development") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT = 'sentry.environment'; +/** The segment name (e.g., "GET /users") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME = 'sentry.segment.name'; +/** The id of the segment that this span belongs to. */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID = 'sentry.segment.id'; +/** The name of the Sentry SDK (e.g., "sentry.php", "sentry.javascript") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name'; +/** The version of the Sentry SDK */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; + +/** The user ID (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; +/** The user email (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email'; +/** The user IP address (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address'; +/** The user username (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.name'; + /** * A custom span name set by users guaranteed to be taken over any automatically * inferred name. This attribute is removed before the span is sent. diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 47d5657a7d87..7cf79e53d07b 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -119,7 +119,8 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly { + * // span is of type StreamedSpanJSON + * return span; + * }), + * }); + * + * @param callback - The callback function that receives and returns a {@link StreamedSpanJSON}. + * @returns A callback that is compatible with the `beforeSendSpan` option when using `traceLifecycle: 'stream'`. + */ +export function withStreamedSpan( + callback: (span: StreamedSpanJSON) => StreamedSpanJSON, +): BeforeSendStramedSpanCallback { + addNonEnumerableProperty(callback, '_streamed', true); + return callback; +} + +/** + * Typesafe check to identify if a `beforeSendSpan` callback expects the streamed span JSON format. + * + * @param callback - The `beforeSendSpan` callback to check. + * @returns `true` if the callback was wrapped with {@link withStreamedSpan}. + */ +export function isStreamedBeforeSendSpanCallback( + callback: ClientOptions['beforeSendSpan'], +): callback is BeforeSendStramedSpanCallback { + return !!callback && '_streamed' in callback && !!callback._streamed; +} diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts new file mode 100644 index 000000000000..979c7b460af1 --- /dev/null +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -0,0 +1,150 @@ +import type { RawAttributes } from '../../attributes'; +import type { Client } from '../../client'; +import type { ScopeData } from '../../scope'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_USER_EMAIL, + SEMANTIC_ATTRIBUTE_USER_ID, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, + SEMANTIC_ATTRIBUTE_USER_USERNAME, +} from '../../semanticAttributes'; +import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; +import { getCombinedScopeData } from '../../utils/scopeData'; +import { + INTERNAL_getSegmentSpan, + showSpanDropWarning, + spanToStreamedSpanJSON, + streamedSpanJsonToSerializedSpan, +} from '../../utils/spanUtils'; +import { getCapturedScopesOnSpan } from '../utils'; +import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; + +export type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { + _segmentSpan: Span; +}; + +/** + * Captures a span and returns a JSON representation to be enqueued for sending. + * + * IMPORTANT: This function converts the span to JSON immediately to avoid writing + * to an already-ended OTel span instance (which is blocked by the OTel Span class). + * + * @returns the final serialized span with a reference to its segment span. This reference + * is needed later on to compute the DSC for the span envelope. + */ +export function captureSpan(span: Span, client: Client): SerializedStreamedSpanWithSegmentSpan { + // Convert to JSON FIRST - we cannot write to an already-ended span + const spanJSON = spanToStreamedSpanJSON(span); + + const segmentSpan = INTERNAL_getSegmentSpan(span); + const serializedSegmentSpan = spanToStreamedSpanJSON(segmentSpan); + + const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); + + const finalScopeData = getCombinedScopeData(spanIsolationScope, spanScope); + + applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData); + + if (spanJSON.is_segment) { + applyScopeToSegmentSpan(spanJSON, finalScopeData); + // Allow hook subscribers to mutate the segment span JSON + client.emit('processSegmentSpan', spanJSON); + } + + // Allow hook subscribers to mutate the span JSON + client.emit('processSpan', spanJSON); + + const { beforeSendSpan } = client.getOptions(); + const processedSpan = + beforeSendSpan && isStreamedBeforeSendSpanCallback(beforeSendSpan) + ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) + : spanJSON; + + // Backfill sentry.span.source from sentry.source. Only `sentry.span.source` is respected by Sentry. + // TODO(v11): Remove this backfill once we renamed SEMANTIC_ATTRIBUTE_SENTRY_SOURCE to sentry.span.source + const spanNameSource = processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + if (spanNameSource) { + safeSetSpanJSONAttributes(processedSpan, { + // Purposefully not using a constant defined here like in other attributes: + // This will be the name for SEMANTIC_ATTRIBUTE_SENTRY_SOURCE in v11 + 'sentry.span.source': spanNameSource, + }); + } + + return { + ...streamedSpanJsonToSerializedSpan(processedSpan), + _segmentSpan: segmentSpan, + }; +} + +function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void { + // TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span + // This will follow in a separate PR +} + +function applyCommonSpanAttributes( + spanJSON: StreamedSpanJSON, + serializedSegmentSpan: StreamedSpanJSON, + client: Client, + scopeData: ScopeData, +): void { + const sdk = client.getSdkMetadata(); + const { release, environment, sendDefaultPii } = client.getOptions(); + + // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version, + ...(sendDefaultPii + ? { + [SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, + } + : {}), + ...scopeData.attributes, + }); +} + +/** + * Apply a user-provided beforeSendSpan callback to a span JSON. + */ +export function applyBeforeSendSpanCallback( + span: StreamedSpanJSON, + beforeSendSpan: (span: StreamedSpanJSON) => StreamedSpanJSON, +): StreamedSpanJSON { + const modifedSpan = beforeSendSpan(span); + if (!modifedSpan) { + showSpanDropWarning(); + return span; + } + return modifedSpan; +} + +/** + * Safely set attributes on a span JSON. + * If an attribute already exists, it will not be overwritten. + */ +export function safeSetSpanJSONAttributes( + spanJSON: StreamedSpanJSON, + newAttributes: RawAttributes>, +): void { + const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); + + Object.entries(newAttributes).forEach(([key, value]) => { + if (value != null && !(key in originalAttributes)) { + originalAttributes[key] = value; + } + }); +} diff --git a/packages/core/src/tracing/spans/envelope.ts b/packages/core/src/tracing/spans/envelope.ts new file mode 100644 index 000000000000..8429b22d7e1c --- /dev/null +++ b/packages/core/src/tracing/spans/envelope.ts @@ -0,0 +1,36 @@ +import type { Client } from '../../client'; +import type { DynamicSamplingContext, SpanContainerItem, StreamedSpanEnvelope } from '../../types-hoist/envelope'; +import type { SerializedStreamedSpan } from '../../types-hoist/span'; +import { dsnToString } from '../../utils/dsn'; +import { createEnvelope, getSdkMetadataForEnvelopeHeader } from '../../utils/envelope'; + +/** + * Creates a span v2 span streaming envelope + */ +export function createStreamedSpanEnvelope( + serializedSpans: Array, + dsc: Partial, + client: Client, +): StreamedSpanEnvelope { + const dsn = client.getDsn(); + const tunnel = client.getOptions().tunnel; + const sdk = getSdkMetadataForEnvelopeHeader(client.getOptions()._metadata); + + const headers: StreamedSpanEnvelope[0] = { + sent_at: new Date().toISOString(), + ...(dscHasRequiredProps(dsc) && { trace: dsc }), + ...(sdk && { sdk }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }; + + const spanContainer: SpanContainerItem = [ + { type: 'span', item_count: serializedSpans.length, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { items: serializedSpans }, + ]; + + return createEnvelope(headers, [spanContainer]); +} + +function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext { + return !!dsc.trace_id && !!dsc.public_key; +} diff --git a/packages/core/src/tracing/spans/estimateSize.ts b/packages/core/src/tracing/spans/estimateSize.ts new file mode 100644 index 000000000000..7d5781862d62 --- /dev/null +++ b/packages/core/src/tracing/spans/estimateSize.ts @@ -0,0 +1,37 @@ +import { estimateTypedAttributesSizeInBytes } from '../../attributes'; +import type { SerializedStreamedSpan } from '../../types-hoist/span'; + +/** + * Estimates the serialized byte size of a {@link SerializedStreamedSpan}. + * + * Uses 2 bytes per character as a UTF-16 approximation, and 8 bytes per number. + * The estimate is intentionally conservative and may be slightly lower than the + * actual byte size on the wire. + * We compensate for this by setting the span buffers internal limit well below the limit + * of how large an actual span v2 envelope may be. + */ +export function estimateSerializedSpanSizeInBytes(span: SerializedStreamedSpan): number { + /* + * Fixed-size fields are pre-computed as a constant for performance: + * - two timestamps (8 bytes each = 16) + * - is_segment boolean (5 bytes, assumed false for most spans) + * - trace_id – always 32 hex chars (64 bytes) + * - span_id – always 16 hex chars (32 bytes) + * - parent_span_id – 16 hex chars, assumed present for most spans (32 bytes) + * - status "ok" – most common value (8 bytes) + * = 156 bytes total base + */ + let weight = 156; + weight += span.name.length * 2; + weight += estimateTypedAttributesSizeInBytes(span.attributes); + if (span.links && span.links.length > 0) { + // Assumption: Links are roughly equal in number of attributes + // probably not always true but allows us to cut down on runtime + const firstLink = span.links[0]; + const attributes = firstLink?.attributes; + // Fixed size 100 due to span_id, trace_id and sampled flag (see above) + const linkWeight = 100 + (attributes ? estimateTypedAttributesSizeInBytes(attributes) : 0); + weight += linkWeight * span.links.length; + } + return weight; +} diff --git a/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts b/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts new file mode 100644 index 000000000000..7d5fa2861c21 --- /dev/null +++ b/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts @@ -0,0 +1,8 @@ +import type { Client } from '../../client'; + +/** + * Determines if span streaming is enabled for the given client + */ +export function hasSpanStreamingEnabled(client: Client): boolean { + return client.getOptions().traceLifecycle === 'stream'; +} diff --git a/packages/core/src/tracing/spans/spanBuffer.ts b/packages/core/src/tracing/spans/spanBuffer.ts new file mode 100644 index 000000000000..cd011df5ac49 --- /dev/null +++ b/packages/core/src/tracing/spans/spanBuffer.ts @@ -0,0 +1,195 @@ +import type { Client } from '../../client'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { SerializedStreamedSpan } from '../../types-hoist/span'; +import { debug } from '../../utils/debug-logger'; +import { safeUnref } from '../../utils/timer'; +import { getDynamicSamplingContextFromSpan } from '../dynamicSamplingContext'; +import type { SerializedStreamedSpanWithSegmentSpan } from './captureSpan'; +import { createStreamedSpanEnvelope } from './envelope'; +import { estimateSerializedSpanSizeInBytes } from './estimateSize'; + +/** + * We must not send more than 1000 spans in one envelope. + * Otherwise the envelope is dropped by Relay. + */ +const MAX_SPANS_PER_ENVELOPE = 1000; + +const MAX_TRACE_WEIGHT_IN_BYTES = 5_000_000; + +interface TraceBucket { + spans: Set; + size: number; + timeout: ReturnType; +} + +export interface SpanBufferOptions { + /** + * Max spans per trace before auto-flush + * Must not exceed 1000. + * + * @default 1_000 + */ + maxSpanLimit?: number; + + /** + * Per-trace flush timeout in ms. A timeout is started when a trace bucket is first created + * and fires flush() for that specific trace when it expires. + * Must be greater than 0. + * + * @default 5_000 + */ + flushInterval?: number; + + /** + * Max accumulated byte weight of spans per trace before auto-flush. + * Size is estimated, not exact. Uses 2 bytes per character for strings (UTF-16). + * + * @default 5_000_000 (5 MB) + */ + maxTraceWeightInBytes?: number; +} + +/** + * A buffer for serialized streamed span JSON objects that flushes them to Sentry in Span v2 envelopes. + * Handles per-trace timeout-based flushing, size thresholds, and graceful shutdown. + * Also handles computation of the Dynamic Sampling Context (DSC) for the trace, if it wasn't yet + * frozen onto the segment span. + * + * For this, we need the reference to the segment span instance, from + * which we compute the DSC. Doing this in the buffer ensures that we compute the DSC as late as possible, + * allowing span name and data updates up to this point. Worth noting here that the segment span is likely + * still active and modifyable when child spans are added to the buffer. + */ +export class SpanBuffer { + /* Bucket spans by their trace id, along with accumulated size and a per-trace flush timeout */ + private _traceBuckets: Map; + + private _client: Client; + private _maxSpanLimit: number; + private _flushInterval: number; + private _maxTraceWeight: number; + + public constructor(client: Client, options?: SpanBufferOptions) { + this._traceBuckets = new Map(); + this._client = client; + + const { maxSpanLimit, flushInterval, maxTraceWeightInBytes } = options ?? {}; + + this._maxSpanLimit = + maxSpanLimit && maxSpanLimit > 0 && maxSpanLimit <= MAX_SPANS_PER_ENVELOPE + ? maxSpanLimit + : MAX_SPANS_PER_ENVELOPE; + this._flushInterval = flushInterval && flushInterval > 0 ? flushInterval : 5_000; + this._maxTraceWeight = + maxTraceWeightInBytes && maxTraceWeightInBytes > 0 ? maxTraceWeightInBytes : MAX_TRACE_WEIGHT_IN_BYTES; + + this._client.on('flush', () => { + this.drain(); + }); + + this._client.on('close', () => { + // No need to drain the buffer here as `Client.close()` internally already calls `Client.flush()` + // which already invokes the `flush` hook and thus drains the buffer. + this._traceBuckets.forEach(bucket => { + clearTimeout(bucket.timeout); + }); + this._traceBuckets.clear(); + }); + } + + /** + * Add a span to the buffer. + */ + public add(spanJSON: SerializedStreamedSpanWithSegmentSpan): void { + const traceId = spanJSON.trace_id; + let bucket = this._traceBuckets.get(traceId); + + if (!bucket) { + bucket = { + spans: new Set(), + size: 0, + timeout: safeUnref( + setTimeout(() => { + this.flush(traceId); + }, this._flushInterval), + ), + }; + this._traceBuckets.set(traceId, bucket); + } + + bucket.spans.add(spanJSON); + bucket.size += estimateSerializedSpanSizeInBytes(spanJSON); + + if (bucket.spans.size >= this._maxSpanLimit || bucket.size >= this._maxTraceWeight) { + this.flush(traceId); + } + } + + /** + * Drain and flush all buffered traces. + */ + public drain(): void { + if (!this._traceBuckets.size) { + return; + } + + DEBUG_BUILD && debug.log(`Flushing span tree map with ${this._traceBuckets.size} traces`); + + this._traceBuckets.forEach((_, traceId) => { + this.flush(traceId); + }); + } + + /** + * Flush spans of a specific trace. + * In contrast to {@link SpanBuffer.drain}, this method does not flush all traces, but only the one with the given traceId. + */ + public flush(traceId: string): void { + const bucket = this._traceBuckets.get(traceId); + if (!bucket) { + return; + } + + if (!bucket.spans.size) { + // we should never get here, given we always add a span when we create a new bucket + // and delete the bucket once we flush out the trace + this._removeTrace(traceId); + return; + } + + const spans = Array.from(bucket.spans); + + const segmentSpan = spans[0]?._segmentSpan; + if (!segmentSpan) { + DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC'); + this._removeTrace(traceId); + return; + } + + const dsc = getDynamicSamplingContextFromSpan(segmentSpan); + + const cleanedSpans: SerializedStreamedSpan[] = spans.map(spanJSON => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _segmentSpan, ...cleanSpanJSON } = spanJSON; + return cleanSpanJSON; + }); + + const envelope = createStreamedSpanEnvelope(cleanedSpans, dsc, this._client); + + DEBUG_BUILD && debug.log(`Sending span envelope for trace ${traceId} with ${cleanedSpans.length} spans`); + + this._client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending streamed span envelope:', reason); + }); + + this._removeTrace(traceId); + } + + private _removeTrace(traceId: string): void { + const bucket = this._traceBuckets.get(traceId); + if (bucket) { + clearTimeout(bucket.timeout); + } + this._traceBuckets.delete(traceId); + } +} diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 28a5bccd4147..59b00bb018c1 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -492,6 +492,7 @@ function _startChildSpan(parentSpan: Span, scope: Scope, spanArguments: SentrySp // If it has an endTimestamp, it's already ended if (spanArguments.endTimestamp) { client.emit('spanEnd', childSpan); + client.emit('afterSpanEnd', childSpan); } } diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index 272f8cde9f62..d8b8a1822b04 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -11,7 +11,7 @@ import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, SessionAggregates } from './session'; -import type { SpanJSON } from './span'; +import type { SerializedStreamedSpanContainer, SpanJSON } from './span'; // Based on: https://develop.sentry.dev/sdk/envelopes/ @@ -91,6 +91,21 @@ type CheckInItemHeaders = { type: 'check_in' }; type ProfileItemHeaders = { type: 'profile' }; type ProfileChunkItemHeaders = { type: 'profile_chunk' }; type SpanItemHeaders = { type: 'span' }; +type SpanContainerItemHeaders = { + /** + * Same as v1 span item type but this envelope is distinguished by {@link SpanContainerItemHeaders.content_type}. + */ + type: 'span'; + /** + * The number of span items in the container. This must be the same as the number of span items in the payload. + */ + item_count: number; + /** + * The content type of the span items. This must be `application/vnd.sentry.items.span.v2+json`. + * (the presence of this field also distinguishes the span item from the v1 span item) + */ + content_type: 'application/vnd.sentry.items.span.v2+json'; +}; type LogContainerItemHeaders = { type: 'log'; /** @@ -123,6 +138,7 @@ export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; +export type SpanContainerItem = BaseEnvelopeItem; export type LogContainerItem = BaseEnvelopeItem; export type MetricContainerItem = BaseEnvelopeItem; export type RawSecurityItem = BaseEnvelopeItem; @@ -133,6 +149,7 @@ type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext }; type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; +type StreamedSpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; type LogEnvelopeHeaders = BaseEnvelopeHeaders; type MetricEnvelopeHeaders = BaseEnvelopeHeaders; export type EventEnvelope = BaseEnvelope< @@ -144,6 +161,7 @@ export type ClientReportEnvelope = BaseEnvelope; export type SpanEnvelope = BaseEnvelope; +export type StreamedSpanEnvelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; export type RawSecurityEnvelope = BaseEnvelope; export type LogEnvelope = BaseEnvelope; @@ -157,6 +175,7 @@ export type Envelope = | ReplayEnvelope | CheckInEnvelope | SpanEnvelope + | StreamedSpanEnvelope | RawSecurityEnvelope | LogEnvelope | MetricEnvelope; diff --git a/packages/core/src/types-hoist/integration.ts b/packages/core/src/types-hoist/integration.ts index 120cb1acc884..fc80cf3f524a 100644 --- a/packages/core/src/types-hoist/integration.ts +++ b/packages/core/src/types-hoist/integration.ts @@ -14,6 +14,15 @@ export interface Integration { */ setupOnce?(): void; + /** + * Called before the `setup` hook of any integration is called. + * This is useful if an integration needs to e.g. modify client options prior to other integrations + * reading client options. + * + * @param client + */ + beforeSetup?(client: Client): void; + /** * Set up an integration for the given client. * Receives the client as argument. diff --git a/packages/core/src/types-hoist/link.ts b/packages/core/src/types-hoist/link.ts index a330dc108b00..9a117258200b 100644 --- a/packages/core/src/types-hoist/link.ts +++ b/packages/core/src/types-hoist/link.ts @@ -22,9 +22,9 @@ export interface SpanLink { * Link interface for the event envelope item. It's a flattened representation of `SpanLink`. * Can include additional fields defined by OTel. */ -export interface SpanLinkJSON extends Record { +export interface SpanLinkJSON extends Record { span_id: string; trace_id: string; sampled?: boolean; - attributes?: SpanLinkAttributes; + attributes?: TAttributes; } diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 92292f8e6e3d..63310a66c3d2 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -6,7 +6,7 @@ import type { Log } from './log'; import type { Metric } from './metric'; import type { TracesSamplerSamplingContext } from './samplingcontext'; import type { SdkMetadata } from './sdkmetadata'; -import type { SpanJSON } from './span'; +import type { SpanJSON, StreamedSpanJSON } from './span'; import type { StackLineParser, StackParser } from './stacktrace'; import type { TracePropagationTargets } from './tracing'; import type { BaseTransportOptions, Transport } from './transport'; @@ -500,6 +500,14 @@ export interface ClientOptions SpanJSON; + beforeSendSpan?: ((span: SpanJSON) => SpanJSON) | BeforeSendStramedSpanCallback; /** * An event-processing callback for transaction events, guaranteed to be invoked after all other event @@ -615,6 +626,19 @@ export interface ClientOptions Breadcrumb | null; } +/** + * A callback for processing streamed spans before they are sent. + * + * @see {@link StreamedSpanJSON} for the streamed span format used with `traceLifecycle: 'stream'` + */ +export type BeforeSendStramedSpanCallback = ((span: StreamedSpanJSON) => StreamedSpanJSON) & { + /** + * When true, indicates this callback is designed to handle the {@link StreamedSpanJSON} format + * used with `traceLifecycle: 'stream'`. Set this by wrapping your callback with `withStreamedSpan`. + */ + _streamed?: true; +}; + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index d82463768b7f..a918cc57859c 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -1,3 +1,4 @@ +import type { Attributes, RawAttributes } from '../attributes'; import type { SpanLink, SpanLinkJSON } from './link'; import type { Measurements } from './measurement'; import type { HrTime } from './opentelemetry'; @@ -34,6 +35,43 @@ export type SpanAttributes = Partial<{ /** This type is aligned with the OpenTelemetry TimeInput type. */ export type SpanTimeInput = HrTime | number | Date; +/** + * Intermediate JSON reporesentation of a v2 span, which users and our SDK integrations will interact with. + * This is NOT the final serialized JSON span, but an intermediate step still holding raw attributes. + * The final, serialized span is a {@link SerializedStreamedSpan}. + * Main reason: Make it easier and safer for users to work with attributes. + */ +export interface StreamedSpanJSON { + trace_id: string; + parent_span_id?: string; + span_id: string; + name: string; + start_timestamp: number; + end_timestamp: number; + status: 'ok' | 'error'; + is_segment: boolean; + attributes?: RawAttributes>; + links?: SpanLinkJSON>>[]; +} + +/** + * Serialized span item. + * This is the final, serialized span format that is sent to Sentry. + * The intermediate representation is {@link StreamedSpanJSON}. + * Main difference: Attributes are converted to {@link Attributes}, thus including the `type` annotation. + */ +export type SerializedStreamedSpan = Omit & { + attributes?: Attributes; + links?: SpanLinkJSON[]; +}; + +/** + * Envelope span item container. + */ +export type SerializedStreamedSpanContainer = { + items: Array; +}; + /** A JSON representation of a span. */ export interface SpanJSON { data: SpanAttributes; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index d7c261ecd73c..d22905670efc 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -1,4 +1,6 @@ import { getAsyncContextStrategy } from '../asyncContext'; +import type { RawAttributes } from '../attributes'; +import { serializeAttributes } from '../attributes'; import { getMainCarrier } from '../carrier'; import { getCurrentScope } from '../currentScopes'; import { @@ -12,7 +14,15 @@ import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; import { getCapturedScopesOnSpan } from '../tracing/utils'; import type { TraceContext } from '../types-hoist/context'; import type { SpanLink, SpanLinkJSON } from '../types-hoist/link'; -import type { Span, SpanAttributes, SpanJSON, SpanOrigin, SpanTimeInput } from '../types-hoist/span'; +import type { + SerializedStreamedSpan, + Span, + SpanAttributes, + SpanJSON, + SpanOrigin, + SpanTimeInput, + StreamedSpanJSON, +} from '../types-hoist/span'; import type { SpanStatus } from '../types-hoist/spanStatus'; import { addNonEnumerableProperty } from '../utils/object'; import { generateSpanId } from '../utils/propagationContext'; @@ -105,6 +115,27 @@ export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[] } } +/** + * Converts the span links array to a flattened version with serialized attributes for V2 spans. + * + * If the links array is empty, it returns `undefined` so the empty value can be dropped before it's sent. + */ +export function getStreamedSpanLinks( + links?: SpanLink[], +): SpanLinkJSON>>[] | undefined { + if (links?.length) { + return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({ + span_id: spanId, + trace_id: traceId, + sampled: traceFlags === TRACE_FLAG_SAMPLED, + attributes, + ...restContext, + })); + } else { + return undefined; + } +} + /** * Convert a span time input into a timestamp in seconds. */ @@ -150,23 +181,12 @@ export function spanToJSON(span: Span): SpanJSON { if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { const { attributes, startTime, name, endTime, status, links } = span; - // In preparation for the next major of OpenTelemetry, we want to support - // looking up the parent span id according to the new API - // In OTel v1, the parent span id is accessed as `parentSpanId` - // In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext` - const parentSpanId = - 'parentSpanId' in span - ? span.parentSpanId - : 'parentSpanContext' in span - ? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId - : undefined; - return { span_id, trace_id, data: attributes, description: name, - parent_span_id: parentSpanId, + parent_span_id: getOtelParentSpanId(span), start_timestamp: spanTimeInputToSeconds(startTime), // This is [0,0] by default in OTEL, in which case we want to interpret this as no end time timestamp: spanTimeInputToSeconds(endTime) || undefined, @@ -187,6 +207,77 @@ export function spanToJSON(span: Span): SpanJSON { }; } +/** + * Convert a span to the intermediate {@link StreamedSpanJSON} representation. + */ +export function spanToStreamedSpanJSON(span: Span): StreamedSpanJSON { + if (spanIsSentrySpan(span)) { + return span.getStreamedSpanJSON(); + } + + const { spanId: span_id, traceId: trace_id } = span.spanContext(); + + // Handle a span from @opentelemetry/sdk-base-trace's `Span` class + if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { + const { attributes, startTime, name, endTime, status, links } = span; + + return { + name, + span_id, + trace_id, + parent_span_id: getOtelParentSpanId(span), + start_timestamp: spanTimeInputToSeconds(startTime), + end_timestamp: spanTimeInputToSeconds(endTime), + is_segment: span === INTERNAL_getSegmentSpan(span), + status: getSimpleStatusMessage(status), + attributes, + links: getStreamedSpanLinks(links), + }; + } + + // Finally, as a fallback, at least we have `spanContext()`.... + // This should not actually happen in reality, but we need to handle it for type safety. + return { + span_id, + trace_id, + start_timestamp: 0, + name: '', + end_timestamp: 0, + status: 'ok', + is_segment: span === INTERNAL_getSegmentSpan(span), + }; +} + +/** + * In preparation for the next major of OpenTelemetry, we want to support + * looking up the parent span id according to the new API + * In OTel v1, the parent span id is accessed as `parentSpanId` + * In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext` + */ +function getOtelParentSpanId(span: OpenTelemetrySdkTraceBaseSpan): string | undefined { + return 'parentSpanId' in span + ? span.parentSpanId + : 'parentSpanContext' in span + ? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId + : undefined; +} + +/** + * Converts a {@link StreamedSpanJSON} to a {@link SerializedSpan}. + * This is the final serialized span format that is sent to Sentry. + * The returned serilaized spans must not be consumed by users or SDK integrations. + */ +export function streamedSpanJsonToSerializedSpan(spanJson: StreamedSpanJSON): SerializedStreamedSpan { + return { + ...spanJson, + attributes: serializeAttributes(spanJson.attributes), + links: spanJson.links?.map(link => ({ + ...link, + attributes: serializeAttributes(link.attributes), + })), + }; +} + function spanIsOpenTelemetrySdkTraceBaseSpan(span: Span): span is OpenTelemetrySdkTraceBaseSpan { const castSpan = span as Partial; return !!castSpan.attributes && !!castSpan.startTime && !!castSpan.name && !!castSpan.endTime && !!castSpan.status; @@ -237,6 +328,18 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef return status.message || 'internal_error'; } +/** + * Convert the various statuses to the simple onces expected by Sentry for steamed spans ('ok' is default). + */ +export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | 'error' { + return !status || + status.code === SPAN_STATUS_OK || + status.code === SPAN_STATUS_UNSET || + status.message === 'cancelled' + ? 'ok' + : 'error'; +} + const CHILD_SPANS_FIELD = '_sentryChildSpans'; const ROOT_SPAN_FIELD = '_sentryRootSpan'; @@ -298,7 +401,12 @@ export function getSpanDescendants(span: SpanWithPotentialChildren): Span[] { /** * Returns the root span of a given span. */ -export function getRootSpan(span: SpanWithPotentialChildren): Span { +export const getRootSpan = INTERNAL_getSegmentSpan; + +/** + * Returns the segment span of a given span. + */ +export function INTERNAL_getSegmentSpan(span: SpanWithPotentialChildren): Span { return span[ROOT_SPAN_FIELD] || span; } diff --git a/packages/core/test/integrations/spanStreaming.test.ts b/packages/core/test/integrations/spanStreaming.test.ts new file mode 100644 index 000000000000..8b2badf575ff --- /dev/null +++ b/packages/core/test/integrations/spanStreaming.test.ts @@ -0,0 +1,139 @@ +import * as SentryCore from '../../src'; +import { debug } from '../../src'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { spanStreamingIntegration } from '../../src/integrations/spanStreaming'; +import { TestClient, getDefaultTestClientOptions } from '../mocks/client'; + +const mockSpanBufferInstance = vi.hoisted(() => ({ + flush: vi.fn(), + add: vi.fn(), + drain: vi.fn(), +})); + +const MockSpanBuffer = vi.hoisted(() => { + return vi.fn(() => mockSpanBufferInstance); +}); + +vi.mock('../../src/tracing/spans/spanBuffer', async () => { + const original = await vi.importActual('../../src/tracing/spans/spanBuffer'); + return { + ...original, + SpanBuffer: MockSpanBuffer, + }; +}); + +describe('spanStreamingIntegration (core)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('has the correct name and setup hook', () => { + const integration = spanStreamingIntegration(); + expect(integration.name).toBe('SpanStreaming'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(integration.setup).toBeDefined(); + }); + + it.each(['static', 'somethingElse'])( + 'logs a warning if traceLifecycle is not set to "stream" but to %s', + traceLifecycle => { + const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + // @ts-expect-error - we want to test the warning for invalid traceLifecycle values + traceLifecycle, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(debugSpy).toHaveBeenCalledWith( + 'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.', + ); + debugSpy.mockRestore(); + + expect(client.getOptions().traceLifecycle).toBe('static'); + }, + ); + + it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => { + const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + beforeSendSpan: (span: SentryCore.SpanJSON) => span, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(debugSpy).toHaveBeenCalledWith( + 'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.', + ); + debugSpy.mockRestore(); + + expect(client.getOptions().traceLifecycle).toBe('static'); + }); + + it('sets up buffer when traceLifecycle is "stream"', () => { + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(MockSpanBuffer).toHaveBeenCalledWith(client); + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); + + it('enqueues a span into the buffer when the span ends', () => { + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test', sampled: true }); + client.emit('afterSpanEnd', span); + + expect(mockSpanBufferInstance.add).toHaveBeenCalledWith( + expect.objectContaining({ + _segmentSpan: span, + trace_id: span.spanContext().traceId, + span_id: span.spanContext().spanId, + name: 'test', + }), + ); + }); + + it('does not enqueue a span into the buffer when the span is not sampled', () => { + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test', sampled: false }); + client.emit('afterSpanEnd', span); + + expect(mockSpanBufferInstance.add).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts new file mode 100644 index 000000000000..79fd838a1b27 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { withStreamedSpan } from '../../../../src'; +import { isStreamedBeforeSendSpanCallback } from '../../../../src/tracing/spans/beforeSendSpan'; + +describe('beforeSendSpan for span streaming', () => { + describe('withStreamedSpan', () => { + it('should be able to modify the span', () => { + const beforeSendSpan = vi.fn(); + const wrapped = withStreamedSpan(beforeSendSpan); + expect(wrapped._streamed).toBe(true); + }); + }); + + describe('isStreamedBeforeSendSpanCallback', () => { + it('returns true if the callback is wrapped with withStreamedSpan', () => { + const beforeSendSpan = vi.fn(); + const wrapped = withStreamedSpan(beforeSendSpan); + expect(isStreamedBeforeSendSpanCallback(wrapped)).toBe(true); + }); + + it('returns false if the callback is not wrapped with withStreamedSpan', () => { + const beforeSendSpan = vi.fn(); + expect(isStreamedBeforeSendSpanCallback(beforeSendSpan)).toBe(false); + }); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts new file mode 100644 index 000000000000..d429d50714a2 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { StreamedSpanJSON } from '../../../../src'; +import { + captureSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + 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, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_USER_EMAIL, + SEMANTIC_ATTRIBUTE_USER_ID, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, + SEMANTIC_ATTRIBUTE_USER_USERNAME, + startInactiveSpan, + startSpan, + withScope, + withStreamedSpan, +} from '../../../../src'; +import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('captureSpan', () => { + it('captures user attributes iff sendDefaultPii is true', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii: true, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + const serializedSpan = captureSpan(span, client); + + expect(serializedSpan).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + 'sentry.span.source': { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_ID]: { + value: '123', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + value: 'user@example.com', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + value: 'testuser', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + value: '127.0.0.1', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + it.each([false, undefined])("doesn't capture user attributes if sendDefaultPii is %s", sendDefaultPii => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + expect(captureSpan(span, client)).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + 'sentry.span.source': { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + it('captures sdk name and version if available', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + _metadata: { + sdk: { + name: 'sentry.javascript.node', + version: '1.0.0', + integrations: ['UnhandledRejection', 'Dedupe'], + }, + }, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + expect(captureSpan(span, client)).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + 'sentry.span.source': { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + value: 'sentry.javascript.node', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + value: '1.0.0', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + describe('client hooks', () => { + it('calls processSpan and processSegmentSpan hooks for a segment span', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + }), + ); + + const processSpanFn = vi.fn(); + const processSegmentSpanFn = vi.fn(); + client.on('processSpan', processSpanFn); + client.on('processSegmentSpan', processSegmentSpanFn); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + + captureSpan(span, client); + + expect(processSpanFn).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId })); + expect(processSegmentSpanFn).toHaveBeenCalledWith( + expect.objectContaining({ span_id: span.spanContext().spanId }), + ); + }); + + it('only calls processSpan hook for a child span', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii: true, + }), + ); + + const processSpanFn = vi.fn(); + const processSegmentSpanFn = vi.fn(); + client.on('processSpan', processSpanFn); + client.on('processSegmentSpan', processSegmentSpanFn); + + const serializedChildSpan = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + return startSpan({ name: 'segment' }, () => { + const childSpan = startInactiveSpan({ name: 'child' }); + childSpan.end(); + return captureSpan(childSpan, client); + }); + }); + + expect(serializedChildSpan?.name).toBe('child'); + expect(serializedChildSpan?.is_segment).toBe(false); + + expect(processSpanFn).toHaveBeenCalledWith(expect.objectContaining({ span_id: serializedChildSpan?.span_id })); + expect(processSegmentSpanFn).not.toHaveBeenCalled(); + }); + }); + + describe('beforeSendSpan', () => { + it('applies beforeSendSpan if it is a span streaming compatible callback', () => { + const beforeSendSpan = withStreamedSpan(vi.fn(span => span)); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + expect(beforeSendSpan).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId })); + }); + + it("doesn't apply beforeSendSpan if it is not a span streaming compatible callback", () => { + const beforeSendSpan = vi.fn(span => span); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + expect(beforeSendSpan).not.toHaveBeenCalled(); + }); + + it('logs a warning if the beforeSendSpan callback returns null', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + // @ts-expect-error - the types dissallow returning null but this is javascript, so we need to test it + const beforeSendSpan = withStreamedSpan(() => null); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.', + ); + + consoleWarnSpy.mockRestore(); + }); + }); +}); + +describe('safeSetSpanJSONAttributes', () => { + it('sets attributes that do not exist', () => { + const spanJSON = { attributes: { a: 1, b: 2 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { c: 3 }); + + expect(spanJSON.attributes).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("doesn't set attributes that already exist", () => { + const spanJSON = { attributes: { a: 1, b: 2 } }; + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 3 }); + + expect(spanJSON.attributes).toEqual({ a: 1, b: 2 }); + }); + + it.each([null, undefined])("doesn't overwrite attributes previously set to %s", val => { + const spanJSON = { attributes: { a: val, b: 2 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1 }); + + expect(spanJSON.attributes).toEqual({ a: val, b: 2 }); + }); + + it("doesn't overwrite falsy attribute values (%s)", () => { + const spanJSON = { attributes: { a: false, b: '', c: 0 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1, b: 'test', c: 1 }); + + expect(spanJSON.attributes).toEqual({ a: false, b: '', c: 0 }); + }); + + it('handles an undefined attributes property', () => { + const spanJSON: Partial = {}; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1 }); + + expect(spanJSON.attributes).toEqual({ a: 1 }); + }); + + it("doesn't apply undefined or null values to attributes", () => { + const spanJSON = { attributes: {} }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: undefined, b: null }); + + expect(spanJSON.attributes).toEqual({}); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/envelope.test.ts b/packages/core/test/lib/tracing/spans/envelope.test.ts new file mode 100644 index 000000000000..197b7ed40365 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/envelope.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; +import { createStreamedSpanEnvelope } from '../../../../src/tracing/spans/envelope'; +import type { DynamicSamplingContext } from '../../../../src/types-hoist/envelope'; +import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +function createMockSerializedSpan(overrides: Partial = {}): SerializedStreamedSpan { + return { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: 1713859200, + end_timestamp: 1713859201, + status: 'ok', + is_segment: false, + ...overrides, + }; +} + +describe('createStreamedSpanEnvelope', () => { + describe('envelope headers', () => { + it('creates an envelope with sent_at header', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('sent_at', expect.any(String)); + }); + + it('includes trace header when DSC has required props (trace_id and public_key)', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: DynamicSamplingContext = { + trace_id: 'trace-123', + public_key: 'public-key-abc', + sample_rate: '1.0', + release: 'v1.0.0', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('trace', dsc); + }); + + it("does't include trace header when DSC is missing trace_id", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = { + public_key: 'public-key-abc', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('trace'); + }); + + it("does't include trace header when DSC is missing public_key", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = { + trace_id: 'trace-123', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('trace'); + }); + + it('includes SDK info when available in client options', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + _metadata: { + sdk: { name: 'sentry.javascript.browser', version: '8.0.0' }, + }, + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('sdk', { name: 'sentry.javascript.browser', version: '8.0.0' }); + }); + + it("does't include SDK info when not available", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('sdk'); + }); + + it('includes DSN when tunnel and DSN are configured', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + tunnel: 'https://tunnel.example.com', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('dsn', 'https://abc123@example.sentry.io/456'); + }); + + it("does't include DSN when tunnel is not configured", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('dsn'); + }); + + it("does't include DSN when DSN is not available", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + tunnel: 'https://tunnel.example.com', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('dsn'); + }); + + it('includes all headers when all options are provided', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + tunnel: 'https://tunnel.example.com', + _metadata: { + sdk: { name: 'sentry.javascript.node', version: '10.38.0' }, + }, + }), + ); + const dsc: DynamicSamplingContext = { + trace_id: 'trace-123', + public_key: 'public-key-abc', + environment: 'production', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toEqual({ + sent_at: expect.any(String), + trace: dsc, + sdk: { name: 'sentry.javascript.node', version: '10.38.0' }, + dsn: 'https://abc123@example.sentry.io/456', + }); + }); + }); + + describe('envelope item', () => { + it('creates a span container item with correct structure', () => { + const mockSpan = createMockSerializedSpan({ name: 'span-1' }); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { + content_type: 'application/vnd.sentry.items.span.v2+json', + item_count: 1, + type: 'span', + }, + { + items: [mockSpan], + }, + ], + ]); + }); + + it('sets correct item_count for multiple spans', () => { + const mockSpan1 = createMockSerializedSpan({ span_id: 'span-1' }); + const mockSpan2 = createMockSerializedSpan({ span_id: 'span-2' }); + const mockSpan3 = createMockSerializedSpan({ span_id: 'span-3' }); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan1, mockSpan2, mockSpan3], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { type: 'span', item_count: 3, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { items: [mockSpan1, mockSpan2, mockSpan3] }, + ], + ]); + }); + + it('handles empty spans array', () => { + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([], dsc, mockClient); + + expect(result).toEqual([ + { + sent_at: expect.any(String), + }, + [ + [ + { + content_type: 'application/vnd.sentry.items.span.v2+json', + item_count: 0, + type: 'span', + }, + { + items: [], + }, + ], + ], + ]); + }); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/estimateSize.test.ts b/packages/core/test/lib/tracing/spans/estimateSize.test.ts new file mode 100644 index 000000000000..35d569691dea --- /dev/null +++ b/packages/core/test/lib/tracing/spans/estimateSize.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { estimateSerializedSpanSizeInBytes } from '../../../../src/tracing/spans/estimateSize'; +import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span'; + +// Produces a realistic trace_id (32 hex chars) and span_id (16 hex chars) +const TRACE_ID = 'a1b2c3d4e5f607189a0b1c2d3e4f5060'; +const SPAN_ID = 'a1b2c3d4e5f60718'; + +describe('estimateSerializedSpanSizeInBytes', () => { + it('estimates a minimal span (no attributes, no links, no parent) within a reasonable range of JSON.stringify', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'GET /api/users', + start_timestamp: 1740000000.123, + end_timestamp: 1740000001.456, + status: 'ok', + is_segment: true, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBe(184); + expect(actual).toBe(196); + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with a parent_span_id within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + parent_span_id: 'b2c3d4e5f6071890', + name: 'db.query', + start_timestamp: 1740000000.0, + end_timestamp: 1740000000.05, + status: 'ok', + is_segment: false, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBe(172); + expect(actual).toBe(222); + + expect(estimate).toBeLessThanOrEqual(actual * 1.1); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.7); + }); + + it('estimates a span with string attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'GET /api/users', + start_timestamp: 1740000000.0, + end_timestamp: 1740000000.1, + status: 'ok', + is_segment: false, + attributes: { + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: 'https://example.com/api/users?page=1&limit=100' }, + 'http.status_code': { type: 'integer', value: 200 }, + 'db.statement': { type: 'string', value: 'SELECT * FROM users WHERE id = $1' }, + 'sentry.origin': { type: 'string', value: 'auto.http.fetch' }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with numeric attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'process.task', + start_timestamp: 1740000000.0, + end_timestamp: 1740000005.0, + status: 'ok', + is_segment: false, + attributes: { + 'items.count': { type: 'integer', value: 42 }, + 'duration.ms': { type: 'double', value: 5000.5 }, + 'retry.count': { type: 'integer', value: 3 }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with boolean attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'cache.get', + start_timestamp: 1740000000.0, + end_timestamp: 1740000000.002, + status: 'ok', + is_segment: false, + attributes: { + 'cache.hit': { type: 'boolean', value: true }, + 'cache.miss': { type: 'boolean', value: false }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with array attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'batch.process', + start_timestamp: 1740000000.0, + end_timestamp: 1740000002.0, + status: 'ok', + is_segment: false, + attributes: { + 'item.ids': { type: 'string[]', value: ['id-001', 'id-002', 'id-003', 'id-004', 'id-005'] }, + scores: { type: 'double[]', value: [1.1, 2.2, 3.3, 4.4] }, + flags: { type: 'boolean[]', value: [true, false, true] }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with links within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'linked.operation', + start_timestamp: 1740000000.0, + end_timestamp: 1740000001.0, + status: 'ok', + is_segment: true, + links: [ + { + trace_id: 'b2c3d4e5f607189a0b1c2d3e4f506070', + span_id: 'c3d4e5f607189a0b', + sampled: true, + attributes: { + 'sentry.link.type': { type: 'string', value: 'previous_trace' }, + }, + }, + { + trace_id: 'c3d4e5f607189a0b1c2d3e4f50607080', + span_id: 'd4e5f607189a0b1c', + }, + ], + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/spanBuffer.test.ts b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts new file mode 100644 index 000000000000..cbcb1bf7ea59 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts @@ -0,0 +1,359 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Client, StreamedSpanEnvelope } from '../../../../src'; +import { SentrySpan, setCurrentClient, SpanBuffer } from '../../../../src'; +import type { SerializedStreamedSpanWithSegmentSpan } from '../../../../src/tracing/spans/captureSpan'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('SpanBuffer', () => { + let client: TestClient; + let sendEnvelopeSpy: ReturnType; + + let sentEnvelopes: Array = []; + + beforeEach(() => { + vi.useFakeTimers(); + sentEnvelopes = []; + sendEnvelopeSpy = vi.fn().mockImplementation(e => { + sentEnvelopes.push(e); + return Promise.resolve(); + }); + + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1.0, + }), + ); + client.sendEnvelope = sendEnvelopeSpy; + client.init(); + setCurrentClient(client as Client); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('flushes all traces on drain()', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment', sampled: true, traceId: 'trace123' }); + const segmentSpan2 = new SentrySpan({ name: 'segment', sampled: true, traceId: 'trace456' }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace456', + span_id: 'span2', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.drain(); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + expect(sentEnvelopes).toHaveLength(2); + expect(sentEnvelopes[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace123'); + expect(sentEnvelopes[1]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace456'); + }); + + it('flushes trace after per-trace timeout', () => { + const buffer = new SpanBuffer(client, { flushInterval: 1000 }); + + const segmentSpan1 = new SentrySpan({ name: 'segment', sampled: true }); + const span1 = { + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }; + + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + const span2 = { + trace_id: 'trace123', + span_id: 'span2', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }; + + buffer.add(span1 as SerializedStreamedSpanWithSegmentSpan); + buffer.add(span2 as SerializedStreamedSpanWithSegmentSpan); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + // the trace bucket was removed after flushing, so no timeout remains and no further sends occur + vi.advanceTimersByTime(1000); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('flushes when maxSpanLimit is reached', () => { + const buffer = new SpanBuffer(client, { maxSpanLimit: 2 }); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span3', + name: 'test span 3', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + // we added another span after flushing but neither limit nor time interval should have been reached + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + // draining will flush out the remaining span + buffer.drain(); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + }); + + it('flushes on client flush event', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + client.emit('flush'); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('groups spans by traceId', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true }); + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + + buffer.add({ + trace_id: 'trace1', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace2', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.drain(); + + // Should send 2 envelopes, one for each trace + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + }); + + it('flushes a specific trace on flush(traceId)', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true }); + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + + buffer.add({ + trace_id: 'trace1', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace2', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.flush('trace1'); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + expect(sentEnvelopes[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace1'); + }); + + it('handles flushing a non-existing trace', () => { + const buffer = new SpanBuffer(client); + + buffer.flush('trace1'); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + }); + + describe('weight-based flushing', () => { + function makeSpan( + traceId: string, + spanId: string, + segmentSpan: InstanceType, + overrides: Partial = {}, + ): SerializedStreamedSpanWithSegmentSpan { + return { + trace_id: traceId, + span_id: spanId, + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + ...overrides, + }; + } + + it('flushes a trace when its weight limit is exceeded', () => { + // Use a very small weight threshold so a single span with attributes tips it over + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 200 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + // First span: small, under threshold + buffer.add(makeSpan('trace1', 'span1', segmentSpan, { name: 'a' })); + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + // Second span: has a large name that pushes it over 200 bytes + buffer.add(makeSpan('trace1', 'span2', segmentSpan, { name: 'a'.repeat(80) })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('does not flush when weight stays below the threshold', () => { + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 10_000 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add(makeSpan('trace1', 'span1', segmentSpan)); + buffer.add(makeSpan('trace1', 'span2', segmentSpan)); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + }); + + it('resets weight tracking after a weight-triggered flush so new spans accumulate fresh weight', () => { + // Base estimate per span is 152 bytes. With threshold 400: + // - big span ('a' * 200): 152 + 200*2 = 552 bytes → exceeds 400, triggers flush + // - small span (name 'b'): 152 + 1*2 = 154 bytes + // - two small spans combined: 308 bytes < 400 → no second flush + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 400 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add(makeSpan('trace1', 'span1', segmentSpan, { name: 'a'.repeat(200) })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + buffer.add(makeSpan('trace1', 'span2', segmentSpan, { name: 'b' })); + buffer.add(makeSpan('trace1', 'span3', segmentSpan, { name: 'c' })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('tracks weight independently per trace', () => { + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 200 }); + const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true }); + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + + // trace1 gets a heavy span that exceeds the limit + buffer.add(makeSpan('trace1', 'span1', segmentSpan1, { name: 'a'.repeat(80) })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + expect((sentEnvelopes[0]?.[1]?.[0]?.[1] as { items: Array<{ trace_id: string }> })?.items[0]?.trace_id).toBe( + 'trace1', + ); + + // trace2 only has a small span and should not be flushed + buffer.add(makeSpan('trace2', 'span2', segmentSpan2, { name: 'b' })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('estimates spans with attributes as heavier than bare spans', () => { + // Use a threshold that a bare span cannot reach but an attributed span can + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 300 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + // A span with many string attributes should tip it over + buffer.add( + makeSpan('trace1', 'span1', segmentSpan, { + attributes: { + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: 'https://example.com/api/v1/users?page=1&limit=100' }, + 'db.statement': { type: 'string', value: 'SELECT * FROM users WHERE id = 1' }, + }, + }), + ); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index bca9a406dd50..e4a0b31990d7 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -4,6 +4,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, SentrySpan, setCurrentClient, SPAN_STATUS_ERROR, @@ -16,7 +17,7 @@ import { TRACEPARENT_REGEXP, } from '../../../src'; import type { SpanLink } from '../../../src/types-hoist/link'; -import type { Span, SpanAttributes, SpanTimeInput } from '../../../src/types-hoist/span'; +import type { Span, SpanAttributes, SpanTimeInput, StreamedSpanJSON } from '../../../src/types-hoist/span'; import type { SpanStatus } from '../../../src/types-hoist/spanStatus'; import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils'; import { @@ -24,7 +25,9 @@ import { spanIsSampled, spanTimeInputToSeconds, spanToJSON, + spanToStreamedSpanJSON, spanToTraceContext, + streamedSpanJsonToSerializedSpan, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED, updateSpanName, @@ -41,6 +44,7 @@ function createMockedOtelSpan({ status = { code: SPAN_STATUS_UNSET }, endTime = Date.now(), parentSpanId, + links = undefined, }: { spanId: string; traceId: string; @@ -51,6 +55,7 @@ function createMockedOtelSpan({ status?: SpanStatus; endTime?: SpanTimeInput; parentSpanId?: string; + links?: SpanLink[]; }): Span { return { spanContext: () => { @@ -66,6 +71,7 @@ function createMockedOtelSpan({ status, endTime, parentSpanId, + links, } as OpenTelemetrySdkTraceBaseSpan; } @@ -409,6 +415,233 @@ describe('spanToJSON', () => { }); }); + describe('spanToStreamedSpanJSON', () => { + describe('SentrySpan', () => { + it('converts a minimal span', () => { + const span = new SentrySpan(); + expect(spanToStreamedSpanJSON(span)).toEqual({ + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + name: '', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + }, + }); + }); + + it('converts a full span', () => { + const span = new SentrySpan({ + op: 'test op', + name: 'test name', + parentSpanId: '1234', + spanId: '5678', + traceId: 'abcd', + startTimestamp: 123, + endTimestamp: 456, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + attr1: 'value1', + attr2: 2, + attr3: true, + }, + links: [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }, + ], + }); + span.setStatus({ code: SPAN_STATUS_OK }); + span.setAttribute('attr4', [1, 2, 3]); + + expect(spanToStreamedSpanJSON(span)).toEqual({ + name: 'test name', + parent_span_id: '1234', + span_id: '5678', + trace_id: 'abcd', + start_timestamp: 123, + end_timestamp: 456, + status: 'ok', + is_segment: true, + attributes: { + attr1: 'value1', + attr2: 2, + attr3: true, + attr4: [1, 2, 3], + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + }); + }); + }); + describe('OpenTelemetry Span', () => { + it('converts a simple span', () => { + const span = createMockedOtelSpan({ + spanId: 'SPAN-1', + traceId: 'TRACE-1', + name: 'test span', + startTime: 123, + endTime: [0, 0], + attributes: {}, + status: { code: SPAN_STATUS_UNSET }, + }); + + expect(spanToStreamedSpanJSON(span)).toEqual({ + span_id: 'SPAN-1', + trace_id: 'TRACE-1', + parent_span_id: undefined, + start_timestamp: 123, + end_timestamp: 0, + name: 'test span', + is_segment: true, + status: 'ok', + attributes: {}, + }); + }); + + it('converts a full span', () => { + const span = createMockedOtelSpan({ + spanId: 'SPAN-1', + traceId: 'TRACE-1', + parentSpanId: 'PARENT-1', + name: 'test span', + startTime: 123, + endTime: 456, + attributes: { + attr1: 'value1', + attr2: 2, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + status: { code: SPAN_STATUS_ERROR, message: 'unknown_error' }, + }); + + expect(spanToStreamedSpanJSON(span)).toEqual({ + span_id: 'SPAN-1', + trace_id: 'TRACE-1', + parent_span_id: 'PARENT-1', + start_timestamp: 123, + end_timestamp: 456, + name: 'test span', + is_segment: true, + status: 'error', + attributes: { + attr1: 'value1', + attr2: 2, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + }); + }); + }); + }); + + describe('streamedSpanJsonToSerializedSpan', () => { + it('converts a streamed span JSON with links to a serialized span', () => { + const spanJson: StreamedSpanJSON = { + name: 'test name', + parent_span_id: '1234', + span_id: '5678', + trace_id: 'abcd', + start_timestamp: 123, + end_timestamp: 456, + status: 'ok', + is_segment: true, + attributes: { + attr1: 'value1', + attr2: 2, + attr3: true, + attr4: [1, 2, 3], + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + }; + + expect(streamedSpanJsonToSerializedSpan(spanJson)).toEqual({ + name: 'test name', + parent_span_id: '1234', + span_id: '5678', + trace_id: 'abcd', + start_timestamp: 123, + end_timestamp: 456, + status: 'ok', + is_segment: true, + attributes: { + attr1: { type: 'string', value: 'value1' }, + attr2: { type: 'integer', value: 2 }, + attr3: { type: 'boolean', value: true }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test op' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto' }, + // notice the absence of `attr4`! + // for now, we don't yet serialize array attributes. This test will fail + // once we allow serializing them. + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { type: 'string', value: 'previous_trace' }, + }, + }, + ], + }); + }); + }); + it('returns minimal object for unknown span implementation', () => { const span = { // This is the minimal interface we require from a span diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 9478b98f5a58..a881f911e31c 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -157,6 +157,7 @@ export { statsigIntegration, unleashIntegration, metrics, + spanStreamingIntegration, } from '@sentry/node'; export { diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 7c92fecd7834..1ac235711ca5 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -23,6 +23,7 @@ export declare function init( export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; +export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; // Different implementation in server and worker export declare const vercelAIIntegration: typeof serverSdk.vercelAIIntegration; diff --git a/packages/node-core/src/common-exports.ts b/packages/node-core/src/common-exports.ts index 3fff4100b352..4877fc159e88 100644 --- a/packages/node-core/src/common-exports.ts +++ b/packages/node-core/src/common-exports.ts @@ -115,6 +115,7 @@ export { consoleIntegration, wrapMcpServerWithSentry, featureFlagsIntegration, + spanStreamingIntegration, metrics, envToBool, } from '@sentry/core'; diff --git a/packages/node-core/src/light/sdk.ts b/packages/node-core/src/light/sdk.ts index ef9229f28de6..77b62e9ab2f9 100644 --- a/packages/node-core/src/light/sdk.ts +++ b/packages/node-core/src/light/sdk.ts @@ -12,6 +12,7 @@ import { linkedErrorsIntegration, propagationContextFromHeaders, requestDataIntegration, + spanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; @@ -162,12 +163,18 @@ function getClientOptions( const integrations = options.integrations; const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(mergedOptions); + const resolvedIntegrations = getIntegrationsToSetup({ + defaultIntegrations, + integrations, + }); + + if (mergedOptions.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) { + resolvedIntegrations.push(spanStreamingIntegration()); + } + return { ...mergedOptions, - integrations: getIntegrationsToSetup({ - defaultIntegrations, - integrations, - }), + integrations: resolvedIntegrations, }; } diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index de1569135cfd..5ae840e6e976 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -14,6 +14,7 @@ import { linkedErrorsIntegration, propagationContextFromHeaders, requestDataIntegration, + spanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import { @@ -212,12 +213,18 @@ function getClientOptions( const integrations = options.integrations; const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(mergedOptions); + const resolvedIntegrations = getIntegrationsToSetup({ + defaultIntegrations, + integrations, + }); + + if (mergedOptions.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) { + resolvedIntegrations.push(spanStreamingIntegration()); + } + return { ...mergedOptions, - integrations: getIntegrationsToSetup({ - defaultIntegrations, - integrations, - }), + integrations: resolvedIntegrations, }; } diff --git a/packages/node-core/test/light/sdk.test.ts b/packages/node-core/test/light/sdk.test.ts index 8b0ef03700d9..48cac52022b0 100644 --- a/packages/node-core/test/light/sdk.test.ts +++ b/packages/node-core/test/light/sdk.test.ts @@ -106,6 +106,39 @@ describe('Light Mode | SDK', () => { expect(integrationNames).toContain('NodeFetch'); }); + + it('does not include spanStreaming integration', () => { + const integrations = Sentry.getDefaultIntegrations({ traceLifecycle: 'stream' }); + const integrationNames = integrations.map(i => i.name); + + expect(integrationNames).not.toContain('SpanStreaming'); + }); + }); + + describe('spanStreamingIntegration', () => { + it('installs spanStreaming integration when traceLifecycle is "stream"', () => { + const client = mockLightSdkInit({ traceLifecycle: 'stream' }); + const integrationNames = client?.getOptions().integrations.map(i => i.name); + + expect(integrationNames).toContain('SpanStreaming'); + }); + + it('does not install spanStreaming integration when traceLifecycle is not "stream"', () => { + const client = mockLightSdkInit(); + const integrationNames = client?.getOptions().integrations.map(i => i.name); + + expect(integrationNames).not.toContain('SpanStreaming'); + }); + + it('installs spanStreaming integration even with custom defaultIntegrations', () => { + const client = mockLightSdkInit({ + traceLifecycle: 'stream', + defaultIntegrations: [], + }); + const integrationNames = client?.getOptions().integrations.map(i => i.name); + + expect(integrationNames).toContain('SpanStreaming'); + }); }); describe('isInitialized', () => { diff --git a/packages/node-core/test/sdk/init.test.ts b/packages/node-core/test/sdk/init.test.ts index 144ff3e2dc37..6ee986c3be75 100644 --- a/packages/node-core/test/sdk/init.test.ts +++ b/packages/node-core/test/sdk/init.test.ts @@ -81,6 +81,39 @@ describe('init()', () => { expect(mockIntegrations[1]?.setupOnce as Mock).toHaveBeenCalledTimes(1); }); + it('installs spanStreaming integration when traceLifecycle is "stream"', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream' }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + + it("doesn't install spanStreaming integration when traceLifecycle is not 'stream'", () => { + init({ dsn: PUBLIC_DSN }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.not.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + + it('installs spanStreaming integration even with custom defaultIntegrations', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream', defaultIntegrations: [] }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + it('installs integrations returned from a callback function', () => { const mockDefaultIntegrations = [ new MockIntegration('Some mock integration 3.1'), diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8458dee5f6a7..9e1e892ed7ce 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -140,6 +140,7 @@ export { consoleIntegration, wrapMcpServerWithSentry, featureFlagsIntegration, + spanStreamingIntegration, createLangChainCallbackHandler, instrumentLangGraph, instrumentStateGraphCompile, diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index a6a76a4439bd..26fe2d9933e6 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -143,6 +143,39 @@ describe('init()', () => { }), ); }); + + it('installs spanStreaming integration when traceLifecycle is "stream"', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream' }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + + it("doesn't install spanStreaming integration when traceLifecycle is not 'stream'", () => { + init({ dsn: PUBLIC_DSN }); + + const client = getClient(); + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.not.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + + it('installs spanStreaming integration even with custom defaultIntegrations', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream', defaultIntegrations: [] }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); }); describe('OpenTelemetry', () => { diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index 7109e7ad9c78..9a0283cfcee8 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -16,6 +16,7 @@ export * from './index.server'; export declare function init(options: Options | SentryNuxtClientOptions | SentryNuxtServerOptions): Client | undefined; 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/opentelemetry/src/spanProcessor.ts b/packages/opentelemetry/src/spanProcessor.ts index 3430456caaee..b4b913c5535e 100644 --- a/packages/opentelemetry/src/spanProcessor.ts +++ b/packages/opentelemetry/src/spanProcessor.ts @@ -6,6 +6,7 @@ import { getClient, getDefaultCurrentScope, getDefaultIsolationScope, + hasSpanStreamingEnabled, logSpanEnd, logSpanStart, setCapturedScopesOnSpan, @@ -14,50 +15,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE } from './semanticAttributes import { SentrySpanExporter } from './spanExporter'; import { getScopesFromContext } from './utils/contextData'; import { setIsSetup } from './utils/setupCheck'; - -function onSpanStart(span: Span, parentContext: Context): void { - // This is a reliable way to get the parent span - because this is exactly how the parent is identified in the OTEL SDK - const parentSpan = trace.getSpan(parentContext); - - let scopes = getScopesFromContext(parentContext); - - // We need access to the parent span in order to be able to move up the span tree for breadcrumbs - if (parentSpan && !parentSpan.spanContext().isRemote) { - addChildSpanToSpan(parentSpan, span); - } - - // We need this in the span exporter - if (parentSpan?.spanContext().isRemote) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE, true); - } - - // The root context does not have scopes stored, so we check for this specifically - // As fallback we attach the global scopes - if (parentContext === ROOT_CONTEXT) { - scopes = { - scope: getDefaultCurrentScope(), - isolationScope: getDefaultIsolationScope(), - }; - } - - // We need the scope at time of span creation in order to apply it to the event when the span is finished - if (scopes) { - setCapturedScopesOnSpan(span, scopes.scope, scopes.isolationScope); - } - - logSpanStart(span); - - const client = getClient(); - client?.emit('spanStart', span); -} - -function onSpanEnd(span: Span): void { - logSpanEnd(span); - - const client = getClient(); - client?.emit('spanEnd', span); -} - /** * Converts OpenTelemetry Spans to Sentry Spans and sends them to Sentry via * the Sentry SDK. @@ -88,13 +45,52 @@ export class SentrySpanProcessor implements SpanProcessorInterface { * @inheritDoc */ public onStart(span: Span, parentContext: Context): void { - onSpanStart(span, parentContext); + // This is a reliable way to get the parent span - because this is exactly how the parent is identified in the OTEL SDK + const parentSpan = trace.getSpan(parentContext); + + let scopes = getScopesFromContext(parentContext); + + // We need access to the parent span in order to be able to move up the span tree for breadcrumbs + if (parentSpan && !parentSpan.spanContext().isRemote) { + addChildSpanToSpan(parentSpan, span); + } + + // We need this in the span exporter + if (parentSpan?.spanContext().isRemote) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE, true); + } + + // The root context does not have scopes stored, so we check for this specifically + // As fallback we attach the global scopes + if (parentContext === ROOT_CONTEXT) { + scopes = { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + }; + } + + // We need the scope at time of span creation in order to apply it to the event when the span is finished + if (scopes) { + setCapturedScopesOnSpan(span, scopes.scope, scopes.isolationScope); + } + + logSpanStart(span); + + const client = getClient(); + client?.emit('spanStart', span); } /** @inheritDoc */ public onEnd(span: Span & ReadableSpan): void { - onSpanEnd(span); + logSpanEnd(span); + + const client = getClient(); + client?.emit('spanEnd', span); - this._exporter.export(span); + if (client && hasSpanStreamingEnabled(client)) { + client.emit('afterSpanEnd', span); + } else { + this._exporter.export(span); + } } } diff --git a/packages/opentelemetry/test/spanProcessor.test.ts b/packages/opentelemetry/test/spanProcessor.test.ts new file mode 100644 index 000000000000..2e3b0b5b999e --- /dev/null +++ b/packages/opentelemetry/test/spanProcessor.test.ts @@ -0,0 +1,57 @@ +import { getClient, startInactiveSpan } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SentrySpanExporter } from '../src/spanExporter'; +import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; + +const exportSpy = vi.spyOn(SentrySpanExporter.prototype, 'export'); + +describe('SentrySpanProcessor', () => { + beforeEach(() => { + exportSpy.mockClear(); + }); + + describe('with traceLifecycle: static (default)', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('exports spans via the exporter', () => { + const span = startInactiveSpan({ name: 'test' }); + span.end(); + + expect(exportSpy).toHaveBeenCalled(); + }); + }); + + describe('with traceLifecycle: stream', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1, traceLifecycle: 'stream' }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('does not export spans via the exporter', () => { + const span = startInactiveSpan({ name: 'test' }); + span.end(); + + expect(exportSpy).not.toHaveBeenCalled(); + }); + + it('emits afterSpanEnd', () => { + const afterSpanEndCallback = vi.fn(); + const client = getClient()!; + client.on('afterSpanEnd', afterSpanEndCallback); + + const span = startInactiveSpan({ name: 'test' }); + span.end(); + + expect(afterSpanEndCallback).toHaveBeenCalledWith(span); + }); + }); +}); diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts index c9c5cb371763..4d0abcb6b0a8 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -16,6 +16,7 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; +export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const defaultStackParser: StackParser; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/remix/src/index.types.ts b/packages/remix/src/index.types.ts index 61d4f7e0b9bb..1cac41c1aacf 100644 --- a/packages/remix/src/index.types.ts +++ b/packages/remix/src/index.types.ts @@ -18,6 +18,7 @@ export declare function init(options: RemixOptions): Client | undefined; export declare const browserTracingIntegration: typeof clientSdk.browserTracingIntegration; 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/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 1533a1ca7221..eccafe08a637 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -131,6 +131,7 @@ export { consoleLoggingIntegration, createConsolaReporter, createSentryWinstonTransport, + spanStreamingIntegration, } from '@sentry/node'; // Keeping the `*` exports for backwards compatibility and types diff --git a/packages/solidstart/src/index.types.ts b/packages/solidstart/src/index.types.ts index 4c5ff491c740..58e642ecba22 100644 --- a/packages/solidstart/src/index.types.ts +++ b/packages/solidstart/src/index.types.ts @@ -18,6 +18,7 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve 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/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 6e2bc1cb9f61..ffc73956a530 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -130,6 +130,7 @@ export { consoleLoggingIntegration, createConsolaReporter, createSentryWinstonTransport, + spanStreamingIntegration, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index d46e88e720ed..78cb27e2e47d 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -47,6 +47,7 @@ export declare function wrapLoadWithSentry any>(orig export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; +export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; // Different implementation in server and worker export declare const vercelAIIntegration: typeof serverSdk.vercelAIIntegration; diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index d42975ef7876..8db039bb4369 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -135,6 +135,7 @@ export { createSentryWinstonTransport, vercelAIIntegration, metrics, + spanStreamingIntegration, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 7ab05bfc3831..0545fbf5c87b 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -18,6 +18,7 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve 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;