diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js new file mode 100644 index 000000000000..f4df5dbe13e8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js @@ -0,0 +1,58 @@ +import * as Sentry from '@sentry/browser'; + +// Create measures BEFORE SDK initializes + +// Create a measure with detail +const measure = performance.measure('restricted-test-measure', { + start: performance.now(), + end: performance.now() + 1, + detail: { test: 'initial-value' }, +}); + +// Simulate Firefox's permission denial by overriding the detail getter +// This mimics the actual Firefox behavior where accessing detail throws +Object.defineProperty(measure, 'detail', { + get() { + throw new DOMException('Permission denied to access object', 'SecurityError'); + }, + configurable: false, + enumerable: true, +}); + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 9000, + }), + ], + tracesSampleRate: 1, +}); + +// Also create a normal measure to ensure SDK still works +performance.measure('normal-measure', { + start: performance.now(), + end: performance.now() + 50, + detail: 'this-should-work', +}); + +// Create a measure with complex detail object +performance.measure('complex-detail-measure', { + start: performance.now(), + end: performance.now() + 25, + detail: { + nested: { + array: [1, 2, 3], + object: { + key: 'value', + }, + }, + metadata: { + type: 'test', + version: '1.0', + tags: ['complex', 'nested', 'object'], + }, + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/test.ts new file mode 100644 index 000000000000..a990694b46bf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/test.ts @@ -0,0 +1,65 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +// This is a regression test for https://github.com/getsentry/sentry-javascript/issues/16347 + +sentryTest( + 'should handle permission denial gracefully and still create measure spans', + async ({ getLocalTestUrl, page, browserName }) => { + // Skip test on webkit because we can't validate the detail in the browser + if (shouldSkipTracingTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + // Find all measure spans + const measureSpans = eventData.spans?.filter(({ op }) => op === 'measure'); + expect(measureSpans?.length).toBe(3); // All three measures should create spans + + // Test 1: Verify the restricted-test-measure span exists but has no detail + const restrictedMeasure = measureSpans?.find(span => span.description === 'restricted-test-measure'); + expect(restrictedMeasure).toBeDefined(); + expect(restrictedMeasure?.data).toMatchObject({ + 'sentry.op': 'measure', + 'sentry.origin': 'auto.resource.browser.metrics', + }); + + // Verify no detail attributes were added due to the permission error + const restrictedDataKeys = Object.keys(restrictedMeasure?.data || {}); + const restrictedDetailKeys = restrictedDataKeys.filter(key => key.includes('detail')); + expect(restrictedDetailKeys).toHaveLength(0); + + // Test 2: Verify the normal measure still captures detail correctly + const normalMeasure = measureSpans?.find(span => span.description === 'normal-measure'); + expect(normalMeasure).toBeDefined(); + expect(normalMeasure?.data).toMatchObject({ + 'sentry.browser.measure.detail': 'this-should-work', + 'sentry.op': 'measure', + 'sentry.origin': 'auto.resource.browser.metrics', + }); + + // Test 3: Verify the complex detail object is captured correctly + const complexMeasure = measureSpans?.find(span => span.description === 'complex-detail-measure'); + expect(complexMeasure).toBeDefined(); + expect(complexMeasure?.data).toMatchObject({ + 'sentry.op': 'measure', + 'sentry.origin': 'auto.resource.browser.metrics', + // The entire nested object is stringified as a single value + 'sentry.browser.measure.detail.nested': JSON.stringify({ + array: [1, 2, 3], + object: { + key: 'value', + }, + }), + 'sentry.browser.measure.detail.metadata': JSON.stringify({ + type: 'test', + version: '1.0', + tags: ['complex', 'nested', 'object'], + }), + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js index db9c448ed19b..f3e6fa567911 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js @@ -10,7 +10,6 @@ performance.measure('Next.js-before-hydration', { window.Sentry = Sentry; Sentry.init({ - debug: true, dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 7695802941a6..4c5e78899b29 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, @@ -483,6 +484,8 @@ export function _addMeasureSpans( attributes['sentry.browser.measure_start_time'] = measureStartTimestamp; } + _addDetailToSpanAttributes(attributes, entry as PerformanceMeasure); + // Measurements from third parties can be off, which would create invalid spans, dropping transactions in the process. if (measureStartTimestamp <= measureEndTimestamp) { startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, { @@ -493,6 +496,50 @@ export function _addMeasureSpans( } } +function _addDetailToSpanAttributes(attributes: SpanAttributes, performanceMeasure: PerformanceMeasure): void { + try { + // Accessing detail might throw in some browsers (e.g., Firefox) due to security restrictions + const detail = performanceMeasure.detail; + + if (!detail) { + return; + } + + // Process detail based on its type + if (typeof detail === 'object') { + // Handle object details + for (const [key, value] of Object.entries(detail)) { + if (value && isPrimitive(value)) { + attributes[`sentry.browser.measure.detail.${key}`] = value as SpanAttributeValue; + } else if (value !== undefined) { + try { + // This is user defined so we can't guarantee it's serializable + attributes[`sentry.browser.measure.detail.${key}`] = JSON.stringify(value); + } catch { + // Skip values that can't be stringified + } + } + } + return; + } + + if (isPrimitive(detail)) { + // Handle primitive details + attributes['sentry.browser.measure.detail'] = detail as SpanAttributeValue; + return; + } + + try { + attributes['sentry.browser.measure.detail'] = JSON.stringify(detail); + } catch { + // Skip if stringification fails + } + } catch { + // Silently ignore any errors when accessing detail + // This handles the Firefox "Permission denied to access object" error + } +} + /** * Instrument navigation entries * exported only for tests