UNPKG

@elastic/synthetics

Version:

Elastic synthetic monitoring agent

251 lines 10.1 kB
"use strict"; /** * 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. * */ Object.defineProperty(exports, "__esModule", { value: true }); exports.Filmstrips = exports.CumulativeLayoutShift = exports.ExperienceMetrics = exports.UserTimings = void 0; 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; } // discard all browser level mark events with frame data 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; } } exports.UserTimings = UserTimings; 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)), }; } } exports.ExperienceMetrics = ExperienceMetrics; class CumulativeLayoutShift { static getLayoutShiftEvents(traceEvents) { const layoutShiftEvents = []; // Chromium will set `had_recent_input` if there was recent user input, which // skips shift events from contributing to CLS. This results in the first few shift // events having `had_recent_input` set to true, so ignore it for those events. // See https://crbug.com/1094974 let ignoreHadRecentInput = true; for (const event of traceEvents) { // weighted_score_delta was added in chrome 90 - https://crbug.com/1173139 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; // 1 seconds gap const limitMicroseconds = 5000000; // 5 seconds window 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, }; } } exports.CumulativeLayoutShift = CumulativeLayoutShift; 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; // Throttle to ~2fps // Keep the frame if it's been more than 500ms since the last frame we kept or the next frame won't happen for at least 500ms 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, }, })); } } exports.Filmstrips = Filmstrips; //# sourceMappingURL=trace-metrics.js.map