Skip to content

Commit fcdc4d7

Browse files
authored
feat(browser): Track measure detail as span attributes (#16240)
resolves #16237 The SDK automatically instruments the `performance.measure` API, but doesn't support `detail`, which is the way you can attach arbitrary data to `performance.measure`. Given you can see `details` in browser dev-tools, we should probably support it in the same way in Sentry. https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure detail docs: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceMeasure/detail Detail is completely arbitrary, so we have to take care before parsing it. I have added tests accordingly.
1 parent 6281d6b commit fcdc4d7

File tree

3 files changed

+197
-8
lines changed

3 files changed

+197
-8
lines changed

.size-limit.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ module.exports = [
5252
path: 'packages/browser/build/npm/esm/index.js',
5353
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
5454
gzip: true,
55-
limit: '70.1 KB',
55+
limit: '71 KB',
5656
modifyWebpackConfig: function (config) {
5757
const webpack = require('webpack');
5858

@@ -206,7 +206,7 @@ module.exports = [
206206
import: createImport('init'),
207207
ignore: ['next/router', 'next/constants'],
208208
gzip: true,
209-
limit: '42 KB',
209+
limit: '42.5 KB',
210210
},
211211
// SvelteKit SDK (ESM)
212212
{

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 (entry.detail) {
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: 163 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,165 @@ 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 object detail value stringification', () => {
243+
const spans: Span[] = [];
244+
245+
getClient()?.on('spanEnd', span => {
246+
spans.push(span);
247+
});
248+
249+
const circular: any = {};
250+
circular.self = circular;
251+
252+
const detail = {
253+
component: 'Button',
254+
action: 'click',
255+
circular,
256+
};
257+
258+
const entry = {
259+
entryType: 'measure',
260+
name: 'measure-1',
261+
duration: 10,
262+
startTime: 12,
263+
detail,
264+
} as PerformanceMeasure;
265+
266+
const timeOrigin = 100;
267+
const startTime = 23;
268+
const duration = 356;
269+
270+
// Should not throw
271+
_addMeasureSpans(span, entry, startTime, duration, timeOrigin);
272+
273+
expect(spans).toHaveLength(1);
274+
const spanData = spanToJSON(spans[0]!).data;
275+
expect(spanData['sentry.browser.measure.detail.component']).toBe('Button');
276+
expect(spanData['sentry.browser.measure.detail.action']).toBe('click');
277+
// The circular reference should be skipped
278+
expect(spanData['sentry.browser.measure.detail.circular']).toBeUndefined();
279+
});
119280
});
120281

121282
describe('_addResourceSpans', () => {
@@ -464,7 +625,6 @@ describe('_addNavigationSpans', () => {
464625
transferSize: 14726,
465626
encodedBodySize: 14426,
466627
decodedBodySize: 67232,
467-
responseStatus: 200,
468628
serverTiming: [],
469629
unloadEventStart: 0,
470630
unloadEventEnd: 0,

0 commit comments

Comments
 (0)