Skip to content

Commit a414c4c

Browse files
committed
feat(browser): Track measure detail as span attributes
1 parent 5dd37a6 commit a414c4c

File tree

2 files changed

+236
-6
lines changed

2 files changed

+236
-6
lines changed

packages/browser-utils/src/metrics/browserMetrics.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
/* eslint-disable max-lines */
2-
import type { Measurements, Span, SpanAttributes, StartSpanOptions } from '@sentry/core';
2+
import type { Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core';
33
import {
44
browserPerformanceTimeOrigin,
55
getActiveSpan,
66
getComponentName,
77
htmlTreeAsString,
8+
isPrimitive,
89
parseUrl,
910
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1011
setMeasurement,
@@ -339,7 +340,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
339340
case 'mark':
340341
case 'paint':
341342
case 'measure': {
342-
_addMeasureSpans(span, entry, startTime, duration, timeOrigin);
343+
_addMeasureSpans(span, entry as PerformanceMeasure, startTime, duration, timeOrigin);
343344

344345
// capture web vitals
345346
const firstHidden = getVisibilityWatcher();
@@ -421,7 +422,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
421422
*/
422423
export function _addMeasureSpans(
423424
span: Span,
424-
entry: PerformanceEntry,
425+
entry: PerformanceMeasure,
425426
startTime: number,
426427
duration: number,
427428
timeOrigin: number,
@@ -450,6 +451,34 @@ export function _addMeasureSpans(
450451
attributes['sentry.browser.measure_start_time'] = measureStartTimestamp;
451452
}
452453

454+
// https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure#detail
455+
if ('detail' in entry) {
456+
// Handle detail as an object
457+
if (typeof entry.detail === 'object') {
458+
for (const [key, value] of Object.entries(entry.detail)) {
459+
if (value && isPrimitive(value)) {
460+
attributes[`sentry.browser.measure.detail.${key}`] = value as SpanAttributeValue;
461+
} else {
462+
try {
463+
// This is user defined so we can't guarantee it's serializable
464+
attributes[`sentry.browser.measure.detail.${key}`] = JSON.stringify(value);
465+
} catch {
466+
// skip
467+
}
468+
}
469+
}
470+
} else if (isPrimitive(entry.detail)) {
471+
attributes['sentry.browser.measure.detail'] = entry.detail as SpanAttributeValue;
472+
} else {
473+
// This is user defined so we can't guarantee it's serializable
474+
try {
475+
attributes['sentry.browser.measure.detail'] = JSON.stringify(entry.detail);
476+
} catch {
477+
// skip
478+
}
479+
}
480+
}
481+
453482
// Measurements from third parties can be off, which would create invalid spans, dropping transactions in the process.
454483
if (measureStartTimestamp <= measureEndTimestamp) {
455484
startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, {

packages/browser-utils/test/browser/browserMetrics.test.ts

Lines changed: 204 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ describe('_addMeasureSpans', () => {
7070
name: 'measure-1',
7171
duration: 10,
7272
startTime: 12,
73-
} as PerformanceEntry;
73+
detail: null,
74+
} as PerformanceMeasure;
7475

7576
const timeOrigin = 100;
7677
const startTime = 23;
@@ -106,7 +107,8 @@ describe('_addMeasureSpans', () => {
106107
name: 'measure-1',
107108
duration: 10,
108109
startTime: 12,
109-
} as PerformanceEntry;
110+
detail: null,
111+
} as PerformanceMeasure;
110112

111113
const timeOrigin = 100;
112114
const startTime = 23;
@@ -116,6 +118,206 @@ describe('_addMeasureSpans', () => {
116118

117119
expect(spans).toHaveLength(0);
118120
});
121+
122+
it('adds measure spans with primitive detail', () => {
123+
const spans: Span[] = [];
124+
125+
getClient()?.on('spanEnd', span => {
126+
spans.push(span);
127+
});
128+
129+
const entry = {
130+
entryType: 'measure',
131+
name: 'measure-1',
132+
duration: 10,
133+
startTime: 12,
134+
detail: 'test-detail',
135+
} as PerformanceMeasure;
136+
137+
const timeOrigin = 100;
138+
const startTime = 23;
139+
const duration = 356;
140+
141+
_addMeasureSpans(span, entry, startTime, duration, timeOrigin);
142+
143+
expect(spans).toHaveLength(1);
144+
expect(spanToJSON(spans[0]!)).toEqual(
145+
expect.objectContaining({
146+
description: 'measure-1',
147+
start_timestamp: timeOrigin + startTime,
148+
timestamp: timeOrigin + startTime + duration,
149+
op: 'measure',
150+
origin: 'auto.resource.browser.metrics',
151+
data: {
152+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'measure',
153+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics',
154+
'sentry.browser.measure.detail': 'test-detail',
155+
},
156+
}),
157+
);
158+
});
159+
160+
it('adds measure spans with object detail', () => {
161+
const spans: Span[] = [];
162+
163+
getClient()?.on('spanEnd', span => {
164+
spans.push(span);
165+
});
166+
167+
const detail = {
168+
component: 'Button',
169+
action: 'click',
170+
metadata: { id: 123 },
171+
};
172+
173+
const entry = {
174+
entryType: 'measure',
175+
name: 'measure-1',
176+
duration: 10,
177+
startTime: 12,
178+
detail,
179+
} as PerformanceMeasure;
180+
181+
const timeOrigin = 100;
182+
const startTime = 23;
183+
const duration = 356;
184+
185+
_addMeasureSpans(span, entry, startTime, duration, timeOrigin);
186+
187+
expect(spans).toHaveLength(1);
188+
expect(spanToJSON(spans[0]!)).toEqual(
189+
expect.objectContaining({
190+
description: 'measure-1',
191+
start_timestamp: timeOrigin + startTime,
192+
timestamp: timeOrigin + startTime + duration,
193+
op: 'measure',
194+
origin: 'auto.resource.browser.metrics',
195+
data: {
196+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'measure',
197+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics',
198+
'sentry.browser.measure.detail.component': 'Button',
199+
'sentry.browser.measure.detail.action': 'click',
200+
'sentry.browser.measure.detail.metadata': JSON.stringify({ id: 123 }),
201+
},
202+
}),
203+
);
204+
});
205+
206+
it('handles non-primitive detail values by stringifying them', () => {
207+
const spans: Span[] = [];
208+
209+
getClient()?.on('spanEnd', span => {
210+
spans.push(span);
211+
});
212+
213+
const detail = {
214+
component: 'Button',
215+
action: 'click',
216+
metadata: { id: 123 },
217+
callback: () => {},
218+
};
219+
220+
const entry = {
221+
entryType: 'measure',
222+
name: 'measure-1',
223+
duration: 10,
224+
startTime: 12,
225+
detail,
226+
} as PerformanceMeasure;
227+
228+
const timeOrigin = 100;
229+
const startTime = 23;
230+
const duration = 356;
231+
232+
_addMeasureSpans(span, entry, startTime, duration, timeOrigin);
233+
234+
expect(spans).toHaveLength(1);
235+
const spanData = spanToJSON(spans[0]!).data;
236+
expect(spanData['sentry.browser.measure.detail.component']).toBe('Button');
237+
expect(spanData['sentry.browser.measure.detail.action']).toBe('click');
238+
expect(spanData['sentry.browser.measure.detail.metadata']).toBe(JSON.stringify({ id: 123 }));
239+
expect(spanData['sentry.browser.measure.detail.callback']).toBe(JSON.stringify(detail.callback));
240+
});
241+
242+
it('handles errors in detail processing gracefully', () => {
243+
const spans: Span[] = [];
244+
245+
getClient()?.on('spanEnd', span => {
246+
spans.push(span);
247+
});
248+
249+
// Create an entry with a detail that will cause an error when processed
250+
const entry = {
251+
entryType: 'measure',
252+
name: 'measure-1',
253+
duration: 10,
254+
startTime: 12,
255+
get detail() {
256+
throw new Error('Test error');
257+
},
258+
} as PerformanceMeasure;
259+
260+
const timeOrigin = 100;
261+
const startTime = 23;
262+
const duration = 356;
263+
264+
// Should not throw
265+
_addMeasureSpans(span, entry, startTime, duration, timeOrigin);
266+
267+
expect(spans).toHaveLength(1);
268+
expect(spanToJSON(spans[0]!)).toEqual(
269+
expect.objectContaining({
270+
description: 'measure-1',
271+
start_timestamp: timeOrigin + startTime,
272+
timestamp: timeOrigin + startTime + duration,
273+
op: 'measure',
274+
origin: 'auto.resource.browser.metrics',
275+
data: {
276+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'measure',
277+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics',
278+
},
279+
}),
280+
);
281+
});
282+
283+
it('handles errors in object detail value stringification', () => {
284+
const spans: Span[] = [];
285+
286+
getClient()?.on('spanEnd', span => {
287+
spans.push(span);
288+
});
289+
290+
const circular: any = {};
291+
circular.self = circular;
292+
293+
const detail = {
294+
component: 'Button',
295+
action: 'click',
296+
circular,
297+
};
298+
299+
const entry = {
300+
entryType: 'measure',
301+
name: 'measure-1',
302+
duration: 10,
303+
startTime: 12,
304+
detail,
305+
} as PerformanceMeasure;
306+
307+
const timeOrigin = 100;
308+
const startTime = 23;
309+
const duration = 356;
310+
311+
// Should not throw
312+
_addMeasureSpans(span, entry, startTime, duration, timeOrigin);
313+
314+
expect(spans).toHaveLength(1);
315+
const spanData = spanToJSON(spans[0]!).data;
316+
expect(spanData['sentry.browser.measure.detail.component']).toBe('Button');
317+
expect(spanData['sentry.browser.measure.detail.action']).toBe('click');
318+
// The circular reference should be skipped
319+
expect(spanData['sentry.browser.measure.detail.circular']).toBeUndefined();
320+
});
119321
});
120322

121323
describe('_addResourceSpans', () => {
@@ -464,7 +666,6 @@ describe('_addNavigationSpans', () => {
464666
transferSize: 14726,
465667
encodedBodySize: 14426,
466668
decodedBodySize: 67232,
467-
responseStatus: 200,
468669
serverTiming: [],
469670
unloadEventStart: 0,
470671
unloadEventEnd: 0,

0 commit comments

Comments
 (0)