UNPKG

chrome-devtools-frontend

Version:
285 lines (244 loc) • 10 kB
// Copyright 2024 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as WebVitals from '../../../third_party/web-vitals/web-vitals.js'; import type * as Trace from '../../trace/trace.js'; import * as OnEachLayoutShift from './OnEachLayoutShift.js'; import * as Spec from './spec/spec.js'; const {onLCP, onCLS, onINP} = WebVitals.Attribution; const {onEachLayoutShift} = OnEachLayoutShift; declare const window: Window&{ getNodeForIndex: (index: number) => Node | undefined, [Spec.INTERNAL_KILL_SWITCH]: () => void, [Spec.EVENT_BINDING_NAME]: (payload: string) => void, devToolsReportSoftNavs?: boolean, }; const eventListenerCleanupController = new AbortController(); const patchAddListener = (proto: typeof Window.prototype|typeof Document.prototype): void => { const original = proto.addEventListener; proto.addEventListener = function( type: string, listener: EventListenerOrEventListenerObject, options?: boolean|AddEventListenerOptions): void { // Standardize options into an object const navOptions = typeof options === 'boolean' ? {capture: options} : {...options}; // If we already have a signal, we should respect it, // but also link it to our global cleanup signal. if (navOptions.signal) { navOptions.signal = AbortSignal.any([navOptions.signal, eventListenerCleanupController.signal]); } else { navOptions.signal = eventListenerCleanupController.signal; } return original.call(this, type, listener, navOptions); }; }; // Patch the core targets patchAddListener(Window.prototype); patchAddListener(Document.prototype); // Use a class wrapper that auto-registers and auto-unregisters const activeObservers = new Set<PerformanceObserver>(); class TrackedPerformanceObserver extends globalThis.PerformanceObserver { constructor(callback: PerformanceObserverCallback) { super(callback); activeObservers.add(this); } // Override disconnect to remove it from our tracking set override disconnect(): void { super.disconnect(); activeObservers.delete(this); } } const nodeList: Array<WeakRef<Node>> = []; const nodeToIdMap = new WeakMap<Node, number>(); function establishNodeIndex(node: Node): number { let index = nodeToIdMap.get(node); if (index !== undefined) { return index; } index = nodeList.length; nodeList.push(new WeakRef(node)); nodeToIdMap.set(node, index); return index; } // Replace the global constructor globalThis.PerformanceObserver = TrackedPerformanceObserver; /** * This is a hack solution to remove any listeners that were added by web-vitals.js * or additional services in this bundle. Once this function is called, the execution * context should be considered dead and a new one will need to be created for live metrics * to be served again. */ let killed = false; window[Spec.INTERNAL_KILL_SWITCH] = () => { if (killed) { return; } for (const observer of activeObservers) { // This calls the overridden disconnect above, // cleaning up BOTH the browser resource and our Set. observer.disconnect(); } activeObservers.clear(); eventListenerCleanupController.abort(); // Explicitly clear the Node List to help GC nodeList.length = 0; killed = true; }; function sendEventToDevTools(event: Spec.WebVitalsEvent): void { const payload = JSON.stringify(event); window[Spec.EVENT_BINDING_NAME]?.(payload); } /** * The data sent over the event binding needs to be JSON serializable, so we * can't send DOM nodes directly. Instead we create an ID for each node (see * `establishNodeIndex`) that we can later use to retrieve a remote object * for that node. * * This function is used by `Runtime.evaluate` calls to get a remote object * for the specified index. */ window.getNodeForIndex = (index: number): Node|undefined => { return nodeList[index].deref(); }; function limitScripts(loafs: Spec.PerformanceLongAnimationFrameTimingJSON[]): Spec.PerformanceLongAnimationFrameTimingJSON[] { return loafs.map(loaf => { const longestScripts: Spec.PerformanceScriptTimingJSON[] = []; for (const script of loaf.scripts) { if (longestScripts.length < Spec.SCRIPTS_PER_LOAF_LIMIT) { longestScripts.push(script); continue; } const shorterIndex = longestScripts.findIndex(s => s.duration < script.duration); if (shorterIndex === -1) { continue; } longestScripts[shorterIndex] = script; } longestScripts.sort((a, b) => a.startTime - b.startTime); loaf.scripts = longestScripts; return loaf; }); } function isPrerendered(): boolean { if (document.prerendering) { return true; } const firstNavStart = self.performance.getEntriesByType?.('navigation')[0]?.activationStart; return firstNavStart !== undefined && firstNavStart > 0; } let startedHidden: boolean|null = null; function initialize(): void { sendEventToDevTools({name: 'reset'}); new PerformanceObserver(list => { for (const entry of list.getEntries()) { if (startedHidden === null && !isPrerendered()) { startedHidden = entry.name === 'hidden'; } } }).observe({type: 'visibility-state', buffered: true}); // We want to treat bfcache navigations like a standard navigations, so emit // a reset event when bfcache is restored. // // Metric functions will also re-emit their values using this listener's callback. // To ensure this event is fired before those values are emitted, register this // callback before any others. WebVitals.onBFCacheRestore(() => { startedHidden = false; sendEventToDevTools({name: 'reset', navigationType: 'back-forward-cache'}); }); let lastLcpNavigationId: number|undefined; onLCP(metric => { if (lastLcpNavigationId && metric.navigationId && metric.navigationId !== lastLcpNavigationId) { sendEventToDevTools({name: 'reset', url: window.location.href, navigationType: metric.navigationType}); } lastLcpNavigationId = metric.navigationId; const event: Spec.LcpChangeEvent = { name: 'LCP', value: metric.value as Trace.Types.Timing.Milli, startedHidden: Boolean(startedHidden), subparts: { timeToFirstByte: metric.attribution.timeToFirstByte as Trace.Types.Timing.Milli, resourceLoadDelay: metric.attribution.resourceLoadDelay as Trace.Types.Timing.Milli, resourceLoadTime: metric.attribution.resourceLoadDuration as Trace.Types.Timing.Milli, elementRenderDelay: metric.attribution.elementRenderDelay as Trace.Types.Timing.Milli, }, }; const element = metric.attribution.lcpEntry?.element; if (element) { event.nodeIndex = establishNodeIndex(element); } sendEventToDevTools(event); }, {reportAllChanges: true, reportSoftNavs: window.devToolsReportSoftNavs}); onCLS(metric => { const event: Spec.ClsChangeEvent = { name: 'CLS', value: metric.value, clusterShiftIds: metric.entries.map(Spec.getUniqueLayoutShiftId), }; sendEventToDevTools(event); }, {reportAllChanges: true, reportSoftNavs: window.devToolsReportSoftNavs}); function onEachInteraction(interaction: WebVitals.INPMetricWithAttribution): void { // Multiple `InteractionEntry` events can be emitted for the same `uniqueInteractionId` // However, it is easier to combine these entries in the DevTools client rather than in // this injected code. const event: Spec.InteractionEntryEvent = { name: 'InteractionEntry', duration: interaction.value as Trace.Types.Timing.Milli, subparts: { inputDelay: interaction.attribution.inputDelay as Trace.Types.Timing.Milli, processingDuration: interaction.attribution.processingDuration as Trace.Types.Timing.Milli, presentationDelay: interaction.attribution.presentationDelay as Trace.Types.Timing.Milli, }, startTime: interaction.entries[0].startTime, navigationId: interaction.navigationId, entryGroupId: interaction.entries[0].interactionId as Spec.InteractionEntryGroupId, nextPaintTime: interaction.attribution.nextPaintTime, interactionType: interaction.attribution.interactionType, eventName: interaction.entries[0].name, // To limit the amount of events, just get the last 5 LoAFs longAnimationFrameEntries: limitScripts( interaction.attribution.longAnimationFrameEntries.slice(-Spec.LOAF_LIMIT).map(loaf => loaf.toJSON())), }; const target = interaction.attribution.interactionTarget; if (target) { event.nodeIndex = Number(target); } sendEventToDevTools(event); } onINP(metric => { const event: Spec.InpChangeEvent = { name: 'INP', value: metric.value as Trace.Types.Timing.Milli, subparts: { inputDelay: metric.attribution.inputDelay as Trace.Types.Timing.Milli, processingDuration: metric.attribution.processingDuration as Trace.Types.Timing.Milli, presentationDelay: metric.attribution.presentationDelay as Trace.Types.Timing.Milli, }, startTime: metric.entries[0].startTime, entryGroupId: metric.entries[0].interactionId as Spec.InteractionEntryGroupId, interactionType: metric.attribution.interactionType, }; sendEventToDevTools(event); }, { reportAllChanges: true, durationThreshold: 0, includeProcessedEventEntries: false, reportSoftNavs: window.devToolsReportSoftNavs, onEachInteraction, generateTarget(el) { if (el) { return String(establishNodeIndex(el)); } return undefined; }, }); onEachLayoutShift(layoutShift => { const event: Spec.LayoutShiftEvent = { name: 'LayoutShift', score: layoutShift.value, uniqueLayoutShiftId: Spec.getUniqueLayoutShiftId(layoutShift.entry), affectedNodeIndices: layoutShift.attribution.affectedNodes.map(establishNodeIndex), }; sendEventToDevTools(event); }); } initialize();