diff --git a/.size-limit.js b/.size-limit.js index 0c03c0ff1b8b..7721304d684a 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -52,7 +52,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '70.1 KB', + limit: '71 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -206,7 +206,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '42 KB', + limit: '42.5 KB', }, // SvelteKit SDK (ESM) { diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 71470a0d8706..646d73ef29c3 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -1,10 +1,11 @@ /* eslint-disable max-lines */ -import type { Measurements, Span, SpanAttributes, StartSpanOptions } from '@sentry/core'; +import type { Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core'; import { browserPerformanceTimeOrigin, getActiveSpan, getComponentName, htmlTreeAsString, + isPrimitive, parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setMeasurement, @@ -339,7 +340,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries case 'mark': case 'paint': case 'measure': { - _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + _addMeasureSpans(span, entry as PerformanceMeasure, startTime, duration, timeOrigin); // capture web vitals const firstHidden = getVisibilityWatcher(); @@ -421,7 +422,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries */ export function _addMeasureSpans( span: Span, - entry: PerformanceEntry, + entry: PerformanceMeasure, startTime: number, duration: number, timeOrigin: number, @@ -450,6 +451,34 @@ export function _addMeasureSpans( attributes['sentry.browser.measure_start_time'] = measureStartTimestamp; } + // https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure#detail + if (entry.detail) { + // Handle detail as an object + if (typeof entry.detail === 'object') { + for (const [key, value] of Object.entries(entry.detail)) { + if (value && isPrimitive(value)) { + attributes[`sentry.browser.measure.detail.${key}`] = value as SpanAttributeValue; + } else { + try { + // This is user defined so we can't guarantee it's serializable + attributes[`sentry.browser.measure.detail.${key}`] = JSON.stringify(value); + } catch { + // skip + } + } + } + } else if (isPrimitive(entry.detail)) { + attributes['sentry.browser.measure.detail'] = entry.detail as SpanAttributeValue; + } else { + // This is user defined so we can't guarantee it's serializable + try { + attributes['sentry.browser.measure.detail'] = JSON.stringify(entry.detail); + } catch { + // skip + } + } + } + // Measurements from third parties can be off, which would create invalid spans, dropping transactions in the process. if (measureStartTimestamp <= measureEndTimestamp) { startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, { diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts index 99cf451f824e..a6004b73622a 100644 --- a/packages/browser-utils/test/browser/browserMetrics.test.ts +++ b/packages/browser-utils/test/browser/browserMetrics.test.ts @@ -70,7 +70,8 @@ describe('_addMeasureSpans', () => { name: 'measure-1', duration: 10, startTime: 12, - } as PerformanceEntry; + detail: null, + } as PerformanceMeasure; const timeOrigin = 100; const startTime = 23; @@ -106,7 +107,8 @@ describe('_addMeasureSpans', () => { name: 'measure-1', duration: 10, startTime: 12, - } as PerformanceEntry; + detail: null, + } as PerformanceMeasure; const timeOrigin = 100; const startTime = 23; @@ -116,6 +118,165 @@ describe('_addMeasureSpans', () => { expect(spans).toHaveLength(0); }); + + it('adds measure spans with primitive detail', () => { + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const entry = { + entryType: 'measure', + name: 'measure-1', + duration: 10, + startTime: 12, + detail: 'test-detail', + } as PerformanceMeasure; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + + expect(spans).toHaveLength(1); + expect(spanToJSON(spans[0]!)).toEqual( + expect.objectContaining({ + description: 'measure-1', + start_timestamp: timeOrigin + startTime, + timestamp: timeOrigin + startTime + duration, + op: 'measure', + origin: 'auto.resource.browser.metrics', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'measure', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', + 'sentry.browser.measure.detail': 'test-detail', + }, + }), + ); + }); + + it('adds measure spans with object detail', () => { + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const detail = { + component: 'Button', + action: 'click', + metadata: { id: 123 }, + }; + + const entry = { + entryType: 'measure', + name: 'measure-1', + duration: 10, + startTime: 12, + detail, + } as PerformanceMeasure; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + + expect(spans).toHaveLength(1); + expect(spanToJSON(spans[0]!)).toEqual( + expect.objectContaining({ + description: 'measure-1', + start_timestamp: timeOrigin + startTime, + timestamp: timeOrigin + startTime + duration, + op: 'measure', + origin: 'auto.resource.browser.metrics', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'measure', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', + 'sentry.browser.measure.detail.component': 'Button', + 'sentry.browser.measure.detail.action': 'click', + 'sentry.browser.measure.detail.metadata': JSON.stringify({ id: 123 }), + }, + }), + ); + }); + + it('handles non-primitive detail values by stringifying them', () => { + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const detail = { + component: 'Button', + action: 'click', + metadata: { id: 123 }, + callback: () => {}, + }; + + const entry = { + entryType: 'measure', + name: 'measure-1', + duration: 10, + startTime: 12, + detail, + } as PerformanceMeasure; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + + expect(spans).toHaveLength(1); + const spanData = spanToJSON(spans[0]!).data; + expect(spanData['sentry.browser.measure.detail.component']).toBe('Button'); + expect(spanData['sentry.browser.measure.detail.action']).toBe('click'); + expect(spanData['sentry.browser.measure.detail.metadata']).toBe(JSON.stringify({ id: 123 })); + expect(spanData['sentry.browser.measure.detail.callback']).toBe(JSON.stringify(detail.callback)); + }); + + it('handles errors in object detail value stringification', () => { + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const circular: any = {}; + circular.self = circular; + + const detail = { + component: 'Button', + action: 'click', + circular, + }; + + const entry = { + entryType: 'measure', + name: 'measure-1', + duration: 10, + startTime: 12, + detail, + } as PerformanceMeasure; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + // Should not throw + _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + + expect(spans).toHaveLength(1); + const spanData = spanToJSON(spans[0]!).data; + expect(spanData['sentry.browser.measure.detail.component']).toBe('Button'); + expect(spanData['sentry.browser.measure.detail.action']).toBe('click'); + // The circular reference should be skipped + expect(spanData['sentry.browser.measure.detail.circular']).toBeUndefined(); + }); }); describe('_addResourceSpans', () => { @@ -464,7 +625,6 @@ describe('_addNavigationSpans', () => { transferSize: 14726, encodedBodySize: 14426, decodedBodySize: 67232, - responseStatus: 200, serverTiming: [], unloadEventStart: 0, unloadEventEnd: 0,