Skip to content

Commit 6933174

Browse files
committed
apply scope contexts, extras, request data attributes
1 parent 8a9efca commit 6933174

File tree

6 files changed

+226
-31
lines changed

6 files changed

+226
-31
lines changed

packages/browser/src/integrations/spanstreaming.ts

Lines changed: 71 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
1-
import type { Client, IntegrationFn, Span, SpanAttributes, SpanAttributeValue, SpanV2JSON } from '@sentry/core';
1+
import type { Client, IntegrationFn, Scope, ScopeData, Span, SpanAttributes, SpanV2JSON } from '@sentry/core';
22
import {
3+
attributesFromObject,
34
createSpanV2Envelope,
45
debug,
56
defineIntegration,
67
getCapturedScopesOnSpan,
78
getDynamicSamplingContextFromSpan,
89
getGlobalScope,
910
getRootSpan as getSegmentSpan,
11+
httpHeadersToSpanAttributes,
1012
isV2BeforeSendSpanCallback,
1113
mergeScopeData,
1214
reparentChildSpans,
15+
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
1316
SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT,
1417
SEMANTIC_ATTRIBUTE_SENTRY_RELEASE,
1518
SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
1619
SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
1720
SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
21+
SEMANTIC_ATTRIBUTE_URL_FULL,
1822
SEMANTIC_ATTRIBUTE_USER_EMAIL,
1923
SEMANTIC_ATTRIBUTE_USER_ID,
2024
SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS,
@@ -24,6 +28,7 @@ import {
2428
spanToV2JSON,
2529
} from '@sentry/core';
2630
import { DEBUG_BUILD } from '../debug-build';
31+
import { getHttpRequestData } from '../helpers';
2732

2833
export interface SpanStreamingOptions {
2934
batchLimit: number;
@@ -40,11 +45,11 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia
4045
}
4146

4247
const options: SpanStreamingOptions = {
48+
...userOptions,
4349
batchLimit:
4450
userOptions?.batchLimit && userOptions.batchLimit <= 1000 && userOptions.batchLimit >= 1
4551
? userOptions.batchLimit
4652
: 1000,
47-
...userOptions,
4853
};
4954

5055
// key: traceId-segmentSpanId
@@ -59,14 +64,14 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia
5964
const initialMessage = 'spanStreamingIntegration requires';
6065
const fallbackMsg = 'Falling back to static trace lifecycle.';
6166

62-
if (clientOptions.traceLifecycle !== 'streamed') {
63-
DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "streamed"! ${fallbackMsg}`);
67+
if (clientOptions.traceLifecycle !== 'stream') {
68+
DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`);
6469
return;
6570
}
6671

6772
if (beforeSendSpan && !isV2BeforeSendSpanCallback(beforeSendSpan)) {
68-
DEBUG_BUILD &&
69-
debug.warn(`${initialMessage} a beforeSendSpan callback using \`makeV2Callback\`! ${fallbackMsg}`);
73+
client.getOptions().traceLifecycle = 'static';
74+
debug.warn(`${initialMessage} a beforeSendSpan callback using \`makeV2Callback\`! ${fallbackMsg}`);
7075
return;
7176
}
7277

@@ -82,16 +87,14 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia
8287

8388
// For now, we send all spans on local segment (root) span end.
8489
// TODO: This will change once we have more concrete ideas about a universal SDK data buffer.
85-
client.on(
86-
'segmentSpanEnd',
87-
segmentSpan => () =>
88-
processAndSendSpans(segmentSpan, {
89-
spanTreeMap: spanTreeMap,
90-
client,
91-
batchLimit: options.batchLimit,
92-
beforeSendSpan,
93-
}),
94-
);
90+
client.on('segmentSpanEnd', segmentSpan => {
91+
processAndSendSpans(segmentSpan, {
92+
spanTreeMap: spanTreeMap,
93+
client,
94+
batchLimit: options.batchLimit,
95+
beforeSendSpan,
96+
});
97+
});
9598
},
9699
};
97100
}) satisfies IntegrationFn);
@@ -122,12 +125,15 @@ function processAndSendSpans(
122125
spanTreeMap.delete(spanTreeMapKey);
123126
return;
124127
}
128+
125129
const segmentSpanJson = spanToV2JSON(segmentSpan);
126130

127131
for (const span of spansOfTrace) {
128132
applyCommonSpanAttributes(span, segmentSpanJson, client);
129133
}
130134

135+
applyScopeToSegmentSpan(segmentSpan, segmentSpanJson, client);
136+
131137
// TODO: Apply scope data and contexts to segment span
132138

133139
const { ignoreSpans } = client.getOptions();
@@ -139,7 +145,12 @@ function processAndSendSpans(
139145
return;
140146
}
141147

142-
const serializedSpans = Array.from(spansOfTrace ?? []).map(spanToV2JSON);
148+
const serializedSpans = Array.from(spansOfTrace ?? []).map(s => {
149+
const serialized = spanToV2JSON(s);
150+
// remove internal span attributes we don't need to send.
151+
delete serialized.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME];
152+
return serialized;
153+
});
143154

144155
const processedSpans = [];
145156
let ignoredSpanCount = 0;
@@ -168,7 +179,7 @@ function processAndSendSpans(
168179
batches.push(processedSpans.slice(i, i + batchLimit));
169180
}
170181

171-
DEBUG_BUILD && debug.log(`Sending trace ${traceId} in ${batches.length} batche${batches.length === 1 ? '' : 's'}`);
182+
DEBUG_BUILD && debug.log(`Sending trace ${traceId} in ${batches.length} batch${batches.length === 1 ? '' : 'es'}`);
172183

173184
const dsc = getDynamicSamplingContextFromSpan(segmentSpan);
174185

@@ -193,14 +204,7 @@ function applyCommonSpanAttributes(span: Span, serializedSegmentSpan: SpanV2JSON
193204

194205
const originalAttributeKeys = Object.keys(spanToV2JSON(span).attributes ?? {});
195206

196-
// TODO: Extract this scope data merge to a helper in core. It's used in multiple places.
197-
const finalScopeData = getGlobalScope().getScopeData();
198-
if (spanIsolationScope) {
199-
mergeScopeData(finalScopeData, spanIsolationScope.getScopeData());
200-
}
201-
if (spanScope) {
202-
mergeScopeData(finalScopeData, spanScope.getScopeData());
203-
}
207+
const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope);
204208

205209
// avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation)
206210
setAttributesIfNotPresent(span, originalAttributeKeys, {
@@ -220,6 +224,35 @@ function applyCommonSpanAttributes(span: Span, serializedSegmentSpan: SpanV2JSON
220224
});
221225
}
222226

227+
/**
228+
* Adds span attributes frome
229+
*/
230+
function applyScopeToSegmentSpan(segmentSpan: Span, serializedSegmentSpan: SpanV2JSON, client: Client): void {
231+
const { isolationScope, scope } = getCapturedScopesOnSpan(segmentSpan);
232+
const finalScopeData = getFinalScopeData(isolationScope, scope);
233+
234+
const browserRequestData = getHttpRequestData();
235+
236+
const tags = finalScopeData.tags ?? {};
237+
238+
let contextAttributes = {};
239+
Object.keys(finalScopeData.contexts).forEach(key => {
240+
if (finalScopeData.contexts[key]) {
241+
contextAttributes = { ...contextAttributes, ...attributesFromObject(finalScopeData.contexts[key]) };
242+
}
243+
});
244+
245+
const extraAttributes = attributesFromObject(finalScopeData.extra);
246+
247+
setAttributesIfNotPresent(segmentSpan, Object.keys(serializedSegmentSpan.attributes ?? {}), {
248+
[SEMANTIC_ATTRIBUTE_URL_FULL]: browserRequestData.url,
249+
...httpHeadersToSpanAttributes(browserRequestData.headers, client.getOptions().sendDefaultPii ?? false),
250+
...tags,
251+
...contextAttributes,
252+
...extraAttributes,
253+
});
254+
}
255+
223256
function applyBeforeSendSpanCallback(span: SpanV2JSON, beforeSendSpan: (span: SpanV2JSON) => SpanV2JSON): SpanV2JSON {
224257
const modifedSpan = beforeSendSpan(span);
225258
if (!modifedSpan) {
@@ -236,3 +269,15 @@ function setAttributesIfNotPresent(span: Span, originalAttributeKeys: string[],
236269
}
237270
});
238271
}
272+
273+
// TODO: Extract this to a helper in core. It's used in multiple places.
274+
function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | undefined): ScopeData {
275+
const finalScopeData = getGlobalScope().getScopeData();
276+
if (isolationScope) {
277+
mergeScopeData(finalScopeData, isolationScope.getScopeData());
278+
}
279+
if (scope) {
280+
mergeScopeData(finalScopeData, scope.getScopeData());
281+
}
282+
return finalScopeData;
283+
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export {
8484
spanToV2JSON,
8585
showSpanDropWarning,
8686
} from './utils/spanUtils';
87+
export { attributesFromObject } from './utils/attributes';
8788
export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope';
8889
export { parseSampleRate } from './utils/parseSampleRate';
8990
export { applySdkMetadata } from './utils/sdkMetadata';

packages/core/src/tracing/sentrySpan.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,6 @@ export class SentrySpan implements Span {
328328
return;
329329
}
330330

331-
client?.emit('segmentSpanEnd', this);
332-
333331
// if this is a standalone span, we send it immediately
334332
if (this._isStandaloneSpan) {
335333
if (this._sampled) {
@@ -342,8 +340,9 @@ export class SentrySpan implements Span {
342340
}
343341
}
344342
return;
345-
} else if (client?.getOptions()._experiments?._INTERNAL_spanStreaming) {
346-
// nothing to do here; the spanStreaming integration will listen to the respective client hook.
343+
} else if (client?.getOptions().traceLifecycle === 'stream') {
344+
// TODO (spans): Remove standalone span custom logic in favor of sending simple v2 web vital spans
345+
client?.emit('segmentSpanEnd', this);
347346
return;
348347
}
349348

packages/core/src/types-hoist/options.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
390390
*
391391
* @default 'static'
392392
*/
393-
traceLifecycle?: 'static' | 'streamed';
393+
traceLifecycle?: 'static' | 'stream';
394394

395395
/**
396396
* The organization ID for your Sentry project.

packages/core/src/utils/attributes.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import { normalize } from '..';
12
import type { SerializedAttribute } from '../types-hoist/attributes';
3+
import { Primitive } from '../types-hoist/misc';
4+
import type { SpanAttributes, SpanAttributeValue } from '../types-hoist/span';
5+
import { isPrimitive } from './is';
26

37
/**
48
* Converts an attribute value to a serialized attribute value object, containing
@@ -47,3 +51,50 @@ export function attributeValueToSerializedAttribute(value: unknown): SerializedA
4751
}
4852
}
4953
}
54+
55+
/**
56+
* Given an object that might contain keys with primitive, array, or object values,
57+
* return a SpanAttributes object that flattens the object into a single level.
58+
* - Nested keys are separated by '.'.
59+
* - arrays are stringified (TODO: might change, depending on how we support array attributes)
60+
* - objects are flattened
61+
* - primitives are added directly
62+
* - nullish values are ignored
63+
* - maxDepth is the maximum depth to flatten the object to
64+
*
65+
* @param obj - The object to flatten into span attributes
66+
* @returns The span attribute object
67+
*/
68+
export function attributesFromObject(obj: Record<string, unknown>, maxDepth = 3): SpanAttributes {
69+
const result: Record<string, number | string | boolean | undefined> = {};
70+
71+
function primitiveOrToString(current: unknown): number | boolean | string {
72+
if (typeof current === 'number' || typeof current === 'boolean' || typeof current === 'string') {
73+
return current;
74+
}
75+
return String(current);
76+
}
77+
78+
function flatten(current: unknown, prefix: string, depth: number): void {
79+
if (current == null) {
80+
return;
81+
} else if (depth >= maxDepth) {
82+
result[prefix] = primitiveOrToString(current);
83+
return;
84+
} else if (Array.isArray(current)) {
85+
result[prefix] = JSON.stringify(current);
86+
} else if (typeof current === 'number' || typeof current === 'string' || typeof current === 'boolean') {
87+
result[prefix] = current;
88+
} else if (typeof current === 'object' && current !== null && !Array.isArray(current) && depth < maxDepth) {
89+
for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
90+
flatten(value, prefix ? `${prefix}.${key}` : key, depth + 1);
91+
}
92+
}
93+
}
94+
95+
const normalizedObj = normalize(obj, maxDepth);
96+
97+
flatten(normalizedObj, '', 0);
98+
99+
return result;
100+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { attributesFromObject } from '../../../src/utils/attributes';
3+
4+
describe('attributesFromObject', () => {
5+
it('flattens an object', () => {
6+
const context = {
7+
a: 1,
8+
b: { c: { d: 2 } },
9+
};
10+
11+
const result = attributesFromObject(context);
12+
13+
expect(result).toEqual({
14+
a: 1,
15+
'b.c.d': 2,
16+
});
17+
});
18+
19+
it('flattens an object with a max depth', () => {
20+
const context = {
21+
a: 1,
22+
b: { c: { d: 2 } },
23+
};
24+
25+
const result = attributesFromObject(context, 2);
26+
27+
expect(result).toEqual({
28+
a: 1,
29+
'b.c': '[Object]',
30+
});
31+
});
32+
33+
it('flattens an object an array', () => {
34+
const context = {
35+
a: 1,
36+
b: { c: { d: 2 } },
37+
integrations: ['foo', 'bar'],
38+
};
39+
40+
const result = attributesFromObject(context);
41+
42+
expect(result).toEqual({
43+
a: 1,
44+
'b.c.d': 2,
45+
integrations: '["foo","bar"]',
46+
});
47+
});
48+
49+
it('handles a circular object', () => {
50+
const context = {
51+
a: 1,
52+
b: { c: { d: 2 } },
53+
};
54+
context.b.c.e = context.b;
55+
56+
const result = attributesFromObject(context, 5);
57+
58+
expect(result).toEqual({
59+
a: 1,
60+
'b.c.d': 2,
61+
'b.c.e': '[Circular ~]',
62+
});
63+
});
64+
65+
it('handles a circular object in an array', () => {
66+
const context = {
67+
a: 1,
68+
b: { c: { d: 2 } },
69+
integrations: ['foo', 'bar'],
70+
};
71+
72+
// @ts-expect-error - this is fine
73+
context.integrations[0] = context.integrations;
74+
75+
const result = attributesFromObject(context, 5);
76+
77+
expect(result).toEqual({
78+
a: 1,
79+
'b.c.d': 2,
80+
integrations: '["[Circular ~]","bar"]',
81+
});
82+
});
83+
84+
it('handles objects in arrays', () => {
85+
const context = {
86+
a: 1,
87+
b: { c: { d: 2 } },
88+
integrations: [{ name: 'foo' }, { name: 'bar' }],
89+
};
90+
91+
const result = attributesFromObject(context);
92+
93+
expect(result).toEqual({
94+
a: 1,
95+
'b.c.d': 2,
96+
integrations: '[{"name":"foo"},{"name":"bar"}]',
97+
});
98+
});
99+
});

0 commit comments

Comments
 (0)