Skip to content

Commit 282eed0

Browse files
committed
add unit tests for captureSpan pipeline and utils
1 parent 07633a3 commit 282eed0

File tree

10 files changed

+606
-130
lines changed

10 files changed

+606
-130
lines changed

packages/browser/src/integrations/httpcontext.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,10 @@ export const httpContextIntegration = defineIntegration(() => {
2727

2828
const attributeHeaders = httpHeadersToSpanAttributes(headers);
2929

30-
safeSetSpanJSONAttributes(
31-
spanJSON,
32-
{
33-
[SEMANTIC_ATTRIBUTE_URL_FULL]: url,
34-
...attributeHeaders,
35-
},
36-
spanJSON.attributes,
37-
);
30+
safeSetSpanJSONAttributes(spanJSON, {
31+
[SEMANTIC_ATTRIBUTE_URL_FULL]: url,
32+
...attributeHeaders,
33+
});
3834
}
3935
});
4036
}

packages/core/src/attributes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export type AttributeObject = {
4242

4343
// Unfortunately, we loose type safety if we did something like Exclude<MeasurementUnit, string>
4444
// so therefore we unionize between the three supported unit categories.
45-
type AttributeUnit = DurationUnit | InformationUnit | FractionUnit;
45+
export type AttributeUnit = DurationUnit | InformationUnit | FractionUnit;
4646

4747
/* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */
4848
export type ValidatedAttributes<T> = {

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export {
9090
showSpanDropWarning,
9191
} from './utils/spanUtils';
9292
export { captureSpan } from './spans/captureSpan';
93-
export { safeSetSpanAttributes, safeSetSpanJSONAttributes } from './spans/spanFirstUtils';
93+
export { safeSetSpanJSONAttributes } from './spans/spanFirstUtils';
9494
export { attributesFromObject } from './utils/attributes';
9595
export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope';
9696
export { parseSampleRate } from './utils/parseSampleRate';
Lines changed: 23 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Attributes, RawAttribute, RawAttributes } from '../attributes';
21
import type { Client } from '../client';
32
import { getClient, getGlobalScope } from '../currentScopes';
43
import { DEBUG_BUILD } from '../debug-build';
@@ -17,13 +16,12 @@ import {
1716
} from '../semanticAttributes';
1817
import { getCapturedScopesOnSpan } from '../tracing/utils';
1918
import type { SerializedAttributes } from '../types-hoist/attributes';
20-
import { Contexts } from '../types-hoist/context';
2119
import type { Span, SpanV2JSON } from '../types-hoist/span';
2220
import { mergeScopeData } from '../utils/applyScopeDataToEvent';
2321
import { isV2BeforeSendSpanCallback } from '../utils/beforeSendSpan';
2422
import { debug } from '../utils/debug-logger';
2523
import { INTERNAL_getSegmentSpan, spanToV2JSON } from '../utils/spanUtils';
26-
import { applyBeforeSendSpanCallback, safeSetSpanJSONAttributes } from './spanFirstUtils';
24+
import { applyBeforeSendSpanCallback, contextsToAttributes, safeSetSpanJSONAttributes } from './spanFirstUtils';
2725
/**
2826
* Captures a span and returns a JSON representation to be enqueued for sending.
2927
*
@@ -43,14 +41,13 @@ export function captureSpan(span: Span, client = getClient()): void {
4341
const serializedSegmentSpan = spanToV2JSON(segmentSpan);
4442

4543
const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span);
46-
const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope);
4744

48-
const originalAttributes = serializedSegmentSpan.attributes ?? {};
45+
const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope);
4946

50-
applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData, originalAttributes);
47+
applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData);
5148

5249
if (span === segmentSpan) {
53-
applyScopeToSegmentSpan(spanJSON, finalScopeData, originalAttributes);
50+
applyScopeToSegmentSpan(spanJSON, finalScopeData);
5451
}
5552

5653
// Allow integrations to add additional data to the span JSON
@@ -69,49 +66,40 @@ export function captureSpan(span: Span, client = getClient()): void {
6966
client.emit('enqueueSpan', spanWithRef);
7067
}
7168

72-
function applyScopeToSegmentSpan(
73-
segmentSpanJSON: SpanV2JSON,
74-
scopeData: ScopeData,
75-
originalAttributes: SerializedAttributes,
76-
): void {
69+
function applyScopeToSegmentSpan(segmentSpanJSON: SpanV2JSON, scopeData: ScopeData): void {
7770
// TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span
7871
const { contexts } = scopeData;
7972

80-
safeSetSpanJSONAttributes(segmentSpanJSON, contextsToAttributes(contexts), originalAttributes);
73+
safeSetSpanJSONAttributes(segmentSpanJSON, contextsToAttributes(contexts));
8174
}
8275

8376
function applyCommonSpanAttributes(
8477
spanJSON: SpanV2JSON,
8578
serializedSegmentSpan: SpanV2JSON,
8679
client: Client,
8780
scopeData: ScopeData,
88-
originalAttributes: SerializedAttributes,
8981
): void {
9082
const sdk = client.getSdkMetadata();
9183
const { release, environment, sendDefaultPii } = client.getOptions();
9284

9385
// avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation)
94-
safeSetSpanJSONAttributes(
95-
spanJSON,
96-
{
97-
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release,
98-
[SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment,
99-
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name,
100-
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id,
101-
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name,
102-
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version,
103-
...(sendDefaultPii
104-
? {
105-
[SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id,
106-
[SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email,
107-
[SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address ?? undefined,
108-
[SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username,
109-
}
110-
: {}),
111-
...scopeData.attributes,
112-
},
113-
originalAttributes,
114-
);
86+
safeSetSpanJSONAttributes(spanJSON, {
87+
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release,
88+
[SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment,
89+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name,
90+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id,
91+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name,
92+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version,
93+
...(sendDefaultPii
94+
? {
95+
[SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id,
96+
[SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email,
97+
[SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address,
98+
[SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username,
99+
}
100+
: {}),
101+
...scopeData.attributes,
102+
});
115103
}
116104

117105
// TODO: Extract this to a helper in core. It's used in multiple places.
@@ -125,37 +113,3 @@ function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | und
125113
}
126114
return finalScopeData;
127115
}
128-
129-
// TODO: This should likely live in the Context integration since most of this data is only avialable in server runtime contexts
130-
function contextsToAttributes(contexts: Contexts): RawAttributes<Record<string, unknown>> {
131-
return {
132-
// os context
133-
'os.build_id': contexts.os?.build,
134-
'os.name': contexts.os?.name,
135-
'os.version': contexts.os?.version,
136-
// TODO: Add to Sentry SemConv
137-
'os.kernel_version': contexts.os?.kernel_version,
138-
139-
// runtime context
140-
// TODO: Add to Sentry SemConv
141-
'runtime.name': contexts.runtime?.name,
142-
// TODO: Add to Sentry SemConv
143-
'runtime.version': contexts.runtime?.version,
144-
145-
// TODO: All of them need to be added to Sentry SemConv (except family and model)
146-
...(contexts.app
147-
? Object.fromEntries(Object.entries(contexts.app).map(([key, value]) => [`app.${key}`, value]))
148-
: {}),
149-
...(contexts.device
150-
? Object.fromEntries(Object.entries(contexts.device).map(([key, value]) => [`device.${key}`, value]))
151-
: {}),
152-
...(contexts.culture
153-
? Object.fromEntries(Object.entries(contexts.culture).map(([key, value]) => [`culture.${key}`, value]))
154-
: {}),
155-
...(contexts.cloud_resource
156-
? Object.fromEntries(
157-
Object.entries(contexts.cloud_resource).map(([key, value]) => [`cloud_resource.${key}`, value]),
158-
)
159-
: {}),
160-
};
161-
}
Lines changed: 88 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,37 @@
11
import type { RawAttributes } from '../attributes';
22
import { isAttributeObject } from '../attributes';
3-
import type { SerializedAttributes } from '../types-hoist/attributes';
4-
import type { Span, SpanV2JSON } from '../types-hoist/span';
3+
import type { Context, Contexts } from '../types-hoist/context';
4+
import type { SpanV2JSON } from '../types-hoist/span';
55
import { attributeValueToSerializedAttribute } from '../utils/attributes';
6+
import { isPrimitive } from '../utils/is';
67
import { showSpanDropWarning } from '../utils/spanUtils';
78

8-
/**
9-
* Only set a span attribute if it is not already set.
10-
*/
11-
export function safeSetSpanAttributes(
12-
span: Span,
13-
newAttributes: RawAttributes<Record<string, unknown>>,
14-
originalAttributeKeys: SerializedAttributes | undefined,
15-
): void {
16-
Object.keys(newAttributes).forEach(key => {
17-
if (!originalAttributeKeys?.[key]) {
18-
setAttributeOnSpanWithMaybeUnit(span, key, newAttributes[key]);
19-
}
20-
});
21-
}
22-
239
/**
2410
* Only set a span JSON attribute if it is not already set.
2511
* This is used to safely set attributes on JSON objects without mutating already-ended span instances.
2612
*/
2713
export function safeSetSpanJSONAttributes(
2814
spanJSON: SpanV2JSON,
2915
newAttributes: RawAttributes<Record<string, unknown>>,
30-
originalAttributeKeys: SerializedAttributes | undefined,
3116
): void {
3217
if (!spanJSON.attributes) {
3318
spanJSON.attributes = {};
3419
}
3520

21+
const originalAttributes = spanJSON.attributes;
22+
3623
Object.keys(newAttributes).forEach(key => {
37-
if (!originalAttributeKeys?.[key]) {
38-
setAttributeOnSpanJSONWithMaybeUnit(spanJSON, key, newAttributes[key]);
24+
if (!originalAttributes?.[key]) {
25+
setAttributeOnSpanJSONWithMaybeUnit(
26+
// type-casting here because we ensured above that the attributes object exists
27+
spanJSON as SpanV2JSON & Required<Pick<SpanV2JSON, 'attributes'>>,
28+
key,
29+
newAttributes[key],
30+
);
3931
}
4032
});
4133
}
4234

43-
function setAttributeOnSpanWithMaybeUnit(span: Span, attributeKey: string, attributeValue: unknown): void {
44-
if (isAttributeObject(attributeValue)) {
45-
const { value, unit } = attributeValue;
46-
47-
if (isSupportedAttributeType(value)) {
48-
span.setAttribute(attributeKey, value);
49-
}
50-
51-
if (unit) {
52-
span.setAttribute(`${attributeKey}.unit`, unit);
53-
}
54-
} else if (isSupportedAttributeType(attributeValue)) {
55-
span.setAttribute(attributeKey, attributeValue);
56-
}
57-
}
58-
5935
/**
6036
* Apply a user-provided beforeSendSpan callback to a span JSON.
6137
*/
@@ -72,34 +48,97 @@ export function applyBeforeSendSpanCallback(
7248
}
7349

7450
function setAttributeOnSpanJSONWithMaybeUnit(
75-
spanJSON: SpanV2JSON,
51+
spanJSON: SpanV2JSON & Required<Pick<SpanV2JSON, 'attributes'>>,
7652
attributeKey: string,
7753
attributeValue: unknown,
7854
): void {
79-
// Ensure attributes object exists (it's initialized in safeSetSpanJSONAttributes)
80-
if (!spanJSON.attributes) {
81-
return;
82-
}
83-
8455
if (isAttributeObject(attributeValue)) {
8556
const { value, unit } = attributeValue;
8657

8758
if (isSupportedSerializableType(value)) {
8859
spanJSON.attributes[attributeKey] = attributeValueToSerializedAttribute(value);
89-
}
90-
91-
if (unit) {
92-
spanJSON.attributes[`${attributeKey}.unit`] = attributeValueToSerializedAttribute(unit);
60+
if (unit) {
61+
spanJSON.attributes[attributeKey].unit = unit;
62+
}
9363
}
9464
} else if (isSupportedSerializableType(attributeValue)) {
9565
spanJSON.attributes[attributeKey] = attributeValueToSerializedAttribute(attributeValue);
9666
}
9767
}
9868

99-
function isSupportedAttributeType(value: unknown): value is Parameters<Span['setAttribute']>[1] {
69+
function isSupportedSerializableType(value: unknown): boolean {
10070
return ['string', 'number', 'boolean'].includes(typeof value) || Array.isArray(value);
10171
}
10272

103-
function isSupportedSerializableType(value: unknown): boolean {
104-
return ['string', 'number', 'boolean'].includes(typeof value) || Array.isArray(value);
73+
// map of attributes->context keys for those attributes that don't correspond 1:1 to the context key
74+
const explicitAttributeToContextMapping = {
75+
'os.build_id': 'os.build',
76+
'app.name': 'app.app_name',
77+
'app.identifier': 'app.app_identifier',
78+
'app.version': 'app.app_version',
79+
'app.memory': 'app.app_memory',
80+
'app.start_time': 'app.app_start_time',
81+
};
82+
83+
const knownContexts = ['app', 'os', 'device', 'culture', 'cloud_resource', 'runtime'];
84+
85+
/**
86+
* Converts a context object to a set of attributes.
87+
* Only includes attributes that are primitives (for now).
88+
* @param contexts - The context object to convert.
89+
* @returns The attributes object.
90+
*/
91+
export function contextsToAttributes(contexts: Contexts): RawAttributes<Record<string, unknown>> {
92+
function contextToAttribute(context: Context): Context {
93+
return Object.keys(context).reduce(
94+
(acc, key) => {
95+
if (!isPrimitive(context[key])) {
96+
return acc;
97+
}
98+
acc[key] = context[key];
99+
return acc;
100+
},
101+
{} as Record<string, unknown>,
102+
);
103+
}
104+
105+
const contextsWithPrimitiveValues = Object.keys(contexts).reduce((acc, key) => {
106+
if (!knownContexts.includes(key)) {
107+
return acc;
108+
}
109+
const context = contexts[key];
110+
if (context) {
111+
acc[key] = contextToAttribute(context);
112+
}
113+
return acc;
114+
}, {} as Contexts);
115+
116+
const explicitlyMappedAttributes = Object.entries(explicitAttributeToContextMapping).reduce(
117+
(acc, [attributeKey, contextKey]) => {
118+
const [contextName, contextValueKey] = contextKey.split('.');
119+
if (contextName && contextValueKey && contextsWithPrimitiveValues[contextName]?.[contextValueKey]) {
120+
acc[attributeKey] = contextsWithPrimitiveValues[contextName]?.[contextValueKey];
121+
// now we delete this key from `contextsWithPrimitiveValues` so we don't include it in the next step
122+
delete contextsWithPrimitiveValues[contextName]?.[contextValueKey];
123+
}
124+
return acc;
125+
},
126+
{} as Record<string, unknown>,
127+
);
128+
129+
return {
130+
...explicitlyMappedAttributes,
131+
...Object.entries(contextsWithPrimitiveValues).reduce(
132+
(acc, [contextName, contextObj]) => {
133+
contextObj &&
134+
Object.entries(contextObj).forEach(([key, value]) => {
135+
if (value) {
136+
acc[`${contextName}.${key}`] = value;
137+
}
138+
});
139+
return acc;
140+
},
141+
{} as Record<string, unknown>,
142+
),
143+
};
105144
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { AttributeUnit } from '../attributes';
2+
13
export type SerializedAttributes = Record<string, SerializedAttribute>;
24
export type SerializedAttribute = (
35
| {
@@ -16,5 +18,5 @@ export type SerializedAttribute = (
1618
type: 'boolean';
1719
value: boolean;
1820
}
19-
) & { unit?: 'ms' | 's' | 'bytes' | 'count' | 'percent' };
21+
) & { unit?: AttributeUnit };
2022
export type SerializedAttributeType = 'string' | 'integer' | 'double' | 'boolean';

packages/core/src/utils/applyScopeDataToEvent.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function mergeScopeData(data: ScopeData, mergeData: ScopeData): void {
3232
const {
3333
extra,
3434
tags,
35+
attributes,
3536
user,
3637
contexts,
3738
level,
@@ -80,6 +81,10 @@ export function mergeScopeData(data: ScopeData, mergeData: ScopeData): void {
8081
data.attachments = [...data.attachments, ...attachments];
8182
}
8283

84+
if (attributes) {
85+
data.attributes = { ...data.attributes, ...attributes };
86+
}
87+
8388
data.propagationContext = { ...data.propagationContext, ...propagationContext };
8489
}
8590

0 commit comments

Comments
 (0)