/**
 * MIT License
 *
 * Copyright (c) 2020-present, Elastic NV
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 *
 */ import { Filmstrip , TraceOutput } from '../common_types' ; import type { LHProcessedNavigation , LHProcessedTrace , TraceEvent , } from './trace-processor' ; export class UserTimings { static compute ( trace ) { const { processEvents } = trace ; const measuresMap = new Map ( ) ; const userTimings = [ ] ; for ( const event of processEvents ) { const { name , ph , ts , args } = event ; if ( ! event . cat . includes ( 'blink.user_timing' ) ) { continue ; } if ( name === 'requestStart' || name === 'navigationStart' || name === 'paintNonDefaultBackgroundColor' || args . frame !== undefined ) { continue ; } /**
             * user timing mark events falls under `R` and i or I
             * measure events starts with `b` and ends with `e`
             * Doc - https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/edit#heading=h.puwqg050lyuy
             */ const phase = ph . toLowerCase ( ) ; if ( phase === 'r' || phase === 'i' ) { userTimings . push ( { name , type : 'mark' , start : { us : ts , } , } ) ; } else if ( phase === 'b' ) { measuresMap . set ( name , ts ) ; } else if ( phase === 'e' ) { const startTime = measuresMap . get ( name ) ; userTimings . push ( { name , type : 'measure' , start : { us : startTime , } , duration : { us : ts - startTime , } , } ) ; } } return userTimings ; } } export class ExperienceMetrics { static buildMetric ( name , timestamp ? ) { if ( ! timestamp ) { return ; } return { name , type : 'mark' , start : { us : timestamp , } , } ; } static compute ( trace ) { const traces = [ ] ; const { timestamps , timings , lcpInvalidated } = trace ; traces . push ( this . buildMetric ( 'navigationStart' , timestamps . timeOrigin ) ) ; traces . push ( this . buildMetric ( 'firstContentfulPaint' , timestamps . firstContentfulPaint ) ) ; /**
         * lcpInvalidated - Denotes when all of the LCP events that comes from the
         * current trace are invalidated. Happens if the previous LCP candidates were
         * suppressed by another event coming later during the page load
         * More info - https://github.com/WICG/largest-contentful-paint/#the-last-candidate
         */ ! lcpInvalidated && traces . push ( this . buildMetric ( 'largestContentfulPaint' , timestamps . largestContentfulPaint ) ) ; traces . push ( this . buildMetric ( 'domContentLoaded' , timestamps . domContentLoaded ) ) ; traces . push ( this . buildMetric ( 'loadEvent' , timestamps . load ) ) ; /**
         * Lighthouse tracer extracts these metrics in milliseconds resolution,
         * we convert them to microseconds resolution to keep them in sync with
         * other timings
         */ const millisToMicros = ( value ? ) => value ? value * 1000 : undefined ; const keys = [ 'firstContentfulPaint' , 'largestContentfulPaint' , 'domContentLoaded' , 'load' , ] ; const values = [ 'fcp' , 'lcp' , 'dcl' , 'load' ] ; const metrics = { } ; for ( let i = 0 ; i < keys . length ; i ++ ) { const microSecs = millisToMicros ( timings [ keys [ i ] ] ) ; if ( microSecs ) { metrics [ values [ i ] ] = { us : microSecs } ; } } return { metrics , traces : traces . filter ( b => Boolean ( b ) ) , } ; } } export class CumulativeLayoutShift { static getLayoutShiftEvents ( traceEvents ) { const layoutShiftEvents = [ ] ; let ignoreHadRecentInput = true ; for ( const event of traceEvents ) { if ( event . name !== 'LayoutShift' || ! event . args ?. data . is_main_frame || ! event . args ?. data . weighted_score_delta ) { continue ; } if ( event . args . data . had_recent_input ) { if ( ! ignoreHadRecentInput ) continue ; } else { ignoreHadRecentInput = false ; } layoutShiftEvents . push ( { ts : event . ts , weightedScore : event . args . data . weighted_score_delta , } ) ; } return layoutShiftEvents ; } /**
     * Calculate cumulative layout shift per sesion window where each session
     * windows lasts for 5 seconds since the last layoutShift event and has 1 second
     * gap between them. Return the maximum score between all windows.
     * More details - https://web.dev/evolving-cls/
     */ static calculateScore ( layoutShiftEvents ) { const gapMicroseconds = 1000000 ; const limitMicroseconds = 5000000 ; let maxScore = 0 ; let currentScore = 0 ; let firstTimestamp = Number . NEGATIVE_INFINITY ; let prevTimestamp = Number . NEGATIVE_INFINITY ; for ( const event of layoutShiftEvents ) { const currTimestamp = event . ts ; /**
             * Two cases
             * - When the next layout shift event happened after the session window of
             * 5 seconds.
             * - When the next and previous layout shift event has a gap of 1 second
             *
             * Then consider the layout shift event as the start of
             * the current session window and reset the cumulative score.
             */ if ( currTimestamp - firstTimestamp > limitMicroseconds || currTimestamp - prevTimestamp > gapMicroseconds ) { firstTimestamp = currTimestamp ; currentScore = 0 ; } prevTimestamp = currTimestamp ; currentScore += event . weightedScore ; maxScore = Math . max ( maxScore , currentScore ) ; } return maxScore ; } static compute ( trace ) { const layoutShiftEvents = this . getLayoutShiftEvents ( trace . frameTreeEvents ) ; const traces = layoutShiftEvents . map ( event => { return { name : 'layoutShift' , type : 'mark' , start : { us : event . ts , } , score : event . weightedScore , } ; } ) ; const cumulativeLayoutShiftScore = this . calculateScore ( layoutShiftEvents ) ; return { cls : cumulativeLayoutShiftScore , traces , } ; } } export class Filmstrips { static filterExcesssiveScreenshots ( events ) { const screenshotEvents = events . filter ( evt => evt . name === 'Screenshot' && evt . args ?. snapshot ) ; const screenshotTimestamps = screenshotEvents . map ( event => event . ts ) ; let lastScreenshotTs = - Infinity ; return screenshotEvents . filter ( evt => { const timeSinceLastScreenshot = evt . ts - lastScreenshotTs ; const nextScreenshotTs = screenshotTimestamps . find ( ts => ts > evt . ts ) ; const timeUntilNextScreenshot = nextScreenshotTs ? nextScreenshotTs - evt . ts : Infinity ; const threshold = 500 * 1000 ; const shouldKeep = timeUntilNextScreenshot > threshold || timeSinceLastScreenshot > threshold ; if ( shouldKeep ) lastScreenshotTs = evt . ts ; return shouldKeep ; } ) ; } static compute ( traceEvents ) { return Filmstrips . filterExcesssiveScreenshots ( traceEvents ) . map ( event => ( { blob : event . args . snapshot , mime : 'image/jpeg' , start : { us : event . ts , } , } ) ) ; } }