Skip to content

Commit d0fd53d

Browse files
committed
feat(node-core): Add POtel server-side span streaming implementation (#19741)
This PR adds a server-side span streaming implementation, for now scoped to POtel SDKs. However we can reuse some stuff from this PR to very easily enable span streaming on Cloudflare, Vercel Edge and other OTel-less platforms. Main changes: - added `spanStreamingIntegration` to `@sentry/core`: This orchestrates the span streaming life cycle via the client and the span buffer. It's very similar to the already existing `spanStreamingIntegration` in browser but doesn't expose some of the behaviour that we need only in browser. - adjusted `SentrySpanProcessor` to emit the right client hooks instead of passing the span to the `SpanExporter`. - adjusted the SDKs' default integrations to include `spanStreamingIntegration` when users set `traceLifecycle: 'stream'` in their SDK init. Rest are tests and small refactors. I'll follow up with Node integration tests once this is merged to avoid bloating this PR further. ref #17836
1 parent a2772dc commit d0fd53d

File tree

30 files changed

+455
-82
lines changed

30 files changed

+455
-82
lines changed

.size-limit.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ module.exports = [
117117
path: 'packages/browser/build/npm/esm/prod/index.js',
118118
import: createImport('init', 'metrics'),
119119
gzip: true,
120-
limit: '27 KB',
120+
limit: '28 KB',
121121
},
122122
{
123123
name: '@sentry/browser (incl. Logs)',
@@ -220,13 +220,13 @@ module.exports = [
220220
name: 'CDN Bundle (incl. Tracing, Replay, Feedback)',
221221
path: createCDNPath('bundle.tracing.replay.feedback.min.js'),
222222
gzip: true,
223-
limit: '86 KB',
223+
limit: '87 KB',
224224
},
225225
{
226226
name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)',
227227
path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'),
228228
gzip: true,
229-
limit: '87 KB',
229+
limit: '88 KB',
230230
},
231231
// browser CDN bundles (non-gzipped)
232232
{
@@ -326,14 +326,14 @@ module.exports = [
326326
import: createImport('init'),
327327
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
328328
gzip: true,
329-
limit: '176 KB',
329+
limit: '177 KB',
330330
},
331331
{
332332
name: '@sentry/node - without tracing',
333333
path: 'packages/node/build/esm/index.js',
334334
import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'),
335335
gzip: true,
336-
limit: '98 KB',
336+
limit: '100 KB',
337337
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
338338
modifyWebpackConfig: function (config) {
339339
const webpack = require('webpack');
@@ -356,7 +356,7 @@ module.exports = [
356356
import: createImport('init'),
357357
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
358358
gzip: true,
359-
limit: '114 KB',
359+
limit: '116 KB',
360360
},
361361
];
362362

packages/astro/src/index.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ export {
172172
statsigIntegration,
173173
unleashIntegration,
174174
growthbookIntegration,
175+
spanStreamingIntegration,
175176
metrics,
176177
} from '@sentry/node';
177178

packages/astro/src/index.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export declare function init(options: Options | clientSdk.BrowserOptions | NodeO
2020

2121
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
2222
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
23+
export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
2324

2425
export declare const getDefaultIntegrations: (options: Options) => Integration[];
2526
export declare const defaultStackParser: StackParser;

packages/aws-serverless/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export {
159159
unleashIntegration,
160160
growthbookIntegration,
161161
metrics,
162+
spanStreamingIntegration,
162163
} from '@sentry/node';
163164

164165
export {

packages/browser/src/integrations/spanstreaming.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,19 @@ export const spanStreamingIntegration = defineIntegration(() => {
2727
setup(client) {
2828
const initialMessage = 'SpanStreaming integration requires';
2929
const fallbackMsg = 'Falling back to static trace lifecycle.';
30+
const clientOptions = client.getOptions();
3031

3132
if (!hasSpanStreamingEnabled(client)) {
33+
clientOptions.traceLifecycle = 'static';
3234
DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`);
3335
return;
3436
}
3537

36-
const beforeSendSpan = client.getOptions().beforeSendSpan;
38+
const beforeSendSpan = clientOptions.beforeSendSpan;
3739
// If users misconfigure their SDK by opting into span streaming but
3840
// using an incompatible beforeSendSpan callback, we fall back to the static trace lifecycle.
3941
if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) {
40-
client.getOptions().traceLifecycle = 'static';
42+
clientOptions.traceLifecycle = 'static';
4143
DEBUG_BUILD &&
4244
debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`);
4345
return;

packages/browser/test/integrations/spanstreaming.test.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,25 +50,29 @@ describe('spanStreamingIntegration', () => {
5050
expect(client.getOptions().traceLifecycle).toBe('stream');
5151
});
5252

53-
it('logs a warning if traceLifecycle is not set to "stream"', () => {
54-
const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});
55-
const client = new BrowserClient({
56-
...getDefaultBrowserClientOptions(),
57-
dsn: 'https://username@domain/123',
58-
integrations: [spanStreamingIntegration()],
59-
traceLifecycle: 'static',
60-
});
61-
62-
SentryCore.setCurrentClient(client);
63-
client.init();
64-
65-
expect(debugSpy).toHaveBeenCalledWith(
66-
'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.',
67-
);
68-
debugSpy.mockRestore();
69-
70-
expect(client.getOptions().traceLifecycle).toBe('static');
71-
});
53+
it.each(['static', 'somethingElse'])(
54+
'logs a warning if traceLifecycle is not set to "stream" but to %s',
55+
traceLifecycle => {
56+
const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});
57+
const client = new BrowserClient({
58+
...getDefaultBrowserClientOptions(),
59+
dsn: 'https://username@domain/123',
60+
integrations: [spanStreamingIntegration()],
61+
// @ts-expect-error - we want to test the warning for invalid traceLifecycle values
62+
traceLifecycle,
63+
});
64+
65+
SentryCore.setCurrentClient(client);
66+
client.init();
67+
68+
expect(debugSpy).toHaveBeenCalledWith(
69+
'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.',
70+
);
71+
debugSpy.mockRestore();
72+
73+
expect(client.getOptions().traceLifecycle).toBe('static');
74+
},
75+
);
7276

7377
it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => {
7478
const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});

packages/bun/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export {
177177
statsigIntegration,
178178
unleashIntegration,
179179
metrics,
180+
spanStreamingIntegration,
180181
} from '@sentry/node';
181182

182183
export {

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export type {
187187

188188
export { SpanBuffer } from './tracing/spans/spanBuffer';
189189
export { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled';
190+
export { spanStreamingIntegration } from './integrations/spanStreaming';
190191

191192
export type { FeatureFlag } from './utils/featureFlags';
192193

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { IntegrationFn } from '../types-hoist/integration';
2+
import { DEBUG_BUILD } from '../debug-build';
3+
import { defineIntegration } from '../integration';
4+
import { isStreamedBeforeSendSpanCallback } from '../tracing/spans/beforeSendSpan';
5+
import { captureSpan } from '../tracing/spans/captureSpan';
6+
import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled';
7+
import { SpanBuffer } from '../tracing/spans/spanBuffer';
8+
import { debug } from '../utils/debug-logger';
9+
import { spanIsSampled } from '../utils/spanUtils';
10+
11+
export const spanStreamingIntegration = defineIntegration(() => {
12+
return {
13+
name: 'SpanStreaming',
14+
15+
setup(client) {
16+
const initialMessage = 'SpanStreaming integration requires';
17+
const fallbackMsg = 'Falling back to static trace lifecycle.';
18+
const clientOptions = client.getOptions();
19+
20+
if (!hasSpanStreamingEnabled(client)) {
21+
clientOptions.traceLifecycle = 'static';
22+
DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`);
23+
return;
24+
}
25+
26+
const beforeSendSpan = clientOptions.beforeSendSpan;
27+
if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) {
28+
clientOptions.traceLifecycle = 'static';
29+
DEBUG_BUILD &&
30+
debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`);
31+
return;
32+
}
33+
34+
const buffer = new SpanBuffer(client);
35+
36+
client.on('afterSpanEnd', span => {
37+
if (!spanIsSampled(span)) {
38+
return;
39+
}
40+
buffer.add(captureSpan(span, client));
41+
});
42+
},
43+
};
44+
}) satisfies IntegrationFn;
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import * as SentryCore from '../../src';
2+
import { debug } from '../../src';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { spanStreamingIntegration } from '../../src/integrations/spanStreaming';
5+
import { TestClient, getDefaultTestClientOptions } from '../mocks/client';
6+
7+
const mockSpanBufferInstance = vi.hoisted(() => ({
8+
flush: vi.fn(),
9+
add: vi.fn(),
10+
drain: vi.fn(),
11+
}));
12+
13+
const MockSpanBuffer = vi.hoisted(() => {
14+
return vi.fn(() => mockSpanBufferInstance);
15+
});
16+
17+
vi.mock('../../src/tracing/spans/spanBuffer', async () => {
18+
const original = await vi.importActual('../../src/tracing/spans/spanBuffer');
19+
return {
20+
...original,
21+
SpanBuffer: MockSpanBuffer,
22+
};
23+
});
24+
25+
describe('spanStreamingIntegration (core)', () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks();
28+
});
29+
30+
it('has the correct name and setup hook', () => {
31+
const integration = spanStreamingIntegration();
32+
expect(integration.name).toBe('SpanStreaming');
33+
// eslint-disable-next-line @typescript-eslint/unbound-method
34+
expect(integration.setup).toBeDefined();
35+
});
36+
37+
it.each(['static', 'somethingElse'])(
38+
'logs a warning if traceLifecycle is not set to "stream" but to %s',
39+
traceLifecycle => {
40+
const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});
41+
const client = new TestClient({
42+
...getDefaultTestClientOptions(),
43+
dsn: 'https://username@domain/123',
44+
integrations: [spanStreamingIntegration()],
45+
// @ts-expect-error - we want to test the warning for invalid traceLifecycle values
46+
traceLifecycle,
47+
});
48+
49+
SentryCore.setCurrentClient(client);
50+
client.init();
51+
52+
expect(debugSpy).toHaveBeenCalledWith(
53+
'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.',
54+
);
55+
debugSpy.mockRestore();
56+
57+
expect(client.getOptions().traceLifecycle).toBe('static');
58+
},
59+
);
60+
61+
it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => {
62+
const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});
63+
const client = new TestClient({
64+
...getDefaultTestClientOptions(),
65+
dsn: 'https://username@domain/123',
66+
integrations: [spanStreamingIntegration()],
67+
traceLifecycle: 'stream',
68+
beforeSendSpan: (span: SentryCore.SpanJSON) => span,
69+
});
70+
71+
SentryCore.setCurrentClient(client);
72+
client.init();
73+
74+
expect(debugSpy).toHaveBeenCalledWith(
75+
'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.',
76+
);
77+
debugSpy.mockRestore();
78+
79+
expect(client.getOptions().traceLifecycle).toBe('static');
80+
});
81+
82+
it('sets up buffer when traceLifecycle is "stream"', () => {
83+
const client = new TestClient({
84+
...getDefaultTestClientOptions(),
85+
dsn: 'https://username@domain/123',
86+
integrations: [spanStreamingIntegration()],
87+
traceLifecycle: 'stream',
88+
});
89+
90+
SentryCore.setCurrentClient(client);
91+
client.init();
92+
93+
expect(MockSpanBuffer).toHaveBeenCalledWith(client);
94+
expect(client.getOptions().traceLifecycle).toBe('stream');
95+
});
96+
97+
it('enqueues a span into the buffer when the span ends', () => {
98+
const client = new TestClient({
99+
...getDefaultTestClientOptions(),
100+
dsn: 'https://username@domain/123',
101+
integrations: [spanStreamingIntegration()],
102+
traceLifecycle: 'stream',
103+
tracesSampleRate: 1,
104+
});
105+
106+
SentryCore.setCurrentClient(client);
107+
client.init();
108+
109+
const span = new SentryCore.SentrySpan({ name: 'test', sampled: true });
110+
client.emit('afterSpanEnd', span);
111+
112+
expect(mockSpanBufferInstance.add).toHaveBeenCalledWith(
113+
expect.objectContaining({
114+
_segmentSpan: span,
115+
trace_id: span.spanContext().traceId,
116+
span_id: span.spanContext().spanId,
117+
name: 'test',
118+
}),
119+
);
120+
});
121+
122+
it('does not enqueue a span into the buffer when the span is not sampled', () => {
123+
const client = new TestClient({
124+
...getDefaultTestClientOptions(),
125+
dsn: 'https://username@domain/123',
126+
integrations: [spanStreamingIntegration()],
127+
traceLifecycle: 'stream',
128+
tracesSampleRate: 1,
129+
});
130+
131+
SentryCore.setCurrentClient(client);
132+
client.init();
133+
134+
const span = new SentryCore.SentrySpan({ name: 'test', sampled: false });
135+
client.emit('afterSpanEnd', span);
136+
137+
expect(mockSpanBufferInstance.add).not.toHaveBeenCalled();
138+
});
139+
});

0 commit comments

Comments
 (0)