// Copyright 2024 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // import * as i18n from '../../../core/i18n/i18n.js'; import * as Helpers from '../helpers/helpers.js'; import * as Types from '../types/types.js'; import { isRequestCompressed } from './Common.js'; import { InsightCategory, InsightKeys, InsightWarning, } from './types.js'; export const UIStrings = { /** * @description Title of an insight that provides a breakdown for how long it took to download the main document. */ title: 'Document request latency', /** * @description Description of an insight that provides a breakdown for how long it took to download the main document. */ description: 'Your first network request is the most important. Reduce its latency by avoiding redirects, ensuring a fast server response, and enabling text compression.', /** * @description Text to tell the user that the document request does not have redirects. */ passingRedirects: 'Avoids redirects', /** * @description Text to tell the user that the document request had redirects. * @example {3} PH1 * @example {1000 ms} PH2 */ failedRedirects: 'Had redirects ({PH1} redirects, +{PH2})', /** * @description Text to tell the user that the time starting the document request to when the server started responding is acceptable. * @example {600 ms} PH1 */ passingServerResponseTime: 'Server responds quickly (observed {PH1})', /** * @description Text to tell the user that the time starting the document request to when the server started responding is not acceptable. * @example {601 ms} PH1 */ failedServerResponseTime: 'Server responded slowly (observed {PH1})', /** * @description Text to tell the user that text compression (like gzip) was applied. */ passingTextCompression: 'Applies text compression', /** * @description Text to tell the user that text compression (like gzip) was not applied. */ failedTextCompression: 'No compression applied', /** * @description Text for a label describing a network request event as having redirects. */ redirectsLabel: 'Redirects', /** * @description Text for a label describing a network request event as taking too long to start delivery by the server. */ serverResponseTimeLabel: 'Server response time', /** * @description Text for a label describing a network request event as taking longer to download because it wasn't compressed. */ uncompressedDownload: 'Uncompressed download', }; // const str_ = i18n.i18n.registerUIStrings('models/trace/insights/DocumentLatency.ts', UIStrings); export const i18nString = (i18nId, values) => ({i18nId, values}); // i18n.i18n.getLocalizedString.bind(undefined, str_); // Due to the way that DevTools throttling works we cannot see if server response took less than ~570ms. // We set our failure threshold to 600ms to avoid those false positives but we want devs to shoot for 100ms. const TOO_SLOW_THRESHOLD_MS = 600; const TARGET_MS = 100; // Threshold for compression savings. const IGNORE_THRESHOLD_IN_BYTES = 1400; export function isDocumentLatency(x) { return x.insightKey === 'DocumentLatency'; } function getServerResponseTime(request, context) { // Prefer the value as given by the Lantern provider. // For PSI, Lighthouse uses this to set a better value for the server response // time. For technical reasons, in Lightrider we do not have `sendEnd` timing // values. See Lighthouse's `asLanternNetworkRequest` function for more. const lanternRequest = context.navigation && context.lantern?.requests.find(r => r.rawRequest === request); if (lanternRequest?.serverResponseTime !== undefined) { return lanternRequest.serverResponseTime; } const timing = request.args.data.timing; if (!timing) { return null; } const ms = Helpers.Timing.microToMilli(request.args.data.syntheticData.waiting); return Math.round(ms); } function getCompressionSavings(request) { const isCompressed = isRequestCompressed(request); if (isCompressed) { return 0; } // We don't know how many bytes this asset used on the network, but we can guess it was // roughly the size of the content gzipped. // See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/optimize-encoding-and-transfer for specific CSS/Script examples // See https://discuss.httparchive.org/t/file-size-and-compression-savings/145 for fallback multipliers // See https://letstalkaboutwebperf.com/en/gzip-brotli-server-config/ for MIME types to compress const originalSize = request.args.data.decodedBodyLength; let estimatedSavings = 0; switch (request.args.data.mimeType) { case 'text/css': // Stylesheets tend to compress extremely well. estimatedSavings = Math.round(originalSize * 0.8); break; case 'text/html': case 'text/javascript': // Scripts and HTML compress fairly well too. estimatedSavings = Math.round(originalSize * 0.67); break; case 'text/plain': case 'text/xml': case 'text/x-component': case 'application/javascript': case 'application/json': case 'application/manifest+json': case 'application/vnd.api+json': case 'application/xml': case 'application/xhtml+xml': case 'application/rss+xml': case 'application/atom+xml': case 'application/vnd.ms-fontobject': case 'application/x-font-ttf': case 'application/x-font-opentype': case 'application/x-font-truetype': case 'image/svg+xml': case 'image/x-icon': case 'image/vnd.microsoft.icon': case 'font/ttf': case 'font/eot': case 'font/otf': case 'font/opentype': // Use the average savings in HTTPArchive. estimatedSavings = Math.round(originalSize * 0.5); break; default: // Any other MIME types are likely already compressed. } // Check if the estimated savings are greater than the byte ignore threshold. // Note that the estimated gzip savings are always more than 10%, so there is // no percent threshold. return estimatedSavings < IGNORE_THRESHOLD_IN_BYTES ? 0 : estimatedSavings; } function finalize(partialModel) { let hasFailure = false; if (partialModel.data) { hasFailure = !partialModel.data.checklist.usesCompression.value || !partialModel.data.checklist.serverResponseIsFast.value || !partialModel.data.checklist.noRedirects.value; } return { insightKey: InsightKeys.DOCUMENT_LATENCY, strings: UIStrings, title: i18nString(UIStrings.title), description: i18nString(UIStrings.description), category: InsightCategory.ALL, state: hasFailure ? 'fail' : 'pass', ...partialModel, }; } export function generateInsight(parsedTrace, context) { if (!context.navigation) { return finalize({}); } const documentRequest = parsedTrace.NetworkRequests.byId.get(context.navigationId); if (!documentRequest) { return finalize({ warnings: [InsightWarning.NO_DOCUMENT_REQUEST] }); } const serverResponseTime = getServerResponseTime(documentRequest, context); if (serverResponseTime === null) { throw new Error('missing document request timing'); } const serverResponseTooSlow = serverResponseTime > TOO_SLOW_THRESHOLD_MS; let overallSavingsMs = 0; if (serverResponseTime > TOO_SLOW_THRESHOLD_MS) { overallSavingsMs = Math.max(serverResponseTime - TARGET_MS, 0); } const redirectDuration = Math.round(documentRequest.args.data.syntheticData.redirectionDuration / 1000); overallSavingsMs += redirectDuration; const metricSavings = { FCP: overallSavingsMs, LCP: overallSavingsMs, }; const uncompressedResponseBytes = getCompressionSavings(documentRequest); const noRedirects = redirectDuration === 0; const serverResponseIsFast = !serverResponseTooSlow; const usesCompression = uncompressedResponseBytes === 0; return finalize({ relatedEvents: [documentRequest], data: { serverResponseTime, redirectDuration: Types.Timing.Milli(redirectDuration), uncompressedResponseBytes, documentRequest, checklist: { noRedirects: { label: noRedirects ? i18nString(UIStrings.passingRedirects) : i18nString(UIStrings.failedRedirects, { PH1: documentRequest.args.data.redirects.length, PH2: (bytes => ({__i18nMillis: bytes}))(redirectDuration), }), value: noRedirects }, serverResponseIsFast: { label: serverResponseIsFast ? i18nString(UIStrings.passingServerResponseTime, { PH1: (bytes => ({__i18nMillis: bytes}))(serverResponseTime) }) : i18nString(UIStrings.failedServerResponseTime, { PH1: (bytes => ({__i18nMillis: bytes}))(serverResponseTime) }), value: serverResponseIsFast }, usesCompression: { label: usesCompression ? i18nString(UIStrings.passingTextCompression) : i18nString(UIStrings.failedTextCompression), value: usesCompression }, }, }, metricSavings, wastedBytes: uncompressedResponseBytes, }); } export function createOverlays(model) { if (!model.data?.documentRequest) { return []; } const overlays = []; const event = model.data.documentRequest; const redirectDurationMicro = Helpers.Timing.milliToMicro(model.data.redirectDuration); const sections = []; if (model.data.redirectDuration) { const bounds = Helpers.Timing.traceWindowFromMicroSeconds(event.ts, (event.ts + redirectDurationMicro)); sections.push({ bounds, label: i18nString(UIStrings.redirectsLabel), showDuration: true }); overlays.push({ type: 'CANDY_STRIPED_TIME_RANGE', bounds, entry: event }); } if (!model.data.checklist.serverResponseIsFast.value) { const serverResponseTimeMicro = Helpers.Timing.milliToMicro(model.data.serverResponseTime); // NOTE: NetworkRequestHandlers never makes a synthetic network request event if `timing` is missing. const sendEnd = event.args.data.timing?.sendEnd ?? Types.Timing.Milli(0); const sendEndMicro = Helpers.Timing.milliToMicro(sendEnd); const bounds = Helpers.Timing.traceWindowFromMicroSeconds(sendEndMicro, (sendEndMicro + serverResponseTimeMicro)); sections.push({ bounds, label: i18nString(UIStrings.serverResponseTimeLabel), showDuration: true }); } if (model.data.uncompressedResponseBytes) { const bounds = Helpers.Timing.traceWindowFromMicroSeconds(event.args.data.syntheticData.downloadStart, (event.args.data.syntheticData.downloadStart + event.args.data.syntheticData.download)); sections.push({ bounds, label: i18nString(UIStrings.uncompressedDownload), showDuration: true }); overlays.push({ type: 'CANDY_STRIPED_TIME_RANGE', bounds, entry: event }); } if (sections.length) { overlays.push({ type: 'TIMESPAN_BREAKDOWN', sections, entry: model.data.documentRequest, // Always render below because the document request is guaranteed to be // the first request in the network track. renderLocation: 'BELOW_EVENT', }); } overlays.push({ type: 'ENTRY_SELECTED', entry: model.data.documentRequest, }); return overlays; } //# sourceMappingURL=DocumentLatency.js.map