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

Improving INP metric calculation #681

Open
wants to merge 3 commits into
base: master
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
126 changes: 126 additions & 0 deletions packages/clarity-js/src/performance/interaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// This calculations is inspired by the web-vitals implementation, for more details: https://github.com/GoogleChrome/web-vitals
import { PerformanceEventTiming, Interaction } from '@clarity-types/data';

// Estimate variables to keep track of interactions
let interactionCountEstimate = 0;
let minKnownInteractionId = Infinity;
let maxKnownInteractionId = 0;

let prevInteractionCount = 0; // Used to track interaction count between pages

const MAX_INTERACTIONS_TO_CONSIDER = 10; // Maximum number of interactions we consider for INP
const DEFAULT_DURATION_THRESHOLD = 40; // Threshold to ignore very short interactions

// List to store the longest interaction events
const longestInteractionList: Interaction[] = [];
// Map to track interactions by their ID, ensuring we handle duplicates
const longestInteractionMap: Map<number, Interaction> = new Map();

/**
* Update the approx number of interactions estimate count if the interactionCount is not supported.
* The difference between `maxKnownInteractionId` and `minKnownInteractionId` gives us a rough range of how many interactions have occurred.
* Dividing by 7 helps approximate the interaction count more accurately, since interaction IDs are spread out across a large range.
*/
const countInteractions = (entry: PerformanceEventTiming) => {
if ('interactionCount' in performance) {
interactionCountEstimate = performance.interactionCount as number;
return;
}

AbdelrhmanMagdy marked this conversation as resolved.
Show resolved Hide resolved
if (entry.interactionId) {
minKnownInteractionId = Math.min(
minKnownInteractionId,
entry.interactionId
);
maxKnownInteractionId = Math.max(
maxKnownInteractionId,
entry.interactionId
);

interactionCountEstimate = maxKnownInteractionId
? (maxKnownInteractionId - minKnownInteractionId) / 7 + 1
: 0;
}
};

const getInteractionCount = () => {
return interactionCountEstimate || 0;
};

const getInteractionCountForNavigation = () => {
return getInteractionCount() - prevInteractionCount;
};

/**
* Estimates the 98th percentile (P98) of the longest interactions by selecting
* the candidate interaction based on the current interaction count.
* Dividing by 50 is a heuristic to estimate the 98th percentile (P98) interaction.
* This assumes one out of every 50 interactions represents the P98 interaction.
* By dividing the total interaction count by 50, we get an index to approximate
* the slowest 2% of interactions, helping identify a likely P98 candidate.
*/
export const estimateP98LongestInteraction = () => {
const candidateInteractionIndex = Math.min(
longestInteractionList.length - 1,
Math.floor(getInteractionCountForNavigation() / 50)
);

return longestInteractionList[candidateInteractionIndex].latency;
};

/**
* Resets the interaction tracking, usually called after navigation to a new page.
*/
export const resetInteractions = () => {
prevInteractionCount = getInteractionCount();
longestInteractionList.length = 0;
longestInteractionMap.clear();
};

/**
* Processes a PerformanceEventTiming entry by updating the longest interaction list.
*
* @param entry - A new PerformanceEventTiming entry for an interaction
*/
export const processInteractionEntry = (entry: PerformanceEventTiming) => {
// Ignore entries with 0 interactionId or very short durations
if (!entry.interactionId || entry.duration < DEFAULT_DURATION_THRESHOLD) {
return;
}

// Count the interactions if manual counting is necessary
countInteractions(entry);

const minLongestInteraction =
longestInteractionList[longestInteractionList.length - 1];

const existingInteraction = longestInteractionMap.get(entry.interactionId!);

// Either update existing, add new, or replace shortest interaction if necessary
if (
existingInteraction ||
longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER ||
entry.duration > minLongestInteraction?.latency
) {
if (!existingInteraction) {
const interaction = {
id: entry.interactionId,
latency: entry.duration,
};
longestInteractionMap.set(interaction.id, interaction);
longestInteractionList.push(interaction);
} else if (entry.duration > existingInteraction.latency) {
existingInteraction.latency = entry.duration;
}

// Sort interactions by latency in descending order
longestInteractionList.sort((a, b) => b.latency - a.latency);

// Trim the list to the maximum number of interactions to consider
if (longestInteractionList.length > MAX_INTERACTIONS_TO_CONSIDER) {
longestInteractionList
.splice(MAX_INTERACTIONS_TO_CONSIDER)
.forEach((i) => longestInteractionMap.delete(i.id));
}
}
};
11 changes: 9 additions & 2 deletions packages/clarity-js/src/performance/observer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Code, Constant, Dimension, Metric, Severity } from "@clarity-types/data";
import { Code, Constant, Dimension, Metric, Severity, PerformanceEventTiming } from "@clarity-types/data";
import config from "@src/core/config";
import { bind } from "@src/core/event";
import measure from "@src/core/measure";
Expand All @@ -7,6 +7,7 @@ import * as dimension from "@src/data/dimension";
import * as metric from "@src/data/metric";
import * as internal from "@src/diagnostic/internal";
import * as navigation from "@src/performance/navigation";
import * as interaction from "@src/performance/interaction";

let observer: PerformanceObserver;
const types: string[] = [Constant.Navigation, Constant.Resource, Constant.LongTask, Constant.FID, Constant.CLS, Constant.LCP, Constant.PerformanceEventTiming];
Expand Down Expand Up @@ -73,7 +74,12 @@ function process(entries: PerformanceEntryList): void {
if (visible) { metric.max(Metric.FirstInputDelay, entry["processingStart"] - entry.startTime); }
break;
case Constant.PerformanceEventTiming:
if (visible) { metric.max(Metric.InteractionNextPaint, entry.duration); }
if (visible && 'PerformanceEventTiming' in window && 'interactionId' in PerformanceEventTiming.prototype)
{
interaction.processInteractionEntry(entry as PerformanceEventTiming);
// Logging it as dimension because we're always looking for the last value.
dimension.log(Dimension.InteractionNextPaint, interaction.estimateP98LongestInteraction().toString());
}
break;
case Constant.CLS:
// Scale the value to avoid sending back floating point number
Expand All @@ -89,6 +95,7 @@ function process(entries: PerformanceEntryList): void {
export function stop(): void {
if (observer) { observer.disconnect(); }
observer = null;
interaction.resetInteractions();
}

function host(url: string): string {
Expand Down
17 changes: 15 additions & 2 deletions packages/clarity-js/types/data.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ export const enum Metric {
DeviceMemory = 34,
Electron = 35,
ConstructedStyles = 36,
InteractionNextPaint = 37,
/**
* @deprecated Move it to dimension as it'll report only last value
*/
InteractionNextPaint = 37
}

export const enum Dimension {
Expand Down Expand Up @@ -153,7 +156,8 @@ export const enum Dimension {
InitialScrollBottom = 32,
AncestorOrigins = 33,
Timezone = 34,
TimezoneOffset = 35
TimezoneOffset = 35,
InteractionNextPaint = 37
}

export const enum Check {
Expand Down Expand Up @@ -461,3 +465,12 @@ export interface ClaritySignal {
type: string
value?: number
}

export interface PerformanceEventTiming extends PerformanceEntry {
duration: DOMHighResTimeStamp;
interactionId: number;
}
export interface Interaction {
id: number;
latency: number;
}
12 changes: 7 additions & 5 deletions packages/clarity-visualize/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class DataHelper {
[Data.Metric.CartTotal]: { name: "Cart Total", unit: "html-price" },
[Data.Metric.ProductPrice]: { name: "Product Price", unit: "ld-price" },
[Data.Metric.ThreadBlockedTime]: { name: "Thread Blocked", unit: "ms" },
[Data.Metric.InteractionNextPaint]: { name: "INP", unit: "ms" }
[Data.Dimension.InteractionNextPaint]: { name: "INP", unit: "ms" }
};

public reset = (): void => {
Expand All @@ -39,13 +39,15 @@ export class DataHelper {
let regionMarkup = [];
// Copy over metrics for future reference
for (let m in event.data) {
if (typeof event.data[m] === "number") {
const eventType = typeof event.data[m];
if (eventType === "number" || (event.event === Data.Event.Dimension && m === Data.Dimension.InteractionNextPaint.toString())) {
if (!(m in this.metrics)) { this.metrics[m] = 0; }
let key = parseInt(m, 10);
let value = eventType === "object" ? Number(event.data[m]?.[0]) : event.data[m];
if (m in DataHelper.METRIC_MAP && (DataHelper.METRIC_MAP[m].unit === "html-price" ||DataHelper.METRIC_MAP[m].unit === "ld-price")) {
this.metrics[m] = event.data[m];
} else { this.metrics[m] += event.data[m]; }
this.lean = key === Data.Metric.Playback && event.data[m] === 0 ? true : this.lean;
this.metrics[m] = value;
} else { this.metrics[m] += value; }
this.lean = key === Data.Metric.Playback && value === 0 ? true : this.lean;
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/clarity-visualize/src/visualizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { EnrichHelper } from "./enrich";
import { HeatmapHelper } from "./heatmap";
import { InteractionHelper } from "./interaction";
import { LayoutHelper } from "./layout";
import { Dimension } from "clarity-js/types/data";

export class Visualizer implements VisualizerType {
_state: PlaybackState = null;
Expand Down Expand Up @@ -152,6 +153,11 @@ export class Visualizer implements VisualizerType {
case Data.Event.Metric:
this.data.metric(entry as DecodedData.MetricEvent);
break;
case Data.Event.Dimension:
if(entry.data[Dimension.InteractionNextPaint]){
this.data.metric(entry as DecodedData.MetricEvent);
}
break;
case Data.Event.Region:
this.data.region(entry as Layout.RegionEvent);
break;
Expand Down