Skip to content

Commit 44f1152

Browse files
committed
feat(core): Add captureSpan pipeline and helpers
1 parent 4eeed05 commit 44f1152

5 files changed

Lines changed: 623 additions & 4 deletions

File tree

packages/core/src/client.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import type { RequestEventData } from './types-hoist/request';
3131
import type { SdkMetadata } from './types-hoist/sdkmetadata';
3232
import type { Session, SessionAggregates } from './types-hoist/session';
3333
import type { SeverityLevel } from './types-hoist/severity';
34-
import type { Span, SpanAttributes, SpanContextData, SpanJSON } from './types-hoist/span';
34+
import type { Span, SpanAttributes, SpanContextData, SpanJSON, StreamedSpanJSON } from './types-hoist/span';
3535
import type { StartSpanOptions } from './types-hoist/startSpanOptions';
3636
import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport';
3737
import { isStreamedBeforeSendSpanCallback } from './utils/beforeSendSpan';
@@ -609,6 +609,16 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
609609
*/
610610
public on(hook: 'spanEnd', callback: (span: Span) => void): () => void;
611611

612+
/**
613+
* Register a callback for when a span JSON is processed, to add some data to the span JSON.
614+
*/
615+
public on(hook: 'processSpan', callback: (streamedSpanJSON: StreamedSpanJSON) => void): () => void;
616+
617+
/**
618+
* Register a callback for when a segment span JSON is processed, to add some data to the segment span JSON.
619+
*/
620+
public on(hook: 'processSegmentSpan', callback: (streamedSpanJSON: StreamedSpanJSON) => void): () => void;
621+
612622
/**
613623
* Register a callback for when an idle span is allowed to auto-finish.
614624
* @returns {() => void} A function that, when executed, removes the registered callback.
@@ -881,6 +891,16 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
881891
/** Fire a hook whenever a span ends. */
882892
public emit(hook: 'spanEnd', span: Span): void;
883893

894+
/**
895+
* Register a callback for when a span JSON is processed, to add some data to the span JSON.
896+
*/
897+
public emit(hook: 'processSpan', streamedSpanJSON: StreamedSpanJSON): void;
898+
899+
/**
900+
* Register a callback for when a segment span JSON is processed, to add some data to the segment span JSON.
901+
*/
902+
public emit(hook: 'processSegmentSpan', streamedSpanJSON: StreamedSpanJSON): void;
903+
884904
/**
885905
* Fire a hook indicating that an idle span is allowed to auto finish.
886906
*/

packages/core/src/semanticAttributes.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
/**
2-
* Use this attribute to represent the source of a span.
3-
* Should be one of: custom, url, route, view, component, task, unknown
4-
*
2+
* Use this attribute to represent the source of a span name.
3+
* Must be one of: custom, url, route, view, component, task
4+
* TODO: Deprecate this attribute in favour of SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE
55
*/
66
export const SEMANTIC_ATTRIBUTE_SENTRY_SOURCE = 'sentry.source';
77

8+
/**
9+
* Use this attribute to represent the source of a span name.
10+
* Must be one of: custom, url, route, view, component, task
11+
*/
12+
export const SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE = 'sentry.span.source';
13+
814
/**
915
* Attributes that holds the sample rate that was locally applied to a span.
1016
* If this attribute is not defined, it means that the span inherited a sampling decision.
@@ -40,6 +46,28 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT = 'sentry.measurement_un
4046
/** The value of a measurement, which may be stored as a TimedEvent. */
4147
export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE = 'sentry.measurement_value';
4248

49+
/** The release version of the application */
50+
export const SEMANTIC_ATTRIBUTE_SENTRY_RELEASE = 'sentry.release';
51+
/** The environment name (e.g., "production", "staging", "development") */
52+
export const SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT = 'sentry.environment';
53+
/** The segment name (e.g., "GET /users") */
54+
export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME = 'sentry.segment.name';
55+
/** The id of the segment that this span belongs to. */
56+
export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID = 'sentry.segment.id';
57+
/** The name of the Sentry SDK (e.g., "sentry.php", "sentry.javascript") */
58+
export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name';
59+
/** The version of the Sentry SDK */
60+
export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version';
61+
62+
/** The user ID (gated by sendDefaultPii) */
63+
export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id';
64+
/** The user email (gated by sendDefaultPii) */
65+
export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email';
66+
/** The user IP address (gated by sendDefaultPii) */
67+
export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address';
68+
/** The user username (gated by sendDefaultPii) */
69+
export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.name';
70+
4371
/**
4472
* A custom span name set by users guaranteed to be taken over any automatically
4573
* inferred name. This attribute is removed before the span is sent.

packages/core/src/tracing/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,6 @@ export {
2323
export { setMeasurement, timedEventsToMeasurements } from './measurement';
2424
export { sampleSpan } from './sampling';
2525
export { logSpanEnd, logSpanStart } from './logSpans';
26+
27+
// Span Streaming
28+
export { captureSpan } from './spans/captureSpan';
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { RawAttributes } from '../../attributes';
2+
import type { Client } from '../../client';
3+
import type { ScopeData } from '../../scope';
4+
import {
5+
SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT,
6+
SEMANTIC_ATTRIBUTE_SENTRY_RELEASE,
7+
SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
8+
SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
9+
SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID,
10+
SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
11+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
12+
SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE,
13+
SEMANTIC_ATTRIBUTE_USER_EMAIL,
14+
SEMANTIC_ATTRIBUTE_USER_ID,
15+
SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS,
16+
SEMANTIC_ATTRIBUTE_USER_USERNAME,
17+
} from '../../semanticAttributes';
18+
import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span';
19+
import { isStreamedBeforeSendSpanCallback } from '../../utils/beforeSendSpan';
20+
import { debug } from '../../utils/debug-logger';
21+
import { getCombinedScopeData } from '../../utils/scopeData';
22+
import {
23+
INTERNAL_getSegmentSpan,
24+
showSpanDropWarning,
25+
spanToStreamedSpanJSON,
26+
streamedSpanJsonToSerializedSpan,
27+
} from '../../utils/spanUtils';
28+
import { getCapturedScopesOnSpan } from '../utils';
29+
30+
type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & {
31+
_segmentSpan: Span;
32+
};
33+
34+
/**
35+
* Captures a span and returns a JSON representation to be enqueued for sending.
36+
*
37+
* IMPORTANT: This function converts the span to JSON immediately to avoid writing
38+
* to an already-ended OTel span instance (which is blocked by the OTel Span class).
39+
*
40+
* @returns the final serialized span with a reference to its segment span. This reference
41+
* is needed later on to compute the DSC for the span envelope.
42+
*/
43+
export function captureSpan(span: Span, client: Client): SerializedStreamedSpanWithSegmentSpan {
44+
// Convert to JSON FIRST - we cannot write to an already-ended span
45+
const spanJSON = spanToStreamedSpanJSON(span);
46+
47+
const segmentSpan = INTERNAL_getSegmentSpan(span);
48+
const serializedSegmentSpan = spanToStreamedSpanJSON(segmentSpan);
49+
50+
const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span);
51+
52+
const finalScopeData = getCombinedScopeData(spanIsolationScope, spanScope);
53+
54+
applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData);
55+
56+
if (span === segmentSpan) {
57+
applyScopeToSegmentSpan(spanJSON, finalScopeData);
58+
// Allow hook subscribers to add additional data to the segment span JSON
59+
client.emit('processSegmentSpan', spanJSON);
60+
}
61+
62+
// Allow hook subscribers to add additional data to the span JSON
63+
client.emit('processSpan', spanJSON);
64+
65+
const { beforeSendSpan } = client.getOptions();
66+
const processedSpan =
67+
beforeSendSpan && isStreamedBeforeSendSpanCallback(beforeSendSpan)
68+
? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan)
69+
: spanJSON;
70+
71+
// Backfill sentry.span.source from sentry.source for the PoC
72+
// TODO(v11): Stop sending `sentry.source` attribute and only send `sentry.span.source`
73+
if (processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]) {
74+
safeSetSpanJSONAttributes(processedSpan, {
75+
[SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE]: processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE],
76+
});
77+
delete processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
78+
}
79+
80+
return {
81+
...streamedSpanJsonToSerializedSpan(processedSpan),
82+
_segmentSpan: segmentSpan,
83+
};
84+
}
85+
86+
function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void {
87+
// TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span
88+
// This will follow in a separate PR
89+
}
90+
91+
function applyCommonSpanAttributes(
92+
spanJSON: StreamedSpanJSON,
93+
serializedSegmentSpan: StreamedSpanJSON,
94+
client: Client,
95+
scopeData: ScopeData,
96+
): void {
97+
const sdk = client.getSdkMetadata();
98+
const { release, environment, sendDefaultPii } = client.getOptions();
99+
100+
// avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation)
101+
safeSetSpanJSONAttributes(spanJSON, {
102+
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release,
103+
[SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment,
104+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name,
105+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id,
106+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name,
107+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version,
108+
...(sendDefaultPii
109+
? {
110+
[SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id,
111+
[SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email,
112+
[SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address,
113+
[SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username,
114+
}
115+
: {}),
116+
...scopeData.attributes,
117+
});
118+
}
119+
120+
/**
121+
* Apply a user-provided beforeSendSpan callback to a span JSON.
122+
*/
123+
export function applyBeforeSendSpanCallback(
124+
span: StreamedSpanJSON,
125+
beforeSendSpan: (span: StreamedSpanJSON) => StreamedSpanJSON,
126+
): StreamedSpanJSON {
127+
const modifedSpan = beforeSendSpan(span);
128+
if (!modifedSpan) {
129+
showSpanDropWarning();
130+
return span;
131+
}
132+
return modifedSpan;
133+
}
134+
135+
/**
136+
* Safely set attributes on a span JSON.
137+
* If an attribute already exists, it will not be overwritten.
138+
*/
139+
export function safeSetSpanJSONAttributes(
140+
spanJSON: StreamedSpanJSON,
141+
newAttributes: RawAttributes<Record<string, unknown>>,
142+
): void {
143+
const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {});
144+
145+
Object.keys(newAttributes).forEach(key => {
146+
if (!originalAttributes?.[key]) {
147+
originalAttributes[key] = newAttributes[key];
148+
}
149+
});
150+
}

0 commit comments

Comments
 (0)