From 74502ad437912d7ce8d4308bc34e6fdba14252c0 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 2 Oct 2025 20:40:12 +0100 Subject: [PATCH 1/8] Get expanded performance results --- injected/src/features/breakage-reporting.js | 12 ++- .../src/features/breakage-reporting/utils.js | 84 +++++++++++++++++++ injected/src/features/performance-metrics.js | 19 ++++- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index fca6012125..8490f57b75 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -1,16 +1,20 @@ import ContentFeature from '../content-feature'; -import { getJsPerformanceMetrics } from './breakage-reporting/utils.js'; +import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; export default class BreakageReporting extends ContentFeature { init() { + const isExpandedPerformanceMetricsEnabled = this.getFeatureSettingEnabled('expandedPerformanceMetrics', 'enabled'); this.messaging.subscribe('getBreakageReportValues', () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; - - this.messaging.notify('breakageReportResult', { + const result = { jsPerformance, referrer, - }); + }; + if (isExpandedPerformanceMetricsEnabled) { + result.expandedPerformanceMetrics = getExpandedPerformanceMetrics(); + } + this.messaging.notify('breakageReportResult', result); }); } } diff --git a/injected/src/features/breakage-reporting/utils.js b/injected/src/features/breakage-reporting/utils.js index e1e0776da8..ba6ea0d755 100644 --- a/injected/src/features/breakage-reporting/utils.js +++ b/injected/src/features/breakage-reporting/utils.js @@ -6,3 +6,87 @@ export function getJsPerformanceMetrics() { const firstPaint = paintResources.find((entry) => entry.name === 'first-contentful-paint'); return firstPaint ? [firstPaint.startTime] : []; } + +/** @typedef {{error: string, success: false}} ErrorObject */ +/** @typedef {{success: true, metrics: any}} PerformanceMetricsResponse */ + +/** + * Convenience function to return an error object + * @param {string} errorMessage + * @returns {ErrorObject} + */ +function returnError(errorMessage) { + return { error: errorMessage, success: false }; +} + +/** + * Get the expanded performance metrics + * @returns {ErrorObject | PerformanceMetricsResponse} + */ +export function getExpandedPerformanceMetrics() { + try { + if (document.readyState !== 'complete') { + return returnError('Document not ready'); + } + + const navigation = /** @type {PerformanceNavigationTiming} */ (performance.getEntriesByType('navigation')[0]); + const paint = performance.getEntriesByType('paint'); + const resources = /** @type {PerformanceResourceTiming[]} */ (performance.getEntriesByType('resource')); + + // Find FCP + const fcp = paint.find(p => p.name === 'first-contentful-paint'); + + // Get largest contentful paint if available + let largestContentfulPaint = null; + if (window.PerformanceObserver && PerformanceObserver.supportedEntryTypes && + PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint')) { + const lcpEntries = performance.getEntriesByType('largest-contentful-paint'); + if (lcpEntries.length > 0) { + largestContentfulPaint = lcpEntries[lcpEntries.length - 1].startTime; + } + } + + // Calculate total resource sizes + const totalResourceSize = resources.reduce((sum, r) => sum + (r.transferSize || 0), 0); + + if (navigation) { + return { + success: true, + metrics: { + // Core timing metrics (in milliseconds) + loadComplete: navigation.loadEventEnd - navigation.fetchStart, + domComplete: navigation.domComplete - navigation.fetchStart, + domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart, + domInteractive: navigation.domInteractive - navigation.fetchStart, + + // Paint metrics + firstContentfulPaint: fcp ? fcp.startTime : null, + largestContentfulPaint: largestContentfulPaint, + + // Network metrics + timeToFirstByte: navigation.responseStart - navigation.fetchStart, + responseTime: navigation.responseEnd - navigation.responseStart, + serverTime: navigation.responseStart - navigation.requestStart, + + // Size metrics (in octets) + transferSize: navigation.transferSize, + encodedBodySize: navigation.encodedBodySize, + decodedBodySize: navigation.decodedBodySize, + + // Resource metrics + resourceCount: resources.length, + totalResourcesSize: totalResourceSize, + + // Additional metadata + protocol: navigation.nextHopProtocol, + redirectCount: navigation.redirectCount, + navigationType: navigation.type + } + }; + } + + return returnError('No navigation timing found'); + } catch (e) { + return returnError('JavaScript execution error: ' + e.message); + } +} \ No newline at end of file diff --git a/injected/src/features/performance-metrics.js b/injected/src/features/performance-metrics.js index 049c72e586..309e23b97f 100644 --- a/injected/src/features/performance-metrics.js +++ b/injected/src/features/performance-metrics.js @@ -1,5 +1,5 @@ import ContentFeature from '../content-feature'; -import { getJsPerformanceMetrics } from './breakage-reporting/utils.js'; +import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; export default class PerformanceMetrics extends ContentFeature { init() { @@ -7,5 +7,22 @@ export default class PerformanceMetrics extends ContentFeature { const vitals = getJsPerformanceMetrics(); this.messaging.notify('vitalsResult', { vitals }); }); + + if (this.getFeatureSettingEnabled('expandedPerformanceMetricsOnLoad', 'enabled')) { + document.addEventListener('load', () => { + this.triggerExpandedPerformanceMetrics(); + }); + } + + if (this.getFeatureSettingEnabled('expandedPerformanceMetricsOnRequest', 'enabled')) { + this.messaging.subscribe('getExpandedPerformanceMetrics', () => { + this.triggerExpandedPerformanceMetrics(); + }); + } + } + + triggerExpandedPerformanceMetrics() { + const expandedPerformanceMetrics = getExpandedPerformanceMetrics(); + this.messaging.notify('expandedPerformanceMetricsResult', expandedPerformanceMetrics); } } From 26a9061fbd8ae76d432350a7829f395d98bb291c Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 2 Oct 2025 20:43:04 +0100 Subject: [PATCH 2/8] Shorthand lint fix --- injected/src/features/breakage-reporting/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/injected/src/features/breakage-reporting/utils.js b/injected/src/features/breakage-reporting/utils.js index ba6ea0d755..dd62401228 100644 --- a/injected/src/features/breakage-reporting/utils.js +++ b/injected/src/features/breakage-reporting/utils.js @@ -61,7 +61,7 @@ export function getExpandedPerformanceMetrics() { // Paint metrics firstContentfulPaint: fcp ? fcp.startTime : null, - largestContentfulPaint: largestContentfulPaint, + largestContentfulPaint, // Network metrics timeToFirstByte: navigation.responseStart - navigation.fetchStart, From 1dabda408b15c66fa710b312f6d0b01669b397cc Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 2 Oct 2025 20:52:15 +0100 Subject: [PATCH 3/8] lint fix --- .../src/features/breakage-reporting/utils.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/injected/src/features/breakage-reporting/utils.js b/injected/src/features/breakage-reporting/utils.js index dd62401228..407f8dd8c8 100644 --- a/injected/src/features/breakage-reporting/utils.js +++ b/injected/src/features/breakage-reporting/utils.js @@ -12,7 +12,7 @@ export function getJsPerformanceMetrics() { /** * Convenience function to return an error object - * @param {string} errorMessage + * @param {string} errorMessage * @returns {ErrorObject} */ function returnError(errorMessage) { @@ -34,12 +34,15 @@ export function getExpandedPerformanceMetrics() { const resources = /** @type {PerformanceResourceTiming[]} */ (performance.getEntriesByType('resource')); // Find FCP - const fcp = paint.find(p => p.name === 'first-contentful-paint'); + const fcp = paint.find((p) => p.name === 'first-contentful-paint'); // Get largest contentful paint if available let largestContentfulPaint = null; - if (window.PerformanceObserver && PerformanceObserver.supportedEntryTypes && - PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint')) { + if ( + window.PerformanceObserver && + PerformanceObserver.supportedEntryTypes && + PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint') + ) { const lcpEntries = performance.getEntriesByType('largest-contentful-paint'); if (lcpEntries.length > 0) { largestContentfulPaint = lcpEntries[lcpEntries.length - 1].startTime; @@ -80,8 +83,8 @@ export function getExpandedPerformanceMetrics() { // Additional metadata protocol: navigation.nextHopProtocol, redirectCount: navigation.redirectCount, - navigationType: navigation.type - } + navigationType: navigation.type, + }, }; } @@ -89,4 +92,4 @@ export function getExpandedPerformanceMetrics() { } catch (e) { return returnError('JavaScript execution error: ' + e.message); } -} \ No newline at end of file +} From 933620c4abb28d5b2a466202117a5d0149a535b2 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 2 Oct 2025 21:06:56 +0100 Subject: [PATCH 4/8] Remove listener --- injected/src/features/performance-metrics.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/injected/src/features/performance-metrics.js b/injected/src/features/performance-metrics.js index 309e23b97f..73e3f7090f 100644 --- a/injected/src/features/performance-metrics.js +++ b/injected/src/features/performance-metrics.js @@ -13,12 +13,6 @@ export default class PerformanceMetrics extends ContentFeature { this.triggerExpandedPerformanceMetrics(); }); } - - if (this.getFeatureSettingEnabled('expandedPerformanceMetricsOnRequest', 'enabled')) { - this.messaging.subscribe('getExpandedPerformanceMetrics', () => { - this.triggerExpandedPerformanceMetrics(); - }); - } } triggerExpandedPerformanceMetrics() { From 527b3f1436c5451ab271f197508fa0e0ab37ebd7 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 2 Oct 2025 21:14:36 +0100 Subject: [PATCH 5/8] Simplify breakage report. Don't trigger expanded on frame --- injected/src/features/breakage-reporting.js | 5 ++++- injected/src/features/performance-metrics.js | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index 8490f57b75..2c11908471 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -12,7 +12,10 @@ export default class BreakageReporting extends ContentFeature { referrer, }; if (isExpandedPerformanceMetricsEnabled) { - result.expandedPerformanceMetrics = getExpandedPerformanceMetrics(); + const expandedPerformanceMetrics = getExpandedPerformanceMetrics(); + if (expandedPerformanceMetrics.success) { + result.expandedPerformanceMetrics = expandedPerformanceMetrics.metrics; + } } this.messaging.notify('breakageReportResult', result); }); diff --git a/injected/src/features/performance-metrics.js b/injected/src/features/performance-metrics.js index 73e3f7090f..29698da9ad 100644 --- a/injected/src/features/performance-metrics.js +++ b/injected/src/features/performance-metrics.js @@ -1,5 +1,6 @@ import ContentFeature from '../content-feature'; import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; +import { isBeingFramed } from '../utils.js'; export default class PerformanceMetrics extends ContentFeature { init() { @@ -8,6 +9,10 @@ export default class PerformanceMetrics extends ContentFeature { this.messaging.notify('vitalsResult', { vitals }); }); + // If the document is being framed, we don't want to collect expanded performance metrics + if (isBeingFramed()) return; + + // If the feature is enabled, we want to collect expanded performance metrics if (this.getFeatureSettingEnabled('expandedPerformanceMetricsOnLoad', 'enabled')) { document.addEventListener('load', () => { this.triggerExpandedPerformanceMetrics(); From 873e5fb3ddadb64e2b1669669abf548f9ca380f8 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 2 Oct 2025 22:36:46 +0100 Subject: [PATCH 6/8] Resolve LCP if exists --- injected/src/features/breakage-reporting.js | 4 +- .../src/features/breakage-reporting/utils.js | 54 +++++++++++++++---- injected/src/features/performance-metrics.js | 4 +- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index 2c11908471..b94172bfa7 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -4,7 +4,7 @@ import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breaka export default class BreakageReporting extends ContentFeature { init() { const isExpandedPerformanceMetricsEnabled = this.getFeatureSettingEnabled('expandedPerformanceMetrics', 'enabled'); - this.messaging.subscribe('getBreakageReportValues', () => { + this.messaging.subscribe('getBreakageReportValues', async () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; const result = { @@ -12,7 +12,7 @@ export default class BreakageReporting extends ContentFeature { referrer, }; if (isExpandedPerformanceMetricsEnabled) { - const expandedPerformanceMetrics = getExpandedPerformanceMetrics(); + const expandedPerformanceMetrics = await getExpandedPerformanceMetrics(); if (expandedPerformanceMetrics.success) { result.expandedPerformanceMetrics = expandedPerformanceMetrics.metrics; } diff --git a/injected/src/features/breakage-reporting/utils.js b/injected/src/features/breakage-reporting/utils.js index 407f8dd8c8..d1cd7c9a49 100644 --- a/injected/src/features/breakage-reporting/utils.js +++ b/injected/src/features/breakage-reporting/utils.js @@ -19,11 +19,50 @@ function returnError(errorMessage) { return { error: errorMessage, success: false }; } +/** + * @returns {Promise} + */ +function waitForLCP(timeoutMs = 500) { + return new Promise((resolve) => { + let timeoutId; + let observer; + + const cleanup = () => { + if (observer) observer.disconnect(); + if (timeoutId) clearTimeout(timeoutId); + }; + + // Set timeout + timeoutId = setTimeout(() => { + cleanup(); + resolve(null); // Resolve with null instead of hanging + }, timeoutMs); + + // Try to get existing LCP + observer = new PerformanceObserver((list) => { + const entries = list.getEntries(); + const lastEntry = entries[entries.length - 1]; + if (lastEntry) { + cleanup(); + resolve(lastEntry.startTime); + } + }); + + try { + observer.observe({ type: 'largest-contentful-paint', buffered: true }); + } catch (error) { + // Handle browser compatibility issues + cleanup(); + resolve(null); + } + }); +} + /** * Get the expanded performance metrics - * @returns {ErrorObject | PerformanceMetricsResponse} + * @returns {Promise} */ -export function getExpandedPerformanceMetrics() { +export async function getExpandedPerformanceMetrics() { try { if (document.readyState !== 'complete') { return returnError('Document not ready'); @@ -38,15 +77,8 @@ export function getExpandedPerformanceMetrics() { // Get largest contentful paint if available let largestContentfulPaint = null; - if ( - window.PerformanceObserver && - PerformanceObserver.supportedEntryTypes && - PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint') - ) { - const lcpEntries = performance.getEntriesByType('largest-contentful-paint'); - if (lcpEntries.length > 0) { - largestContentfulPaint = lcpEntries[lcpEntries.length - 1].startTime; - } + if (PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint')) { + largestContentfulPaint = await waitForLCP(); } // Calculate total resource sizes diff --git a/injected/src/features/performance-metrics.js b/injected/src/features/performance-metrics.js index 29698da9ad..6796204bcf 100644 --- a/injected/src/features/performance-metrics.js +++ b/injected/src/features/performance-metrics.js @@ -20,8 +20,8 @@ export default class PerformanceMetrics extends ContentFeature { } } - triggerExpandedPerformanceMetrics() { - const expandedPerformanceMetrics = getExpandedPerformanceMetrics(); + async triggerExpandedPerformanceMetrics() { + const expandedPerformanceMetrics = await getExpandedPerformanceMetrics(); this.messaging.notify('expandedPerformanceMetricsResult', expandedPerformanceMetrics); } } From ecc1036c1c9e6221e18171ec42dd0383c9fed8f1 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 2 Oct 2025 23:13:38 +0100 Subject: [PATCH 7/8] Disable lint --- injected/src/features/breakage-reporting/utils.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/injected/src/features/breakage-reporting/utils.js b/injected/src/features/breakage-reporting/utils.js index d1cd7c9a49..aa7f9e66a7 100644 --- a/injected/src/features/breakage-reporting/utils.js +++ b/injected/src/features/breakage-reporting/utils.js @@ -24,7 +24,9 @@ function returnError(errorMessage) { */ function waitForLCP(timeoutMs = 500) { return new Promise((resolve) => { + // eslint-disable-next-line prefer-const let timeoutId; + // eslint-disable-next-line prefer-const let observer; const cleanup = () => { From f3f4fa83742dc0b860153ba5602bb8b8c9b09f07 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 2 Oct 2025 23:27:56 +0100 Subject: [PATCH 8/8] Call on load --- injected/src/features/performance-metrics.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/injected/src/features/performance-metrics.js b/injected/src/features/performance-metrics.js index 6796204bcf..0bb1bd02c9 100644 --- a/injected/src/features/performance-metrics.js +++ b/injected/src/features/performance-metrics.js @@ -14,12 +14,20 @@ export default class PerformanceMetrics extends ContentFeature { // If the feature is enabled, we want to collect expanded performance metrics if (this.getFeatureSettingEnabled('expandedPerformanceMetricsOnLoad', 'enabled')) { - document.addEventListener('load', () => { + this.waitForPageLoad(() => { this.triggerExpandedPerformanceMetrics(); }); } } + waitForPageLoad(callback) { + if (document.readyState === 'complete') { + callback(); + } else { + window.addEventListener('load', callback, { once: true }); + } + } + async triggerExpandedPerformanceMetrics() { const expandedPerformanceMetrics = await getExpandedPerformanceMetrics(); this.messaging.notify('expandedPerformanceMetricsResult', expandedPerformanceMetrics);