Skip to content

Commit 9610b94

Browse files
Lms24JPeer264
andcommitted
feat(browser): Add spanStreamingIntegration (#19218)
This PR adds the final big building block for span streaming functionality in the browser SDK: `spanStreamingIntegation`. This integration: - enables `traceLifecycle: 'stream'` if not already set by users. This allows us to avoid the double-opt-in problem we usually have in browser SDKs because we want to keep integration tree-shakeable but also support the runtime-agnostic `traceLifecycle` option. - to do this properly, I decided to introduce a new integration hook: `beforeSetup`. This is allows us to safely modify client options before other integrations read it. We'll need this because `browserTracingIntegration` needs to check for span streaming later on. Let me know what you think! - validates that `beforeSendSpan` is compatible with span streaming. If not, it falls back to static tracing (transactions). - listens to a new `afterSpanEnd` hook. Once called, it will capture the span and hand it off to the span buffer. - listens to a new `afterSegmentSpanEnd` hook. Once called it will flush the trace from the buffer to ensure we flush out the trace as soon as possible. In browser, it's more likely that users refresh or close the tab/window before our buffer's internal flush interval triggers. We don't _have_ to do this but I figured it would be a good trigger point. While "final building block" sounds nice, there's still a lot of stuff to take care of in the browser. But with this in place we can also start integration-testing the browser SDKs. ref #17836 --------- Co-authored-by: Jan Peer Stöcklmair <jan.peer@sentry.io>
1 parent 3720395 commit 9610b94

File tree

15 files changed

+305
-17
lines changed

15 files changed

+305
-17
lines changed

.size-limit.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ module.exports = [
1515
path: 'packages/browser/build/npm/esm/prod/index.js',
1616
import: createImport('init'),
1717
gzip: true,
18-
limit: '24.5 KB',
18+
limit: '25 KB',
1919
modifyWebpackConfig: function (config) {
2020
const webpack = require('webpack');
2121

@@ -82,7 +82,7 @@ module.exports = [
8282
path: 'packages/browser/build/npm/esm/prod/index.js',
8383
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
8484
gzip: true,
85-
limit: '86 KB',
85+
limit: '87 KB',
8686
},
8787
{
8888
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
@@ -255,7 +255,7 @@ module.exports = [
255255
path: createCDNPath('bundle.tracing.logs.metrics.min.js'),
256256
gzip: false,
257257
brotli: false,
258-
limit: '131 KB',
258+
limit: '133 KB',
259259
},
260260
{
261261
name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed',
@@ -269,7 +269,7 @@ module.exports = [
269269
path: createCDNPath('bundle.tracing.replay.min.js'),
270270
gzip: false,
271271
brotli: false,
272-
limit: '245 KB',
272+
limit: '247 KB',
273273
},
274274
{
275275
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed',
@@ -308,7 +308,7 @@ module.exports = [
308308
import: createImport('init'),
309309
ignore: ['$app/stores'],
310310
gzip: true,
311-
limit: '43 KB',
311+
limit: '44 KB',
312312
},
313313
// Node-Core SDK (ESM)
314314
{

packages/browser/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export {
4141
} from './tracing/browserTracingIntegration';
4242
export { reportPageLoaded } from './tracing/reportPageLoaded';
4343
export { setActiveSpanInBrowser } from './tracing/setActiveSpan';
44+
export { spanStreamingIntegration } from './integrations/spanstreaming';
4445

4546
export type { RequestInstrumentationOptions } from './tracing/request';
4647
export {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { IntegrationFn } from '@sentry/core';
2+
import {
3+
captureSpan,
4+
debug,
5+
defineIntegration,
6+
hasSpanStreamingEnabled,
7+
isStreamedBeforeSendSpanCallback,
8+
SpanBuffer,
9+
} from '@sentry/core';
10+
import { DEBUG_BUILD } from '../debug-build';
11+
12+
export const spanStreamingIntegration = defineIntegration(() => {
13+
return {
14+
name: 'SpanStreaming',
15+
16+
beforeSetup(client) {
17+
// If users only set spanStreamingIntegration, without traceLifecycle, we set it to "stream" for them.
18+
// This avoids the classic double-opt-in problem we'd otherwise have in the browser SDK.
19+
const clientOptions = client.getOptions();
20+
if (!clientOptions.traceLifecycle) {
21+
DEBUG_BUILD && debug.warn('[SpanStreaming] set `traceLifecycle` to "stream"');
22+
clientOptions.traceLifecycle = 'stream';
23+
}
24+
},
25+
26+
setup(client) {
27+
const initialMessage = 'SpanStreaming integration requires';
28+
const fallbackMsg = 'Falling back to static trace lifecycle.';
29+
30+
if (!hasSpanStreamingEnabled(client)) {
31+
DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`);
32+
return;
33+
}
34+
35+
const beforeSendSpan = client.getOptions().beforeSendSpan;
36+
// If users misconfigure their SDK by opting into span streaming but
37+
// using an incompatible beforeSendSpan callback, we fall back to the static trace lifecycle.
38+
if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) {
39+
client.getOptions().traceLifecycle = 'static';
40+
DEBUG_BUILD &&
41+
debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`);
42+
return;
43+
}
44+
45+
const buffer = new SpanBuffer(client);
46+
47+
client.on('afterSpanEnd', span => buffer.add(captureSpan(span, client)));
48+
49+
// In addition to capturing the span, we also flush the trace when the segment
50+
// span ends to ensure things are sent timely. We never know when the browser
51+
// is closed, users navigate away, etc.
52+
client.on('afterSegmentSpanEnd', segmentSpan => buffer.flush(segmentSpan.spanContext().traceId));
53+
},
54+
};
55+
}) satisfies IntegrationFn;
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import * as SentryCore from '@sentry/core';
2+
import { debug } from '@sentry/core';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { BrowserClient, spanStreamingIntegration } from '../../src';
5+
import { getDefaultBrowserClientOptions } from '../helper/browser-client-options';
6+
7+
// Mock SpanBuffer as a class that can be instantiated
8+
const mockSpanBufferInstance = vi.hoisted(() => ({
9+
flush: vi.fn(),
10+
add: vi.fn(),
11+
drain: vi.fn(),
12+
}));
13+
14+
const MockSpanBuffer = vi.hoisted(() => {
15+
return vi.fn(() => mockSpanBufferInstance);
16+
});
17+
18+
vi.mock('@sentry/core', async () => {
19+
const original = await vi.importActual('@sentry/core');
20+
return {
21+
...original,
22+
SpanBuffer: MockSpanBuffer,
23+
};
24+
});
25+
26+
describe('spanStreamingIntegration', () => {
27+
it('has the correct hooks', () => {
28+
const integration = spanStreamingIntegration();
29+
expect(integration.name).toBe('SpanStreaming');
30+
// eslint-disable-next-line @typescript-eslint/unbound-method
31+
expect(integration.beforeSetup).toBeDefined();
32+
// eslint-disable-next-line @typescript-eslint/unbound-method
33+
expect(integration.setup).toBeDefined();
34+
});
35+
36+
it('sets traceLifecycle to "stream" if not set', () => {
37+
const client = new BrowserClient({
38+
...getDefaultBrowserClientOptions(),
39+
dsn: 'https://username@domain/123',
40+
integrations: [spanStreamingIntegration()],
41+
});
42+
43+
SentryCore.setCurrentClient(client);
44+
client.init();
45+
46+
expect(client.getOptions().traceLifecycle).toBe('stream');
47+
});
48+
49+
it('logs a warning if traceLifecycle is not set to "stream"', () => {
50+
const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});
51+
const client = new BrowserClient({
52+
...getDefaultBrowserClientOptions(),
53+
dsn: 'https://username@domain/123',
54+
integrations: [spanStreamingIntegration()],
55+
traceLifecycle: 'static',
56+
});
57+
58+
SentryCore.setCurrentClient(client);
59+
client.init();
60+
61+
expect(debugSpy).toHaveBeenCalledWith(
62+
'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.',
63+
);
64+
debugSpy.mockRestore();
65+
66+
expect(client.getOptions().traceLifecycle).toBe('static');
67+
});
68+
69+
it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => {
70+
const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});
71+
const client = new BrowserClient({
72+
...getDefaultBrowserClientOptions(),
73+
dsn: 'https://username@domain/123',
74+
integrations: [spanStreamingIntegration()],
75+
traceLifecycle: 'stream',
76+
beforeSendSpan: (span: Span) => span,
77+
});
78+
79+
SentryCore.setCurrentClient(client);
80+
client.init();
81+
82+
expect(debugSpy).toHaveBeenCalledWith(
83+
'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.',
84+
);
85+
debugSpy.mockRestore();
86+
87+
expect(client.getOptions().traceLifecycle).toBe('static');
88+
});
89+
90+
it('does nothing if traceLifecycle set to "stream"', () => {
91+
const client = new BrowserClient({
92+
...getDefaultBrowserClientOptions(),
93+
dsn: 'https://username@domain/123',
94+
integrations: [spanStreamingIntegration()],
95+
traceLifecycle: 'stream',
96+
});
97+
98+
SentryCore.setCurrentClient(client);
99+
client.init();
100+
101+
expect(client.getOptions().traceLifecycle).toBe('stream');
102+
});
103+
104+
it('enqueues a span into the buffer when the span ends', () => {
105+
const client = new BrowserClient({
106+
...getDefaultBrowserClientOptions(),
107+
dsn: 'https://username@domain/123',
108+
integrations: [spanStreamingIntegration()],
109+
});
110+
111+
SentryCore.setCurrentClient(client);
112+
client.init();
113+
114+
const span = new SentryCore.SentrySpan({ name: 'test' });
115+
client.emit('afterSpanEnd', span);
116+
117+
expect(mockSpanBufferInstance.add).toHaveBeenCalledWith({
118+
_segmentSpan: span,
119+
trace_id: span.spanContext().traceId,
120+
span_id: span.spanContext().spanId,
121+
end_timestamp: expect.any(Number),
122+
is_segment: true,
123+
name: 'test',
124+
start_timestamp: expect.any(Number),
125+
status: 'ok',
126+
attributes: {
127+
'sentry.origin': {
128+
type: 'string',
129+
value: 'manual',
130+
},
131+
'sentry.sdk.name': {
132+
type: 'string',
133+
value: 'sentry.javascript.browser',
134+
},
135+
'sentry.sdk.version': {
136+
type: 'string',
137+
value: expect.any(String),
138+
},
139+
'sentry.segment.id': {
140+
type: 'string',
141+
value: span.spanContext().spanId,
142+
},
143+
'sentry.segment.name': {
144+
type: 'string',
145+
value: 'test',
146+
},
147+
},
148+
});
149+
});
150+
151+
it('flushes the trace when the segment span ends', () => {
152+
const client = new BrowserClient({
153+
...getDefaultBrowserClientOptions(),
154+
dsn: 'https://username@domain/123',
155+
integrations: [spanStreamingIntegration()],
156+
traceLifecycle: 'stream',
157+
});
158+
159+
SentryCore.setCurrentClient(client);
160+
client.init();
161+
162+
const span = new SentryCore.SentrySpan({ name: 'test' });
163+
client.emit('afterSegmentSpanEnd', span);
164+
165+
expect(mockSpanBufferInstance.flush).toHaveBeenCalledWith(span.spanContext().traceId);
166+
});
167+
});

packages/core/src/client.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
1111
import type { Scope } from './scope';
1212
import { updateSession } from './session';
1313
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
14+
import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan';
1415
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base';
1516
import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types-hoist/breadcrumb';
1617
import type { CheckIn, MonitorConfig } from './types-hoist/checkin';
@@ -34,7 +35,6 @@ import type { SeverityLevel } from './types-hoist/severity';
3435
import type { Span, SpanAttributes, SpanContextData, SpanJSON, StreamedSpanJSON } from './types-hoist/span';
3536
import type { StartSpanOptions } from './types-hoist/startSpanOptions';
3637
import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport';
37-
import { isStreamedBeforeSendSpanCallback } from './utils/beforeSendSpan';
3838
import { createClientReportEnvelope } from './utils/clientreport';
3939
import { debug } from './utils/debug-logger';
4040
import { dsnToString, makeDsn } from './utils/dsn';
@@ -504,6 +504,10 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
504504
public addIntegration(integration: Integration): void {
505505
const isAlreadyInstalled = this._integrations[integration.name];
506506

507+
if (!isAlreadyInstalled && integration.beforeSetup) {
508+
integration.beforeSetup(this);
509+
}
510+
507511
// This hook takes care of only installing if not already installed
508512
setupIntegration(this, integration, this._integrations);
509513
// Here we need to check manually to make sure to not run this multiple times
@@ -614,6 +618,18 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
614618
*/
615619
public on(hook: 'spanEnd', callback: (span: Span) => void): () => void;
616620

621+
/**
622+
* Register a callback for after a span is ended and the `spanEnd` hook has run.
623+
* NOTE: The span cannot be mutated anymore in this callback.
624+
*/
625+
public on(hook: 'afterSpanEnd', callback: (immutableSegmentSpan: Readonly<Span>) => void): () => void;
626+
627+
/**
628+
* Register a callback for after a segment span is ended and the `segmentSpanEnd` hook has run.
629+
* NOTE: The segment span cannot be mutated anymore in this callback.
630+
*/
631+
public on(hook: 'afterSegmentSpanEnd', callback: (immutableSegmentSpan: Readonly<Span>) => void): () => void;
632+
617633
/**
618634
* Register a callback for when a span JSON is processed, to add some data to the span JSON.
619635
*/
@@ -897,12 +913,22 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
897913
public emit(hook: 'spanEnd', span: Span): void;
898914

899915
/**
900-
* Register a callback for when a span JSON is processed, to add some data to the span JSON.
916+
* Fire a hook event after a span ends and the `spanEnd` hook has run.
917+
*/
918+
public emit(hook: 'afterSpanEnd', immutableSpan: Readonly<Span>): void;
919+
920+
/**
921+
* Fire a hook event after a segment span ends and the `spanEnd` hook has run.
922+
*/
923+
public emit(hook: 'afterSegmentSpanEnd', immutableSegmentSpan: Readonly<Span>): void;
924+
925+
/**
926+
* Fire a hook event when a span JSON is processed, to add some data to the span JSON.
901927
*/
902928
public emit(hook: 'processSpan', streamedSpanJSON: StreamedSpanJSON): void;
903929

904930
/**
905-
* Register a callback for when a segment span JSON is processed, to add some data to the segment span JSON.
931+
* Fire a hook event for when a segment span JSON is processed, to add some data to the segment span JSON.
906932
*/
907933
public emit(hook: 'processSegmentSpan', streamedSpanJSON: StreamedSpanJSON): void;
908934

packages/core/src/envelope.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Client } from './client';
22
import { getDynamicSamplingContextFromSpan } from './tracing/dynamicSamplingContext';
33
import type { SentrySpan } from './tracing/sentrySpan';
4+
import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan';
45
import type { LegacyCSPReport } from './types-hoist/csp';
56
import type { DsnComponents } from './types-hoist/dsn';
67
import type {
@@ -18,7 +19,6 @@ import type { Event } from './types-hoist/event';
1819
import type { SdkInfo } from './types-hoist/sdkinfo';
1920
import type { SdkMetadata } from './types-hoist/sdkmetadata';
2021
import type { Session, SessionAggregates } from './types-hoist/session';
21-
import { isStreamedBeforeSendSpanCallback } from './utils/beforeSendSpan';
2222
import { dsnToString } from './utils/dsn';
2323
import {
2424
createEnvelope,

packages/core/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ export { prepareEvent } from './utils/prepareEvent';
6868
export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent';
6969
export { createCheckInEnvelope } from './checkin';
7070
export { hasSpansEnabled } from './utils/hasSpansEnabled';
71-
export { withStreamedSpan } from './utils/beforeSendSpan';
71+
export { withStreamedSpan } from './tracing/spans/beforeSendSpan';
72+
export { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan';
7273
export { isSentryRequestUrl } from './utils/isSentryRequestUrl';
7374
export { handleCallbackErrors } from './utils/handleCallbackErrors';
7475
export { parameterize, fmt } from './utils/parameterize';
@@ -182,6 +183,7 @@ export type {
182183
} from './tracing/google-genai/types';
183184

184185
export { SpanBuffer } from './tracing/spans/spanBuffer';
186+
export { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled';
185187

186188
export type { FeatureFlag } from './utils/featureFlags';
187189

packages/core/src/integration.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ export function getIntegrationsToSetup(
7676
export function setupIntegrations(client: Client, integrations: Integration[]): IntegrationIndex {
7777
const integrationIndex: IntegrationIndex = {};
7878

79+
integrations.forEach((integration: Integration | undefined) => {
80+
if (integration?.beforeSetup) {
81+
integration.beforeSetup(client);
82+
}
83+
});
84+
7985
integrations.forEach((integration: Integration | undefined) => {
8086
// guard against empty provided integrations
8187
if (integration) {

0 commit comments

Comments
 (0)