diff --git a/packages/clarity-js/src/performance/interaction.ts b/packages/clarity-js/src/performance/interaction.ts new file mode 100644 index 00000000..a81aa983 --- /dev/null +++ b/packages/clarity-js/src/performance/interaction.ts @@ -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 = 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; + } + + 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)); + } + } +}; diff --git a/packages/clarity-js/src/performance/observer.ts b/packages/clarity-js/src/performance/observer.ts index d9ef0dd1..8162ec0c 100644 --- a/packages/clarity-js/src/performance/observer.ts +++ b/packages/clarity-js/src/performance/observer.ts @@ -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"; @@ -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]; @@ -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 @@ -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 { diff --git a/packages/clarity-js/types/data.d.ts b/packages/clarity-js/types/data.d.ts index 44119f61..f5fb7c4b 100644 --- a/packages/clarity-js/types/data.d.ts +++ b/packages/clarity-js/types/data.d.ts @@ -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 { @@ -153,7 +156,8 @@ export const enum Dimension { InitialScrollBottom = 32, AncestorOrigins = 33, Timezone = 34, - TimezoneOffset = 35 + TimezoneOffset = 35, + InteractionNextPaint = 37 } export const enum Check { @@ -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; +} \ No newline at end of file diff --git a/packages/clarity-visualize/src/data.ts b/packages/clarity-visualize/src/data.ts index c7bb1084..c35e7abe 100644 --- a/packages/clarity-visualize/src/data.ts +++ b/packages/clarity-visualize/src/data.ts @@ -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 => { @@ -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; } } diff --git a/packages/clarity-visualize/src/visualizer.ts b/packages/clarity-visualize/src/visualizer.ts index aec8b160..957e86f0 100644 --- a/packages/clarity-visualize/src/visualizer.ts +++ b/packages/clarity-visualize/src/visualizer.ts @@ -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; @@ -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;