UNPKG

chrome-devtools-frontend

Version:
320 lines (288 loc) • 12.4 kB
// Copyright 2024 The Chromium Authors // 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 type * as Handlers from '../handlers/handlers.js'; import * as Helpers from '../helpers/helpers.js'; import * as Types from '../types/types.js'; import {isRequestCompressed} from './Common.js'; import { type Checklist, InsightCategory, InsightKeys, type InsightModel, type InsightSetContext, InsightWarning, type PartialInsightModel, } 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](https://developer.chrome.com/docs/performance/insights/document-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', } as const; const str_ = i18n.i18n.registerUIStrings('models/trace/insights/DocumentLatency.ts', UIStrings); export const i18nString = 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 isDocumentLatencyInsight(x: InsightModel): x is DocumentLatencyInsightModel { return x.insightKey === 'DocumentLatency'; } export type DocumentLatencyInsightModel = InsightModel<typeof UIStrings, { data?: { serverResponseTime: Types.Timing.Milli, redirectDuration: Types.Timing.Milli, uncompressedResponseBytes: number, checklist: Checklist<'noRedirects'|'serverResponseIsFast'|'usesCompression'>, documentRequest?: Types.Events.SyntheticNetworkRequest, }, }>; function getServerResponseTime(request: Types.Events.SyntheticNetworkRequest): Types.Timing.Milli|null { // For technical reasons, Lightrider does not have `sendEnd` timing values. The // closest we can get to the server response time is from a header that Lightrider // sets. // @ts-expect-error const isLightrider = globalThis.isLightrider; if (isLightrider) { return request.args.data.lrServerResponseTime ?? null; } const timing = request.args.data.timing; if (!timing) { return null; } const ms = Helpers.Timing.microToMilli(request.args.data.syntheticData.serverResponseTime); return Math.round(ms) as Types.Timing.Milli; } function getCompressionSavings(request: Types.Events.SyntheticNetworkRequest): number { 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: PartialInsightModel<DocumentLatencyInsightModel>): DocumentLatencyInsightModel { 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), docs: 'https://developer.chrome.com/docs/performance/insights/document-latency', category: InsightCategory.ALL, state: hasFailure ? 'fail' : 'pass', ...partialModel, }; } export function generateInsight( data: Handlers.Types.HandlerData, context: InsightSetContext): DocumentLatencyInsightModel { if (!context.navigation) { return finalize({}); } const millisToString = context.options.insightTimeFormatters?.milli ?? i18n.TimeUtilities.millisToString; const documentRequest = data.NetworkRequests.byId.get(context.navigationId); if (!documentRequest) { return finalize({warnings: [InsightWarning.NO_DOCUMENT_REQUEST]}); } const serverResponseTime = getServerResponseTime(documentRequest); 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) as Types.Timing.Milli; overallSavingsMs += redirectDuration; const metricSavings = { FCP: overallSavingsMs as Types.Timing.Milli, LCP: overallSavingsMs as Types.Timing.Milli, }; 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: millisToString(redirectDuration), }), value: noRedirects }, serverResponseIsFast: { label: serverResponseIsFast ? i18nString(UIStrings.passingServerResponseTime, {PH1: millisToString(serverResponseTime)}) : i18nString(UIStrings.failedServerResponseTime, {PH1: millisToString(serverResponseTime)}), value: serverResponseIsFast }, usesCompression: { label: usesCompression ? i18nString(UIStrings.passingTextCompression) : i18nString(UIStrings.failedTextCompression), value: usesCompression }, }, }, metricSavings, wastedBytes: uncompressedResponseBytes, }); } export function createOverlays(model: DocumentLatencyInsightModel): Types.Overlays.Overlay[] { if (!model.data?.documentRequest) { return []; } const overlays: Types.Overlays.Overlay[] = []; 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) as Types.Timing.Micro, ); 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) as Types.Timing.Micro, ); 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) as Types.Timing.Micro, ); 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; }