diff --git a/README.md b/README.md index 78b4762a..51fbde16 100644 --- a/README.md +++ b/README.md @@ -927,6 +927,125 @@ interface INPAttribution { * are detect, this array will be empty. */ longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[]; + /** + * If the browser supports the Long Animation Frame API, this object + * summarises information relevant to INP across the long animation frames + * intersecting the INP interaction. See the LongAnimationFrameSummary + * definition for an explanation of what is included. + */ + longAnimationFrameSummary?: LongAnimationFrameSummary; + { + /** + * The number of Long Animation Frame scripts that intersect the INP + * interaction. + * NOTE: This may be be less than the total count of scripts in the Long + * Animation Frames as some scripts may occur before the interaction. + */ + numIntersectingScripts: number; + /** + * The number of Long Animation Frames intersecting the INP interaction. + */ + numLongAnimationFrames: number; + /** + * The slowest Long Animation Frame script that intersects the INP + * interaction. + */ + slowestScript: SlowestScriptSummary; + { + /** + * The slowest Long Animation Frame script that intersects the INP + * interaction. + */ + entry: PerformanceScriptTiming; + /** + * The INP sub-part where the longest script ran. + */ + subpart: INPSubpart; //'inputDelay' | 'processingDuration' | 'presentationDelay'; + /** + * The amount of time the slowest script intersected the INP duration. + */ + intersectingDuration: number; + /** + * The total duration time of the slowest script (compile and execution, + * including forced style and layout). Note this may be longer than the + * intersectingScriptDuration if the INP interaction happened mid-script. + */ + totalDuration: number; + /** + * The compile duration of the slowest script. Note this may be longer + * than the intersectingScriptDuration if the INP interaction happened + * mid-script. + */ + compileDuration: number; + /** + * The execution duration of the slowest script. Note this may be longer + * than the intersectingScriptDuration if the INP interaction happened + * mid-script. + */ + executionDuration: number; + /** + /** + * The forced style and layoult duration of the slowest script. Note this + * may be longer than the intersectingScriptDuration if the INP interaction + * happened mid-script. + */ + forcedStyleAndLayoutDuration: number; + /** + * The pause duration of the slowest script. Note this may be longer + * than the intersectingScriptDuration if the INP interaction happened + * mid-script. + */ + pauseDuration: number; + /** + * The invokerType of the slowest script. + */ + invokerType: ScriptInvokerType; + /** + * The invoker of the slowest script. + */ + invoker?: string; + /** + * The sourceURL of the slowest script. + */ + sourceURL?: string; + /** + * The sourceFunctionName of the slowest script. + */ + sourceFunctionName?: string; + /** + * The sourceCharPosition of the slowest script. + */ + sourceCharPosition?: number; + } + /** + * The total intersecting durations in each sub-part by invoker for + * scripts that intersect the INP interaction. + * For example: + * { + * 'inputDelay': { 'event-listener': 185, 'user-callback': 28}, + * 'processingDuration': { 'event-listener': 144}, + * } + */ + totalDurationsPerSubpart: Partial< + Record>> + >; + /** + * The total forced style and layout durations as provided by Long Animation + * Frame scripts intersecting the INP interaction. + */ + totalForcedStyleAndLayoutDuration: number; + /** + * The total non-force (i.e. end-of-frame) style and layout duration from any + * Long Animation Frames intersecting INP interaction. + */ + totalNonForcedStyleAndLayoutDuration: number; + /** + * The total duration of Long Animation Frame scripts that intersect the INP + * duration. Note, this includes forced style and layout within those + * scripts and is limited to scripts > 5 milliseconds. + */ + totalIntersectingScriptsDuration: number; + } /** * The time from when the user interacted with the page until when the * browser was first able to start processing event listeners for that diff --git a/src/attribution/onINP.ts b/src/attribution/onINP.ts index 81c3f367..9e755103 100644 --- a/src/attribution/onINP.ts +++ b/src/attribution/onINP.ts @@ -25,7 +25,9 @@ import { INPAttribution, INPMetric, INPMetricWithAttribution, + INPSubpart, INPAttributionReportOpts, + LongAnimationFrameSummary, } from '../types.js'; interface pendingEntriesGroup { @@ -260,6 +262,99 @@ export const onINP = ( return intersectingLoAFs; }; + const getLoAFSummary = (attribution: INPAttribution) => { + // Stats across all LoAF entries and scripts. + const interactionTime = attribution.interactionTime; + const inputDelay = attribution.inputDelay; + const processingDuration = attribution.processingDuration; + let totalNonForcedStyleAndLayoutDuration = 0; + let totalForcedStyleAndLayout = 0; + let totalIntersectingScriptsDuration = 0; + let numScripts = 0; + let slowestScriptDuration = 0; + let slowestScriptEntry!: PerformanceScriptTiming; + let slowestScriptSubpart!: INPSubpart; + const subparts: Partial< + Record>> + > = {}; + + for (const loafEntry of attribution.longAnimationFrameEntries) { + totalNonForcedStyleAndLayoutDuration += + loafEntry.startTime + + loafEntry.duration - + loafEntry.styleAndLayoutStart; + + for (const script of loafEntry.scripts) { + const scriptEndTime = script.startTime + script.duration; + if (scriptEndTime < interactionTime) { + return; + } + const intersectingScriptDuration = + scriptEndTime - Math.max(interactionTime, script.startTime); + totalIntersectingScriptsDuration += intersectingScriptDuration; + numScripts++; + totalForcedStyleAndLayout += script.forcedStyleAndLayoutDuration; + const invokerType = script.invokerType; + let subpart: INPSubpart = 'processingDuration'; + if (script.startTime < interactionTime + inputDelay) { + subpart = 'inputDelay'; + } else if ( + script.startTime >= + interactionTime + inputDelay + processingDuration + ) { + subpart = 'presentationDelay'; + } + + // Define the record if necessary. Annoyingly TypeScript doesn't yet + // recognize this so need a few `!`s on the next two lines to convinced + // it is typed. + subparts[subpart] ??= {}; + subparts[subpart]![invokerType] ??= 0; + // Increment it with this value + subparts[subpart]![invokerType]! += intersectingScriptDuration; + + if (intersectingScriptDuration > slowestScriptDuration) { + slowestScriptEntry = script; + slowestScriptSubpart = subpart; + slowestScriptDuration = intersectingScriptDuration; + } + } + } + + // Gather the loaf summary information into the loafAttribution object + const loafSummary: LongAnimationFrameSummary = { + numLongAnimationFrames: attribution.longAnimationFrameEntries.length, + numIntersectingScripts: numScripts, + slowestScript: { + entry: slowestScriptEntry, + subpart: slowestScriptSubpart, + intersectingDuration: slowestScriptDuration, + totalDuration: slowestScriptEntry?.duration, + compileDuration: + slowestScriptEntry?.executionStart - slowestScriptEntry?.startTime, + executionDuration: + slowestScriptEntry?.startTime + + slowestScriptEntry?.duration - + slowestScriptEntry?.executionStart, + forcedStyleAndLayoutDuration: + slowestScriptEntry?.forcedStyleAndLayoutDuration, + pauseDuration: slowestScriptEntry?.pauseDuration, + invokerType: slowestScriptEntry?.invokerType, + invoker: slowestScriptEntry?.invoker, + sourceURL: slowestScriptEntry?.sourceURL, + sourceFunctionName: slowestScriptEntry?.sourceFunctionName, + sourceCharPosition: slowestScriptEntry?.sourceCharPosition, + }, + totalDurationsPerSubpart: subparts, + totalForcedStyleAndLayoutDuration: totalForcedStyleAndLayout, + totalNonForcedStyleAndLayoutDuration: + totalNonForcedStyleAndLayoutDuration, + totalIntersectingScriptsDuration: totalIntersectingScriptsDuration, + }; + + return loafSummary; + }; + const attributeINP = (metric: INPMetric): INPMetricWithAttribution => { const firstEntry = metric.entries[0]; const group = entryToEntriesGroupMap.get(firstEntry)!; @@ -311,6 +406,8 @@ export const onINP = ( loadState: getLoadState(firstEntry.startTime), }; + attribution.longAnimationFrameSummary = getLoAFSummary(attribution); + // Use `Object.assign()` to ensure the original metric object is returned. const metricWithAttribution: INPMetricWithAttribution = Object.assign( metric, diff --git a/src/types.ts b/src/types.ts index 34d3a7ca..72f95997 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,9 +88,47 @@ declare global { readonly element: Element | null; } - // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming + // https://w3c.github.io/long-animation-frames/#sec-PerformanceLongAnimationFrameTiming + export type ScriptInvokerType = + | 'classic-script' + | 'module-script' + | 'event-listener' + | 'user-callback' + | 'resolve-promise' + | 'reject-promise'; + export type ScriptWindowAttribution = + | 'self' + | 'descendant' + | 'ancestor' + | 'same-page' + | 'other'; + interface PerformanceScriptTiming extends PerformanceEntry { + readonly startTime: DOMHighResTimeStamp; + readonly duration: DOMHighResTimeStamp; + readonly name: string; + readonly entryType: string; + + readonly invokerType: ScriptInvokerType; + readonly invoker: string; + readonly executionStart: DOMHighResTimeStamp; + readonly sourceURL: string; + readonly sourceFunctionName: string; + readonly sourceCharPosition: number; + readonly pauseDuration: DOMHighResTimeStamp; + readonly forcedStyleAndLayoutDuration: DOMHighResTimeStamp; + readonly window?: Window; + readonly windowAttribution: ScriptWindowAttribution; + } interface PerformanceLongAnimationFrameTiming extends PerformanceEntry { - renderStart: DOMHighResTimeStamp; - duration: DOMHighResTimeStamp; + readonly startTime: DOMHighResTimeStamp; + readonly duration: DOMHighResTimeStamp; + readonly name: string; + readonly entryType: string; + + readonly renderStart: DOMHighResTimeStamp; + readonly styleAndLayoutStart: DOMHighResTimeStamp; + readonly blockingDuration: DOMHighResTimeStamp; + readonly firstUIEventTimestamp: DOMHighResTimeStamp; + readonly scripts: Array; } } diff --git a/src/types/inp.ts b/src/types/inp.ts index c5fa7a0f..919478d6 100644 --- a/src/types/inp.ts +++ b/src/types/inp.ts @@ -37,6 +37,140 @@ export interface INPMetric extends Metric { entries: PerformanceEventTiming[]; } +export type INPSubpart = + | 'inputDelay' + | 'processingDuration' + | 'presentationDelay'; + +/** + * Summary information about the slowest script intersecting the INP duration + * as provided by the Long Animation Frame API. + * + * NOTE: Only scripts above 5 milliseconds are included in long animation + * frames. + */ +export interface SlowestScriptSummary { + /** + * The slowest Long Animation Frame script that intersects the INP + * interaction. + */ + entry: PerformanceScriptTiming; + /** + * The INP sub-part where the longest script ran. + */ + subpart: INPSubpart; //'inputDelay' | 'processingDuration' | 'presentationDelay'; + /** + * The amount of time the slowest script intersected the INP duration. + */ + intersectingDuration: number; + /** + * The total duration time of the slowest script (compile and execution, + * including forced style and layout). Note this may be longer than the + * intersectingScriptDuration if the INP interaction happened mid-script. + */ + totalDuration: number; + /** + * The compile duration of the slowest script. Note this may be longer + * than the intersectingScriptDuration if the INP interaction happened + * mid-script. + */ + compileDuration: number; + /** + * The execution duration of the slowest script. Note this may be longer + * than the intersectingScriptDuration if the INP interaction happened + * mid-script. + */ + executionDuration: number; + /** + /** + * The forced style and layoult duration of the slowest script. Note this + * may be longer than the intersectingScriptDuration if the INP interaction + * happened mid-script. + */ + forcedStyleAndLayoutDuration: number; + /** + * The pause duration of the slowest script. Note this may be longer + * than the intersectingScriptDuration if the INP interaction happened + * mid-script. + */ + pauseDuration: number; + /** + * The invokerType of the slowest script. + */ + invokerType: ScriptInvokerType; + /** + * The invoker of the slowest script. + */ + invoker?: string; + /** + * The sourceURL of the slowest script. + */ + sourceURL?: string; + /** + * The sourceFunctionName of the slowest script. + */ + sourceFunctionName?: string; + /** + * The sourceCharPosition of the slowest script. + */ + sourceCharPosition?: number; +} + +/** + * An object containing potentially-helpful debugging information summarized + * from the LongAnimationFrames intersecting the INP interaction. + * + * NOTE: Long Animation Frames below 50 milliseconds are not reported, and + * so their scripts cannot be included. For Long Animation Frames that are + * reported, only scripts above 5 milliseconds are included. + */ +export interface LongAnimationFrameSummary { + /** + * The number of Long Animation Frame scripts that intersect the INP + * interaction. + * NOTE: This may be be less than the total count of scripts in the Long + * Animation Frames as some scripts may occur before the interaction. + */ + numIntersectingScripts: number; + /** + * The number of Long Animation Frames intersecting the INP interaction. + */ + numLongAnimationFrames: number; + /** + * The slowest Long Animation Frame script that intersects the INP + * interaction. + */ + slowestScript: SlowestScriptSummary; + /** + * The total intersecting durations in each sub-part by invoker for + * scripts that intersect the INP interaction. + * For example: + * { + * 'inputDelay': { 'event-listener': 185, 'user-callback': 28}, + * 'processingDuration': { 'event-listener': 144}, + * } + */ + totalDurationsPerSubpart: Partial< + Record>> + >; + /** + * The total forced style and layout durations as provided by Long Animation + * Frame scripts intersecting the INP interaction. + */ + totalForcedStyleAndLayoutDuration: number; + /** + * The total non-force (i.e. end-of-frame) style and layout duration from any + * Long Animation Frames intersecting INP interaction. + */ + totalNonForcedStyleAndLayoutDuration: number; + /** + * The total duration of Long Animation Frame scripts that intersect the INP + * duration. Note, this includes forced style and layout within those + * scriptsn and is limited to scripts > 5 milliseconds. + */ + totalIntersectingScriptsDuration: number; +} + /** * An object containing potentially-helpful debugging information that * can be sent along with the INP value for the current page visit in order @@ -88,6 +222,13 @@ export interface INPAttribution { * are detect, this array will be empty. */ longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[]; + /** + * If the browser supports the Long Animation Frame API, this object + * summarises information relevant to INP across the long animation frames + * intersecting the INP interaction. See the LongAnimationFrameSummary + * definition for an explanation of what is included. + */ + longAnimationFrameSummary?: LongAnimationFrameSummary; /** * The time from when the user interacted with the page until when the * browser was first able to start processing event listeners for that diff --git a/test/e2e/onINP-test.js b/test/e2e/onINP-test.js index ff1a7b96..ce8d822a 100644 --- a/test/e2e/onINP-test.js +++ b/test/e2e/onINP-test.js @@ -841,6 +841,56 @@ describe('onINP()', async function () { const [inp1] = await getBeacons(); assert(inp1.attribution.longAnimationFrameEntries.length > 0); + assert( + Object.keys(inp1.attribution.longAnimationFrameSummary).length !== 0, + ); + assert.equal( + inp1.attribution.longAnimationFrameSummary.numIntersectingScripts, + 1, + ); + assert.equal( + JSON.stringify( + inp1.attribution.longAnimationFrameSummary.slowestScript.entry, + ), + JSON.stringify( + inp1.attribution.longAnimationFrameEntries[0].scripts[0], + ), + ); + assert.equal( + inp1.attribution.longAnimationFrameSummary.slowestScript.subpart, + 'processingDuration', + ); + assert.equal( + Object.keys( + inp1.attribution.longAnimationFrameSummary.totalDurationsPerSubpart, + ), + 'processingDuration', + ); + assert.equal( + Object.keys( + inp1.attribution.longAnimationFrameSummary.totalDurationsPerSubpart + .processingDuration, + ), + 'event-listener', + ); + assert( + inp1.attribution.longAnimationFrameSummary.totalDurationsPerSubpart + .processingDuration['event-listener'] >= 100, + ); + assert.equal( + inp1.attribution.longAnimationFrameSummary + .totalForcedStyleAndLayoutDuration, + 0, + ); + assert( + inp1.attribution.longAnimationFrameSummary + .totalNonForcedStyleAndLayoutDuration <= + inp1.attribution.presentationDelay, + ); + assert( + inp1.attribution.longAnimationFrameSummary + .totalIntersectingScriptsDuration >= 100, + ); }); }); });