Skip to content

Commit 5da68ef

Browse files
committed
Add support for capturing performance measure details as span attributes
1 parent 0ce6dc5 commit 5da68ef

File tree

7 files changed

+216
-5
lines changed

7 files changed

+216
-5
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
// Create measures BEFORE SDK initializes
4+
5+
// Create a measure with detail
6+
const measure = performance.measure('firefox-test-measure', {
7+
duration: 100,
8+
detail: { test: 'initial-value' },
9+
});
10+
11+
// Simulate Firefox's permission denial by overriding the detail getter
12+
// This mimics the actual Firefox behavior where accessing detail throws
13+
Object.defineProperty(measure, 'detail', {
14+
get() {
15+
throw new DOMException('Permission denied to access object', 'SecurityError');
16+
},
17+
configurable: false,
18+
enumerable: true,
19+
});
20+
21+
// Also create a normal measure to ensure SDK still works
22+
performance.measure('normal-measure', {
23+
duration: 50,
24+
detail: 'this-should-work',
25+
});
26+
27+
window.Sentry = Sentry;
28+
29+
Sentry.init({
30+
debug: true,
31+
dsn: 'https://[email protected]/1337',
32+
integrations: [
33+
Sentry.browserTracingIntegration({
34+
idleTimeout: 9000,
35+
}),
36+
],
37+
tracesSampleRate: 1,
38+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/core';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
5+
6+
sentryTest(
7+
'should handle Firefox permission denial gracefully and still create measure spans',
8+
async ({ getLocalTestUrl, page }) => {
9+
if (shouldSkipTracingTest()) {
10+
sentryTest.skip();
11+
}
12+
13+
const url = await getLocalTestUrl({ testDir: __dirname });
14+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
15+
16+
// Find all measure spans
17+
const measureSpans = eventData.spans?.filter(({ op }) => op === 'measure');
18+
expect(measureSpans?.length).toBe(2); // Both measures should create spans
19+
20+
// Test 1: Verify the firefox-test-measure span exists but has no detail
21+
const firefoxMeasure = measureSpans?.find(span => span.description === 'firefox-test-measure');
22+
expect(firefoxMeasure).toBeDefined();
23+
expect(firefoxMeasure?.data).toMatchObject({
24+
'sentry.op': 'measure',
25+
'sentry.origin': 'auto.resource.browser.metrics',
26+
});
27+
28+
// Verify no detail attributes were added due to the permission error
29+
const firefoxDataKeys = Object.keys(firefoxMeasure?.data || {});
30+
const firefoxDetailKeys = firefoxDataKeys.filter(key => key.includes('detail'));
31+
expect(firefoxDetailKeys).toHaveLength(0);
32+
33+
// Test 2: Verify the normal measure still captures detail correctly
34+
const normalMeasure = measureSpans?.find(span => span.description === 'normal-measure');
35+
expect(normalMeasure).toBeDefined();
36+
expect(normalMeasure?.data).toMatchObject({
37+
'sentry.browser.measure.detail': 'this-should-work',
38+
'sentry.op': 'measure',
39+
'sentry.origin': 'auto.resource.browser.metrics',
40+
});
41+
},
42+
);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
// Create a simple measure with detail before SDK init
4+
performance.measure('test-measure', {
5+
duration: 100,
6+
detail: { foo: 'bar' },
7+
});
8+
9+
window.Sentry = Sentry;
10+
window._testBaseTimestamp = performance.timeOrigin / 1000;
11+
12+
Sentry.init({
13+
dsn: 'https://[email protected]/1337',
14+
integrations: [Sentry.browserTracingIntegration()],
15+
tracesSampleRate: 1,
16+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/core';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
5+
6+
sentryTest('should capture measure detail as span attributes', async ({ getLocalTestUrl, page }) => {
7+
if (shouldSkipTracingTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
const url = await getLocalTestUrl({ testDir: __dirname });
12+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
13+
14+
const measureSpan = eventData.spans?.find(({ op }) => op === 'measure');
15+
expect(measureSpan).toBeDefined();
16+
expect(measureSpan?.description).toBe('test-measure');
17+
18+
// Verify detail was captured
19+
expect(measureSpan?.data).toMatchObject({
20+
'sentry.browser.measure.detail.foo': 'bar',
21+
'sentry.op': 'measure',
22+
'sentry.origin': 'auto.resource.browser.metrics',
23+
});
24+
});

dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,33 @@
22
import * as Sentry from '@sentry/browser';
33

44
const end = performance.now();
5+
6+
// Test 1: Measure with object detail
57
performance.measure('Next.js-before-hydration', {
68
duration: 1000,
79
end,
10+
detail: {
11+
component: 'HomePage',
12+
renderTime: 123.45,
13+
isSSR: true,
14+
},
15+
});
16+
17+
// Test 2: Measure with primitive detail
18+
performance.measure('custom-metric', {
19+
duration: 500,
20+
detail: 'simple-string-detail',
21+
});
22+
23+
// Test 3: Measure with complex detail
24+
performance.measure('complex-measure', {
25+
duration: 200,
26+
detail: {
27+
nested: {
28+
value: 'test',
29+
array: [1, 2, 3],
30+
},
31+
},
832
});
933

1034
window.Sentry = Sentry;

dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/test.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,40 @@ sentryTest('should add browser-related spans to pageload transaction', async ({
2222
expect(requestSpan).toBeDefined();
2323
expect(requestSpan?.description).toBe(page.url());
2424

25-
const measureSpan = eventData.spans?.find(({ op }) => op === 'measure');
26-
expect(measureSpan).toBeDefined();
25+
// Find all measure spans
26+
const measureSpans = eventData.spans?.filter(({ op }) => op === 'measure');
27+
expect(measureSpans?.length).toBe(3); // We created 3 measures in init.js
2728

28-
expect(requestSpan!.start_timestamp).toBeLessThanOrEqual(measureSpan!.start_timestamp);
29-
expect(measureSpan?.data).toEqual({
29+
// Test 1: Verify object detail is captured
30+
const nextJsMeasure = measureSpans?.find(span => span.description === 'Next.js-before-hydration');
31+
expect(nextJsMeasure).toBeDefined();
32+
expect(nextJsMeasure?.data).toMatchObject({
3033
'sentry.browser.measure_happened_before_request': true,
3134
'sentry.browser.measure_start_time': expect.any(Number),
35+
'sentry.browser.measure.detail.component': 'HomePage',
36+
'sentry.browser.measure.detail.renderTime': 123.45,
37+
'sentry.browser.measure.detail.isSSR': true,
3238
'sentry.op': 'measure',
3339
'sentry.origin': 'auto.resource.browser.metrics',
3440
});
41+
42+
// Test 2: Verify primitive detail is captured
43+
const customMetricMeasure = measureSpans?.find(span => span.description === 'custom-metric');
44+
expect(customMetricMeasure).toBeDefined();
45+
expect(customMetricMeasure?.data).toMatchObject({
46+
'sentry.browser.measure.detail': 'simple-string-detail',
47+
'sentry.op': 'measure',
48+
'sentry.origin': 'auto.resource.browser.metrics',
49+
});
50+
51+
// Test 3: Verify complex detail is stringified
52+
const complexMeasure = measureSpans?.find(span => span.description === 'complex-measure');
53+
expect(complexMeasure).toBeDefined();
54+
expect(complexMeasure?.data).toMatchObject({
55+
'sentry.browser.measure.detail.nested': '{"value":"test","array":[1,2,3]}',
56+
'sentry.op': 'measure',
57+
'sentry.origin': 'auto.resource.browser.metrics',
58+
});
59+
60+
expect(requestSpan!.start_timestamp).toBeLessThanOrEqual(nextJsMeasure!.start_timestamp);
3561
});

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

Lines changed: 42 additions & 1 deletion
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,
@@ -483,6 +484,46 @@ export function _addMeasureSpans(
483484
attributes['sentry.browser.measure_start_time'] = measureStartTimestamp;
484485
}
485486

487+
// Safely access and process detail property
488+
// Cast to PerformanceMeasure to access detail property
489+
const performanceMeasure = entry as PerformanceMeasure;
490+
if (performanceMeasure.detail !== undefined) {
491+
try {
492+
// Accessing detail might throw in some browsers (e.g., Firefox) due to security restrictions
493+
const detail = performanceMeasure.detail;
494+
495+
// Process detail based on its type
496+
if (detail && typeof detail === 'object') {
497+
// Handle object details
498+
for (const [key, value] of Object.entries(detail)) {
499+
if (value && isPrimitive(value)) {
500+
attributes[`sentry.browser.measure.detail.${key}`] = value as SpanAttributeValue;
501+
} else if (value !== undefined) {
502+
try {
503+
// This is user defined so we can't guarantee it's serializable
504+
attributes[`sentry.browser.measure.detail.${key}`] = JSON.stringify(value);
505+
} catch {
506+
// Skip values that can't be stringified
507+
}
508+
}
509+
}
510+
} else if (isPrimitive(detail)) {
511+
// Handle primitive details
512+
attributes['sentry.browser.measure.detail'] = detail as SpanAttributeValue;
513+
} else if (detail !== null) {
514+
// Handle non-primitive, non-object details
515+
try {
516+
attributes['sentry.browser.measure.detail'] = JSON.stringify(detail);
517+
} catch {
518+
// Skip if stringification fails
519+
}
520+
}
521+
} catch {
522+
// Silently ignore any errors when accessing detail
523+
// This handles the Firefox "Permission denied to access object" error
524+
}
525+
}
526+
486527
// Measurements from third parties can be off, which would create invalid spans, dropping transactions in the process.
487528
if (measureStartTimestamp <= measureEndTimestamp) {
488529
startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, {

0 commit comments

Comments
 (0)