feat: enhance performance metrics tracking by observing LCP and CLS events, ensuring accurate data reporting only when metrics are available
This commit is contained in:
@@ -24,6 +24,8 @@
|
|||||||
// * Performance Monitoring (Core Web Vitals) State
|
// * Performance Monitoring (Core Web Vitals) State
|
||||||
let currentEventId = null;
|
let currentEventId = null;
|
||||||
let metrics = { lcp: 0, cls: 0, inp: 0 };
|
let metrics = { lcp: 0, cls: 0, inp: 0 };
|
||||||
|
let lcpObserved = false;
|
||||||
|
let clsObserved = false;
|
||||||
let performanceInsightsEnabled = false;
|
let performanceInsightsEnabled = false;
|
||||||
|
|
||||||
// * Session Replay State
|
// * Session Replay State
|
||||||
@@ -42,20 +44,22 @@
|
|||||||
try {
|
try {
|
||||||
if (typeof PerformanceObserver === 'undefined') return;
|
if (typeof PerformanceObserver === 'undefined') return;
|
||||||
|
|
||||||
// * LCP (Largest Contentful Paint)
|
// * LCP (Largest Contentful Paint) - fires when the browser has determined the LCP element (often 2–4s+ after load)
|
||||||
new PerformanceObserver((entryList) => {
|
new PerformanceObserver((entryList) => {
|
||||||
const entries = entryList.getEntries();
|
const entries = entryList.getEntries();
|
||||||
const lastEntry = entries[entries.length - 1];
|
const lastEntry = entries[entries.length - 1];
|
||||||
if (lastEntry) {
|
if (lastEntry) {
|
||||||
metrics.lcp = lastEntry.startTime;
|
metrics.lcp = lastEntry.startTime;
|
||||||
|
lcpObserved = true;
|
||||||
}
|
}
|
||||||
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||||||
|
|
||||||
// * CLS (Cumulative Layout Shift)
|
// * CLS (Cumulative Layout Shift) - accumulates when elements shift after load
|
||||||
new PerformanceObserver((entryList) => {
|
new PerformanceObserver((entryList) => {
|
||||||
for (const entry of entryList.getEntries()) {
|
for (const entry of entryList.getEntries()) {
|
||||||
if (!entry.hadRecentInput) {
|
if (!entry.hadRecentInput) {
|
||||||
metrics.cls += entry.value;
|
metrics.cls += entry.value;
|
||||||
|
clsObserved = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).observe({ type: 'layout-shift', buffered: true });
|
}).observe({ type: 'layout-shift', buffered: true });
|
||||||
@@ -75,16 +79,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendMetrics() {
|
function sendMetrics() {
|
||||||
// * Only send metrics if performance insights are enabled
|
if (!performanceInsightsEnabled || !currentEventId) return;
|
||||||
if (!performanceInsightsEnabled || !currentEventId || (metrics.lcp === 0 && metrics.cls === 0 && metrics.inp === 0)) return;
|
|
||||||
|
|
||||||
// * Use sendBeacon if available for reliability on unload
|
// * Only include LCP/CLS when the browser actually reported them. Sending 0 overwrites
|
||||||
const data = JSON.stringify({
|
// * the DB before LCP/CLS have fired (they fire late). The backend does partial updates
|
||||||
event_id: currentEventId,
|
// * and leaves unset fields unchanged.
|
||||||
lcp: metrics.lcp,
|
const payload = { event_id: currentEventId, inp: metrics.inp };
|
||||||
cls: metrics.cls,
|
if (lcpObserved) payload.lcp = metrics.lcp;
|
||||||
inp: metrics.inp
|
if (clsObserved) payload.cls = metrics.cls;
|
||||||
});
|
|
||||||
|
const data = JSON.stringify(payload);
|
||||||
|
|
||||||
if (navigator.sendBeacon) {
|
if (navigator.sendBeacon) {
|
||||||
navigator.sendBeacon(apiUrl + '/api/v1/metrics', new Blob([data], {type: 'application/json'}));
|
navigator.sendBeacon(apiUrl + '/api/v1/metrics', new Blob([data], {type: 'application/json'}));
|
||||||
@@ -105,12 +109,13 @@
|
|||||||
// * Send metrics when user leaves or hides the page
|
// * Send metrics when user leaves or hides the page
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') {
|
if (document.visibilityState === 'hidden') {
|
||||||
sendMetrics();
|
// * Flush replay immediately (page may be torn down soon)
|
||||||
// Also flush replay data
|
|
||||||
if (replayEnabled) {
|
if (replayEnabled) {
|
||||||
sendReplayChunk();
|
sendReplayChunk();
|
||||||
endReplaySession();
|
endReplaySession();
|
||||||
}
|
}
|
||||||
|
// * Delay metrics slightly so in-flight LCP/CLS callbacks can run before we send
|
||||||
|
setTimeout(sendMetrics, 150);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,13 +169,13 @@
|
|||||||
// * No, trackPageview runs early.
|
// * No, trackPageview runs early.
|
||||||
// * BUT for SPA navigation, we want to reset.
|
// * BUT for SPA navigation, we want to reset.
|
||||||
if (currentEventId) {
|
if (currentEventId) {
|
||||||
// If we already had an event ID, it means this is a subsequent navigation
|
// * SPA nav: visibilitychange may not fire, so send previous page's metrics now
|
||||||
// We should try to send metrics for the *previous* page before resetting?
|
|
||||||
// Ideally visibilitychange handles this, but for SPA nav it might not trigger visibilitychange.
|
|
||||||
sendMetrics();
|
sendMetrics();
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics = { lcp: 0, cls: 0, inp: 0 };
|
metrics = { lcp: 0, cls: 0, inp: 0 };
|
||||||
|
lcpObserved = false;
|
||||||
|
clsObserved = false;
|
||||||
currentEventId = null;
|
currentEventId = null;
|
||||||
|
|
||||||
const path = window.location.pathname + window.location.search;
|
const path = window.location.pathname + window.location.search;
|
||||||
|
|||||||
Reference in New Issue
Block a user