Skip to content

Commit f7cde61

Browse files
committed
Add percentiles helper for RUM reporting in loafHelpers
1 parent 4813937 commit f7cde61

File tree

1 file changed

+358
-0
lines changed

1 file changed

+358
-0
lines changed

pages/Interaction/Long-Animation-Frames-Helpers.mdx

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ loafHelpers.exportJSON();
5454
* - loafHelpers.topScripts(n) Show top N slowest scripts
5555
* - loafHelpers.filter(options) Filter frames by duration
5656
* - loafHelpers.findByURL(search) Find frames by script URL
57+
* - loafHelpers.percentiles(pcts) Calculate percentiles for RUM reporting
5758
* - loafHelpers.exportJSON() Download data as JSON
5859
* - loafHelpers.exportCSV() Download data as CSV
5960
* - loafHelpers.getRawData() Get raw captured data
@@ -64,6 +65,7 @@ loafHelpers.exportJSON();
6465
* loafHelpers.topScripts(5)
6566
* loafHelpers.filter({ minDuration: 200 })
6667
* loafHelpers.findByURL('analytics')
68+
* loafHelpers.percentiles()
6769
* loafHelpers.exportJSON()
6870
*
6971
* @author Joan León
@@ -334,6 +336,49 @@ loafHelpers.exportJSON();
334336
console.log("✅ CSV exported:", capturedFrames.length, "frames");
335337
},
336338

339+
/**
340+
* Calculate percentiles of frame durations
341+
* Useful for RUM reporting - send percentiles instead of all frames
342+
* @param {Array<number>} pcts - Percentiles to calculate (default: [50, 75, 95, 99])
343+
* @returns {Object} Percentile values
344+
*/
345+
percentiles(pcts = [50, 75, 95, 99]) {
346+
if (capturedFrames.length === 0) {
347+
console.log("ℹ️ No frames captured yet.");
348+
return {};
349+
}
350+
351+
// Extract and sort durations
352+
const durations = capturedFrames.map((f) => f.duration).sort((a, b) => a - b);
353+
354+
const result = {};
355+
356+
// Calculate percentiles using nearest rank method
357+
pcts.forEach((p) => {
358+
const index = Math.ceil((p / 100) * durations.length) - 1;
359+
const safeIndex = Math.max(0, Math.min(index, durations.length - 1));
360+
result[`p${p}`] = durations[safeIndex];
361+
});
362+
363+
// Display formatted output
364+
console.group("📊 FRAME DURATION PERCENTILES");
365+
console.log("Total frames analyzed:", capturedFrames.length);
366+
console.log("");
367+
368+
Object.entries(result).forEach(([key, value]) => {
369+
// Severity indicators
370+
const severity = value > 200 ? "🔴" : value > 150 ? "🟠" : value > 100 ? "🟡" : "🟢";
371+
const label = key.toUpperCase();
372+
console.log(` ${severity} ${label}: ${value.toFixed(2)}ms`);
373+
});
374+
375+
console.log("");
376+
console.log("💡 Tip: Use percentiles for RUM reporting instead of sending all frames");
377+
console.groupEnd();
378+
379+
return result;
380+
},
381+
337382
/**
338383
* Get raw captured data
339384
* @returns {Array} Array of captured frame objects
@@ -386,6 +431,11 @@ loafHelpers.exportJSON();
386431
"Find frames by script URL",
387432
'loafHelpers.findByURL("analytics")',
388433
);
434+
logCommand(
435+
"percentiles(pcts)",
436+
"Calculate percentiles for RUM reporting (default: [50, 75, 95, 99])",
437+
"loafHelpers.percentiles()",
438+
);
389439
logCommand("exportJSON()", "Download captured data as JSON", "loafHelpers.exportJSON()");
390440
logCommand("exportCSV()", "Download captured data as CSV", "loafHelpers.exportCSV()");
391441
logCommand("getRawData()", "Get raw captured data array", "loafHelpers.getRawData()");
@@ -415,3 +465,311 @@ loafHelpers.exportJSON();
415465
);
416466
})();
417467
```
468+
469+
#### Snippet (minified version)
470+
471+
```js copy
472+
/**
473+
* LoAF Helpers - WebPerf Snippet
474+
*
475+
* Long Animation Frames API debugging helpers for Chrome DevTools
476+
*
477+
* Usage:
478+
* 1. Copy this entire code
479+
* 2. Paste in Chrome DevTools Console (or save as Snippet in Sources panel)
480+
* 3. Use window.loafHelpers.* functions
481+
*
482+
* Available functions:
483+
* - loafHelpers.summary() Show overview of captured frames
484+
* - loafHelpers.topScripts(n) Show top N slowest scripts
485+
* - loafHelpers.filter(options) Filter frames by duration
486+
* - loafHelpers.findByURL(search) Find frames by script URL
487+
* - loafHelpers.percentiles(pcts) Calculate percentiles for RUM reporting
488+
* - loafHelpers.exportJSON() Download data as JSON
489+
* - loafHelpers.exportCSV() Download data as CSV
490+
* - loafHelpers.getRawData() Get raw captured data
491+
* - loafHelpers.clear() Clear captured data
492+
*
493+
* Examples:
494+
* loafHelpers.summary()
495+
* loafHelpers.topScripts(5)
496+
* loafHelpers.filter({ minDuration: 200 })
497+
* loafHelpers.findByURL('analytics')
498+
* loafHelpers.percentiles()
499+
* loafHelpers.exportJSON()
500+
*
501+
* @author Joan León
502+
* @url https://webperf-snippets.nucliweb.net
503+
*/
504+
505+
(function () {
506+
"use strict";
507+
if (
508+
!("PerformanceObserver" in window) ||
509+
!PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")
510+
) {
511+
console.warn("⚠️ Long Animation Frames API not supported in this browser");
512+
console.warn(" Chrome 116+ required");
513+
return;
514+
}
515+
const e = [];
516+
const t = new PerformanceObserver((n) => {
517+
for (const r of n.getEntries()) {
518+
const o = {
519+
startTime: r.startTime,
520+
duration: r.duration,
521+
renderStart: r.renderStart,
522+
styleAndLayoutStart: r.styleAndLayoutStart,
523+
firstUIEventTimestamp: r.firstUIEventTimestamp,
524+
blockingDuration: r.blockingDuration,
525+
scripts: r.scripts.map((s) => ({
526+
sourceURL: s.sourceURL || "",
527+
sourceFunctionName: s.sourceFunctionName || "(anonymous)",
528+
duration: s.duration,
529+
forcedStyleAndLayoutDuration: s.forcedStyleAndLayoutDuration,
530+
invoker: s.invoker || "",
531+
})),
532+
};
533+
e.push(o);
534+
}
535+
});
536+
try {
537+
t.observe({ type: "long-animation-frame", buffered: !0 });
538+
} catch (n) {
539+
console.error("Failed to start LoAF observer:", n);
540+
return;
541+
}
542+
window.loafHelpers = {
543+
summary() {
544+
if (e.length === 0) {
545+
console.log("ℹ️ No frames captured yet. Interact with the page to generate long frames.");
546+
return;
547+
}
548+
const n = e.reduce((r, o) => r + o.duration, 0);
549+
const r = n / e.length;
550+
const o = Math.max(...e.map((s) => s.duration));
551+
const s = {
552+
critical: e.filter((a) => a.duration > 200).length,
553+
high: e.filter((a) => a.duration > 150 && a.duration <= 200).length,
554+
medium: e.filter((a) => a.duration > 100 && a.duration <= 150).length,
555+
low: e.filter((a) => a.duration <= 100).length,
556+
};
557+
console.group("📊 LOAF SUMMARY");
558+
console.log("Total frames:", e.length);
559+
console.log("Total blocking time:", n.toFixed(2) + "ms");
560+
console.log("Average duration:", r.toFixed(2) + "ms");
561+
console.log("Max duration:", o.toFixed(2) + "ms");
562+
console.log("");
563+
console.log("By severity:");
564+
console.log(" 🔴 Critical (>200ms):", s.critical);
565+
console.log(" 🟠 High (150-200ms):", s.high);
566+
console.log(" 🟡 Medium (100-150ms):", s.medium);
567+
console.log(" 🟢 Low (<100ms):", s.low);
568+
console.groupEnd();
569+
},
570+
topScripts(n = 10) {
571+
if (e.length === 0) {
572+
console.log("ℹ️ No frames captured yet.");
573+
return;
574+
}
575+
const r = e.flatMap((o) => o.scripts);
576+
if (r.length === 0) {
577+
console.log("ℹ️ No scripts found in captured frames.");
578+
return;
579+
}
580+
const o = r.sort((s, a) => a.duration - s.duration).slice(0, n);
581+
console.log(`📋 Top ${Math.min(n, o.length)} slowest scripts:`);
582+
console.table(
583+
o.map((s) => {
584+
let a = s.sourceURL;
585+
try {
586+
a = new URL(s.sourceURL || location.href).pathname;
587+
} catch {}
588+
return {
589+
URL: a,
590+
Function: s.sourceFunctionName,
591+
Duration: s.duration.toFixed(2) + "ms",
592+
"Forced Layout": s.forcedStyleAndLayoutDuration.toFixed(2) + "ms",
593+
};
594+
}),
595+
);
596+
},
597+
filter(n = {}) {
598+
if (e.length === 0) {
599+
console.log("ℹ️ No frames captured yet.");
600+
return [];
601+
}
602+
let r = e;
603+
if (n.minDuration) r = r.filter((o) => o.duration >= n.minDuration);
604+
if (n.maxDuration) r = r.filter((o) => o.duration <= n.maxDuration);
605+
console.log(`🔍 Filtered: ${r.length} of ${e.length} frames`);
606+
if (r.length > 0)
607+
console.table(
608+
r.map((o) => ({
609+
Start: o.startTime.toFixed(2) + "ms",
610+
Duration: o.duration.toFixed(2) + "ms",
611+
Scripts: o.scripts.length,
612+
Blocking: o.blockingDuration.toFixed(2) + "ms",
613+
})),
614+
);
615+
return r;
616+
},
617+
findByURL(n) {
618+
if (e.length === 0) {
619+
console.log("ℹ️ No frames captured yet.");
620+
return [];
621+
}
622+
const r = e.filter((o) => o.scripts.some((s) => s.sourceURL.includes(n)));
623+
console.log(`🔎 Found ${r.length} frames with scripts matching "${n}"`);
624+
if (r.length > 0)
625+
console.table(
626+
r.map((o) => {
627+
const s = o.scripts.find((a) => a.sourceURL.includes(n));
628+
return {
629+
"Frame Start": o.startTime.toFixed(2) + "ms",
630+
"Frame Duration": o.duration.toFixed(2) + "ms",
631+
"Script URL": s.sourceURL,
632+
"Script Duration": s.duration.toFixed(2) + "ms",
633+
};
634+
}),
635+
);
636+
return r;
637+
},
638+
exportJSON() {
639+
if (e.length === 0) {
640+
console.log("ℹ️ No frames to export.");
641+
return;
642+
}
643+
const n = JSON.stringify(e, null, 2),
644+
r = new Blob([n], { type: "application/json" }),
645+
o = URL.createObjectURL(r),
646+
s = document.createElement("a");
647+
s.href = o;
648+
s.download = `loaf-data-${Date.now()}.json`;
649+
s.click();
650+
URL.revokeObjectURL(o);
651+
console.log("✅ JSON exported:", e.length, "frames");
652+
},
653+
exportCSV() {
654+
if (e.length === 0) {
655+
console.log("ℹ️ No frames to export.");
656+
return;
657+
}
658+
const n = [
659+
[
660+
"Frame Start",
661+
"Duration",
662+
"Blocking",
663+
"Scripts",
664+
"Script URL",
665+
"Function",
666+
"Script Duration",
667+
"Forced Layout",
668+
],
669+
];
670+
e.forEach((r) => {
671+
r.scripts.forEach((o) => {
672+
n.push([
673+
r.startTime.toFixed(2),
674+
r.duration.toFixed(2),
675+
r.blockingDuration.toFixed(2),
676+
r.scripts.length,
677+
o.sourceURL,
678+
o.sourceFunctionName,
679+
o.duration.toFixed(2),
680+
o.forcedStyleAndLayoutDuration.toFixed(2),
681+
]);
682+
});
683+
});
684+
const r = n.map((o) => o.map((s) => `"${s}"`).join(",")).join("\n"),
685+
o = new Blob([r], { type: "text/csv" }),
686+
s = URL.createObjectURL(o),
687+
a = document.createElement("a");
688+
a.href = s;
689+
a.download = `loaf-data-${Date.now()}.csv`;
690+
a.click();
691+
URL.revokeObjectURL(s);
692+
console.log("✅ CSV exported:", e.length, "frames");
693+
},
694+
percentiles(n = [50, 75, 95, 99]) {
695+
if (e.length === 0) {
696+
console.log("ℹ️ No frames captured yet.");
697+
return {};
698+
}
699+
const r = e.map((o) => o.duration).sort((o, s) => o - s),
700+
o = {};
701+
n.forEach((s) => {
702+
const a = Math.ceil((s / 100) * r.length) - 1,
703+
c = Math.max(0, Math.min(a, r.length - 1));
704+
o[`p${s}`] = r[c];
705+
});
706+
console.group("📊 FRAME DURATION PERCENTILES");
707+
console.log("Total frames analyzed:", e.length);
708+
console.log("");
709+
Object.entries(o).forEach(([s, a]) => {
710+
const c = a > 200 ? "🔴" : a > 150 ? "🟠" : a > 100 ? "🟡" : "🟢",
711+
i = s.toUpperCase();
712+
console.log(` ${c} ${i}: ${a.toFixed(2)}ms`);
713+
});
714+
console.log("");
715+
console.log("💡 Tip: Use percentiles for RUM reporting instead of sending all frames");
716+
console.groupEnd();
717+
return o;
718+
},
719+
getRawData() {
720+
return e;
721+
},
722+
clear() {
723+
e.length = 0;
724+
console.log("✅ Captured data cleared");
725+
},
726+
help() {
727+
console.log(
728+
"%c LoAF Helpers - Available Commands ",
729+
"background: #1a73e8; color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold;",
730+
);
731+
console.log("");
732+
const n = "font-weight: bold; color: #1a73e8;",
733+
r = "color: #888888; font-family: monospace;",
734+
o = (s, a, c) => {
735+
console.log(`%c${s}`, n);
736+
console.log(` ${a}`);
737+
console.log(` %cExample: ${c}`, r);
738+
console.log("");
739+
};
740+
o("summary()", "Show overview of all captured frames", "loafHelpers.summary()");
741+
o("topScripts(n)", "Show top N slowest scripts (default: 10)", "loafHelpers.topScripts(5)");
742+
o("filter(options)", "Filter frames by duration", "loafHelpers.filter({ minDuration: 200 })");
743+
o("findByURL(search)", "Find frames by script URL", 'loafHelpers.findByURL("analytics")');
744+
o(
745+
"percentiles(pcts)",
746+
"Calculate percentiles for RUM reporting (default: [50, 75, 95, 99])",
747+
"loafHelpers.percentiles()",
748+
);
749+
o("exportJSON()", "Download captured data as JSON", "loafHelpers.exportJSON()");
750+
o("exportCSV()", "Download captured data as CSV", "loafHelpers.exportCSV()");
751+
o("getRawData()", "Get raw captured data array", "loafHelpers.getRawData()");
752+
o("clear()", "Clear all captured data", "loafHelpers.clear()");
753+
},
754+
};
755+
console.log(
756+
"%c✅ LoAF Helpers Loaded ",
757+
"background: #CACACA; color: #242424; padding: 2px 4px; border-radius: 4px;",
758+
);
759+
console.log("");
760+
console.log(
761+
"📚 Type %cloafHelpers.help()%c for available commands",
762+
"font-weight: bold; color: #1a73e8",
763+
"",
764+
);
765+
console.log("🚀 Quick start: %cloafHelpers.summary()%c", "font-weight: bold; color: #1a73e8", "");
766+
console.log("");
767+
console.log("Observing long animation frames (>50ms)...");
768+
console.log("");
769+
console.log(
770+
"%cLoAF WebPerf Snippet",
771+
"background: #4caf50; color: white; padding: 2px 4px; border-radius: 4px; font-weight: bold;",
772+
"| https://webperf-snippets.nucliweb.net",
773+
);
774+
})();
775+
```

0 commit comments

Comments
 (0)