diff --git a/.size-limit.js b/.size-limit.js
index 6e6ee0f68303..e6c0f5f3b1ab 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -47,12 +47,19 @@ module.exports = [
gzip: true,
limit: '48 KB',
},
+ // {
+ // name: '@sentry/browser (incl. Tracing Span-First)',
+ // path: 'packages/browser/build/npm/esm/index.js',
+ // import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'),
+ // gzip: true,
+ // limit: '44 KB',
+ // },
{
name: '@sentry/browser (incl. Tracing, Replay)',
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
- limit: '80 KB',
+ limit: '82 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
@@ -82,14 +89,14 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
- limit: '85 KB',
+ limit: '86 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'),
gzip: true,
- limit: '97 KB',
+ limit: '98 KB',
},
{
name: '@sentry/browser (incl. Feedback)',
@@ -103,7 +110,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'sendFeedback'),
gzip: true,
- limit: '30 KB',
+ limit: '31 KB',
},
{
name: '@sentry/browser (incl. FeedbackAsync)',
@@ -127,7 +134,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
- limit: '44 KB',
+ limit: '45 KB',
},
// Vue SDK (ESM)
{
@@ -135,7 +142,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init'),
gzip: true,
- limit: '30 KB',
+ limit: '31 KB',
},
{
name: '@sentry/vue (incl. Tracing)',
@@ -163,7 +170,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
- limit: '42.5 KB',
+ limit: '43 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
@@ -213,7 +220,7 @@ module.exports = [
import: createImport('init'),
ignore: ['next/router', 'next/constants'],
gzip: true,
- limit: '46 KB',
+ limit: '47 KB',
},
// SvelteKit SDK (ESM)
{
@@ -222,7 +229,7 @@ module.exports = [
import: createImport('init'),
ignore: ['$app/stores'],
gzip: true,
- limit: '42 KB',
+ limit: '43 KB',
},
// Node-Core SDK (ESM)
{
@@ -240,7 +247,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
- limit: '160 KB',
+ limit: '161 KB',
},
{
name: '@sentry/node - without tracing',
diff --git a/.vscode/settings.json b/.vscode/settings.json
index c3515b80ced8..cd7be1cecd9f 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -28,5 +28,6 @@
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
- }
+ },
+ "angular.enable-strict-mode-prompt": false
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c903d7df829..576e41db1dde 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -502,6 +502,16 @@ Work in this release was contributed by @hanseo0507. Thank you for your contribu
Work in this release was contributed by @0xbad0c0d3. Thank you for your contribution!
+## 10.21.0-alpha.1
+
+This release is a preview release for sending spans in browser via spanV2 instead of transaction event envelopes. All of this is experimental and subject to change. Use at your own risk. [More Details.](https://github.com/getsentry/sentry-javascript/pull/17852)
+
+- export withStreamSpan from `@sentry/browser`
+
+## 10.21.0-alpha.0
+
+This release is a preview release for sending spans in browser via spanV2 instead of transaction event envelopes. All of this is experimental and subject to change. Use at your own risk. [More Details.](https://github.com/getsentry/sentry-javascript/pull/17852)
+
## 10.20.0
### Important Changes
diff --git a/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/init.js b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/init.js
new file mode 100644
index 000000000000..5541015d7585
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/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',
+ traceLifecycle: 'stream',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+ sendDefaultPii: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/subject.js b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/subject.js
new file mode 100644
index 000000000000..b657f38ac009
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/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/span-first/backgroundtab-pageload/template.html b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/template.html
new file mode 100644
index 000000000000..31cfc73ec3c3
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/template.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/test.ts b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/test.ts
new file mode 100644
index 000000000000..368820e754fb
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/test.ts
@@ -0,0 +1,22 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../utils/fixtures';
+import { shouldSkipTracingTest } from '../../../utils/helpers';
+import { getSpanOp, waitForV2Spans } from '../../../utils/spanFirstUtils';
+
+sentryTest('ends pageload span when the page goes to background', async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spanPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload'));
+
+ await page.goto(url);
+ await page.locator('#go-background').click();
+
+ const pageloadSpan = (await spanPromise).find(span => getSpanOp(span) === 'pageload');
+
+ expect(pageloadSpan?.status).toBe('error'); // a cancelled span previously mapped to status error with message cancelled.
+ expect(pageloadSpan?.attributes?.['sentry.op']?.value).toBe('pageload');
+ expect(pageloadSpan?.attributes?.['sentry.cancellation_reason']?.value).toBe('document.hidden');
+});
diff --git a/dev-packages/browser-integration-tests/suites/span-first/error/init.js b/dev-packages/browser-integration-tests/suites/span-first/error/init.js
new file mode 100644
index 000000000000..853d9ec8f605
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/error/init.js
@@ -0,0 +1,13 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ traceLifecycle: 'stream',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+ sendDefaultPii: true,
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/span-first/error/test.ts b/dev-packages/browser-integration-tests/suites/span-first/error/test.ts
new file mode 100644
index 000000000000..682cece57172
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/error/test.ts
@@ -0,0 +1,50 @@
+import { expect } from '@playwright/test';
+import type { Event } from '@sentry/core';
+import { sentryTest } from '../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ runScriptInSandbox,
+ shouldSkipTracingTest,
+ waitForErrorRequest,
+} from '../../../utils/helpers';
+import { getSpanOp, waitForV2Spans } from '../../../utils/spanFirstUtils';
+
+sentryTest(
+ 'puts the pageload span name onto an error event caught during pageload',
+ async ({ getLocalTestUrl, page, browserName }) => {
+ if (browserName === 'webkit') {
+ // This test fails on Webkit as errors thrown from `runScriptInSandbox` are Script Errors and skipped by Sentry
+ sentryTest.skip();
+ }
+
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const errorEventPromise = waitForErrorRequest(page);
+ const spanPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload'));
+
+ await page.goto(url);
+
+ await runScriptInSandbox(page, {
+ content: `
+ throw new Error('Error during pageload');
+ `,
+ });
+
+ const errorEvent = envelopeRequestParser(await errorEventPromise);
+ const pageloadSpan = (await spanPromise).find(span => getSpanOp(span) === 'pageload');
+
+ expect(pageloadSpan?.attributes?.['sentry.op']?.value).toEqual('pageload');
+ expect(errorEvent.exception?.values?.[0]).toBeDefined();
+
+ expect(pageloadSpan?.name).toEqual('/index.html');
+
+ expect(pageloadSpan?.status).toBe('error');
+ expect(pageloadSpan?.attributes?.['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout');
+
+ expect(errorEvent.transaction).toEqual(pageloadSpan?.name);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/span-first/init.js b/dev-packages/browser-integration-tests/suites/span-first/init.js
new file mode 100644
index 000000000000..5541015d7585
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/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',
+ traceLifecycle: 'stream',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+ sendDefaultPii: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts b/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts
new file mode 100644
index 000000000000..4709fd6ae81c
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts
@@ -0,0 +1,100 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../utils/fixtures';
+import { shouldSkipTracingTest } from '../../../utils/helpers';
+import { getSpanOp, waitForV2Spans } from '../../../utils/spanFirstUtils';
+
+sentryTest("navigation spans link back to previous trace's root span", async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpan = await sentryTest.step('Initial pageload', async () => {
+ const pageloadSpanPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload'));
+ await page.goto(url);
+ return (await pageloadSpanPromise).find(span => getSpanOp(span) === 'pageload');
+ });
+
+ const navigation1Span = await sentryTest.step('First navigation', async () => {
+ const navigation1SpanPromise = waitForV2Spans(
+ page,
+ spans => !!spans.find(span => getSpanOp(span) === 'navigation'),
+ );
+ await page.goto(`${url}#foo`);
+ return (await navigation1SpanPromise).find(span => getSpanOp(span) === 'navigation');
+ });
+
+ const navigation2Span = await sentryTest.step('Second navigation', async () => {
+ const navigation2SpanPromise = waitForV2Spans(
+ page,
+ spans => !!spans.find(span => getSpanOp(span) === 'navigation'),
+ );
+ await page.goto(`${url}#bar`);
+ return (await navigation2SpanPromise).find(span => getSpanOp(span) === 'navigation');
+ });
+
+ 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]: { value: 'previous_trace', type: 'string' },
+ },
+ },
+ ]);
+
+ expect(navigation1Span?.attributes).toMatchObject({
+ 'sentry.previous_trace': { 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]: { value: 'previous_trace', type: 'string' },
+ },
+ },
+ ]);
+
+ expect(navigation2Span?.attributes).toMatchObject({
+ 'sentry.previous_trace': { 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 }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await sentryTest.step('First pageload', async () => {
+ const pageloadRequestPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload'));
+ await page.goto(url);
+ return (await pageloadRequestPromise).find(span => getSpanOp(span) === 'pageload');
+ });
+
+ await sentryTest.step('Second pageload', async () => {
+ const pageload2RequestPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload'));
+ await page.reload();
+ const pageload2Span = (await pageload2RequestPromise).find(span => getSpanOp(span) === 'pageload');
+
+ expect(pageload2Span?.trace_id).toBeDefined();
+ expect(pageload2Span?.links).toBeUndefined();
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/span-first/pageload/init.js b/dev-packages/browser-integration-tests/suites/span-first/pageload/init.js
new file mode 100644
index 000000000000..5541015d7585
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/pageload/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',
+ traceLifecycle: 'stream',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+ sendDefaultPii: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts b/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts
new file mode 100644
index 000000000000..e488d96bc9e6
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts
@@ -0,0 +1,130 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../utils/fixtures';
+import { shouldSkipTracingTest } from '../../../utils/helpers';
+import { getSpanOp, waitForSpanV2Envelope } from '../../../utils/spanFirstUtils';
+
+sentryTest('sends a span v2 envelope for the pageload', async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const spanEnvelopePromise = waitForSpanV2Envelope(page);
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.goto(url);
+
+ const spanEnvelope = await spanEnvelopePromise;
+
+ const envelopeHeaders = spanEnvelope[0];
+
+ const envelopeItem0 = spanEnvelope[1][0];
+ const envelopeItemHeader = envelopeItem0[0];
+ const envelopeItem = envelopeItem0[1];
+
+ expect(envelopeHeaders).toEqual({
+ sent_at: expect.any(String),
+ trace: {
+ environment: 'production',
+ public_key: 'public',
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ sampled: 'true',
+ sample_rand: expect.any(String),
+ sample_rate: '1',
+ },
+ sdk: {
+ name: 'sentry.javascript.browser',
+ packages: [
+ {
+ name: 'npm:@sentry/browser',
+ version: expect.any(String),
+ },
+ ],
+ version: expect.any(String),
+ settings: {
+ infer_ip: 'auto',
+ },
+ },
+ });
+
+ expect(envelopeItemHeader).toEqual({
+ content_type: 'application/vnd.sentry.items.span.v2+json',
+ item_count: expect.any(Number),
+ type: 'span',
+ });
+
+ // test the shape of the item first, then the content
+ expect(envelopeItem).toEqual({
+ items: expect.any(Array),
+ });
+
+ expect(envelopeItem.items.length).toBe(envelopeItemHeader.item_count);
+
+ const pageloadSpan = envelopeItem.items.find(item => getSpanOp(item) === 'pageload');
+
+ expect(pageloadSpan).toBeDefined();
+
+ expect(pageloadSpan).toEqual({
+ attributes: expect.objectContaining({
+ 'performance.activationStart': {
+ type: 'integer',
+ value: 0,
+ },
+ 'performance.timeOrigin': {
+ type: 'double',
+ value: expect.any(Number),
+ },
+ 'sentry.op': {
+ type: 'string',
+ value: 'pageload',
+ },
+ 'sentry.origin': {
+ type: 'string',
+ value: 'auto.pageload.browser',
+ },
+ 'sentry.sample_rate': {
+ type: 'integer',
+ value: 1,
+ },
+ 'sentry.sdk.name': {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ 'sentry.sdk.version': {
+ type: 'string',
+ value: expect.any(String),
+ },
+ 'sentry.segment.id': {
+ type: 'string',
+ value: pageloadSpan?.span_id, // pageload is always the segment
+ },
+ 'sentry.segment.name': {
+ type: 'string',
+ value: '/index.html',
+ },
+ 'sentry.source': {
+ type: 'string',
+ value: 'url',
+ },
+ 'sentry.idle_span_finish_reason': {
+ type: 'string',
+ value: 'idleTimeout',
+ },
+ 'url.full': {
+ type: 'string',
+ value: 'http://sentry-test.io/index.html',
+ },
+ 'http.request.header.user_agent': {
+ type: 'string',
+ value: expect.any(String),
+ },
+ }),
+ trace_id: expect.stringMatching(/^[a-f\d]{32}$/),
+ span_id: expect.stringMatching(/^[a-f\d]{16}$/),
+ name: '/index.html',
+ status: 'ok',
+ is_segment: true,
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/init.js b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/init.js
new file mode 100644
index 000000000000..853d9ec8f605
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/init.js
@@ -0,0 +1,13 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ traceLifecycle: 'stream',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+ sendDefaultPii: true,
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/template.html b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/template.html
new file mode 100644
index 000000000000..e98eee38c4e3
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/template.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+ Rendered
+
+
diff --git a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts
new file mode 100644
index 000000000000..a54f4c1bdb24
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts
@@ -0,0 +1,33 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest } from '../../../../utils/helpers';
+import { getSpanOp, waitForV2Spans } from '../../../../utils/spanFirstUtils';
+
+sentryTest('captures TTFB web vital', async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+ const pageloadSpansPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload'));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const pageloadSpan = (await pageloadSpansPromise).find(span => getSpanOp(span) === 'pageload');
+
+ expect(pageloadSpan).toBeDefined();
+
+ // If responseStart === 0, ttfb is not reported
+ // This seems to happen somewhat randomly, so we just ignore this in that case
+ const responseStart = await page.evaluate("performance.getEntriesByType('navigation')[0].responseStart;");
+ if (responseStart !== 0) {
+ expect(pageloadSpan!.attributes?.['ui.web_vital.ttfb']).toEqual({
+ type: expect.stringMatching(/^double$/),
+ value: expect.any(Number),
+ });
+ }
+
+ expect(pageloadSpan!.attributes?.['ui.web_vital.ttfb.requestTime']).toEqual({
+ type: expect.stringMatching(/^integer|double$/),
+ value: expect.any(Number),
+ });
+});
diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts
index dd75d2f6ee86..0495a539ff53 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/spanFirstUtils.ts b/dev-packages/browser-integration-tests/utils/spanFirstUtils.ts
new file mode 100644
index 000000000000..212355f5e780
--- /dev/null
+++ b/dev-packages/browser-integration-tests/utils/spanFirstUtils.ts
@@ -0,0 +1,62 @@
+import type { Page } from '@playwright/test';
+import type { SpanV2Envelope, SpanV2JSON } from '@sentry/core';
+import { properFullEnvelopeParser } from './helpers';
+
+/**
+ * Wait for a span v2 envelope
+ */
+export async function waitForSpanV2Envelope(
+ page: Page,
+ callback?: (spanEnvelope: SpanV2Envelope) => 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.
+ * (We might need a more sophisticated helper that waits for N envelopes and buckets by traceId)
+ * For now, this should do.
+ * @param page
+ * @param callback - Callback being called with all spans
+ */
+export async function waitForV2Spans(page: Page, callback?: (spans: SpanV2JSON[]) => boolean): Promise {
+ const spanEnvelope = await waitForSpanV2Envelope(page, envelope => {
+ if (callback) {
+ return callback(envelope[1][0][1].items);
+ }
+ return true;
+ });
+ return spanEnvelope[1][0][1].items;
+}
+
+export function getSpanOp(span: SpanV2JSON): string | undefined {
+ return span.attributes?.['sentry.op']?.type === 'string' ? span.attributes?.['sentry.op']?.value : undefined;
+}
diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts
index 25dbb9416fe6..7e9597f9f39e 100644
--- a/packages/astro/src/server/sdk.ts
+++ b/packages/astro/src/server/sdk.ts
@@ -15,6 +15,7 @@ export function init(options: NodeOptions): NodeClient | undefined {
const client = initNodeSdk(opts);
+ // TODO (span-streaming): remove this event processor. In this case, can probably just disable http integration server spans
client?.addEventProcessor(
Object.assign(
(event: Event) => {
diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts
index 3c3dee074cb5..13ba874ff055 100644
--- a/packages/browser-utils/src/metrics/browserMetrics.ts
+++ b/packages/browser-utils/src/metrics/browserMetrics.ts
@@ -3,6 +3,7 @@ import type { Client, Measurements, Span, SpanAttributes, SpanAttributeValue, St
import {
browserPerformanceTimeOrigin,
getActiveSpan,
+ getClient,
getComponentName,
htmlTreeAsString,
isPrimitive,
@@ -402,7 +403,12 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
delete _measurements.lcp;
}
+ const isSpanFirst = getClient()?.getOptions().traceLifecycle === 'stream';
Object.entries(_measurements).forEach(([measurementName, measurement]) => {
+ if (isSpanFirst) {
+ span.setAttribute(`ui.web_vital.${measurementName}`, measurement.value);
+ return;
+ }
setMeasurement(measurementName, measurement.value, measurement.unit);
});
diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts
index 6e7c54198edc..fdf089c5bb1a 100644
--- a/packages/browser/src/index.ts
+++ b/packages/browser/src/index.ts
@@ -51,6 +51,7 @@ export {
startInactiveSpan,
startSpanManual,
withActiveSpan,
+ withStreamSpan,
startNewTrace,
getSpanDescendants,
setMeasurement,
@@ -83,3 +84,4 @@ export { growthbookIntegration } from './integrations/featureFlags/growthbook';
export { statsigIntegration } from './integrations/featureFlags/statsig';
export { diagnoseSdkConnectivity } from './diagnose-sdk';
export { webWorkerIntegration, registerWebWorker } from './integrations/webWorker';
+export { spanStreamingIntegration } from './integrations/spanstreaming';
diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts
index 9517b2364e83..4c9884ddb848 100644
--- a/packages/browser/src/integrations/httpcontext.ts
+++ b/packages/browser/src/integrations/httpcontext.ts
@@ -1,4 +1,9 @@
-import { defineIntegration } from '@sentry/core';
+import {
+ defineIntegration,
+ httpHeadersToSpanAttributes,
+ safeSetSpanJSONAttributes,
+ SEMANTIC_ATTRIBUTE_URL_FULL,
+} from '@sentry/core';
import { getHttpRequestData, WINDOW } from '../helpers';
/**
@@ -6,11 +11,33 @@ import { getHttpRequestData, WINDOW } from '../helpers';
* attaches them to the event.
*/
export const httpContextIntegration = defineIntegration(() => {
+ const inBrowserEnvironment = WINDOW.navigator || WINDOW.location || WINDOW.document;
+
return {
name: 'HttpContext',
+ setup(client) {
+ if (!inBrowserEnvironment) {
+ return;
+ }
+
+ if (client.getOptions().traceLifecycle === 'stream') {
+ client.on('processSpan', spanJSON => {
+ if (spanJSON.is_segment) {
+ const { url, headers } = getHttpRequestData();
+
+ const attributeHeaders = httpHeadersToSpanAttributes(headers);
+
+ safeSetSpanJSONAttributes(spanJSON, {
+ [SEMANTIC_ATTRIBUTE_URL_FULL]: url,
+ ...attributeHeaders,
+ });
+ }
+ });
+ }
+ },
preprocessEvent(event) {
// if none of the information we want exists, don't bother
- if (!WINDOW.navigator && !WINDOW.location && !WINDOW.document) {
+ if (!inBrowserEnvironment) {
return;
}
diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts
new file mode 100644
index 000000000000..e1d215d8c29d
--- /dev/null
+++ b/packages/browser/src/integrations/spanstreaming.ts
@@ -0,0 +1,137 @@
+import type { Client, IntegrationFn, Span, SpanV2JSON, SpanV2JSONWithSegmentRef } from '@sentry/core';
+import {
+ captureSpan,
+ createSpanV2Envelope,
+ debug,
+ defineIntegration,
+ getDynamicSamplingContextFromSpan,
+ isV2BeforeSendSpanCallback,
+} from '@sentry/core';
+import { DEBUG_BUILD } from '../debug-build';
+
+export interface SpanStreamingOptions {
+ batchLimit: number;
+}
+
+export const spanStreamingIntegration = defineIntegration(((userOptions?: Partial) => {
+ const validatedUserProvidedBatchLimit =
+ userOptions?.batchLimit && userOptions.batchLimit <= 1000 && userOptions.batchLimit >= 1
+ ? userOptions.batchLimit
+ : undefined;
+
+ if (DEBUG_BUILD && userOptions?.batchLimit && !validatedUserProvidedBatchLimit) {
+ debug.warn('SpanStreaming batchLimit must be between 1 and 1000, defaulting to 1000');
+ }
+
+ const options: SpanStreamingOptions = {
+ ...userOptions,
+ batchLimit:
+ userOptions?.batchLimit && userOptions.batchLimit <= 1000 && userOptions.batchLimit >= 1
+ ? userOptions.batchLimit
+ : 1000,
+ };
+
+ // key: traceId-segmentSpanId
+ const spanTreeMap = new Map>();
+
+ return {
+ name: 'SpanStreaming',
+ setup(client) {
+ const clientOptions = client.getOptions();
+ const beforeSendSpan = clientOptions.beforeSendSpan;
+
+ const initialMessage = 'spanStreamingIntegration requires';
+ const fallbackMsg = 'Falling back to static trace lifecycle.';
+
+ if (clientOptions.traceLifecycle !== 'stream') {
+ DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`);
+ return;
+ }
+
+ if (beforeSendSpan && !isV2BeforeSendSpanCallback(beforeSendSpan)) {
+ client.getOptions().traceLifecycle = 'static';
+ debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamSpan\`! ${fallbackMsg}`);
+ return;
+ }
+
+ client.on('enqueueSpan', spanJSON => {
+ const spanTreeMapKey = getSpanTreeMapKey(spanJSON as SpanV2JSONWithSegmentRef);
+ const spanBuffer = spanTreeMap.get(spanTreeMapKey);
+ if (spanBuffer) {
+ spanBuffer.add(spanJSON as SpanV2JSONWithSegmentRef);
+ } else {
+ spanTreeMap.set(spanTreeMapKey, new Set([spanJSON as SpanV2JSONWithSegmentRef]));
+ }
+ });
+
+ client.on('afterSpanEnd', span => {
+ captureSpan(span, client);
+ });
+
+ // For now, we send all spans on local segment (root) span end.
+ // TODO: This will change once we have more concrete ideas about a universal SDK data buffer.
+ client.on('afterSegmentSpanEnd', segmentSpan => {
+ sendSegment(segmentSpan, {
+ spanTreeMap,
+ client,
+ batchLimit: options.batchLimit,
+ });
+ });
+ },
+ };
+}) satisfies IntegrationFn);
+
+interface SpanProcessingOptions {
+ client: Client;
+ spanTreeMap: Map>;
+ batchLimit: number;
+}
+
+/**
+ * Just the traceid alone isn't enough because there can be multiple span trees with the same traceid.
+ */
+function getSpanTreeMapKey(spanJSON: SpanV2JSONWithSegmentRef): string {
+ return `${spanJSON.trace_id}-${spanJSON._segmentSpan?.spanContext().spanId || spanJSON.span_id}`;
+}
+
+function sendSegment(segmentSpan: Span, { client, spanTreeMap, batchLimit }: SpanProcessingOptions): void {
+ const traceId = segmentSpan.spanContext().traceId;
+ const segmentSpanId = segmentSpan.spanContext().spanId;
+ const spanTreeMapKey = `${traceId}-${segmentSpanId}`;
+ const spansOfTrace = spanTreeMap.get(spanTreeMapKey);
+
+ if (!spansOfTrace?.size) {
+ spanTreeMap.delete(spanTreeMapKey);
+ return;
+ }
+
+ // Apply beforeSendSpan callback and clean up segment span references
+ const finalSpans = Array.from(spansOfTrace).map(spanJSON => {
+ // Remove the segment span reference before processing
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { _segmentSpan, ...cleanSpanJSON } = spanJSON;
+ return cleanSpanJSON;
+ });
+
+ const batches: SpanV2JSON[][] = [];
+ for (let i = 0; i < finalSpans.length; i += batchLimit) {
+ batches.push(finalSpans.slice(i, i + batchLimit));
+ }
+
+ DEBUG_BUILD && debug.log(`Sending trace ${traceId} in ${batches.length} batch${batches.length === 1 ? '' : 'es'}`);
+
+ // Compute DSC from the segment span (passed as parameter)
+ const dsc = getDynamicSamplingContextFromSpan(segmentSpan);
+
+ for (const batch of batches) {
+ const envelope = createSpanV2Envelope(batch, dsc, client);
+ // no need to handle client reports for network errors,
+ // buffer overflows or rate limiting here. All of this is handled
+ // by client and transport.
+ client.sendEnvelope(envelope).then(null, reason => {
+ DEBUG_BUILD && debug.error('Error while sending span stream envelope:', reason);
+ });
+ }
+
+ spanTreeMap.delete(spanTreeMapKey);
+}
diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts
index 025b08b12168..692fc131230b 100644
--- a/packages/browser/src/tracing/request.ts
+++ b/packages/browser/src/tracing/request.ts
@@ -156,6 +156,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial {
if (event.type === 'transaction' && event.spans) {
event.spans.forEach(span => {
diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts
index d979d5c4350f..0ed74a31ad67 100644
--- a/packages/core/src/attributes.ts
+++ b/packages/core/src/attributes.ts
@@ -42,7 +42,7 @@ export type AttributeObject = {
// Unfortunately, we loose type safety if we did something like Exclude
// so therefore we unionize between the three supported unit categories.
-type AttributeUnit = DurationUnit | InformationUnit | FractionUnit;
+export type AttributeUnit = DurationUnit | InformationUnit | FractionUnit;
/* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */
export type ValidatedAttributes = {
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index aad363905a68..1cacceeba007 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -31,9 +31,17 @@ 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,
+ SpanV2JSON,
+ SpanV2JSONWithSegmentRef,
+} from './types-hoist/span';
import type { StartSpanOptions } from './types-hoist/startSpanOptions';
import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport';
+import { isV2BeforeSendSpanCallback } from './utils/beforeSendSpan';
import { createClientReportEnvelope } from './utils/clientreport';
import { debug } from './utils/debug-logger';
import { dsnToString, makeDsn } from './utils/dsn';
@@ -607,6 +615,24 @@ export abstract class Client {
*/
public on(hook: 'spanEnd', callback: (span: Span) => void): () => void;
+ // Hooks reserved for Span-First span processing:
+ /**
+ * Register a callback for after a span is ended.
+ */
+ public on(hook: 'afterSpanEnd', callback: (span: Span) => void): () => void;
+ /**
+ * Register a callback for after a segment span is ended.
+ */
+ public on(hook: 'afterSegmentSpanEnd', callback: (span: Span) => void): () => void;
+ /**
+ * Register a callback for when the span JSON is ready to be enqueued into the span buffer.
+ */
+ public on(hook: 'enqueueSpan', callback: (spanJSON: SpanV2JSONWithSegmentRef) => void): () => void;
+ /**
+ * Register a callback for when a span JSON is processed, to add some attributes to the span JSON.
+ */
+ public on(hook: 'processSpan', callback: (spanJSON: SpanV2JSON, hint: { readOnlySpan: Span }) => 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.
@@ -879,6 +905,16 @@ export abstract class Client {
/** Fire a hook whenever a span ends. */
public emit(hook: 'spanEnd', span: Span): void;
+ // Hooks reserved for Span-First span processing:
+ /** Fire a hook after the `spanEnd` hook */
+ public emit(hook: 'afterSpanEnd', span: Span): void;
+ /** Fire a hook after a span is processed, to add some attributes to the span JSON. */
+ public emit(hook: 'processSpan', spanJSON: SpanV2JSON, hint: { readOnlySpan: Span }): void;
+ /** Fire a hook after the `segmentSpanEnd` hook is fired. */
+ public emit(hook: 'afterSegmentSpanEnd', span: Span): void;
+ /** Fire a hook after a span ready to be enqueued into the span buffer. */
+ public emit(hook: 'enqueueSpan', spanJSON: SpanV2JSONWithSegmentRef): void;
+
/**
* Fire a hook indicating that an idle span is allowed to auto finish.
*/
@@ -1492,13 +1528,17 @@ function _validateBeforeSendResult(
/**
* Process the matching `beforeSendXXX` callback.
*/
+// eslint-disable-next-line complexity
function processBeforeSend(
client: Client,
options: ClientOptions,
event: Event,
hint: EventHint,
): PromiseLike | Event | null {
- const { beforeSend, beforeSendTransaction, beforeSendSpan, ignoreSpans } = options;
+ const { beforeSend, beforeSendTransaction, ignoreSpans } = options;
+
+ const beforeSendSpan = !isV2BeforeSendSpanCallback(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..aa392314d1db 100644
--- a/packages/core/src/envelope.ts
+++ b/packages/core/src/envelope.ts
@@ -11,13 +11,17 @@ import type {
RawSecurityItem,
SessionEnvelope,
SessionItem,
+ SpanContainerItem,
SpanEnvelope,
SpanItem,
+ SpanV2Envelope,
} from './types-hoist/envelope';
import type { Event } from './types-hoist/event';
import type { SdkInfo } from './types-hoist/sdkinfo';
import type { SdkMetadata } from './types-hoist/sdkmetadata';
import type { Session, SessionAggregates } from './types-hoist/session';
+import type { SpanV2JSON } from './types-hoist/span';
+import { isV2BeforeSendSpanCallback } from './utils/beforeSendSpan';
import { dsnToString } from './utils/dsn';
import {
createEnvelope,
@@ -120,10 +124,6 @@ export function createEventEnvelope(
* Takes an optional client and runs spans through `beforeSendSpan` if available.
*/
export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client?: Client): SpanEnvelope {
- function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext {
- return !!dsc.trace_id && !!dsc.public_key;
- }
-
// For the moment we'll obtain the DSC from the first span in the array
// This might need to be changed if we permit sending multiple spans from
// different segments in one envelope
@@ -138,7 +138,8 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client?
...(!!tunnel && dsn && { dsn: dsnToString(dsn) }),
};
- const { beforeSendSpan, ignoreSpans } = client?.getOptions() || {};
+ const options = client?.getOptions();
+ const ignoreSpans = options?.ignoreSpans;
const filteredSpans = ignoreSpans?.length
? spans.filter(span => !shouldIgnoreSpan(spanToJSON(span), ignoreSpans))
@@ -149,10 +150,14 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client?
client?.recordDroppedEvent('before_send', 'span', droppedSpans);
}
- const convertToSpanJSON = beforeSendSpan
+ // checking against traceLifeCycle so that TS can infer the correct type for
+ // beforeSendSpan. This is a workaround for now as most likely, this entire function
+ // will be removed in the future (once we send standalone spans as spans v2)
+ const convertToSpanJSON = options?.beforeSendSpan
? (span: SentrySpan) => {
const spanJson = spanToJSON(span);
- const processedSpan = beforeSendSpan(spanJson);
+ const processedSpan =
+ !isV2BeforeSendSpanCallback(options?.beforeSendSpan) && options?.beforeSendSpan?.(spanJson);
if (!processedSpan) {
showSpanDropWarning();
@@ -174,6 +179,33 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client?
return createEnvelope(headers, items);
}
+/**
+ * Creates a span v2 envelope
+ */
+export function createSpanV2Envelope(
+ serializedSpans: SpanV2JSON[],
+ dsc: Partial,
+ client: Client,
+): SpanV2Envelope {
+ const dsn = client?.getDsn();
+ const tunnel = client?.getOptions().tunnel;
+ const sdk = client?.getOptions()._metadata?.sdk;
+
+ const headers: SpanV2Envelope[0] = {
+ sent_at: new Date().toISOString(),
+ ...(dscHasRequiredProps(dsc) && { trace: dsc }),
+ ...(sdk && { 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]);
+}
+
/**
* Create an Envelope from a CSP report.
*/
@@ -196,3 +228,7 @@ export function createRawSecurityEnvelope(
return createEnvelope(envelopeHeaders, [eventItem]);
}
+
+function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext {
+ return !!dsc.trace_id && !!dsc.public_key;
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 387ba0aba4a2..d7404dc9a98b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -9,7 +9,7 @@ export type { IntegrationIndex } from './integration';
export * from './tracing';
export * from './semanticAttributes';
-export { createEventEnvelope, createSessionEnvelope, createSpanEnvelope } from './envelope';
+export { createEventEnvelope, createSessionEnvelope, createSpanEnvelope, createSpanV2Envelope } from './envelope';
export {
captureCheckIn,
withMonitor,
@@ -81,11 +81,17 @@ export {
getSpanDescendants,
getStatusMessage,
getRootSpan,
+ INTERNAL_getSegmentSpan,
getActiveSpan,
addChildSpanToSpan,
spanTimeInputToSeconds,
updateSpanName,
+ spanToV2JSON,
+ showSpanDropWarning,
} from './utils/spanUtils';
+export { captureSpan } from './spans/captureSpan';
+export { safeSetSpanJSONAttributes } from './spans/spanFirstUtils';
+export { attributesFromObject } from './utils/attributes';
export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope';
export { parseSampleRate } from './utils/parseSampleRate';
export { applySdkMetadata } from './utils/sdkMetadata';
@@ -321,6 +327,8 @@ export { flushIfServerless } from './utils/flushIfServerless';
export { SDK_VERSION } from './utils/version';
export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids';
export { escapeStringForRegex } from './vendor/escapeStringForRegex';
+export { isV2BeforeSendSpanCallback, withStreamSpan } from './utils/beforeSendSpan';
+export { shouldIgnoreSpan, reparentChildSpans } from './utils/should-ignore-span';
export type { Attachment } from './types-hoist/attachment';
export type {
@@ -372,6 +380,7 @@ export type {
ProfileChunkEnvelope,
ProfileChunkItem,
SpanEnvelope,
+ SpanV2Envelope,
SpanItem,
LogEnvelope,
MetricEnvelope,
@@ -439,6 +448,8 @@ export type {
SpanJSON,
SpanContextData,
TraceFlag,
+ SpanV2JSON,
+ SpanV2JSONWithSegmentRef,
} 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/integrations/eventFilters.ts b/packages/core/src/integrations/eventFilters.ts
index 84ae5d4c4139..4278d234a0f9 100644
--- a/packages/core/src/integrations/eventFilters.ts
+++ b/packages/core/src/integrations/eventFilters.ts
@@ -145,7 +145,7 @@ function _shouldDropEvent(event: Event, options: Partial):
}
} else if (event.type === 'transaction') {
// Filter transactions
-
+ // TODO (span-streaming): replace with ignoreSpans defaults (if we have any)
if (_isIgnoredTransaction(event, options.ignoreTransactions)) {
DEBUG_BUILD &&
debug.warn(
diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts
index a72fbed70d7e..5a45bc9c9861 100644
--- a/packages/core/src/integrations/requestdata.ts
+++ b/packages/core/src/integrations/requestdata.ts
@@ -40,6 +40,8 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) =
return {
name: INTEGRATION_NAME,
+ // TODO (span-streaming): probably fine to leave as-is for errors.
+ // For spans, we go through global context -> attribute conversion or omit this completely (TBD)
processEvent(event, _hint, client) {
const { sdkProcessingMetadata = {} } = event;
const { normalizedRequest, ipAddress } = sdkProcessingMetadata;
diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts
index 9b90809c0091..ac7bc2c3b188 100644
--- a/packages/core/src/semanticAttributes.ts
+++ b/packages/core/src/semanticAttributes.ts
@@ -77,3 +77,27 @@ export const SEMANTIC_ATTRIBUTE_URL_FULL = 'url.full';
* @see https://develop.sentry.dev/sdk/telemetry/traces/span-links/#link-types
*/
export const SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE = 'sentry.link.type';
+
+// some attributes for now exclusively used for span streaming
+// @see https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/#common-attribute-keys
+
+/** 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 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';
+/** 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';
diff --git a/packages/core/src/spans/captureSpan.ts b/packages/core/src/spans/captureSpan.ts
new file mode 100644
index 000000000000..5ac3826c42e9
--- /dev/null
+++ b/packages/core/src/spans/captureSpan.ts
@@ -0,0 +1,115 @@
+import type { Client } from '../client';
+import { getClient, getGlobalScope } from '../currentScopes';
+import { DEBUG_BUILD } from '../debug-build';
+import type { Scope, 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_USER_EMAIL,
+ SEMANTIC_ATTRIBUTE_USER_ID,
+ SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS,
+ SEMANTIC_ATTRIBUTE_USER_USERNAME,
+} from '../semanticAttributes';
+import { getCapturedScopesOnSpan } from '../tracing/utils';
+import type { SerializedAttributes } from '../types-hoist/attributes';
+import type { Span, SpanV2JSON } from '../types-hoist/span';
+import { mergeScopeData } from '../utils/applyScopeDataToEvent';
+import { isV2BeforeSendSpanCallback } from '../utils/beforeSendSpan';
+import { debug } from '../utils/debug-logger';
+import { INTERNAL_getSegmentSpan, spanToV2JSON } from '../utils/spanUtils';
+import { applyBeforeSendSpanCallback, contextsToAttributes, safeSetSpanJSONAttributes } from './spanFirstUtils';
+/**
+ * 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).
+ */
+export function captureSpan(span: Span, client = getClient()): void {
+ if (!client) {
+ DEBUG_BUILD && debug.warn('No client available to capture span.');
+ return;
+ }
+
+ // Convert to JSON FIRST - we cannot write to an already-ended span
+ const spanJSON = spanToV2JSON(span);
+
+ const segmentSpan = INTERNAL_getSegmentSpan(span);
+ const serializedSegmentSpan = spanToV2JSON(segmentSpan);
+
+ const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span);
+
+ const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope);
+
+ applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData);
+
+ if (span === segmentSpan) {
+ applyScopeToSegmentSpan(spanJSON, finalScopeData);
+ }
+
+ // Allow integrations to add additional data to the span JSON
+ client.emit('processSpan', spanJSON, { readOnlySpan: span });
+
+ const beforeSendSpan = client.getOptions().beforeSendSpan;
+ const processedSpan = isV2BeforeSendSpanCallback(beforeSendSpan)
+ ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan)
+ : spanJSON;
+
+ const spanWithRef = {
+ ...processedSpan,
+ _segmentSpan: segmentSpan,
+ };
+
+ client.emit('enqueueSpan', spanWithRef);
+}
+
+function applyScopeToSegmentSpan(segmentSpanJSON: SpanV2JSON, scopeData: ScopeData): void {
+ // TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span
+ const { contexts } = scopeData;
+
+ safeSetSpanJSONAttributes(segmentSpanJSON, contextsToAttributes(contexts));
+}
+
+function applyCommonSpanAttributes(
+ spanJSON: SpanV2JSON,
+ serializedSegmentSpan: SpanV2JSON,
+ 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,
+ });
+}
+
+// TODO: Extract this to a helper in core. It's used in multiple places.
+function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | undefined): ScopeData {
+ const finalScopeData = getGlobalScope().getScopeData();
+ if (isolationScope) {
+ mergeScopeData(finalScopeData, isolationScope.getScopeData());
+ }
+ if (scope) {
+ mergeScopeData(finalScopeData, scope.getScopeData());
+ }
+ return finalScopeData;
+}
diff --git a/packages/core/src/spans/spanFirstUtils.ts b/packages/core/src/spans/spanFirstUtils.ts
new file mode 100644
index 000000000000..7cad2045b42a
--- /dev/null
+++ b/packages/core/src/spans/spanFirstUtils.ts
@@ -0,0 +1,144 @@
+import type { RawAttributes } from '../attributes';
+import { isAttributeObject } from '../attributes';
+import type { Context, Contexts } from '../types-hoist/context';
+import type { SpanV2JSON } from '../types-hoist/span';
+import { attributeValueToSerializedAttribute } from '../utils/attributes';
+import { isPrimitive } from '../utils/is';
+import { showSpanDropWarning } from '../utils/spanUtils';
+
+/**
+ * Only set a span JSON attribute if it is not already set.
+ * This is used to safely set attributes on JSON objects without mutating already-ended span instances.
+ */
+export function safeSetSpanJSONAttributes(
+ spanJSON: SpanV2JSON,
+ newAttributes: RawAttributes>,
+): void {
+ if (!spanJSON.attributes) {
+ spanJSON.attributes = {};
+ }
+
+ const originalAttributes = spanJSON.attributes;
+
+ Object.keys(newAttributes).forEach(key => {
+ if (!originalAttributes?.[key]) {
+ setAttributeOnSpanJSONWithMaybeUnit(
+ // type-casting here because we ensured above that the attributes object exists
+ spanJSON as SpanV2JSON & Required>,
+ key,
+ newAttributes[key],
+ );
+ }
+ });
+}
+
+/**
+ * Apply a user-provided beforeSendSpan callback to a span JSON.
+ */
+export function applyBeforeSendSpanCallback(
+ span: SpanV2JSON,
+ beforeSendSpan: (span: SpanV2JSON) => SpanV2JSON,
+): SpanV2JSON {
+ const modifedSpan = beforeSendSpan(span);
+ if (!modifedSpan) {
+ showSpanDropWarning();
+ return span;
+ }
+ return modifedSpan;
+}
+
+function setAttributeOnSpanJSONWithMaybeUnit(
+ spanJSON: SpanV2JSON & Required>,
+ attributeKey: string,
+ attributeValue: unknown,
+): void {
+ if (isAttributeObject(attributeValue)) {
+ const { value, unit } = attributeValue;
+
+ if (isSupportedSerializableType(value)) {
+ spanJSON.attributes[attributeKey] = attributeValueToSerializedAttribute(value);
+ if (unit) {
+ spanJSON.attributes[attributeKey].unit = unit;
+ }
+ }
+ } else if (isSupportedSerializableType(attributeValue)) {
+ spanJSON.attributes[attributeKey] = attributeValueToSerializedAttribute(attributeValue);
+ }
+}
+
+function isSupportedSerializableType(value: unknown): boolean {
+ return ['string', 'number', 'boolean'].includes(typeof value) || Array.isArray(value);
+}
+
+// map of attributes->context keys for those attributes that don't correspond 1:1 to the context key
+const explicitAttributeToContextMapping = {
+ 'os.build_id': 'os.build',
+ 'app.name': 'app.app_name',
+ 'app.identifier': 'app.app_identifier',
+ 'app.version': 'app.app_version',
+ 'app.memory': 'app.app_memory',
+ 'app.start_time': 'app.app_start_time',
+};
+
+const knownContexts = ['app', 'os', 'device', 'culture', 'cloud_resource', 'runtime'];
+
+/**
+ * Converts a context object to a set of attributes.
+ * Only includes attributes that are primitives (for now).
+ * @param contexts - The context object to convert.
+ * @returns The attributes object.
+ */
+export function contextsToAttributes(contexts: Contexts): RawAttributes> {
+ function contextToAttribute(context: Context): Context {
+ return Object.keys(context).reduce(
+ (acc, key) => {
+ if (!isPrimitive(context[key])) {
+ return acc;
+ }
+ acc[key] = context[key];
+ return acc;
+ },
+ {} as Record,
+ );
+ }
+
+ const contextsWithPrimitiveValues = Object.keys(contexts).reduce((acc, key) => {
+ if (!knownContexts.includes(key)) {
+ return acc;
+ }
+ const context = contexts[key];
+ if (context) {
+ acc[key] = contextToAttribute(context);
+ }
+ return acc;
+ }, {} as Contexts);
+
+ const explicitlyMappedAttributes = Object.entries(explicitAttributeToContextMapping).reduce(
+ (acc, [attributeKey, contextKey]) => {
+ const [contextName, contextValueKey] = contextKey.split('.');
+ if (contextName && contextValueKey && contextsWithPrimitiveValues[contextName]?.[contextValueKey]) {
+ acc[attributeKey] = contextsWithPrimitiveValues[contextName]?.[contextValueKey];
+ // now we delete this key from `contextsWithPrimitiveValues` so we don't include it in the next step
+ delete contextsWithPrimitiveValues[contextName]?.[contextValueKey];
+ }
+ return acc;
+ },
+ {} as Record,
+ );
+
+ return {
+ ...explicitlyMappedAttributes,
+ ...Object.entries(contextsWithPrimitiveValues).reduce(
+ (acc, [contextName, contextObj]) => {
+ contextObj &&
+ Object.entries(contextObj).forEach(([key, value]) => {
+ if (value) {
+ acc[`${contextName}.${key}`] = value;
+ }
+ });
+ return acc;
+ },
+ {} as Record,
+ ),
+ };
+}
diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts
index 9bd98b9741c6..574ba9ab2478 100644
--- a/packages/core/src/tracing/sentrySpan.ts
+++ b/packages/core/src/tracing/sentrySpan.ts
@@ -1,3 +1,4 @@
+/* eslint-disable max-lines */
import { getClient, getCurrentScope } from '../currentScopes';
import { DEBUG_BUILD } from '../debug-build';
import { createSpanEnvelope } from '../envelope';
@@ -21,6 +22,7 @@ import type {
SpanJSON,
SpanOrigin,
SpanTimeInput,
+ SpanV2JSON,
} from '../types-hoist/span';
import type { SpanStatus } from '../types-hoist/spanStatus';
import type { TimedEvent } from '../types-hoist/timedEvent';
@@ -31,6 +33,9 @@ import {
getRootSpan,
getSpanDescendants,
getStatusMessage,
+ getV2Attributes,
+ getV2SpanLinks,
+ getV2StatusMessage,
spanTimeInputToSeconds,
spanToJSON,
spanToTransactionTraceContext,
@@ -241,6 +246,30 @@ export class SentrySpan implements Span {
};
}
+ /**
+ * Get SpanV2JSON representation of this span.
+ *
+ * @hidden
+ * @internal This method is purely for internal purposes and should not be used outside
+ * of SDK code. If you need to get a JSON representation of a span,
+ * use `spanToV2JSON(span)` instead.
+ */
+ public getSpanV2JSON(): SpanV2JSON {
+ return {
+ name: this._name ?? '',
+ span_id: this._spanId,
+ trace_id: this._traceId,
+ parent_span_id: this._parentSpanId,
+ start_timestamp: this._startTime,
+ // just in case _endTime is not set, we use the start time (i.e. duration 0)
+ end_timestamp: this._endTime ?? this._startTime,
+ is_segment: this._isStandaloneSpan || this === getRootSpan(this),
+ status: getV2StatusMessage(this._status),
+ attributes: getV2Attributes(this._attributes),
+ links: getV2SpanLinks(this._links),
+ };
+ }
+
/** @inheritdoc */
public isRecording(): boolean {
return !this._endTime && !!this._sampled;
@@ -287,6 +316,7 @@ export class SentrySpan implements Span {
const client = getClient();
if (client) {
client.emit('spanEnd', this);
+ client.emit('afterSpanEnd', this);
}
// A segment span is basically the root span of a local span tree.
@@ -310,6 +340,10 @@ export class SentrySpan implements Span {
}
}
return;
+ } else if (client?.getOptions().traceLifecycle === 'stream') {
+ // TODO (spans): Remove standalone span custom logic in favor of sending simple v2 web vital spans
+ client?.emit('afterSegmentSpanEnd', this);
+ return;
}
const transactionEvent = this._convertSpanToTransaction();
diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts
index b147bb92fa63..2c45275eef0b 100644
--- a/packages/core/src/tracing/trace.ts
+++ b/packages/core/src/tracing/trace.ts
@@ -491,6 +491,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/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts
index 93be1ca33423..d8cf5f2753fa 100644
--- a/packages/core/src/tracing/vercel-ai/index.ts
+++ b/packages/core/src/tracing/vercel-ai/index.ts
@@ -74,6 +74,7 @@ function onVercelAiSpanStart(span: Span): void {
processGenerateSpan(span, name, attributes);
}
+// TODO (span-streaming): move to client hook. What to do about parent modifications?
function vercelAiEventProcessor(event: Event): Event {
if (event.type === 'transaction' && event.spans) {
// Map to accumulate token data by parent span ID
diff --git a/packages/core/src/types-hoist/attributes.ts b/packages/core/src/types-hoist/attributes.ts
new file mode 100644
index 000000000000..ca5bce15f0a6
--- /dev/null
+++ b/packages/core/src/types-hoist/attributes.ts
@@ -0,0 +1,22 @@
+import type { AttributeUnit } from '../attributes';
+
+export type SerializedAttributes = Record;
+export type SerializedAttribute = (
+ | {
+ type: 'string';
+ value: string;
+ }
+ | {
+ type: 'integer';
+ value: number;
+ }
+ | {
+ type: 'double';
+ value: number;
+ }
+ | {
+ type: 'boolean';
+ value: boolean;
+ }
+) & { unit?: AttributeUnit };
+export type SerializedAttributeType = 'string' | 'integer' | 'double' | 'boolean';
diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts
index 272f8cde9f62..7251f85b5df0 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 { SerializedSpanContainer, 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 SpanV2EnvelopeHeaders = 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 SpanV2Envelope = BaseEnvelope;
export type ProfileChunkEnvelope = BaseEnvelope;
export type RawSecurityEnvelope = BaseEnvelope;
export type LogEnvelope = BaseEnvelope;
@@ -157,6 +175,7 @@ export type Envelope =
| ReplayEnvelope
| CheckInEnvelope
| SpanEnvelope
+ | SpanV2Envelope
| RawSecurityEnvelope
| LogEnvelope
| MetricEnvelope;
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 c33d0107df5f..e7dcbb460da5 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, SpanV2JSON } from './span';
import type { StackLineParser, StackParser } from './stacktrace';
import type { TracePropagationTargets } from './tracing';
import type { BaseTransportOptions, Transport } from './transport';
@@ -382,6 +382,16 @@ export interface ClientOptions SpanJSON;
+ beforeSendSpan?: ((span: SpanJSON) => SpanJSON) | SpanV2CompatibleBeforeSendSpanCallback;
/**
* An event-processing callback for transaction events, guaranteed to be invoked after all other event
@@ -497,6 +507,12 @@ export interface ClientOptions Breadcrumb | null;
}
+/**
+ * A callback that is known to be compatible with actually receiving and returning a span v2 JSON object.
+ * Only useful in conjunction with the {@link CoreOptions.traceLifecycle} option.
+ */
+export type SpanV2CompatibleBeforeSendSpanCallback = ((span: SpanV2JSON) => SpanV2JSON) & { _v2: true };
+
/** Base configuration options for every SDK. */
export interface CoreOptions
extends Omit>, 'integrations' | 'transport' | 'stackParser'> {
diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts
index d82463768b7f..76e11072af3d 100644
--- a/packages/core/src/types-hoist/span.ts
+++ b/packages/core/src/types-hoist/span.ts
@@ -1,3 +1,4 @@
+import type { SerializedAttributes } from './attributes';
import type { SpanLink, SpanLinkJSON } from './link';
import type { Measurements } from './measurement';
import type { HrTime } from './opentelemetry';
@@ -34,6 +35,35 @@ export type SpanAttributes = Partial<{
/** This type is aligned with the OpenTelemetry TimeInput type. */
export type SpanTimeInput = HrTime | number | Date;
+/**
+ * JSON representation of a v2 span, as it should be sent to Sentry.
+ */
+export interface SpanV2JSON {
+ 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?: SerializedAttributes;
+ links?: SpanLinkJSON[];
+}
+
+/**
+ * A SpanV2JSON with an attached reference to the segment span.
+ * This reference is used to compute dynamic sampling context before sending.
+ * The reference MUST be removed before sending the span envelope.
+ */
+export interface SpanV2JSONWithSegmentRef extends SpanV2JSON {
+ _segmentSpan: Span;
+}
+
+export type SerializedSpanContainer = {
+ items: Array;
+};
+
/** A JSON representation of a span. */
export interface SpanJSON {
data: SpanAttributes;
diff --git a/packages/core/src/utils/applyScopeDataToEvent.ts b/packages/core/src/utils/applyScopeDataToEvent.ts
index 5fcce32be2ba..c2bfc06dd216 100644
--- a/packages/core/src/utils/applyScopeDataToEvent.ts
+++ b/packages/core/src/utils/applyScopeDataToEvent.ts
@@ -32,6 +32,7 @@ export function mergeScopeData(data: ScopeData, mergeData: ScopeData): void {
const {
extra,
tags,
+ attributes,
user,
contexts,
level,
@@ -80,6 +81,10 @@ export function mergeScopeData(data: ScopeData, mergeData: ScopeData): void {
data.attachments = [...data.attachments, ...attachments];
}
+ if (attributes) {
+ data.attributes = { ...data.attributes, ...attributes };
+ }
+
data.propagationContext = { ...data.propagationContext, ...propagationContext };
}
diff --git a/packages/core/src/utils/attributes.ts b/packages/core/src/utils/attributes.ts
new file mode 100644
index 000000000000..99419ce1afbd
--- /dev/null
+++ b/packages/core/src/utils/attributes.ts
@@ -0,0 +1,98 @@
+import type { SerializedAttribute } from '../types-hoist/attributes';
+import type { SpanAttributes } from '../types-hoist/span';
+import { normalize } from '../utils/normalize';
+
+/**
+ * Converts an attribute value to a serialized attribute value object, containing
+ * a type descriptor as well as the value.
+ *
+ * TODO: dedupe this with the logs version of the function (didn't do this yet to avoid
+ * dependance on logs/spans for the open questions RE array and object attribute types)
+ *
+ * @param value - The value of the log attribute.
+ * @returns The serialized log attribute.
+ */
+export function attributeValueToSerializedAttribute(value: unknown): SerializedAttribute {
+ switch (typeof value) {
+ case 'number':
+ if (Number.isInteger(value)) {
+ return {
+ value,
+ type: 'integer',
+ };
+ }
+ return {
+ value,
+ type: 'double',
+ };
+ case 'boolean':
+ return {
+ value,
+ type: 'boolean',
+ };
+ case 'string':
+ return {
+ value,
+ type: 'string',
+ };
+ default: {
+ let stringValue = '';
+ try {
+ stringValue = JSON.stringify(value) ?? '';
+ } catch {
+ // Do nothing
+ }
+ return {
+ value: stringValue,
+ type: 'string',
+ };
+ }
+ }
+}
+
+/**
+ * Given an object that might contain keys with primitive, array, or object values,
+ * return a SpanAttributes object that flattens the object into a single level.
+ * - Nested keys are separated by '.'.
+ * - arrays are stringified (TODO: might change, depending on how we support array attributes)
+ * - objects are flattened
+ * - primitives are added directly
+ * - nullish values are ignored
+ * - maxDepth is the maximum depth to flatten the object to
+ *
+ * @param obj - The object to flatten into span attributes
+ * @returns The span attribute object
+ */
+export function attributesFromObject(obj: Record, maxDepth = 3): SpanAttributes {
+ const result: Record = {};
+
+ function primitiveOrToString(current: unknown): number | boolean | string {
+ if (typeof current === 'number' || typeof current === 'boolean' || typeof current === 'string') {
+ return current;
+ }
+ return String(current);
+ }
+
+ function flatten(current: unknown, prefix: string, depth: number): void {
+ if (current == null) {
+ return;
+ } else if (depth >= maxDepth) {
+ result[prefix] = primitiveOrToString(current);
+ return;
+ } else if (Array.isArray(current)) {
+ result[prefix] = JSON.stringify(current);
+ } else if (typeof current === 'number' || typeof current === 'string' || typeof current === 'boolean') {
+ result[prefix] = current;
+ } else if (typeof current === 'object' && current !== null && !Array.isArray(current) && depth < maxDepth) {
+ for (const [key, value] of Object.entries(current as Record)) {
+ flatten(value, prefix ? `${prefix}.${key}` : key, depth + 1);
+ }
+ }
+ }
+
+ const normalizedObj = normalize(obj, maxDepth);
+
+ flatten(normalizedObj, '', 0);
+
+ return result;
+}
diff --git a/packages/core/src/utils/beforeSendSpan.ts b/packages/core/src/utils/beforeSendSpan.ts
new file mode 100644
index 000000000000..3bfe2fa0c301
--- /dev/null
+++ b/packages/core/src/utils/beforeSendSpan.ts
@@ -0,0 +1,32 @@
+import type { ClientOptions, SpanV2CompatibleBeforeSendSpanCallback } from '../types-hoist/options';
+import type { SpanV2JSON } from '../types-hoist/span';
+import { addNonEnumerableProperty } from './object';
+
+/**
+ * A wrapper to use the new span format in your `beforeSendSpan` callback.
+ *
+ * @example
+ *
+ * Sentry.init({
+ * beforeSendSpan: withStreamSpan((span) => {
+ * return span;
+ * }),
+ * });
+ *
+ * @param callback
+ * @returns
+ */
+export function withStreamSpan(callback: (span: SpanV2JSON) => SpanV2JSON): SpanV2CompatibleBeforeSendSpanCallback {
+ addNonEnumerableProperty(callback, '_v2', true);
+ // type-casting here because TS can't infer the type correctly
+ return callback as SpanV2CompatibleBeforeSendSpanCallback;
+}
+
+/**
+ * Typesafe check to identify the expected span json format of the `beforeSendSpan` callback.
+ */
+export function isV2BeforeSendSpanCallback(
+ callback: ClientOptions['beforeSendSpan'],
+): callback is SpanV2CompatibleBeforeSendSpanCallback {
+ return !!callback && '_v2' in callback && !!callback._v2;
+}
diff --git a/packages/core/src/utils/featureFlags.ts b/packages/core/src/utils/featureFlags.ts
index 4fa3cdc5ac8d..671f615b32cf 100644
--- a/packages/core/src/utils/featureFlags.ts
+++ b/packages/core/src/utils/featureFlags.ts
@@ -27,6 +27,7 @@ const SPAN_FLAG_ATTRIBUTE_PREFIX = 'flag.evaluation.';
/**
* Copies feature flags that are in current scope context to the event context
*/
+// TODO (span-streaming): should flags be added to (segment) spans? If so, probably do this via globally applying context data to spans
export function _INTERNAL_copyFlagsFromScopeToEvent(event: Event): Event {
const scope = getCurrentScope();
const flagContext = scope.getScopeData().contexts.flags;
diff --git a/packages/core/src/utils/should-ignore-span.ts b/packages/core/src/utils/should-ignore-span.ts
index a8d3ac0211c7..f05f0dc5402e 100644
--- a/packages/core/src/utils/should-ignore-span.ts
+++ b/packages/core/src/utils/should-ignore-span.ts
@@ -1,28 +1,47 @@
import { DEBUG_BUILD } from '../debug-build';
+import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../semanticAttributes';
import type { ClientOptions } from '../types-hoist/options';
-import type { SpanJSON } from '../types-hoist/span';
+import type { SpanJSON, SpanV2JSON } from '../types-hoist/span';
import { debug } from './debug-logger';
import { isMatchingPattern } from './string';
-function logIgnoredSpan(droppedSpan: Pick): void {
- debug.log(`Ignoring span ${droppedSpan.op} - ${droppedSpan.description} because it matches \`ignoreSpans\`.`);
+function logIgnoredSpan(spanName: string, spanOp: string | undefined): void {
+ debug.log(`Ignoring span ${spanOp ? `${spanOp} - ` : ''}${spanName} because it matches \`ignoreSpans\`.`);
}
/**
* Check if a span should be ignored based on the ignoreSpans configuration.
*/
export function shouldIgnoreSpan(
- span: Pick,
+ span: Pick | Pick,
ignoreSpans: Required['ignoreSpans'],
): boolean {
- if (!ignoreSpans?.length || !span.description) {
+ if (!ignoreSpans?.length) {
+ return false;
+ }
+
+ const { spanName, spanOp: spanOpAttributeOrString } =
+ 'description' in span
+ ? { spanName: span.description, spanOp: span.op }
+ : 'name' in span
+ ? { spanName: span.name, spanOp: span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] }
+ : { spanName: '', spanOp: '' };
+
+ const spanOp =
+ typeof spanOpAttributeOrString === 'string'
+ ? spanOpAttributeOrString
+ : spanOpAttributeOrString?.type === 'string'
+ ? spanOpAttributeOrString.value
+ : undefined;
+
+ if (!spanName) {
return false;
}
for (const pattern of ignoreSpans) {
if (isStringOrRegExp(pattern)) {
- if (isMatchingPattern(span.description, pattern)) {
- DEBUG_BUILD && logIgnoredSpan(span);
+ if (isMatchingPattern(spanName, pattern)) {
+ DEBUG_BUILD && logIgnoredSpan(spanName, spanOp);
return true;
}
continue;
@@ -32,15 +51,15 @@ export function shouldIgnoreSpan(
continue;
}
- const nameMatches = pattern.name ? isMatchingPattern(span.description, pattern.name) : true;
- const opMatches = pattern.op ? span.op && isMatchingPattern(span.op, pattern.op) : true;
+ const nameMatches = pattern.name ? isMatchingPattern(spanName, pattern.name) : true;
+ const opMatches = pattern.op ? spanOp && isMatchingPattern(spanOp, pattern.op) : true;
// This check here is only correct because we can guarantee that we ran `isMatchingPattern`
// for at least one of `nameMatches` and `opMatches`. So in contrary to how this looks,
// not both op and name actually have to match. This is the most efficient way to check
// for all combinations of name and op patterns.
if (nameMatches && opMatches) {
- DEBUG_BUILD && logIgnoredSpan(span);
+ DEBUG_BUILD && logIgnoredSpan(spanName, spanOp);
return true;
}
}
@@ -52,7 +71,10 @@ export function shouldIgnoreSpan(
* Takes a list of spans, and a span that was dropped, and re-parents the child spans of the dropped span to the parent of the dropped span, if possible.
* This mutates the spans array in place!
*/
-export function reparentChildSpans(spans: SpanJSON[], dropSpan: SpanJSON): void {
+export function reparentChildSpans(
+ spans: Pick[],
+ dropSpan: Pick,
+): void {
const droppedSpanParentId = dropSpan.parent_span_id;
const droppedSpanId = dropSpan.span_id;
diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts
index d7c261ecd73c..6f0977411639 100644
--- a/packages/core/src/utils/spanUtils.ts
+++ b/packages/core/src/utils/spanUtils.ts
@@ -10,14 +10,16 @@ import {
import type { SentrySpan } from '../tracing/sentrySpan';
import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus';
import { getCapturedScopesOnSpan } from '../tracing/utils';
+import type { SerializedAttributes } from '../types-hoist/attributes';
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 { Span, SpanAttributes, SpanJSON, SpanOrigin, SpanTimeInput, SpanV2JSON } from '../types-hoist/span';
import type { SpanStatus } from '../types-hoist/spanStatus';
import { addNonEnumerableProperty } from '../utils/object';
import { generateSpanId } from '../utils/propagationContext';
import { timestampInSeconds } from '../utils/time';
import { generateSentryTraceHeader, generateTraceparentHeader } from '../utils/tracing';
+import { attributeValueToSerializedAttribute } from './attributes';
import { consoleSandbox } from './debug-logger';
import { _getSpanForScope } from './spanOnScope';
@@ -92,7 +94,7 @@ export function spanToTraceparentHeader(span: Span): string {
* If the links array is empty, it returns `undefined` so the empty value can be dropped before it's sent.
*/
export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[] | undefined {
- if (links && links.length > 0) {
+ if (links?.length) {
return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({
span_id: spanId,
trace_id: traceId,
@@ -104,6 +106,24 @@ export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[]
return undefined;
}
}
+/**
+ *
+ * @param links
+ * @returns
+ */
+export function getV2SpanLinks(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 && { attributes: getV2Attributes(attributes) }),
+ ...restContext,
+ }));
+ } else {
+ return undefined;
+ }
+}
/**
* Convert a span time input into a timestamp in seconds.
@@ -187,6 +207,59 @@ export function spanToJSON(span: Span): SpanJSON {
};
}
+/**
+ * Convert a span to a SpanV2JSON representation.
+ * @returns
+ */
+export function spanToV2JSON(span: Span): SpanV2JSON {
+ if (spanIsSentrySpan(span)) {
+ return span.getSpanV2JSON();
+ }
+
+ 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;
+
+ // 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 {
+ name,
+ span_id,
+ trace_id,
+ parent_span_id: parentSpanId,
+ start_timestamp: spanTimeInputToSeconds(startTime),
+ end_timestamp: spanTimeInputToSeconds(endTime),
+ is_segment: span === INTERNAL_getSegmentSpan(span),
+ status: getV2StatusMessage(status),
+ attributes: getV2Attributes(attributes),
+ links: getV2SpanLinks(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),
+ };
+}
+
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 +310,23 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef
return status.message || 'internal_error';
}
+/**
+ * Convert the various statuses to the ones expected by Sentry ('ok' is default)
+ */
+export function getV2StatusMessage(status: SpanStatus | undefined): 'ok' | 'error' {
+ return !status || status.code === SPAN_STATUS_OK || status.code === SPAN_STATUS_UNSET ? 'ok' : 'error';
+}
+
+/**
+ * Convert the attributes to the ones expected by Sentry, including the type annotation
+ */
+export function getV2Attributes(attributes: SpanAttributes): SerializedAttributes {
+ return Object.entries(attributes).reduce((acc, [key, value]) => {
+ acc[key] = attributeValueToSerializedAttribute(value);
+ return acc;
+ }, {} as SerializedAttributes);
+}
+
const CHILD_SPANS_FIELD = '_sentryChildSpans';
const ROOT_SPAN_FIELD = '_sentryRootSpan';
@@ -298,7 +388,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/lib/spans/captureSpan.test.ts b/packages/core/test/lib/spans/captureSpan.test.ts
new file mode 100644
index 000000000000..bfb3d5fd3341
--- /dev/null
+++ b/packages/core/test/lib/spans/captureSpan.test.ts
@@ -0,0 +1,325 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Client } from '../../../src';
+import {
+ getCurrentScope,
+ getGlobalScope,
+ Scope,
+ SentrySpan,
+ setCapturedScopesOnSpan,
+ setCurrentClient,
+ withStreamSpan,
+} from '../../../src';
+import { captureSpan } from '../../../src/spans/captureSpan';
+import { getDefaultTestClientOptions, TestClient } from '../../mocks/client';
+
+describe('captureSpan', () => {
+ let client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://username@domain/123',
+ environment: 'staging',
+ release: '1.1.1',
+ }),
+ );
+
+ const currentScope = new Scope();
+ const isolationScope = new Scope();
+
+ const enqueueSpanCallback = vi.fn();
+
+ beforeEach(() => {
+ client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://username@domain/123',
+ environment: 'staging',
+ release: '1.1.1',
+ }),
+ );
+ client.on('enqueueSpan', enqueueSpanCallback);
+ client.init();
+ setCurrentClient(client as Client);
+ currentScope.clear();
+ isolationScope.clear();
+ getGlobalScope().clear();
+ currentScope.setClient(client as Client);
+ isolationScope.setClient(client as Client);
+ vi.clearAllMocks();
+ });
+
+ it("doesn't enqueue a span if no client is set", () => {
+ getCurrentScope().setClient(undefined);
+ const span = new SentrySpan({ name: 'spanName' });
+
+ captureSpan(span);
+
+ expect(enqueueSpanCallback).not.toHaveBeenCalled();
+ });
+
+ it('applies attributes from client and scopess to all spans', () => {
+ client.getOptions()._metadata = {
+ sdk: {
+ name: 'sentry.javascript.browser',
+ version: '1.0.0',
+ },
+ };
+ const span = new SentrySpan({ name: 'spanName' });
+
+ span.setAttribute('span_attr', 0);
+
+ const segmentSpan = new SentrySpan({ name: 'segmentSpanName' });
+
+ span.addLink({ context: segmentSpan.spanContext(), attributes: { 'sentry.link.type': 'my_link' } });
+
+ // @ts-expect-error - this field part of the public contract
+ span._sentryRootSpan = segmentSpan;
+
+ currentScope.setAttribute('current_scope_attr', 1);
+ isolationScope.setAttribute('isolation_scope_attr', { value: 2, unit: 'day' });
+ getGlobalScope().setAttribute('global_scope_attr', { value: 3 });
+
+ // this should NOT be applied to `span` because it's not a segment span
+ currentScope.setContext('os', { name: 'os1' });
+
+ setCapturedScopesOnSpan(span, currentScope, isolationScope);
+
+ captureSpan(span, client);
+
+ expect(enqueueSpanCallback).toHaveBeenCalledOnce();
+ expect(enqueueSpanCallback).toHaveBeenCalledWith({
+ _segmentSpan: segmentSpan, // <-- we need this reference to the segment span later on
+ attributes: {
+ 'sentry.environment': {
+ type: 'string',
+ value: 'staging',
+ },
+ 'sentry.origin': {
+ type: 'string',
+ value: 'manual',
+ },
+ 'sentry.release': {
+ type: 'string',
+ value: '1.1.1',
+ },
+ 'sentry.segment.id': {
+ type: 'string',
+ value: segmentSpan.spanContext().spanId,
+ },
+ 'sentry.segment.name': {
+ type: 'string',
+ value: 'segmentSpanName',
+ },
+ span_attr: {
+ type: 'integer',
+ value: 0,
+ },
+ current_scope_attr: {
+ type: 'integer',
+ value: 1,
+ },
+ isolation_scope_attr: {
+ type: 'integer',
+ value: 2,
+ unit: 'day',
+ },
+ global_scope_attr: {
+ type: 'integer',
+ value: 3,
+ },
+ 'sentry.sdk.name': {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ 'sentry.sdk.version': {
+ type: 'string',
+ value: '1.0.0',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ start_timestamp: expect.any(Number),
+ is_segment: false,
+ links: [
+ {
+ attributes: {
+ 'sentry.link.type': {
+ type: 'string',
+ value: 'my_link',
+ },
+ },
+ sampled: false,
+ span_id: segmentSpan.spanContext().spanId,
+ trace_id: segmentSpan.spanContext().traceId,
+ },
+ ],
+ name: 'spanName',
+ parent_span_id: undefined,
+ span_id: expect.stringMatching(/^[0-9a-f]{16}$/),
+ trace_id: expect.stringMatching(/^[0-9a-f]{32}$/),
+ status: 'ok',
+ });
+ });
+
+ it('applies scope data to a segment span', () => {
+ const span = new SentrySpan({ name: 'spanName' }); // if I don't set a segment explicitly, it will be a segment span
+
+ getGlobalScope().setContext('os', { name: 'os3' });
+ isolationScope.setContext('app', { name: 'myApp' });
+ currentScope.setContext('os', { name: 'os1' });
+
+ setCapturedScopesOnSpan(span, currentScope, isolationScope);
+
+ captureSpan(span, client);
+
+ expect(enqueueSpanCallback).toHaveBeenCalledOnce();
+ expect(enqueueSpanCallback).toHaveBeenCalledWith({
+ _segmentSpan: span,
+ is_segment: true,
+ attributes: {
+ 'sentry.release': {
+ type: 'string',
+ value: '1.1.1',
+ },
+ 'sentry.segment.id': {
+ type: 'string',
+ value: span.spanContext().spanId,
+ },
+ 'sentry.segment.name': {
+ type: 'string',
+ value: 'spanName',
+ },
+ 'sentry.environment': {
+ type: 'string',
+ value: 'staging',
+ },
+ 'sentry.origin': {
+ type: 'string',
+ value: 'manual',
+ },
+ 'app.name': {
+ type: 'string',
+ value: 'myApp',
+ },
+ 'os.name': {
+ type: 'string',
+ value: 'os1',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ start_timestamp: expect.any(Number),
+ name: 'spanName',
+ parent_span_id: undefined,
+ span_id: span.spanContext().spanId,
+ trace_id: span.spanContext().traceId,
+ links: undefined,
+ status: 'ok',
+ });
+ });
+
+ it('applies the beforeSendSpan callback to the span', () => {
+ client.getOptions().beforeSendSpan = withStreamSpan(span => {
+ return {
+ ...span,
+ attributes: {
+ ...span.attributes,
+ attribute_from_beforeSendSpan: {
+ type: 'string',
+ value: 'value_from_beforeSendSpan',
+ },
+ },
+ };
+ });
+ const span = new SentrySpan({ name: 'spanName' });
+
+ span.setAttribute('span_attr', 0);
+
+ const segmentSpan = new SentrySpan({ name: 'segmentSpanName' });
+
+ // @ts-expect-error - this field part of the public contract
+ span._sentryRootSpan = segmentSpan;
+
+ currentScope.setAttribute('current_scope_attr', 1);
+ isolationScope.setAttribute('isolation_scope_attr', { value: 2, unit: 'day' });
+ getGlobalScope().setAttribute('global_scope_attr', { value: 3 });
+
+ setCapturedScopesOnSpan(span, currentScope, isolationScope);
+
+ captureSpan(span, client);
+
+ expect(enqueueSpanCallback).toHaveBeenCalledOnce();
+ expect(enqueueSpanCallback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ attribute_from_beforeSendSpan: {
+ type: 'string',
+ value: 'value_from_beforeSendSpan',
+ },
+ }),
+ }),
+ );
+ });
+
+ it('applies user data iff sendDefaultPii is true and userdata is set', () => {
+ client.getOptions().sendDefaultPii = true;
+ currentScope.setUser({ id: '123', email: 'user@example.com', username: 'testuser' });
+
+ const span = new SentrySpan({ name: 'spanName' });
+ setCapturedScopesOnSpan(span, currentScope, isolationScope);
+
+ captureSpan(span, client);
+
+ expect(enqueueSpanCallback).toHaveBeenCalledOnce();
+ expect(enqueueSpanCallback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'user.id': expect.objectContaining({
+ type: 'string',
+ value: '123',
+ }),
+ 'user.email': expect.objectContaining({
+ type: 'string',
+ value: 'user@example.com',
+ }),
+ 'user.name': expect.objectContaining({
+ type: 'string',
+ value: 'testuser',
+ }),
+ }),
+ }),
+ );
+ });
+
+ it("doesn't apply user data if sendDefaultPii is not set and userdata is available", () => {
+ currentScope.setUser({ id: '123', email: 'user@example.com', username: 'testuser' });
+
+ const span = new SentrySpan({ name: 'spanName' });
+ setCapturedScopesOnSpan(span, currentScope, isolationScope);
+
+ captureSpan(span, client);
+
+ expect(enqueueSpanCallback).toHaveBeenCalledOnce();
+ expect(enqueueSpanCallback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: {
+ 'sentry.environment': {
+ type: 'string',
+ value: 'staging',
+ },
+ 'sentry.origin': {
+ type: 'string',
+ value: 'manual',
+ },
+ 'sentry.release': {
+ type: 'string',
+ value: '1.1.1',
+ },
+ 'sentry.segment.id': {
+ type: 'string',
+ value: span.spanContext().spanId,
+ },
+ 'sentry.segment.name': {
+ type: 'string',
+ value: 'spanName',
+ },
+ },
+ }),
+ );
+ });
+});
diff --git a/packages/core/test/lib/spans/spanFirstUtils.test.ts b/packages/core/test/lib/spans/spanFirstUtils.test.ts
new file mode 100644
index 000000000000..bf534f166228
--- /dev/null
+++ b/packages/core/test/lib/spans/spanFirstUtils.test.ts
@@ -0,0 +1,155 @@
+import { describe, expect, it } from 'vitest';
+import type { SpanV2JSON } from '../../../src';
+import { safeSetSpanJSONAttributes, SentrySpan, spanToV2JSON } from '../../../src';
+import { applyBeforeSendSpanCallback, contextsToAttributes } from '../../../src/spans/spanFirstUtils';
+
+describe('safeSetSpanJSONAttributes', () => {
+ it('only sets attributes that are not already set', () => {
+ const span = new SentrySpan({ attributes: { 'app.name': 'original' }, name: 'spanName' });
+ const spanJson = spanToV2JSON(span);
+
+ const newAttributes = { 'app.name': 'new', 'app.version': '1.0.0' };
+ safeSetSpanJSONAttributes(spanJson, newAttributes);
+
+ expect(spanJson.attributes).toStrictEqual({
+ 'app.name': { type: 'string', value: 'original' },
+ 'app.version': { type: 'string', value: '1.0.0' },
+ 'sentry.origin': {
+ type: 'string',
+ value: 'manual',
+ },
+ });
+ });
+
+ it('creates an attributes object on the span if it does not exist', () => {
+ const span = new SentrySpan({ name: 'spanName' });
+ const spanJson = spanToV2JSON(span);
+ spanJson.attributes = undefined;
+
+ const newAttributes = { 'app.name': 'new', 'app.version': '1.0.0' };
+ safeSetSpanJSONAttributes(spanJson, newAttributes);
+ expect(spanJson.attributes).toStrictEqual({
+ 'app.name': { type: 'string', value: 'new' },
+ 'app.version': { type: 'string', value: '1.0.0' },
+ });
+ });
+
+ it('sets attribute objects with units', () => {
+ const span = new SentrySpan({ name: 'spanName' });
+ const spanJson = spanToV2JSON(span);
+ const newAttributes = { 'app.name': { value: 'new', unit: 'ms' }, 'app.version': '1.0.0' };
+ safeSetSpanJSONAttributes(spanJson, newAttributes);
+ expect(spanJson.attributes).toStrictEqual({
+ 'app.name': { type: 'string', value: 'new', unit: 'ms' },
+ 'app.version': { type: 'string', value: '1.0.0' },
+ 'sentry.origin': {
+ type: 'string',
+ value: 'manual',
+ },
+ });
+ });
+
+ it('ignores attribute values other than primitives, arrays and attribute objects', () => {
+ const span = new SentrySpan({ name: 'spanName' });
+ const spanJson = spanToV2JSON(span);
+ const newAttributes = { foo: { bar: 'baz' } };
+ safeSetSpanJSONAttributes(spanJson, newAttributes);
+ expect(spanJson.attributes).toStrictEqual({
+ 'sentry.origin': {
+ type: 'string',
+ value: 'manual',
+ },
+ });
+ });
+});
+
+describe('applyBeforeSendSpanCallback', () => {
+ it('updates the span if the beforeSendSpan callback returns a new span', () => {
+ const span = new SentrySpan({ name: 'originalName' });
+ const spanJson = spanToV2JSON(span);
+ const beforeSendSpan = (_span: SpanV2JSON) => {
+ return { ...spanJson, name: 'newName' };
+ };
+ const result = applyBeforeSendSpanCallback(spanJson, beforeSendSpan);
+ expect(result.name).toBe('newName');
+ });
+ it('returns the span if the beforeSendSpan callback returns undefined', () => {
+ const span = new SentrySpan({ name: 'spanName' });
+ const spanJson = spanToV2JSON(span);
+ const beforeSendSpan = (_span: SpanV2JSON) => {
+ return undefined;
+ };
+ // @ts-expect-error - types don't allow undefined by design but we still test against it
+ const result = applyBeforeSendSpanCallback(spanJson, beforeSendSpan);
+ expect(result).toBe(spanJson);
+ });
+});
+
+describe('_contextsToAttributes', () => {
+ it('converts context values that are primitives to attributes', () => {
+ const contexts = {
+ app: { app_name: 'test', app_version: '1.0.0' },
+ };
+ const attributes = contextsToAttributes(contexts);
+ expect(attributes).toStrictEqual({ 'app.name': 'test', 'app.version': '1.0.0' });
+ });
+
+ it('ignores non-primitive context values', () => {
+ const contexts = {
+ app: { app_name: 'test', app_version: '1.0.0', app_metadata: { whatever: 'whenever' } },
+ someContext: { someValue: 'test', arrValue: [1, 2, 3] },
+ objContext: { objValue: { a: 1, b: 2 } },
+ };
+ const attributes = contextsToAttributes(contexts);
+ expect(attributes).toStrictEqual({ 'app.name': 'test', 'app.version': '1.0.0' });
+ });
+
+ it('ignores unknown contexts', () => {
+ const contexts = {
+ app: { app_name: 'test', app_version: '1.0.0' },
+ unknownContext: { unknownValue: 'test' },
+ };
+ const attributes = contextsToAttributes(contexts);
+ expect(attributes).toStrictEqual({ 'app.name': 'test', 'app.version': '1.0.0' });
+ });
+
+ it('converts explicitly mapped context values to attributes', () => {
+ const contexts = {
+ os: { build: '1032' },
+ app: {
+ app_name: 'test',
+ app_version: '1.0.0',
+ app_identifier: 'com.example.app',
+ build_type: 'minified',
+ app_memory: 1024,
+ app_start_time: '2021-01-01T00:00:00Z',
+ },
+ culture: undefined,
+ device: {
+ name: undefined,
+ },
+ someContext: { someValue: 'test', arrValue: [1, 2, 3] },
+ objContext: { objValue: { a: 1, b: 2 } },
+ };
+ const attributes = contextsToAttributes(contexts);
+ expect(attributes).toStrictEqual({
+ 'os.build_id': '1032',
+ 'app.name': 'test',
+ 'app.version': '1.0.0',
+ 'app.identifier': 'com.example.app',
+ 'app.build_type': 'minified',
+ 'app.memory': 1024,
+ 'app.start_time': '2021-01-01T00:00:00Z',
+ });
+ });
+
+ it("doesn't modify the original contexts object", () => {
+ // tests that we actually deep-copy the individual contexts so that we can filter and delete keys as needed
+ const contexts = {
+ app: { app_name: 'test', app_version: '1.0.0' },
+ };
+ const attributes = contextsToAttributes(contexts);
+ expect(attributes).toStrictEqual({ 'app.name': 'test', 'app.version': '1.0.0' });
+ expect(contexts).toStrictEqual({ app: { app_name: 'test', app_version: '1.0.0' } });
+ });
+});
diff --git a/packages/core/test/lib/utils/attributes.test.ts b/packages/core/test/lib/utils/attributes.test.ts
new file mode 100644
index 000000000000..9dd05e0e5b28
--- /dev/null
+++ b/packages/core/test/lib/utils/attributes.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest';
+import { attributesFromObject } from '../../../src/utils/attributes';
+
+describe('attributesFromObject', () => {
+ it('flattens an object', () => {
+ const context = {
+ a: 1,
+ b: { c: { d: 2 } },
+ };
+
+ const result = attributesFromObject(context);
+
+ expect(result).toEqual({
+ a: 1,
+ 'b.c.d': 2,
+ });
+ });
+
+ it('flattens an object with a max depth', () => {
+ const context = {
+ a: 1,
+ b: { c: { d: 2 } },
+ };
+
+ const result = attributesFromObject(context, 2);
+
+ expect(result).toEqual({
+ a: 1,
+ 'b.c': '[Object]',
+ });
+ });
+
+ it('flattens an object an array', () => {
+ const context = {
+ a: 1,
+ b: { c: { d: 2 } },
+ integrations: ['foo', 'bar'],
+ };
+
+ const result = attributesFromObject(context);
+
+ expect(result).toEqual({
+ a: 1,
+ 'b.c.d': 2,
+ integrations: '["foo","bar"]',
+ });
+ });
+
+ it('handles a circular object', () => {
+ const context = {
+ a: 1,
+ b: { c: { d: 2 } },
+ };
+ context.b.c.e = context.b;
+
+ const result = attributesFromObject(context, 5);
+
+ expect(result).toEqual({
+ a: 1,
+ 'b.c.d': 2,
+ 'b.c.e': '[Circular ~]',
+ });
+ });
+
+ it('handles a circular object in an array', () => {
+ const context = {
+ a: 1,
+ b: { c: { d: 2 } },
+ integrations: ['foo', 'bar'],
+ };
+
+ // @ts-expect-error - this is fine
+ context.integrations[0] = context.integrations;
+
+ const result = attributesFromObject(context, 5);
+
+ expect(result).toEqual({
+ a: 1,
+ 'b.c.d': 2,
+ integrations: '["[Circular ~]","bar"]',
+ });
+ });
+
+ it('handles objects in arrays', () => {
+ const context = {
+ a: 1,
+ b: { c: { d: 2 } },
+ integrations: [{ name: 'foo' }, { name: 'bar' }],
+ };
+
+ const result = attributesFromObject(context);
+
+ expect(result).toEqual({
+ a: 1,
+ 'b.c.d': 2,
+ integrations: '[{"name":"foo"},{"name":"bar"}]',
+ });
+ });
+});
diff --git a/packages/deno/src/integrations/context.ts b/packages/deno/src/integrations/context.ts
index 979ffff7d0e8..4dc9c723fbeb 100644
--- a/packages/deno/src/integrations/context.ts
+++ b/packages/deno/src/integrations/context.ts
@@ -56,6 +56,7 @@ const _denoContextIntegration = (() => {
return {
name: INTEGRATION_NAME,
processEvent(event) {
+ // TODO (span-streaming): we probably need to apply this to spans via a hook IF we decide to apply contexts to (segment) spans
return addDenoRuntimeContext(event);
},
};
diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts
index 07d1ee5c4e84..7906818766b7 100644
--- a/packages/nextjs/src/client/index.ts
+++ b/packages/nextjs/src/client/index.ts
@@ -66,11 +66,13 @@ export function init(options: BrowserOptions): Client | undefined {
const client = reactInit(opts);
+ // TODO (span-streaming): replace with ignoreSpans default?
const filterTransactions: EventProcessor = event =>
event.type === 'transaction' && event.transaction === '/404' ? null : event;
filterTransactions.id = 'NextClient404Filter';
addEventProcessor(filterTransactions);
+ // TODO (span-streaming): replace with ignoreSpans default?
const filterIncompleteNavigationTransactions: EventProcessor = event =>
event.type === 'transaction' && event.transaction === INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME
? null
diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts
index bc5372274ad6..ccd0971e3cf9 100644
--- a/packages/nextjs/src/server/index.ts
+++ b/packages/nextjs/src/server/index.ts
@@ -233,6 +233,9 @@ export function init(options: NodeOptions): NodeClient | undefined {
}
});
+ // TODO (span-streaming):
+ // - replace with ignoreSpans default
+ // - allow ignoreSpans to filter on arbitrary span attributes (not just op)
getGlobalScope().addEventProcessor(
Object.assign(
(event => {
diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts
index 7557d73c74a2..73f225652130 100644
--- a/packages/node-core/src/index.ts
+++ b/packages/node-core/src/index.ts
@@ -136,6 +136,7 @@ export {
wrapMcpServerWithSentry,
featureFlagsIntegration,
metrics,
+ withStreamSpan,
} from '@sentry/core';
export type {
diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts
index cad8a1c4a443..f7fbbc65c69f 100644
--- a/packages/node-core/src/integrations/context.ts
+++ b/packages/node-core/src/integrations/context.ts
@@ -15,7 +15,13 @@ import type {
IntegrationFn,
OsContext,
} from '@sentry/core';
-import { defineIntegration } from '@sentry/core';
+import {
+ debug,
+ defineIntegration,
+ getCapturedScopesOnSpan,
+ getGlobalScope,
+ INTERNAL_getSegmentSpan,
+} from '@sentry/core';
export const readFileAsync = promisify(readFile);
export const readDirAsync = promisify(readdir);
@@ -107,6 +113,44 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => {
return {
name: INTEGRATION_NAME,
+ setup(client) {
+ // first set all contexts on the global scope
+ _getContexts()
+ .then(updatedContext => {
+ const globalScope = getGlobalScope();
+ const previousContexts = globalScope.getScopeData().contexts;
+
+ const contexts = {
+ app: { ...updatedContext.app, ...previousContexts?.app },
+ os: { ...updatedContext.os, ...previousContexts?.os },
+ device: { ...updatedContext.device, ...previousContexts?.device },
+ culture: { ...updatedContext.culture, ...previousContexts?.culture },
+ cloud_resource: { ...updatedContext.cloud_resource, ...previousContexts?.cloud_resource },
+ runtime: { name: 'node', version: global.process.version, ...previousContexts?.runtime },
+ };
+
+ Object.keys(contexts).forEach(key => {
+ globalScope.setContext(key, contexts[key as keyof Event['contexts']]);
+ });
+ })
+ .catch(() => {
+ debug.warn(`[${INTEGRATION_NAME}] Failed to get contexts from Node`);
+ });
+
+ client.on('spanEnd', span => {
+ if (INTERNAL_getSegmentSpan(span) !== span) {
+ return;
+ }
+ const currentScopeOfSpan = getCapturedScopesOnSpan(span).scope;
+ if (currentScopeOfSpan) {
+ const updatedContext = _updateContext(getGlobalScope().getScopeData().contexts);
+ Object.keys(updatedContext).forEach(key => {
+ currentScopeOfSpan.setContext(key, updatedContext[key as keyof Event['contexts']] ?? null);
+ });
+ }
+ });
+ },
+ // TODO (span-streaming): we probably need to apply this to spans via a hook IF we decide to apply contexts to (segment) spans
processEvent(event) {
return addContext(event);
},
diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts
index 7909482a5923..4834968cbe68 100644
--- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts
+++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts
@@ -219,6 +219,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions
},
processEvent(event) {
// Drop transaction if it has a status code that should be ignored
+ // TODO (span-streaming): port this logic to spans via a hook or ignoreSpans default
if (event.type === 'transaction') {
const statusCode = event.contexts?.trace?.data?.['http.response.status_code'];
if (typeof statusCode === 'number') {
diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts
index 19859b68f3c0..30ae4a468323 100644
--- a/packages/node-core/src/integrations/http/index.ts
+++ b/packages/node-core/src/integrations/http/index.ts
@@ -167,6 +167,7 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) =>
instrumentSentryHttp(httpInstrumentationOptions);
},
+ // TODO (span-streaming): port this logic to spans via a hook or ignoreSpans default; check with serverSpans migration strategy
processEvent(event) {
// Note: We always run this, even if spans are disabled
// The reason being that e.g. the remix integration disables span creation here but still wants to use the ignore status codes option
diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts
index 9d5941c41c8e..41dc72c4e2d2 100644
--- a/packages/node/src/index.ts
+++ b/packages/node/src/index.ts
@@ -191,4 +191,5 @@ export {
cron,
NODE_VERSION,
validateOpenTelemetrySetup,
+ withStreamSpan,
} from '@sentry/node-core';
diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts
index a0f1951c376b..6492c9dbb101 100644
--- a/packages/node/src/sdk/initOtel.ts
+++ b/packages/node/src/sdk/initOtel.ts
@@ -107,6 +107,7 @@ export function setupOtel(
spanProcessors: [
new SentrySpanProcessor({
timeout: _clampSpanProcessorTimeout(client.getOptions().maxSpanWaitDuration),
+ client,
}),
...(options.spanProcessors || []),
],
diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts
index 2b492b1249ac..b5639cd13da6 100644
--- a/packages/nuxt/src/server/sdk.ts
+++ b/packages/nuxt/src/server/sdk.ts
@@ -37,6 +37,7 @@ export function init(options: SentryNuxtServerOptions): Client | undefined {
*
* Only exported for testing
*/
+// TODO (span-streaming): replace with ignoreSpans default
export function lowQualityTransactionsFilter(options: SentryNuxtServerOptions): EventProcessor {
return Object.assign(
(event => {
diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts
index ea85641387a5..e81768269c7d 100644
--- a/packages/opentelemetry/src/spanExporter.ts
+++ b/packages/opentelemetry/src/spanExporter.ts
@@ -46,10 +46,16 @@ interface FinishedSpanBucket {
spans: Set;
}
+export interface ISentrySpanExporter {
+ export(span: ReadableSpan): void;
+ flush(): void;
+ clear(): void;
+}
+
/**
* A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions.
*/
-export class SentrySpanExporter {
+export class SentrySpanExporter implements ISentrySpanExporter {
/*
* A quick explanation on the buckets: We do bucketing of finished spans for efficiency. This span exporter is
* accumulating spans until a root span is encountered and then it flushes all the spans that are descendants of that
@@ -385,7 +391,10 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentS
});
}
-function getSpanData(span: ReadableSpan): {
+/**
+ * Get span data from the OTEL span
+ */
+export function getSpanData(span: ReadableSpan): {
data: Record;
op?: string;
description: string;
diff --git a/packages/opentelemetry/src/spanProcessor.ts b/packages/opentelemetry/src/spanProcessor.ts
index 3430456caaee..2feea6cd6e7e 100644
--- a/packages/opentelemetry/src/spanProcessor.ts
+++ b/packages/opentelemetry/src/spanProcessor.ts
@@ -1,8 +1,10 @@
import type { Context } from '@opentelemetry/api';
import { ROOT_CONTEXT, trace } from '@opentelemetry/api';
import type { ReadableSpan, Span, SpanProcessor as SpanProcessorInterface } from '@opentelemetry/sdk-trace-base';
+import type { Client } from '@sentry/core';
import {
addChildSpanToSpan,
+ captureSpan,
getClient,
getDefaultCurrentScope,
getDefaultIsolationScope,
@@ -11,7 +13,9 @@ import {
setCapturedScopesOnSpan,
} from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE } from './semanticAttributes';
+import type { ISentrySpanExporter } from './spanExporter';
import { SentrySpanExporter } from './spanExporter';
+import { StreamingSpanExporter } from './streamingSpanExporter';
import { getScopesFromContext } from './utils/contextData';
import { setIsSetup } from './utils/setupCheck';
@@ -51,23 +55,22 @@ function onSpanStart(span: Span, parentContext: Context): void {
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.
*/
export class SentrySpanProcessor implements SpanProcessorInterface {
- private _exporter: SentrySpanExporter;
+ private _exporter: ISentrySpanExporter;
+ private _client: Client | undefined;
- public constructor(options?: { timeout?: number }) {
+ public constructor(options?: { timeout?: number; client?: Client }) {
setIsSetup('SentrySpanProcessor');
- this._exporter = new SentrySpanExporter(options);
+ this._client = options?.client ?? getClient();
+ if (this._client?.getOptions().traceLifecycle === 'stream') {
+ this._exporter = new StreamingSpanExporter(this._client, { flushInterval: options?.timeout });
+ } else {
+ this._exporter = new SentrySpanExporter(options);
+ }
}
/**
@@ -93,7 +96,9 @@ export class SentrySpanProcessor implements SpanProcessorInterface {
/** @inheritDoc */
public onEnd(span: Span & ReadableSpan): void {
- onSpanEnd(span);
+ logSpanEnd(span);
+
+ this._client?.emit('spanEnd', span);
this._exporter.export(span);
}
diff --git a/packages/opentelemetry/src/streamingSpanExporter.ts b/packages/opentelemetry/src/streamingSpanExporter.ts
new file mode 100644
index 000000000000..1247d2e0d0d7
--- /dev/null
+++ b/packages/opentelemetry/src/streamingSpanExporter.ts
@@ -0,0 +1,175 @@
+import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
+import type { Client, Span, SpanV2JSON } from '@sentry/core';
+import {
+ type SpanV2JSONWithSegmentRef,
+ captureSpan,
+ createSpanV2Envelope,
+ debug,
+ getDynamicSamplingContextFromSpan,
+ safeSetSpanJSONAttributes,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+} from '@sentry/core';
+import { DEBUG_BUILD } from './debug-build';
+import { type ISentrySpanExporter, getSpanData } from './spanExporter';
+
+type StreamingSpanExporterOptions = {
+ flushInterval?: number;
+ maxSpanLimit?: number;
+};
+
+/**
+ * A Sentry-specific exporter that buffers span JSON objects and streams them to Sentry
+ * in Span v2 envelopes. This exporter works with pre-serialized span JSON rather than
+ * OTel span instances to avoid mutating already-ended spans.
+ */
+export class StreamingSpanExporter implements ISentrySpanExporter {
+ private _flushInterval: number;
+ private _maxSpanLimit: number;
+
+ private _spanTreeMap: Map>;
+
+ private _flushIntervalId: NodeJS.Timeout | null;
+
+ private _client: Client;
+
+ public constructor(client: Client, options?: StreamingSpanExporterOptions) {
+ this._spanTreeMap = new Map();
+ this._client = client;
+
+ const safeMaxSpanLimit =
+ options?.maxSpanLimit && options.maxSpanLimit > 0 && options.maxSpanLimit <= 1000 ? options.maxSpanLimit : 1000;
+ const safeFlushInterval = options?.flushInterval && options?.flushInterval > 0 ? options.flushInterval : 5_000;
+ this._flushInterval = safeFlushInterval;
+ this._maxSpanLimit = safeMaxSpanLimit;
+
+ this._flushIntervalId = setInterval(() => {
+ this.flush();
+ }, this._flushInterval);
+
+ this._client.on('processSpan', (spanJSON, hint) => {
+ const { readOnlySpan } = hint;
+ // TODO: This can be simplified by using spanJSON to get the data instead of the readOnlySpan
+ // for now this is the easiest backwards-compatible way to get the data.
+ const { op, description, data, origin = 'manual' } = getSpanData(readOnlySpan as unknown as ReadableSpan);
+ const allData = {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
+ ...data,
+ };
+ safeSetSpanJSONAttributes(spanJSON, allData);
+ spanJSON.name = description;
+ });
+
+ this._client.on('enqueueSpan', spanJSON => {
+ const traceId = spanJSON.trace_id;
+ let traceBucket = this._spanTreeMap.get(traceId);
+ if (traceBucket) {
+ traceBucket.add(spanJSON);
+ } else {
+ traceBucket = new Set([spanJSON]);
+ this._spanTreeMap.set(traceId, traceBucket);
+ }
+
+ if (traceBucket.size >= this._maxSpanLimit) {
+ this._flushTrace(traceId);
+ this._debounceFlushInterval();
+ }
+ });
+ }
+
+ /**
+ * Enqueue a span JSON into the buffer
+ */
+ public export(span: ReadableSpan & Span): void {
+ captureSpan(span, this._client);
+ }
+
+ /**
+ * Try to flush any pending spans immediately.
+ * This is called internally by the exporter (via _debouncedFlush),
+ * but can also be triggered externally if we force-flush.
+ */
+ public flush(): void {
+ if (!this._spanTreeMap.size) {
+ return;
+ }
+
+ debug.log(`Flushing span tree map with ${this._spanTreeMap.size} traces`);
+
+ this._spanTreeMap.forEach((_, traceId) => {
+ this._flushTrace(traceId);
+ });
+ this._debounceFlushInterval();
+ }
+
+ /**
+ * Clear the exporter.
+ * This is called when the span processor is shut down.
+ */
+ public clear(): void {
+ if (this._flushIntervalId) {
+ clearInterval(this._flushIntervalId);
+ this._flushIntervalId = null;
+ }
+ // TODO (span-streaming): record client outcome for leftover spans?
+ this._spanTreeMap.clear();
+ }
+
+ /**
+ * Flush a trace from the span tree map.
+ */
+ private _flushTrace(traceId: string): void {
+ const traceBucket = this._spanTreeMap.get(traceId);
+ if (!traceBucket) {
+ return;
+ }
+
+ if (!traceBucket.size) {
+ this._spanTreeMap.delete(traceId);
+ return;
+ }
+
+ // we checked against empty bucket above, so we can safely get the first span JSON here
+ const firstSpanJSON = traceBucket.values().next().value;
+
+ // Extract the segment span reference for DSC calculation
+ const segmentSpan = firstSpanJSON?._segmentSpan;
+ if (!segmentSpan) {
+ DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC');
+ this._spanTreeMap.delete(traceId);
+ return;
+ }
+
+ const dsc = getDynamicSamplingContextFromSpan(segmentSpan);
+
+ // Clean up segment span references before sending
+ const cleanedSpans: SpanV2JSON[] = Array.from(traceBucket).map(spanJSON => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { _segmentSpan, ...cleanSpanJSON } = spanJSON;
+ return cleanSpanJSON;
+ });
+
+ const envelope = createSpanV2Envelope(cleanedSpans, dsc, this._client);
+
+ 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 span stream envelope:', reason);
+ });
+
+ this._spanTreeMap.delete(traceId);
+ }
+
+ /**
+ * Debounce (reset) the flush interval.
+ */
+ private _debounceFlushInterval(): void {
+ if (this._flushIntervalId) {
+ clearInterval(this._flushIntervalId);
+ }
+ this._flushIntervalId = setInterval(() => {
+ this.flush();
+ }, this._flushInterval);
+ }
+}
diff --git a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts
index e4471167f7ce..dd91820af152 100644
--- a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts
+++ b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts
@@ -15,6 +15,7 @@ function _lowQualityTransactionsFilterIntegration(options: NodeOptions): {
return {
name: 'LowQualityTransactionsFilter',
+ // TODO (span-streaming): port this logic to spans via a hook or ignoreSpans default;
processEvent(event: Event, _hint: EventHint, _client: Client): Event | null {
if (event.type !== 'transaction' || !event.transaction) {
return event;
diff --git a/packages/react-router/src/server/integration/reactRouterServer.ts b/packages/react-router/src/server/integration/reactRouterServer.ts
index 4625d1cb979e..10b3fe0ddbd7 100644
--- a/packages/react-router/src/server/integration/reactRouterServer.ts
+++ b/packages/react-router/src/server/integration/reactRouterServer.ts
@@ -30,6 +30,7 @@ export const reactRouterServerIntegration = defineIntegration(() => {
instrumentReactRouterServer();
}
},
+ // TODO (span-streaming): port this logic to spans via a hook or ignoreSpans default;
processEvent(event) {
// Express generates bogus `*` routes for data loaders, which we want to remove here
// we cannot do this earlier because some OTEL instrumentation adds this at some unexpected point
diff --git a/packages/solidstart/src/server/utils.ts b/packages/solidstart/src/server/utils.ts
index 8276c32da9e0..0d838c601827 100644
--- a/packages/solidstart/src/server/utils.ts
+++ b/packages/solidstart/src/server/utils.ts
@@ -44,5 +44,6 @@ export function lowQualityTransactionsFilter(options: Options): EventProcessor {
* e.g. to filter out transactions for build assets
*/
export function filterLowQualityTransactions(options: Options): void {
+ // TODO (span-streaming): replace with ignoreSpans defaults
getGlobalScope().addEventProcessor(lowQualityTransactionsFilter(options));
}
diff --git a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts
index c38108c75542..92eca161d38f 100644
--- a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts
+++ b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts
@@ -11,6 +11,7 @@ export function svelteKitSpansIntegration(): Integration {
name: 'SvelteKitSpansEnhancement',
// Using preprocessEvent to ensure the processing happens before user-configured
// event processors are executed
+ // TODO (span-streaming): replace with client hook
preprocessEvent(event) {
// only iterate over the spans if the root span was emitted by SvelteKit
// TODO: Right now, we can't optimize this to only check traces with a kit-emitted root span
diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts
index 5c8387c9bc7a..b09c506f15c2 100644
--- a/packages/vercel-edge/src/sdk.ts
+++ b/packages/vercel-edge/src/sdk.ts
@@ -170,6 +170,7 @@ export function setupOtel(client: VercelEdgeClient): void {
spanProcessors: [
new SentrySpanProcessor({
timeout: client.getOptions().maxSpanWaitDuration,
+ client,
}),
],
});