Skip to content

Commit 2293bfc

Browse files
committed
fix(core): Preserve mutated top-level SpanJSON fields in v1 to v2 conversion
v1 SpanJSON mirrors op/origin/profile_id/exclusive_time as top-level fields alongside their attributes. A static `beforeSendSpan` edits the top-level field, but the forward converter only copied `data`, so those edits were dropped from the sent v2 span. Fold the top-level fields back into attributes (top-level wins over the initially identical attribute), the inverse of `getSpanJSON`.
1 parent f3d1a02 commit 2293bfc

2 files changed

Lines changed: 69 additions & 1 deletion

File tree

packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
11
import type { RawAttributes } from '../../attributes';
2+
import {
3+
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
4+
SEMANTIC_ATTRIBUTE_PROFILE_ID,
5+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
6+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
7+
} from '../../semanticAttributes';
28
import type { SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../types/span';
39
import { streamedSpanJsonToSerializedSpan } from '../../utils/spanUtils';
410

11+
// v1 SpanJSON mirrors some attributes as top-level fields (see `SentrySpan.getSpanJSON`). A
12+
// `beforeSendSpan` callback edits the top-level field, so those edits have to be folded back into
13+
// attributes, letting the top-level value win over the (initially identical) attribute. This is the
14+
// inverse of `getSpanJSON` and mirrors how `convertSpanJsonToTransactionEvent` rebuilds `data`.
15+
const TOP_LEVEL_ATTRIBUTE_FIELDS = [
16+
['op', SEMANTIC_ATTRIBUTE_SENTRY_OP],
17+
['origin', SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN],
18+
['profile_id', SEMANTIC_ATTRIBUTE_PROFILE_ID],
19+
['exclusive_time', SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME],
20+
] as const;
21+
522
/**
623
* Converts a v1 SpanJSON (from a legacy transaction) to the intermediate v2 {@link StreamedSpanJSON}
724
* (raw attributes), before serialization. Use this when a hook needs to mutate the span JSON.
825
*/
926
export function spanJsonToStreamedSpanJSON(span: SpanJSON): StreamedSpanJSON {
27+
const attributes = { ...(span.data as RawAttributes<Record<string, unknown>>) };
28+
29+
for (const [field, attribute] of TOP_LEVEL_ATTRIBUTE_FIELDS) {
30+
attributes[attribute] = span[field] ?? attributes[attribute];
31+
}
32+
1033
return {
1134
trace_id: span.trace_id,
1235
span_id: span.span_id,
@@ -16,7 +39,7 @@ export function spanJsonToStreamedSpanJSON(span: SpanJSON): StreamedSpanJSON {
1639
end_timestamp: span.timestamp || span.start_timestamp,
1740
status: !span.status || span.status === 'ok' || span.status === 'cancelled' ? 'ok' : 'error',
1841
is_segment: false,
19-
attributes: { ...(span.data as RawAttributes<Record<string, unknown>>) },
42+
attributes,
2043
links: span.links,
2144
};
2245
}

packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,51 @@ describe('spanJsonToSerializedStreamedSpan', () => {
7979
expect(result.attributes['gen_ai.usage.output_tokens']).toEqual({ type: 'integer', value: 50 });
8080
});
8181

82+
it('maps top-level op/origin into sentry.op/sentry.origin attributes', () => {
83+
const span = makeSpanJSON({ op: 'ui.interaction.click', origin: 'auto.http.browser.inp', data: {} });
84+
85+
const result = spanJsonToSerializedStreamedSpan(span);
86+
87+
expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'ui.interaction.click' });
88+
expect(result.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.inp' });
89+
});
90+
91+
it('lets a mutated top-level op/origin win over the original data attribute', () => {
92+
// A `beforeSendSpan` callback mutates the top-level field while the data attribute stays stale.
93+
const span = makeSpanJSON({
94+
op: 'changed.op',
95+
origin: 'changed.origin',
96+
data: { 'sentry.op': 'stale.op', 'sentry.origin': 'stale.origin' },
97+
});
98+
99+
const result = spanJsonToSerializedStreamedSpan(span);
100+
101+
expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'changed.op' });
102+
expect(result.attributes['sentry.origin']).toEqual({ type: 'string', value: 'changed.origin' });
103+
});
104+
105+
it('keeps the data attribute when there is no top-level op/origin', () => {
106+
const span = makeSpanJSON({ op: undefined, origin: undefined, data: { 'sentry.op': 'from.data' } });
107+
108+
const result = spanJsonToSerializedStreamedSpan(span);
109+
110+
expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'from.data' });
111+
expect(result.attributes['sentry.origin']).toBeUndefined();
112+
});
113+
114+
it('maps mutated top-level profile_id/exclusive_time back into attributes', () => {
115+
const span = makeSpanJSON({
116+
profile_id: 'new-profile',
117+
exclusive_time: 42,
118+
data: { 'sentry.profile_id': 'stale-profile', 'sentry.exclusive_time': 1 },
119+
});
120+
121+
const result = spanJsonToSerializedStreamedSpan(span);
122+
123+
expect(result.attributes['sentry.profile_id']).toEqual({ type: 'string', value: 'new-profile' });
124+
expect(result.attributes['sentry.exclusive_time']).toEqual({ type: 'integer', value: 42 });
125+
});
126+
82127
it('carries over links', () => {
83128
const span = makeSpanJSON({
84129
links: [{ trace_id: 'aabb', span_id: 'ccdd', sampled: true, attributes: { foo: 'bar' } }],

0 commit comments

Comments
 (0)