Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LoAF Summary attribution information #574

Open
wants to merge 25 commits into
base: v5
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather that just listing the number of intersecting scripts, I wonder if it would be more useful to create an array of intersecting scripts (which then someone could easily call .length on).

Related, how common is it for scripts from multiple different LoAF entries to all be intersecting with INP?

/**
* The number of Long Animation Frames intersecting the INP interaction.
*/
numLongAnimationFrames: number;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unnecessary because someone could easily do longAnimationFrameEntries.length, right?

/**
* The slowest Long Animation Frame script that intersects the INP
* interaction.
*/
slowestScript: SlowestScriptSummary;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now the SlowestScriptSummary object duplicates many of the native PerformanceScriptTiming properties, and it's not clear to me how valuable that is. AFAICT the only "computed" values are subpart and intersectingDuration.

What if instead we just had three top-level attribution properties?

  • slowestScriptEntry
  • slowestScriptPhase (or subpart)
  • slowestScriptIntersectingDuration

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I wonder if longestScript would be better and more consistent with other API names (e.g. Long Tasks and Long Animation Frames).

{
/**
* 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';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On web.dev these are currently referred to as "phases". Are we planning to change that?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brendankenny I think it was you that told me were were moving towards subparts to mirror LCP, is that right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we should definitely unify. I suggested it specifically because we didn't want to simultaneously cement "phases" here in web-vitals and "subparts" over in CrUX, and everyone in the LCP discussions agreed on "subpart".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
subpart: INPSubpart; //'inputDelay' | 'processingDuration' | 'presentationDelay';
subpart: INPSubpart; // 'input-delay' | 'processing-duration' | 'presentation-delay'

For consistency with other enum-type options.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting. Yes as an enum they should use that format. However this is then exposed in output—as both property names and values:

      "slowestScript": {
...
        "subpart": "processingDuration",
        "intersectingDuration": 100,
...
      },
      "totalDurationsPerSubpart": {
        "processingDuration": {
          "event-listener": 100
        }
      },

And so they should really follow camelCase? Though the invoker type is hyphenated (and that's part of the spec) so it's a little inconsistent anyway in that regards

The other consideration is that camelCase is also consistent with what we use for LCP sub parts, but there they are strict property names (not values) and we don't have them defined as an enum.

/**
* 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<
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd rather we name this something like phaseBreakdown (or subPartBreakdown).

Record<INPSubpart, Partial<Record<ScriptInvokerType, number>>>
>;
/**
* 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather have totalStyleAndLayoutDuration and totalForcedStyleAndLayoutDuration (and then I can compute the total non-forced duration if needed). It's unclear to me what the use case is for knowing just the total non-forced duration.

Copy link
Member Author

@tunetheweb tunetheweb Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'd definitely rather have the "nonForced" time (i.e. the end of frame style and layout time) separately and maybe total time and then the forced time can we (somewhat) calculated from that. But agree totalNonForcedStyleAndLayoutDuration isn't a great name. Open to ideas here.

The problem is the "total forced" time is not really know since it's only made up on scripts > 5ms (see w3c/long-animation-frames#23) so I don't think it's accurate to say the "total forced time".

/**
* 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
Expand Down
97 changes: 97 additions & 0 deletions src/attribution/onINP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import {
INPAttribution,
INPMetric,
INPMetricWithAttribution,
INPSubpart,
INPAttributionReportOpts,
LongAnimationFrameSummary,
} from '../types.js';

interface pendingEntriesGroup {
Expand Down Expand Up @@ -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<INPSubpart, Partial<Record<ScriptInvokerType, number>>>
> = {};

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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the ?. optional chaining operator here seems risky because if slowestScriptEntry is undefined then you'll end up doing math operations on undefined, which will result in NaN.

I know there's been some discussion of removing these properties anyway, but if we don't then it's probably better to just omit these (or set them to 0) if there's no slowest script.

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)!;
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 41 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PerformanceScriptTiming>;
}
}
Loading
Loading