UNPKG

chrome-devtools-frontend

Version:
620 lines (537 loc) • 23.4 kB
// 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 type * as Lantern from '../types/types.js'; import {LanternError} from './LanternError.js'; class UrlUtils { /** * There is fancy URL rewriting logic for the chrome://settings page that we need to work around. * Why? Special handling was added by Chrome team to allow a pushState transition between chrome:// pages. * As a result, the network URL (chrome://chrome/settings/) doesn't match the final document URL (chrome://settings/). */ static rewriteChromeInternalUrl(url: string): string { if (!url?.startsWith('chrome://')) { return url; } // Chrome adds a trailing slash to `chrome://` URLs, but the spec does not. // https://github.com/GoogleChrome/lighthouse/pull/3941#discussion_r154026009 if (url.endsWith('/')) { url = url.replace(/\/$/, ''); } return url.replace(/^chrome:\/\/chrome\//, 'chrome://'); } /** * Determine if url1 equals url2, ignoring URL fragments. */ static equalWithExcludedFragments(url1: string, url2: string): boolean { [url1, url2] = [url1, url2].map(this.rewriteChromeInternalUrl); try { const urla = new URL(url1); urla.hash = ''; const urlb = new URL(url2); urlb.hash = ''; return urla.href === urlb.href; } catch { return false; } } } interface Summary { min: number; max: number; avg: number; median: number; } interface RTTEstimateOptions { /** * TCP connection handshake information will be used when available, but in * some circumstances this data can be unreliable. This flag exposes an * option to ignore the handshake data and use the coarse download/TTFB timing data. */ forceCoarseEstimates?: boolean; /** * Coarse estimates include lots of extra time and noise multiply by some factor * to deflate the estimates a bit. */ coarseEstimateMultiplier?: number; /** Useful for testing to isolate the different methods of estimation. */ useDownloadEstimates?: boolean; /** Useful for testing to isolate the different methods of estimation. */ useSendStartEstimates?: boolean; /** Useful for testing to isolate the different methods of estimation. */ useHeadersEndEstimates?: boolean; } interface RequestInfo { request: Lantern.NetworkRequest; timing: Lantern.ResourceTiming; connectionReused?: boolean; } const INITIAL_CWD = 14 * 1024; // Assume that 40% of TTFB was server response time by default for static assets const DEFAULT_SERVER_RESPONSE_PERCENTAGE = 0.4; /** * For certain resource types, server response time takes up a greater percentage of TTFB (dynamic * assets like HTML documents, XHR/API calls, etc) */ const SERVER_RESPONSE_PERCENTAGE_OF_TTFB: Partial<Record<Lantern.ResourceType, number>> = { Document: 0.9, XHR: 0.9, Fetch: 0.9, }; class NetworkAnalyzer { static get summary(): string { return '__SUMMARY__'; } static groupByOrigin(records: Lantern.NetworkRequest[]): Map<string, Lantern.NetworkRequest[]> { const grouped = new Map(); records.forEach(item => { const key = item.parsedURL.securityOrigin; const group = grouped.get(key) || []; group.push(item); grouped.set(key, group); }); return grouped; } static getSummary(values: number[]): Summary { values.sort((a, b) => a - b); let median; if (values.length === 0) { median = values[0]; } else if (values.length % 2 === 0) { const a = values[Math.floor((values.length - 1) / 2)]; const b = values[Math.floor((values.length - 1) / 2) + 1]; median = (a + b) / 2; } else { median = values[Math.floor((values.length - 1) / 2)]; } return { min: values[0], max: values[values.length - 1], avg: values.reduce((a, b) => a + b, 0) / values.length, median, }; } static summarize(values: Map<string, number[]>): Map<string, Summary> { const summaryByKey = new Map(); const allEstimates = []; for (const [key, estimates] of values) { summaryByKey.set(key, NetworkAnalyzer.getSummary(estimates)); allEstimates.push(...estimates); } summaryByKey.set(NetworkAnalyzer.summary, NetworkAnalyzer.getSummary(allEstimates)); return summaryByKey; } static estimateValueByOrigin( requests: Lantern.NetworkRequest[], iteratee: (e: RequestInfo) => number | number[] | undefined): Map<string, number[]> { const connectionWasReused = NetworkAnalyzer.estimateIfConnectionWasReused(requests); const groupedByOrigin = NetworkAnalyzer.groupByOrigin(requests); const estimates = new Map(); for (const [origin, originRequests] of groupedByOrigin.entries()) { let originEstimates: number[] = []; for (const request of originRequests) { const timing = request.timing; if (!timing) { continue; } const value = iteratee({ request, timing, connectionReused: connectionWasReused.get(request.requestId), }); if (typeof value !== 'undefined') { originEstimates = originEstimates.concat(value); } } if (!originEstimates.length) { continue; } estimates.set(origin, originEstimates); } return estimates; } /** * Estimates the observed RTT to each origin based on how long the connection setup. * For h1 and h2, this could includes two estimates - one for the TCP handshake, another for * SSL negotiation. * For h3, we get only one estimate since QUIC establishes a secure connection in a * single handshake. * This is the most accurate and preferred method of measurement when the data is available. */ static estimateRTTViaConnectionTiming(info: RequestInfo): number[]|number|undefined { const {timing, connectionReused, request} = info; if (connectionReused) { return; } const {connectStart, sslStart, sslEnd, connectEnd} = timing; if (connectEnd >= 0 && connectStart >= 0 && request.protocol.startsWith('h3')) { // These values are equal to sslStart and sslEnd for h3. return connectEnd - connectStart; } if (sslStart >= 0 && sslEnd >= 0 && sslStart !== connectStart) { // SSL can also be more than 1 RT but assume False Start was used. return [connectEnd - sslStart, sslStart - connectStart]; } if (connectStart >= 0 && connectEnd >= 0) { return connectEnd - connectStart; } return; } /** * Estimates the observed RTT to each origin based on how long a download took on a fresh connection. * NOTE: this will tend to overestimate the actual RTT quite significantly as the download can be * slow for other reasons as well such as bandwidth constraints. */ static estimateRTTViaDownloadTiming(info: RequestInfo): number|undefined { const {timing, connectionReused, request} = info; if (connectionReused) { return; } // Only look at downloads that went past the initial congestion window if (request.transferSize <= INITIAL_CWD) { return; } if (!Number.isFinite(timing.receiveHeadersEnd) || timing.receiveHeadersEnd < 0) { return; } // Compute the amount of time downloading everything after the first congestion window took const totalTime = request.networkEndTime - request.networkRequestTime; const downloadTimeAfterFirstByte = totalTime - timing.receiveHeadersEnd; const numberOfRoundTrips = Math.log2(request.transferSize / INITIAL_CWD); // Ignore requests that required a high number of round trips since bandwidth starts to play // a larger role than latency if (numberOfRoundTrips > 5) { return; } return downloadTimeAfterFirstByte / numberOfRoundTrips; } /** * Estimates the observed RTT to each origin based on how long it took until Chrome could * start sending the actual request when a new connection was required. * NOTE: this will tend to overestimate the actual RTT as the request can be delayed for other * reasons as well such as more SSL handshakes if TLS False Start is not enabled. */ static estimateRTTViaSendStartTiming(info: RequestInfo): number|undefined { const {timing, connectionReused, request} = info; if (connectionReused) { return; } if (!Number.isFinite(timing.sendStart) || timing.sendStart < 0) { return; } // Assume everything before sendStart was just DNS + (SSL)? + TCP handshake // 1 RT for DNS, 1 RT (maybe) for SSL, 1 RT for TCP let roundTrips = 1; // TCP if (!request.protocol.startsWith('h3')) { roundTrips += 1; } if (request.parsedURL.scheme === 'https') { roundTrips += 1; } return timing.sendStart / roundTrips; } /** * Estimates the observed RTT to each origin based on how long it took until Chrome received the * headers of the response (~TTFB). * NOTE: this is the most inaccurate way to estimate the RTT, but in some environments it's all * we have access to :( */ static estimateRTTViaHeadersEndTiming(info: RequestInfo): number|undefined { const {timing, connectionReused, request} = info; if (!Number.isFinite(timing.receiveHeadersEnd) || timing.receiveHeadersEnd < 0) { return; } if (!request.resourceType) { return; } const serverResponseTimePercentage = SERVER_RESPONSE_PERCENTAGE_OF_TTFB[request.resourceType] || DEFAULT_SERVER_RESPONSE_PERCENTAGE; const estimatedServerResponseTime = timing.receiveHeadersEnd * serverResponseTimePercentage; // When connection was reused... // TTFB = 1 RT for request + server response time let roundTrips = 1; // When connection was fresh... // TTFB = DNS + (SSL)? + TCP handshake + 1 RT for request + server response time if (!connectionReused) { roundTrips += 1; // DNS if (!request.protocol.startsWith('h3')) { roundTrips += 1; // TCP } if (request.parsedURL.scheme === 'https') { roundTrips += 1; // SSL } } // subtract out our estimated server response time return Math.max((timing.receiveHeadersEnd - estimatedServerResponseTime) / roundTrips, 3); } /** * Given the RTT to each origin, estimates the observed server response times. */ static estimateResponseTimeByOrigin(records: Lantern.NetworkRequest[], rttByOrigin: Map<string, number>): Map<string, number[]> { return NetworkAnalyzer.estimateValueByOrigin(records, ({request, timing}) => { if (request.serverResponseTime !== undefined) { return request.serverResponseTime; } if (!Number.isFinite(timing.receiveHeadersEnd) || timing.receiveHeadersEnd < 0) { return; } if (!Number.isFinite(timing.sendEnd) || timing.sendEnd < 0) { return; } const ttfb = timing.receiveHeadersEnd - timing.sendEnd; const origin = request.parsedURL.securityOrigin; const rtt = rttByOrigin.get(origin) || rttByOrigin.get(NetworkAnalyzer.summary) || 0; return Math.max(ttfb - rtt, 0); }); } static canTrustConnectionInformation(requests: Lantern.NetworkRequest[]): boolean { const connectionIdWasStarted = new Map(); for (const request of requests) { const started = connectionIdWasStarted.get(request.connectionId) || !request.connectionReused; connectionIdWasStarted.set(request.connectionId, started); } // We probably can't trust the network information if all the connection IDs were the same if (connectionIdWasStarted.size <= 1) { return false; } // Or if there were connections that were always reused (a connection had to have started at some point) return Array.from(connectionIdWasStarted.values()).every(started => started); } /** * Returns a map of requestId -> connectionReused, estimating the information if the information * available in the records themselves appears untrustworthy. */ static estimateIfConnectionWasReused(records: Lantern.NetworkRequest[], options?: {forceCoarseEstimates: boolean}): Map<string, boolean> { const {forceCoarseEstimates = false} = options || {}; // Check if we can trust the connection information coming from the protocol if (!forceCoarseEstimates && NetworkAnalyzer.canTrustConnectionInformation(records)) { return new Map(records.map(request => [request.requestId, Boolean(request.connectionReused)])); } // Otherwise we're on our own, a request may not have needed a fresh connection if... // - It was not the first request to the domain // - It was H2 // - It was after the first request to the domain ended const connectionWasReused = new Map(); const groupedByOrigin = NetworkAnalyzer.groupByOrigin(records); for (const originRecords of groupedByOrigin.values()) { const earliestReusePossible = originRecords.map(request => request.networkEndTime).reduce((a, b) => Math.min(a, b), Infinity); for (const request of originRecords) { connectionWasReused.set( request.requestId, request.networkRequestTime >= earliestReusePossible || request.protocol === 'h2', ); } const firstRecord = originRecords.reduce((a, b) => { return a.networkRequestTime > b.networkRequestTime ? b : a; }); connectionWasReused.set(firstRecord.requestId, false); } return connectionWasReused; } /** * Estimates the RTT to each origin by examining observed network timing information. * Attempts to use the most accurate information first and falls back to coarser estimates when it * is unavailable. */ static estimateRTTByOrigin(records: Lantern.NetworkRequest[], options?: RTTEstimateOptions): Map<string, Summary> { const { forceCoarseEstimates = false, // coarse estimates include lots of extra time and noise // multiply by some factor to deflate the estimates a bit. coarseEstimateMultiplier = 0.3, useDownloadEstimates = true, useSendStartEstimates = true, useHeadersEndEstimates = true, } = options || {}; const connectionWasReused = NetworkAnalyzer.estimateIfConnectionWasReused(records); const groupedByOrigin = NetworkAnalyzer.groupByOrigin(records); const estimatesByOrigin = new Map(); for (const [origin, originRequests] of groupedByOrigin.entries()) { const originEstimates: number[] = []; function collectEstimates(estimator: (e: RequestInfo) => number[] | number | undefined, multiplier = 1): void { for (const request of originRequests) { const timing = request.timing; if (!timing || !request.transferSize) { continue; } const estimates = estimator({ request, timing, connectionReused: connectionWasReused.get(request.requestId), }); if (estimates === undefined) { continue; } if (!Array.isArray(estimates)) { originEstimates.push(estimates * multiplier); } else { originEstimates.push(...estimates.map(e => e * multiplier)); } } } if (!forceCoarseEstimates) { collectEstimates(this.estimateRTTViaConnectionTiming); } // Connection timing can be missing for a few reasons: // - Origin was preconnected, which we don't have instrumentation for. // - Trace began recording after a connection has already been established (for example, in timespan mode) // - Perhaps Chrome established a connection already in the background (service worker? Just guessing here) // - Not provided in LR netstack. if (!originEstimates.length) { if (useDownloadEstimates) { collectEstimates(this.estimateRTTViaDownloadTiming, coarseEstimateMultiplier); } if (useSendStartEstimates) { collectEstimates(this.estimateRTTViaSendStartTiming, coarseEstimateMultiplier); } if (useHeadersEndEstimates) { collectEstimates(this.estimateRTTViaHeadersEndTiming, coarseEstimateMultiplier); } } if (originEstimates.length) { estimatesByOrigin.set(origin, originEstimates); } } if (!estimatesByOrigin.size) { throw new LanternError('No timing information available'); } return NetworkAnalyzer.summarize(estimatesByOrigin); } /** * Estimates the server response time of each origin. RTT times can be passed in or will be * estimated automatically if not provided. */ static estimateServerResponseTimeByOrigin(records: Lantern.NetworkRequest[], options?: RTTEstimateOptions&{ rttByOrigin?: Map<string, number>, }): Map<string, Summary> { let rttByOrigin = options?.rttByOrigin; if (!rttByOrigin) { rttByOrigin = new Map(); const rttSummaryByOrigin = NetworkAnalyzer.estimateRTTByOrigin(records, options); for (const [origin, summary] of rttSummaryByOrigin.entries()) { rttByOrigin.set(origin, summary.min); } } const estimatesByOrigin = NetworkAnalyzer.estimateResponseTimeByOrigin(records, rttByOrigin); return NetworkAnalyzer.summarize(estimatesByOrigin); } /** * Computes the average throughput for the given requests in bits/second. * Excludes data URI, failed or otherwise incomplete, and cached requests. * Returns null if there were no analyzable network requests. */ static estimateThroughput(records: Lantern.NetworkRequest[]): number|null { let totalBytes = 0; // We will measure throughput by summing the total bytes downloaded by the total time spent // downloading those bytes. We slice up all the network requests into start/end boundaries, so // it's easier to deal with the gaps in downloading. const timeBoundaries = records .reduce( (boundaries, request) => { const scheme = request.parsedURL?.scheme; // Requests whose bodies didn't come over the network or didn't completely finish will mess // with the computation, just skip over them. if (scheme === 'data' || request.failed || !request.finished || request.statusCode > 300 || !request.transferSize) { return boundaries; } // If we've made it this far, all the times we need should be valid (i.e. not undefined/-1). totalBytes += request.transferSize; boundaries.push({time: request.responseHeadersEndTime / 1000, isStart: true}); boundaries.push({time: request.networkEndTime / 1000, isStart: false}); return boundaries; }, [] as Array<{time: number, isStart: boolean}>) .sort((a, b) => a.time - b.time); if (!timeBoundaries.length) { return null; } let inflight = 0; let currentStart = 0; let totalDuration = 0; timeBoundaries.forEach(boundary => { if (boundary.isStart) { if (inflight === 0) { // We just ended a quiet period, keep track of when the download period started currentStart = boundary.time; } inflight++; } else { inflight--; if (inflight === 0) { // We just entered a quiet period, update our duration with the time we spent downloading totalDuration += boundary.time - currentStart; } } }); return totalBytes * 8 / totalDuration; } static computeRTTAndServerResponseTime(records: Lantern.NetworkRequest[]): {rtt: number, additionalRttByOrigin: Map<string, number>, serverResponseTimeByOrigin: Map<string, number>} { // First pass compute the estimated observed RTT to each origin's servers. const rttByOrigin = new Map<string, number>(); for (const [origin, summary] of NetworkAnalyzer.estimateRTTByOrigin(records).entries()) { rttByOrigin.set(origin, summary.min); } // We'll use the minimum RTT as the assumed connection latency since we care about how much addt'l // latency each origin introduces as Lantern will be simulating with its own connection latency. const minimumRtt = Math.min(...Array.from(rttByOrigin.values())); // We'll use the observed RTT information to help estimate the server response time const responseTimeSummaries = NetworkAnalyzer.estimateServerResponseTimeByOrigin(records, { rttByOrigin, }); const additionalRttByOrigin = new Map<string, number>(); const serverResponseTimeByOrigin = new Map<string, number>(); for (const [origin, summary] of responseTimeSummaries.entries()) { // Not all origins have usable timing data, we'll default to using no additional latency. const rttForOrigin = rttByOrigin.get(origin) || minimumRtt; additionalRttByOrigin.set(origin, rttForOrigin - minimumRtt); serverResponseTimeByOrigin.set(origin, summary.median); } return { rtt: minimumRtt, additionalRttByOrigin, serverResponseTimeByOrigin, }; } static analyze(records: Lantern.NetworkRequest[]): Lantern.Simulation.Settings['networkAnalysis']|null { const throughput = NetworkAnalyzer.estimateThroughput(records); if (throughput === null) { return null; } return { throughput, ...NetworkAnalyzer.computeRTTAndServerResponseTime(records), }; } static findResourceForUrl<T extends Lantern.NetworkRequest>(records: T[], resourceUrl: string): T|undefined { // equalWithExcludedFragments is expensive, so check that the resourceUrl starts with the request url first return records.find( request => resourceUrl.startsWith(request.url) && UrlUtils.equalWithExcludedFragments(request.url, resourceUrl), ); } static findLastDocumentForUrl<T extends Lantern.NetworkRequest>(records: T[], resourceUrl: string): T|undefined { // equalWithExcludedFragments is expensive, so check that the resourceUrl starts with the request url first const matchingRequests = records.filter( request => request.resourceType === 'Document' && !request.failed && // Note: `request.url` should never have a fragment, else this optimization gives wrong results. resourceUrl.startsWith(request.url) && UrlUtils.equalWithExcludedFragments(request.url, resourceUrl), ); return matchingRequests[matchingRequests.length - 1]; } /** * Resolves redirect chain given a main document. * See: {@link NetworkAnalyzer.findLastDocumentForUrl} for how to retrieve main document. */ static resolveRedirects<T extends Lantern.NetworkRequest>(request: T): T { while (request.redirectDestination) { request = request.redirectDestination as T; } return request; } } export {NetworkAnalyzer};