Skip to content

Commit 0e80fde

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 6007a29 commit 0e80fde

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
@@ -173,6 +173,7 @@ export {
173173
statsigIntegration,
174174
unleashIntegration,
175175
growthbookIntegration,
176+
spanStreamingIntegration,
176177
metrics,
177178
} from '@sentry/node';
178179

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
@@ -160,6 +160,7 @@ export {
160160
unleashIntegration,
161161
growthbookIntegration,
162162
metrics,
163+
spanStreamingIntegration,
163164
} from '@sentry/node';
164165

165166
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
@@ -178,6 +178,7 @@ export {
178178
statsigIntegration,
179179
unleashIntegration,
180180
metrics,
181+
spanStreamingIntegration,
181182
} from '@sentry/node';
182183

183184
export {

packages/core/src/index.ts

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

195195
export { SpanBuffer } from './tracing/spans/spanBuffer';
196196
export { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled';
197+
export { spanStreamingIntegration } from './integrations/spanStreaming';
197198

198199
export type { FeatureFlag } from './utils/featureFlags';
199200

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)