Skip to content

Commit 84e9c97

Browse files
authored
feat(core): Add options to start standalone (segment) spans via start*Span APIs (#11696)
Add an experimental option to our `StartSpanOptions` API to allow sending a span as a standalone (segment) span via our `start*Span` APIs: - `experimantal.standalone`: if `true`, the span will be sent as a span envelope instead of a transaction event envelope
1 parent 1b3fb8b commit 84e9c97

File tree

12 files changed

+334
-15
lines changed

12 files changed

+334
-15
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
tracesSampleRate: 1.0,
8+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Sentry.startSpan({ name: 'outer' }, () => {
2+
Sentry.startSpan({ name: 'inner' }, () => {});
3+
Sentry.startSpan({ name: 'standalone', experimental: { standalone: true } }, () => {});
4+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { expect } from '@playwright/test';
2+
import type { Envelope, EventEnvelope, SpanEnvelope, TransactionEvent } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import {
6+
getMultipleSentryEnvelopeRequests,
7+
properFullEnvelopeRequestParser,
8+
shouldSkipTracingTest,
9+
} from '../../../../utils/helpers';
10+
11+
sentryTest(
12+
'sends a transaction and a span envelope if a standalone span is created as a child of an ongoing span tree',
13+
async ({ getLocalTestPath, page }) => {
14+
if (shouldSkipTracingTest()) {
15+
sentryTest.skip();
16+
}
17+
18+
const url = await getLocalTestPath({ testDir: __dirname });
19+
const envelopes = await getMultipleSentryEnvelopeRequests<Envelope>(
20+
page,
21+
2,
22+
{ url, envelopeType: ['transaction', 'span'] },
23+
properFullEnvelopeRequestParser,
24+
);
25+
26+
const spanEnvelope = envelopes.find(envelope => envelope[1][0][0].type === 'span') as SpanEnvelope;
27+
const transactionEnvelope = envelopes.find(envelope => envelope[1][0][0].type === 'transaction') as EventEnvelope;
28+
29+
const spanEnvelopeHeader = spanEnvelope[0];
30+
const spanEnvelopeItem = spanEnvelope[1][0][1];
31+
32+
const transactionEnvelopeHeader = transactionEnvelope[0];
33+
const transactionEnvelopeItem = transactionEnvelope[1][0][1] as TransactionEvent;
34+
35+
const traceId = transactionEnvelopeHeader.trace!.trace_id!;
36+
const parentSpanId = transactionEnvelopeItem.contexts?.trace?.span_id;
37+
38+
expect(traceId).toMatch(/[a-f0-9]{32}/);
39+
expect(parentSpanId).toMatch(/[a-f0-9]{16}/);
40+
41+
// TODO: the span envelope also needs to contain the `trace` header (follow-up PR)
42+
expect(spanEnvelopeHeader).toEqual({
43+
sent_at: expect.any(String),
44+
});
45+
46+
expect(transactionEnvelopeHeader).toEqual({
47+
event_id: expect.any(String),
48+
sdk: {
49+
name: 'sentry.javascript.browser',
50+
version: expect.any(String),
51+
},
52+
sent_at: expect.any(String),
53+
trace: {
54+
environment: 'production',
55+
public_key: 'public',
56+
sample_rate: '1',
57+
sampled: 'true',
58+
trace_id: traceId,
59+
transaction: 'outer',
60+
},
61+
});
62+
63+
expect(spanEnvelopeItem).toEqual({
64+
data: {
65+
'sentry.origin': 'manual',
66+
},
67+
description: 'standalone',
68+
segment_id: transactionEnvelopeItem.contexts?.trace?.span_id,
69+
parent_span_id: parentSpanId,
70+
origin: 'manual',
71+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
72+
start_timestamp: expect.any(Number),
73+
timestamp: expect.any(Number),
74+
trace_id: traceId,
75+
});
76+
77+
expect(transactionEnvelopeItem).toEqual({
78+
contexts: {
79+
trace: {
80+
data: {
81+
'sentry.origin': 'manual',
82+
'sentry.sample_rate': 1,
83+
'sentry.source': 'custom',
84+
},
85+
origin: 'manual',
86+
span_id: parentSpanId,
87+
trace_id: traceId,
88+
},
89+
},
90+
environment: 'production',
91+
event_id: expect.any(String),
92+
platform: 'javascript',
93+
request: {
94+
headers: expect.any(Object),
95+
url: expect.any(String),
96+
},
97+
sdk: expect.any(Object),
98+
spans: [
99+
{
100+
data: {
101+
'sentry.origin': 'manual',
102+
},
103+
description: 'inner',
104+
origin: 'manual',
105+
parent_span_id: parentSpanId,
106+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
107+
start_timestamp: expect.any(Number),
108+
timestamp: expect.any(Number),
109+
trace_id: traceId,
110+
},
111+
],
112+
start_timestamp: expect.any(Number),
113+
timestamp: expect.any(Number),
114+
transaction: 'outer',
115+
transaction_info: {
116+
source: 'custom',
117+
},
118+
type: 'transaction',
119+
});
120+
},
121+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
tracesSampleRate: 1.0,
8+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Sentry.startSpan({ name: 'standalone_segment_span', experimental: { standalone: true } }, () => {});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect } from '@playwright/test';
2+
import type { SpanEnvelope } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import {
6+
getFirstSentryEnvelopeRequest,
7+
properFullEnvelopeRequestParser,
8+
shouldSkipTracingTest,
9+
} from '../../../../utils/helpers';
10+
11+
sentryTest('sends a segment span envelope', async ({ getLocalTestPath, page }) => {
12+
if (shouldSkipTracingTest()) {
13+
sentryTest.skip();
14+
}
15+
16+
const url = await getLocalTestPath({ testDir: __dirname });
17+
const spanEnvelope = await getFirstSentryEnvelopeRequest<SpanEnvelope>(page, url, properFullEnvelopeRequestParser);
18+
19+
const headers = spanEnvelope[0];
20+
const item = spanEnvelope[1][0];
21+
22+
const itemHeader = item[0];
23+
const spanJson = item[1];
24+
25+
expect(headers).toEqual({
26+
sent_at: expect.any(String),
27+
});
28+
29+
expect(itemHeader).toEqual({
30+
type: 'span',
31+
});
32+
33+
expect(spanJson).toEqual({
34+
data: {
35+
'sentry.origin': 'manual',
36+
'sentry.sample_rate': 1,
37+
'sentry.source': 'custom',
38+
},
39+
description: 'standalone_segment_span',
40+
origin: 'manual',
41+
span_id: expect.stringMatching(/^[0-9a-f]{16}$/),
42+
start_timestamp: expect.any(Number),
43+
timestamp: expect.any(Number),
44+
trace_id: expect.stringMatching(/^[0-9a-f]{32}$/),
45+
is_segment: true,
46+
segment_id: spanJson.span_id,
47+
});
48+
});

dev-packages/browser-integration-tests/utils/helpers.ts

+7
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ export const properEnvelopeRequestParser = <T = Event>(request: Request | null,
7474
return properEnvelopeParser(request)[0][envelopeIndex] as T;
7575
};
7676

77+
export const properFullEnvelopeRequestParser = <T extends Envelope>(request: Request | null): T => {
78+
// https://develop.sentry.dev/sdk/envelopes/
79+
const envelope = request?.postData() || '';
80+
81+
return parseEnvelope(envelope) as T;
82+
};
83+
7784
export const envelopeHeaderRequestParser = (request: Request | null): EventEnvelopeHeaders => {
7885
// https://develop.sentry.dev/sdk/envelopes/
7986
const envelope = request?.postData() || '';

packages/core/src/tracing/sentrySpan.ts

+61-9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
SpanAttributeValue,
55
SpanAttributes,
66
SpanContextData,
7+
SpanEnvelope,
78
SpanJSON,
89
SpanOrigin,
910
SpanStatus,
@@ -16,6 +17,7 @@ import { dropUndefinedKeys, logger, timestampInSeconds, uuid4 } from '@sentry/ut
1617
import { getClient, getCurrentScope } from '../currentScopes';
1718
import { DEBUG_BUILD } from '../debug-build';
1819

20+
import { createSpanEnvelope } from '../envelope';
1921
import { getMetricSummaryJsonForSpan } from '../metrics/metric-summary';
2022
import {
2123
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
@@ -58,6 +60,9 @@ export class SentrySpan implements Span {
5860
/** The timed events added to this span. */
5961
protected _events: TimedEvent[];
6062

63+
/** if true, treat span as a standalone span (not part of a transaction) */
64+
private _isStandaloneSpan?: boolean;
65+
6166
/**
6267
* You should never call the constructor manually, always use `Sentry.startSpan()`
6368
* or other span methods.
@@ -96,6 +101,8 @@ export class SentrySpan implements Span {
96101
if (this._endTime) {
97102
this._onSpanEnded();
98103
}
104+
105+
this._isStandaloneSpan = spanContext.isStandalone;
99106
}
100107

101108
/** @inheritdoc */
@@ -188,6 +195,8 @@ export class SentrySpan implements Span {
188195
profile_id: this._attributes[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined,
189196
exclusive_time: this._attributes[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME] as number | undefined,
190197
measurements: timedEventsToMeasurements(this._events),
198+
is_segment: (this._isStandaloneSpan && getRootSpan(this) === this) || undefined,
199+
segment_id: this._isStandaloneSpan ? getRootSpan(this).spanContext().spanId : undefined,
191200
});
192201
}
193202

@@ -220,20 +229,44 @@ export class SentrySpan implements Span {
220229
return this;
221230
}
222231

232+
/**
233+
* This method should generally not be used,
234+
* but for now we need a way to publicly check if the `_isStandaloneSpan` flag is set.
235+
* USE THIS WITH CAUTION!
236+
* @internal
237+
* @hidden
238+
* @experimental
239+
*/
240+
public isStandaloneSpan(): boolean {
241+
return !!this._isStandaloneSpan;
242+
}
243+
223244
/** Emit `spanEnd` when the span is ended. */
224245
private _onSpanEnded(): void {
225246
const client = getClient();
226247
if (client) {
227248
client.emit('spanEnd', this);
228249
}
229250

230-
// If this is a root span, send it when it is endedf
231-
if (this === getRootSpan(this)) {
232-
const transactionEvent = this._convertSpanToTransaction();
233-
if (transactionEvent) {
234-
const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope();
235-
scope.captureEvent(transactionEvent);
236-
}
251+
// A segment span is basically the root span of a local span tree.
252+
// So for now, this is either what we previously refer to as the root span,
253+
// or a standalone span.
254+
const isSegmentSpan = this._isStandaloneSpan || this === getRootSpan(this);
255+
256+
if (!isSegmentSpan) {
257+
return;
258+
}
259+
260+
// if this is a standalone span, we send it immediately
261+
if (this._isStandaloneSpan) {
262+
sendSpanEnvelope(createSpanEnvelope([this]));
263+
return;
264+
}
265+
266+
const transactionEvent = this._convertSpanToTransaction();
267+
if (transactionEvent) {
268+
const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope();
269+
scope.captureEvent(transactionEvent);
237270
}
238271
}
239272

@@ -266,8 +299,8 @@ export class SentrySpan implements Span {
266299
return undefined;
267300
}
268301

269-
// The transaction span itself should be filtered out
270-
const finishedSpans = getSpanDescendants(this).filter(span => span !== this);
302+
// The transaction span itself as well as any potential standalone spans should be filtered out
303+
const finishedSpans = getSpanDescendants(this).filter(span => span !== this && !isStandaloneSpan(span));
271304

272305
const spans = finishedSpans.map(span => spanToJSON(span)).filter(isFullFinishedSpan);
273306

@@ -318,3 +351,22 @@ function isSpanTimeInput(value: undefined | SpanAttributes | SpanTimeInput): val
318351
function isFullFinishedSpan(input: Partial<SpanJSON>): input is SpanJSON {
319352
return !!input.start_timestamp && !!input.timestamp && !!input.span_id && !!input.trace_id;
320353
}
354+
355+
/** `SentrySpan`s can be sent as a standalone span rather than belonging to a transaction */
356+
function isStandaloneSpan(span: Span): boolean {
357+
return span instanceof SentrySpan && span.isStandaloneSpan();
358+
}
359+
360+
function sendSpanEnvelope(envelope: SpanEnvelope): void {
361+
const client = getClient();
362+
if (!client) {
363+
return;
364+
}
365+
366+
const transport = client.getTransport();
367+
if (transport) {
368+
transport.send(envelope).then(null, reason => {
369+
DEBUG_BUILD && logger.error('Error while sending span:', reason);
370+
});
371+
}
372+
}

packages/core/src/tracing/trace.ts

+12-6
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function startSpan<T>(context: StartSpanOptions, callback: (span: Span) =
4545
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
4646
const activeSpan = shouldSkipSpan
4747
? new SentryNonRecordingSpan()
48-
: createChildSpanOrTransaction({
48+
: createChildOrRootSpan({
4949
parentSpan,
5050
spanContext,
5151
forceTransaction: context.forceTransaction,
@@ -92,7 +92,7 @@ export function startSpanManual<T>(context: StartSpanOptions, callback: (span: S
9292
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
9393
const activeSpan = shouldSkipSpan
9494
? new SentryNonRecordingSpan()
95-
: createChildSpanOrTransaction({
95+
: createChildOrRootSpan({
9696
parentSpan,
9797
spanContext,
9898
forceTransaction: context.forceTransaction,
@@ -144,7 +144,7 @@ export function startInactiveSpan(context: StartSpanOptions): Span {
144144
return new SentryNonRecordingSpan();
145145
}
146146

147-
return createChildSpanOrTransaction({
147+
return createChildOrRootSpan({
148148
parentSpan,
149149
spanContext,
150150
forceTransaction: context.forceTransaction,
@@ -212,7 +212,7 @@ export function suppressTracing<T>(callback: () => T): T {
212212
});
213213
}
214214

215-
function createChildSpanOrTransaction({
215+
function createChildOrRootSpan({
216216
parentSpan,
217217
spanContext,
218218
forceTransaction,
@@ -291,14 +291,20 @@ function createChildSpanOrTransaction({
291291
* Eventually the StartSpanOptions will be more aligned with OpenTelemetry.
292292
*/
293293
function normalizeContext(context: StartSpanOptions): SentrySpanArguments {
294+
const exp = context.experimental || {};
295+
const initialCtx: SentrySpanArguments = {
296+
isStandalone: exp.standalone,
297+
...context,
298+
};
299+
294300
if (context.startTime) {
295-
const ctx: SentrySpanArguments & { startTime?: SpanTimeInput } = { ...context };
301+
const ctx: SentrySpanArguments & { startTime?: SpanTimeInput } = { ...initialCtx };
296302
ctx.startTimestamp = spanTimeInputToSeconds(context.startTime);
297303
delete ctx.startTime;
298304
return ctx;
299305
}
300306

301-
return context;
307+
return initialCtx;
302308
}
303309

304310
function getAcs(): AsyncContextStrategy {

0 commit comments

Comments
 (0)