@@ -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