UNPKG

chrome-devtools-frontend

Version:
437 lines (385 loc) • 15.8 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 * as Protocol from '../../generated/protocol.js'; import * as Handlers from './handlers/handlers.js'; import * as Lantern from './lantern/lantern.js'; import type * as Types from './types/types.js'; type NetworkRequest = Lantern.Types.NetworkRequest<Types.Events.SyntheticNetworkRequest>; function createProcessedNavigation(parsedTrace: Handlers.Types.ParsedTrace, frameId: string, navigationId: string): Lantern.Types.Simulation.ProcessedNavigation { const scoresByNav = parsedTrace.PageLoadMetrics.metricScoresByFrameId.get(frameId); if (!scoresByNav) { throw new Lantern.Core.LanternError('missing metric scores for frame'); } const scores = scoresByNav.get(navigationId); if (!scores) { throw new Lantern.Core.LanternError('missing metric scores for specified navigation'); } const getTimestampOrUndefined = (metric: Handlers.ModelHandlers.PageLoadMetrics.MetricName): Types.Timing.Micro|undefined => { const metricScore = scores.get(metric); if (!metricScore?.event) { return; } return metricScore.event.ts; }; const getTimestamp = (metric: Handlers.ModelHandlers.PageLoadMetrics.MetricName): Types.Timing.Micro => { const metricScore = scores.get(metric); if (!metricScore?.event) { throw new Lantern.Core.LanternError(`missing metric: ${metric}`); } return metricScore.event.ts; }; return { timestamps: { firstContentfulPaint: getTimestamp(Handlers.ModelHandlers.PageLoadMetrics.MetricName.FCP), largestContentfulPaint: getTimestampOrUndefined(Handlers.ModelHandlers.PageLoadMetrics.MetricName.LCP), }, }; } function createParsedUrl(url: URL|string): Lantern.Types.ParsedURL { if (typeof url === 'string') { url = new URL(url); } return { scheme: url.protocol.split(':')[0], // Intentional, DevTools uses different terminology host: url.hostname, securityOrigin: url.origin, }; } /** * Returns a map of `pid` -> `tid[]`. */ function findWorkerThreads(trace: Lantern.Types.Trace): Map<number, number[]> { // TODO: WorkersHandler in Trace Engine needs to be updated to also include `pid` (only had `tid`). const workerThreads = new Map(); const workerCreationEvents = ['ServiceWorker thread', 'DedicatedWorker thread']; for (const event of trace.traceEvents) { if (event.name !== 'thread_name' || !event.args.name) { continue; } if (!workerCreationEvents.includes(event.args.name)) { continue; } const tids = workerThreads.get(event.pid); if (tids) { tids.push(event.tid); } else { workerThreads.set(event.pid, [event.tid]); } } return workerThreads; } function createLanternRequest( parsedTrace: Readonly<Handlers.Types.ParsedTrace>, workerThreads: Map<number, number[]>, request: Types.Events.SyntheticNetworkRequest): NetworkRequest|undefined { if (request.args.data.hasResponse && request.args.data.connectionId === undefined) { throw new Lantern.Core.LanternError('Trace is too old'); } let url; try { url = new URL(request.args.data.url); } catch { return; } const timing = request.args.data.timing ? { // These two timings are not included in the trace. workerFetchStart: -1, workerRespondWithSettled: -1, ...request.args.data.timing, } : undefined; const networkRequestTime = timing ? timing.requestTime * 1000 : request.args.data.syntheticData.downloadStart / 1000; let fromWorker = false; const tids = workerThreads.get(request.pid); if (tids?.includes(request.tid)) { fromWorker = true; } // Trace Engine collects worker thread ids in a different manner than `workerThreads` does. // AFAIK these should be equivalent, but in case they are not let's also check this for now. if (parsedTrace.Workers.workerIdByThread.has(request.tid)) { fromWorker = true; } // `initiator` in the trace does not contain the stack trace for JS-initiated // requests. Instead, that is stored in the `stackTrace` property of the SyntheticNetworkRequest. // There are some minor differences in the fields, accounted for here. // Most importantly, there seems to be fewer frames in the trace than the equivalent // events over the CDP. This results in less accuracy in determining the initiator request, // which means less edges in the graph, which mean worse results. // TODO: Should fix in Chromium. const initiator: Lantern.Types.NetworkRequest['initiator'] = request.args.data.initiator ?? {type: Protocol.Network.InitiatorType.Other}; if (request.args.data.stackTrace) { const callFrames = request.args.data.stackTrace.map(f => { return { scriptId: String(f.scriptId) as Protocol.Runtime.ScriptId, url: f.url, lineNumber: f.lineNumber - 1, columnNumber: f.columnNumber - 1, functionName: f.functionName, }; }); initiator.stack = {callFrames}; // Note: there is no `parent` to set ... } let resourceType = request.args.data.resourceType; if (request.args.data.initiator?.fetchType === 'xmlhttprequest') { // @ts-expect-error yes XHR is a valid ResourceType. TypeScript const enums are so unhelpful. resourceType = 'XHR'; } else if (request.args.data.initiator?.fetchType === 'fetch') { // @ts-expect-error yes Fetch is a valid ResourceType. TypeScript const enums are so unhelpful. resourceType = 'Fetch'; } // TODO: set decodedBodyLength for data urls in Trace Engine. let resourceSize = request.args.data.decodedBodyLength ?? 0; if (url.protocol === 'data:' && resourceSize === 0) { const commaIndex = url.pathname.indexOf(','); if (url.pathname.substring(0, commaIndex).includes(';base64')) { resourceSize = atob(url.pathname.substring(commaIndex + 1)).length; } else { resourceSize = url.pathname.length - commaIndex - 1; } } return { rawRequest: request, requestId: request.args.data.requestId, connectionId: request.args.data.connectionId ?? 0, connectionReused: request.args.data.connectionReused ?? false, url: request.args.data.url, protocol: request.args.data.protocol, parsedURL: createParsedUrl(url), documentURL: request.args.data.requestingFrameUrl, rendererStartTime: request.ts / 1000, networkRequestTime, responseHeadersEndTime: request.args.data.syntheticData.downloadStart / 1000, networkEndTime: request.args.data.syntheticData.finishTime / 1000, transferSize: request.args.data.encodedDataLength, resourceSize, fromDiskCache: request.args.data.syntheticData.isDiskCached, fromMemoryCache: request.args.data.syntheticData.isMemoryCached, isLinkPreload: request.args.data.isLinkPreload, finished: request.args.data.finished, failed: request.args.data.failed, statusCode: request.args.data.statusCode, initiator, timing, resourceType, mimeType: request.args.data.mimeType, priority: request.args.data.priority, frameId: request.args.data.frame, fromWorker, // Set later. redirects: undefined, redirectSource: undefined, redirectDestination: undefined, initiatorRequest: undefined, }; } /** * @param request The request to find the initiator of */ function chooseInitiatorRequest(request: NetworkRequest, requestsByURL: Map<string, NetworkRequest[]>): NetworkRequest| null { if (request.redirectSource) { return request.redirectSource; } const initiatorURL = Lantern.Graph.PageDependencyGraph.getNetworkInitiators(request)[0]; let candidates = requestsByURL.get(initiatorURL) || []; // The (valid) initiator must come before the initiated request. candidates = candidates.filter(c => { return c.responseHeadersEndTime <= request.rendererStartTime && c.finished && !c.failed; }); if (candidates.length > 1) { // Disambiguate based on prefetch. Prefetch requests have type 'Other' and cannot // initiate requests, so we drop them here. const nonPrefetchCandidates = candidates.filter(cand => cand.resourceType !== Lantern.Types.NetworkRequestTypes.Other); if (nonPrefetchCandidates.length) { candidates = nonPrefetchCandidates; } } if (candidates.length > 1) { // Disambiguate based on frame. It's likely that the initiator comes from the same frame. const sameFrameCandidates = candidates.filter(cand => cand.frameId === request.frameId); if (sameFrameCandidates.length) { candidates = sameFrameCandidates; } } if (candidates.length > 1 && request.initiator.type === 'parser') { // Filter to just Documents when initiator type is parser. const documentCandidates = candidates.filter(cand => cand.resourceType === Lantern.Types.NetworkRequestTypes.Document); if (documentCandidates.length) { candidates = documentCandidates; } } if (candidates.length > 1) { // If all real loads came from successful preloads (url preloaded and // loads came from the cache), filter to link rel=preload request(s). const linkPreloadCandidates = candidates.filter(c => c.isLinkPreload); if (linkPreloadCandidates.length) { const nonPreloadCandidates = candidates.filter(c => !c.isLinkPreload); const allPreloaded = nonPreloadCandidates.every(c => c.fromDiskCache || c.fromMemoryCache); if (nonPreloadCandidates.length && allPreloaded) { candidates = linkPreloadCandidates; } } } // Only return an initiator if the result is unambiguous. return candidates.length === 1 ? candidates[0] : null; } function linkInitiators(lanternRequests: NetworkRequest[]): void { const requestsByURL = new Map<string, NetworkRequest[]>(); for (const request of lanternRequests) { const requests = requestsByURL.get(request.url) || []; requests.push(request); requestsByURL.set(request.url, requests); } for (const request of lanternRequests) { const initiatorRequest = chooseInitiatorRequest(request, requestsByURL); if (initiatorRequest) { request.initiatorRequest = initiatorRequest; } } } function createNetworkRequests( trace: Lantern.Types.Trace, parsedTrace: Handlers.Types.ParsedTrace, startTime = 0, endTime = Number.POSITIVE_INFINITY): NetworkRequest[] { const workerThreads = findWorkerThreads(trace); const lanternRequestsNoRedirects: NetworkRequest[] = []; for (const request of parsedTrace.NetworkRequests.byTime) { if (request.ts >= startTime && request.ts < endTime) { const lanternRequest = createLanternRequest(parsedTrace, workerThreads, request); if (lanternRequest) { lanternRequestsNoRedirects.push(lanternRequest); } } } const lanternRequests: NetworkRequest[] = []; // Trace Engine consolidates all redirects into a single request object, but lantern needs // an entry for each redirected request. for (const request of [...lanternRequestsNoRedirects]) { if (!request.rawRequest) { continue; } const redirects = request.rawRequest.args.data.redirects; if (!redirects.length) { lanternRequests.push(request); continue; } const requestChain = []; for (const redirect of redirects) { const redirectedRequest = structuredClone(request); redirectedRequest.networkRequestTime = redirect.ts / 1000; redirectedRequest.rendererStartTime = redirectedRequest.networkRequestTime; redirectedRequest.networkEndTime = (redirect.ts + redirect.dur) / 1000; redirectedRequest.responseHeadersEndTime = redirectedRequest.networkEndTime; redirectedRequest.timing = { requestTime: redirectedRequest.networkRequestTime / 1000, receiveHeadersStart: redirectedRequest.responseHeadersEndTime, receiveHeadersEnd: redirectedRequest.responseHeadersEndTime, proxyStart: -1, proxyEnd: -1, dnsStart: -1, dnsEnd: -1, connectStart: -1, connectEnd: -1, sslStart: -1, sslEnd: -1, sendStart: -1, sendEnd: -1, workerStart: -1, workerReady: -1, workerFetchStart: -1, workerRespondWithSettled: -1, pushStart: -1, pushEnd: -1, }; redirectedRequest.url = redirect.url; redirectedRequest.parsedURL = createParsedUrl(redirect.url); // TODO: Trace Engine is not retaining the actual status code. redirectedRequest.statusCode = 302; redirectedRequest.resourceType = undefined; // TODO: Trace Engine is not retaining transfer size of redirected request. redirectedRequest.transferSize = 400; requestChain.push(redirectedRequest); lanternRequests.push(redirectedRequest); } requestChain.push(request); lanternRequests.push(request); for (let i = 0; i < requestChain.length; i++) { const request = requestChain[i]; if (i > 0) { request.redirectSource = requestChain[i - 1]; request.redirects = requestChain.slice(0, i); } if (i !== requestChain.length - 1) { request.redirectDestination = requestChain[i + 1]; } } // Apply the `:redirect` requestId convention: only redirects[0].requestId is the actual // requestId, all the rest have n occurrences of `:redirect` as a suffix. for (let i = 1; i < requestChain.length; i++) { requestChain[i].requestId = `${requestChain[i - 1].requestId}:redirect`; } } linkInitiators(lanternRequests); return lanternRequests; } function collectMainThreadEvents( trace: Lantern.Types.Trace, parsedTrace: Handlers.Types.ParsedTrace): Lantern.Types.TraceEvent[] { const Meta = parsedTrace.Meta; const mainFramePids = Meta.mainFrameNavigations.length ? new Set(Meta.mainFrameNavigations.map(nav => nav.pid)) : Meta.topLevelRendererIds; const rendererPidToTid = new Map(); for (const pid of mainFramePids) { const threads = Meta.threadsInProcess.get(pid) ?? []; let found = false; for (const [tid, thread] of threads) { if (thread.args.name === 'CrRendererMain') { rendererPidToTid.set(pid, tid); found = true; break; } } if (found) { continue; } // `CrRendererMain` can be missing if chrome is launched with the `--single-process` flag. // In this case, page tasks will be run in the browser thread. for (const [tid, thread] of threads) { if (thread.args.name === 'CrBrowserMain') { rendererPidToTid.set(pid, tid); found = true; break; } } } return trace.traceEvents.filter(e => rendererPidToTid.get(e.pid) === e.tid); } function createGraph( requests: Lantern.Types.NetworkRequest[], trace: Lantern.Types.Trace, parsedTrace: Handlers.Types.ParsedTrace, url?: Lantern.Types.Simulation.URL): Lantern.Graph.Node<Types.Events.SyntheticNetworkRequest> { const mainThreadEvents = collectMainThreadEvents(trace, parsedTrace); // url defines the initial request that the Lantern graph starts at (the root node) and the // main document request. These are equal if there are no redirects. if (!url) { url = { requestedUrl: requests[0].url, mainDocumentUrl: '', }; let request = requests[0]; while (request.redirectDestination) { request = request.redirectDestination; } url.mainDocumentUrl = request.url; } return Lantern.Graph.PageDependencyGraph.createGraph(mainThreadEvents, requests, url); } export { createGraph, createNetworkRequests, createProcessedNavigation, };