diff --git a/profiler-cli/README.md b/profiler-cli/README.md index 315f47e9ee..b72eb541de 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -39,7 +39,7 @@ profiler-cli thread samples-top-down # Show top-down call tree (where CPU profiler-cli thread samples-bottom-up # Show bottom-up call tree (what calls hot functions) profiler-cli thread markers # List markers with aggregated statistics [--list for flat per-marker view] profiler-cli thread functions # List all functions with CPU percentages -profiler-cli thread network # Show network requests with timing phases [--search] [--min-duration] [--max-duration] [--limit] +profiler-cli thread network # Show network requests with timing phases [--search] [--min-duration] [--max-duration] [--limit] [--sort] profiler-cli thread page-load # Show page load summary (navigation timing, resources, CPU, jank) profiler-cli marker info # Show detailed marker information (e.g., m-1234) profiler-cli marker stack # Show full stack trace for a marker @@ -86,6 +86,7 @@ profiler-cli thread info --thread t-0 # View info for specific thread witho | `--json` | Output as JSON (for use with `jq`, etc.) | | `--limit ` | Limit number of results shown | | `--max-lines ` | Limit call tree nodes for `samples-top-down`/`samples-bottom-up` (default: 100) | +| `--sort ` | Order `thread network` requests: `duration` (default, slowest first) or `start` (chronological) | | `--scoring ` | Call tree scoring: `exponential-0.95`, `exponential-0.9` (default), `exponential-0.8`, `harmonic-0.1`, `harmonic-0.5`, `harmonic-1.0`, `percentage-only` | | `--navigation ` | Select which navigation to show in `thread page-load` (1-based, default: last completed) | | `--jank-limit ` | Max jank periods to show in `thread page-load` (default: 10, 0 = show all) | diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 65884b75a1..f1fb723d3b 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -31,6 +31,10 @@ CORE WORKFLOW profiler-cli profile info --search GeckoMain Filter by process/thread name, pid, or tid profiler-cli profile logs Print all Log markers in MOZ_LOG format (across all threads) + See the "Network activity" section in "profile info": if requests are in + flight for much of the profile while threads are idle, the bottleneck may be + the network -- follow the m-N handles (zoom push / marker info) to dig in. + Step 3: Select a thread to analyze profiler-cli thread select t-0 Select a specific thread (persists for future commands) @@ -68,7 +72,8 @@ CORE WORKFLOW profiler-cli thread markers --list Flat chronological list (one row per marker) profiler-cli thread markers --search X --list List all individual X markers with handles - profiler-cli thread network First 20 requests with timing phases (default) + profiler-cli thread network Top 20 requests (slowest first) with timing phases + profiler-cli thread network --sort start Chronological order instead of slowest-first profiler-cli thread network --limit 0 All requests (no limit) profiler-cli thread network --search "api.example" Filter by URL substring profiler-cli thread network --min-duration 200 Only requests >= 200ms @@ -320,7 +325,8 @@ COMMON ANALYSIS PATTERNS Marker handles shown in page-load output can be passed to "marker info" or "zoom push". Analyze network activity: - profiler-cli thread network All requests with timing phases + profiler-cli thread network Top requests, slowest first, with timing phases + profiler-cli thread network --sort start Chronological order instead profiler-cli thread network --min-duration 200 Slow requests only (>= 200ms) profiler-cli thread network --search "api" Filter by URL substring profiler-cli thread markers --category Network Cross-reference with marker view @@ -349,10 +355,19 @@ UNDERSTANDING THE OUTPUT samples-bottom-up Hot leaf functions at top with their callers. Use to understand what calls a frequently-seen function. + Network summary ("thread network"): + "In flight" is the wall-clock signal: time at least one request was open + (overlaps counted once) plus peak concurrency. "Phase totals" are summed + across concurrent requests, so they can exceed the wall clock -- judge + network stall by "In flight", not the phase sum. + TIPS - Start with "profile info"; GeckoMain is usually the main thread -- use "--search GeckoMain" to find it quickly + - CPU time is not the only signal. A profile where threads are mostly idle but + network is in flight for most of the duration is a network-bound profile -- + report the slow requests, not the hottest function. - Idle time alone is not a finding: only investigate idle during a period when the thread should be busy (e.g. inside a zoom on a jank marker), which may indicate lock contention or blocking on another thread diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index c39c05eab3..c495ebd243 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -23,9 +23,27 @@ profiler-cli profile info --json counters?: [CounterSummary] }], remainingProcesses?: { count, combinedCpuMs, maxCpuMs }, + networkActivity: ProfileNetworkSummary | null, context: SessionContext } +NetworkSummaryRequest: + { + markerHandle, threadHandle, url, + durationMs, startTime, + transferSizeKB?, httpStatus?, status, + incomplete, startedBeforeRecording + } + +ProfileNetworkSummary: + { + requestCount, incompleteCount, + inFlightMs, inFlightPercentage, + peakConcurrency, errorCount, rangeDurationMs, + slowest: [NetworkSummaryRequest], + byThread: [{ threadHandle, threadName, requestCount, inFlightMs }] + } + CounterSummary: { counterHandle, counterIndex, name, label, category, @@ -67,9 +85,19 @@ profiler-cli thread info --json sampleCount, markerCount, cpuActivity: [{ startTime, startTimeName, startTimeStr, endTime, endTimeName, endTimeStr, cpuMs, depthLevel }] | null, + networkActivity: ThreadNetworkSummary | null, context: SessionContext } +ThreadNetworkSummary: + { + threadHandle, threadName, + requestCount, incompleteCount, + inFlightMs, inFlightPercentage, peakConcurrency, + errorCount, cacheHit, cacheMiss, cacheUnknown, rangeDurationMs, + slowest: [NetworkSummaryRequest] + } + profiler-cli thread samples --json { type: "thread-samples", @@ -150,15 +178,19 @@ profiler-cli thread network --json type: "thread-network", threadHandle, friendlyThreadName, totalRequestCount, + incompleteCount, filteredRequestCount, + sort: "duration" | "start", filters?: { searchString?, minDuration?, maxDuration?, limit? }, summary: { cacheHit, cacheMiss, cacheUnknown, + inFlightMs, inFlightPercentage, peakConcurrency, rangeDurationMs, phaseTotals: { dns?, tcp?, tls?, ttfb?, download?, mainThread? } }, requests: [{ markerHandle, url, httpStatus?, httpVersion?, cacheStatus?, - transferSizeKB?, startTime, duration, + transferSizeKB?, startTime, duration, status, + incomplete, startedBeforeRecording, phases: { dns?, tcp?, tls?, ttfb?, download?, mainThread? } }], context: SessionContext diff --git a/profiler-cli/src/commands/thread.ts b/profiler-cli/src/commands/thread.ts index 6793a455f2..0bbf432e31 100644 --- a/profiler-cli/src/commands/thread.ts +++ b/profiler-cli/src/commands/thread.ts @@ -304,17 +304,29 @@ export function registerThreadCommand( 'Filter by maximum total request duration in milliseconds' ) .option('--limit ', 'Max requests to show (default: 20, 0 = show all)') + .option( + '--sort ', + 'Sort requests by "duration" (default, slowest first) or "start" (chronological)' + ) ).action(async (opts) => { const networkFilters: { searchString?: string; minDuration?: number; maxDuration?: number; limit?: number; + sort?: 'start' | 'duration'; } = {}; if (opts.search !== undefined) { networkFilters.searchString = opts.search; } + if (opts.sort !== undefined) { + if (opts.sort !== 'start' && opts.sort !== 'duration') { + console.error('Error: --sort must be "start" or "duration"'); + process.exit(1); + } + networkFilters.sort = opts.sort; + } if (opts.minDuration !== undefined) { networkFilters.minDuration = parseFloatArg( '--min-duration', diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 45e702c79c..13f259f638 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -28,6 +28,9 @@ import type { ThreadNetworkResult, ThreadPageLoadResult, NetworkPhaseTimings, + NetworkSummaryRequest, + ThreadNetworkSummary, + ProfileNetworkSummary, MarkerGroupData, CallTreeNode, InlineStatus, @@ -280,6 +283,11 @@ CPU activity over time:`; output += '\nNo significant activity.'; } + if (result.networkActivity) { + output += + '\n\n' + formatThreadNetworkActivity(result.networkActivity).join('\n'); + } + return output; } @@ -455,6 +463,13 @@ Name: ${result.name}\n`; output += 'No significant activity.\n'; } + if (result.networkActivity) { + output += + '\n' + + formatProfileNetworkActivity(result.networkActivity).join('\n') + + '\n'; + } + return output; } @@ -1221,6 +1236,172 @@ export function formatThreadFunctionsResult( return lines.join('\n'); } +const STARTED_BEFORE_SUFFIX = ' (started before recording)'; +const IN_FLIGHT_SUFFIX = ' (in flight at end)'; +const REDIRECT_SUFFIX = ' (redirect)'; +const CANCELED_SUFFIX = ' (canceled)'; + +/** + * Status-derived suffix for a summary request. STATUS_STOP is the normal + * completed request (no suffix); in-flight is handled separately via + * `incomplete`. Redirect / cancel legs are surfaced but flagged so they aren't + * mistaken for slow completed requests. + */ +function networkStatusSuffix(request: NetworkSummaryRequest): string { + switch (request.status) { + case 'STATUS_REDIRECT': + return REDIRECT_SUFFIX; + case 'STATUS_CANCEL': + return CANCELED_SUFFIX; + default: + return ''; + } +} + +/** + * Middle-truncate a URL keeping the host (which carries the signal) and the + * tail of the path (bare filenames like "channel" are useless on their own). + */ +function truncateNetworkUrl(url: string, maxLen: number = 70): string { + if (url.length <= maxLen) { + return url; + } + let host = ''; + let rest = url; + try { + const parsed = new URL(url); + host = parsed.host; + rest = parsed.pathname + parsed.search; + } catch { + // Not a parseable URL; fall through to a raw middle-truncation. + } + if (host === '') { + const keep = Math.max(0, maxLen - 3); + const head = Math.ceil(keep / 2); + const tail = Math.floor(keep / 2); + return url.slice(0, head) + '...' + url.slice(url.length - tail); + } + const budget = maxLen - host.length; + if (budget <= 4) { + return host.length > maxLen ? host.slice(0, maxLen - 3) + '...' : host; + } + if (rest.length <= budget) { + return host + rest; + } + const keep = budget - 3; + const head = Math.ceil(keep / 2); + const tail = Math.floor(keep / 2); + return host + rest.slice(0, head) + '...' + rest.slice(rest.length - tail); +} + +/** + * Render a thread-scoped network-activity section (used by `thread info`). + * Returns the lines; the caller joins and places them. + */ +function formatThreadNetworkActivity(summary: ThreadNetworkSummary): string[] { + const lines: string[] = []; + const incompletePart = + summary.incompleteCount > 0 + ? `, ${summary.incompleteCount} still in flight at end of recording` + : ''; + lines.push( + `Network activity: ${summary.requestCount} completed requests${incompletePart}` + ); + + const pct = Math.round(summary.inFlightPercentage); + const cacheParts: string[] = []; + if (summary.cacheHit > 0) { + cacheParts.push(`${summary.cacheHit} hit`); + } + if (summary.cacheMiss > 0) { + cacheParts.push(`${summary.cacheMiss} miss`); + } + if (summary.cacheUnknown > 0) { + cacheParts.push(`${summary.cacheUnknown} unknown`); + } + const cacheStr = + cacheParts.length > 0 ? `; cache: ${cacheParts.join(' / ')}` : ''; + lines.push( + ` In flight ${pct}% of the thread's range (${formatDuration(summary.inFlightMs)} of ${formatDuration(summary.rangeDurationMs)}), peak ${summary.peakConcurrency} concurrent${cacheStr}; ${summary.errorCount} failed` + ); + + if (summary.slowest.length > 0) { + lines.push(' Slowest requests:'); + for (const request of summary.slowest) { + const size = + request.transferSizeKB !== undefined + ? `${request.transferSizeKB.toFixed(1)}KB` + : ''; + const suffix = + (request.incomplete ? IN_FLIGHT_SUFFIX : '') + + networkStatusSuffix(request) + + (request.startedBeforeRecording ? STARTED_BEFORE_SUFFIX : ''); + const parts = [ + ` ${request.markerHandle}`, + formatDuration(request.durationMs), + truncateNetworkUrl(request.url, 60), + size, + ].filter((part) => part !== ''); + lines.push(parts.join(' ') + suffix); + } + } + return lines; +} + +/** + * Render a profile-wide network-activity section (used by `profile info`). + * Returns the lines; the caller joins and places them. + */ +function formatProfileNetworkActivity( + summary: ProfileNetworkSummary +): string[] { + const lines: string[] = []; + const incompletePart = + summary.incompleteCount > 0 + ? `, ${summary.incompleteCount} still in flight at end of recording` + : ''; + lines.push( + `Network activity: ${summary.requestCount} completed requests (deduped across processes)${incompletePart}` + ); + + const pct = Math.round(summary.inFlightPercentage); + lines.push( + ` In flight ${pct}% of the profile (${formatDuration(summary.inFlightMs)} of ${formatDuration(summary.rangeDurationMs)}), peak ${summary.peakConcurrency} concurrent; ${summary.errorCount} failed` + ); + + if (summary.slowest.length > 0) { + lines.push(' Slowest requests:'); + for (const request of summary.slowest) { + const size = + request.transferSizeKB !== undefined + ? `${request.transferSizeKB.toFixed(1)}KB` + : ''; + const suffix = + (request.incomplete ? IN_FLIGHT_SUFFIX : '') + + networkStatusSuffix(request) + + (request.startedBeforeRecording ? STARTED_BEFORE_SUFFIX : ''); + const parts = [ + ` ${request.markerHandle}`, + formatDuration(request.durationMs), + truncateNetworkUrl(request.url, 60), + `[${request.threadHandle}]`, + size, + ].filter((part) => part !== ''); + lines.push(parts.join(' ') + suffix); + } + } + + if (summary.byThread.length > 0) { + const parts = summary.byThread.map( + (thread) => + `${thread.threadHandle} ${thread.threadName} (${thread.requestCount} reqs, ${formatDuration(thread.inFlightMs)} in flight)` + ); + lines.push(` By thread: ${parts.join(', ')}`); + } + + return lines; +} + function formatNetworkPhases(phases: NetworkPhaseTimings): string { const parts: string[] = []; if (phases.dns !== undefined) { @@ -1249,25 +1430,39 @@ export function formatThreadNetworkResult( ): string { const lines: string[] = [formatContextHeader(result.context), '']; + // totalRequestCount counts only completed requests; the candidate set the + // filters run against also includes the incomplete (in-flight) ones. + const totalCandidates = result.totalRequestCount + result.incompleteCount; const filterSuffix = result.filters !== undefined && - result.filteredRequestCount !== result.totalRequestCount - ? ` (filtered from ${result.totalRequestCount})` + result.filteredRequestCount !== totalCandidates + ? ` (filtered from ${totalCandidates})` : ''; const truncated = result.requests.length < result.filteredRequestCount; + const sortLabel = + result.sort === 'duration' ? 'slowest first' : 'chronological'; const countStr = truncated - ? `${result.requests.length} of ${result.filteredRequestCount} requests` - : `${result.filteredRequestCount} requests`; + ? `${result.requests.length} of ${result.filteredRequestCount} requests (${sortLabel})` + : `${result.filteredRequestCount} requests (${sortLabel})`; lines.push( `Network requests in thread ${result.threadHandle} (${result.friendlyThreadName}) — ${countStr}${filterSuffix}` ); + if (result.incompleteCount > 0) { + lines.push( + `${result.incompleteCount} request(s) did not complete during the recording (in flight at end).` + ); + } lines.push(''); // Summary const s = result.summary; lines.push('Summary:'); + // Lead with the wall-clock metric + lines.push( + ` In flight: ${formatDuration(s.inFlightMs)} of ${formatDuration(s.rangeDurationMs)} (${Math.round(s.inFlightPercentage)}%), peak ${s.peakConcurrency} concurrent` + ); lines.push( ` Cache: ${s.cacheHit} hit, ${s.cacheMiss} miss, ${s.cacheUnknown} unknown` ); @@ -1282,7 +1477,7 @@ export function formatThreadNetworkResult( pt.mainThread !== undefined; if (hasPhaseTotals) { - lines.push(' Phase totals:'); + lines.push(' Phase totals (summed across concurrent requests):'); if (pt.dns !== undefined) { lines.push(` DNS: ${formatDuration(pt.dns)}`); } @@ -1312,8 +1507,21 @@ export function formatThreadNetworkResult( for (const req of result.requests) { const url = req.url.length > 100 ? req.url.slice(0, 97) + '...' : req.url; - const status = - req.httpStatus !== undefined ? String(req.httpStatus) : '???'; + let status: string; + if (req.incomplete) { + status = 'in flight'; + } else if (req.status === 'STATUS_REDIRECT') { + status = 'redirect'; + } else if (req.status === 'STATUS_CANCEL') { + status = 'canceled'; + } else if (req.httpStatus !== undefined) { + status = String(req.httpStatus); + } else { + status = '???'; + } + if (req.startedBeforeRecording) { + status += ' (started before recording)'; + } const version = req.httpVersion !== undefined ? ` ${req.httpVersion}` : ''; const cache = req.cacheStatus !== undefined ? ` cache=${req.cacheStatus}` : ''; @@ -1337,11 +1545,11 @@ export function formatThreadNetworkResult( if (truncated) { lines.push( - `Use --limit 0 to show all requests, or --limit to set a different limit.` + `Use --limit 0 to show all requests, --limit for a different window, or --sort start for chronological order.` ); } else { lines.push( - 'Use --search , --min-duration , --max-duration , or --limit to filter.' + 'Use --search , --min-duration , --max-duration , --limit , or --sort start|duration to filter and order.' ); } diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 3e817f9b43..e4f8adec54 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -33,7 +33,12 @@ export type { ThreadMarkersResult, ThreadNetworkResult, NetworkRequestEntry, + NetworkRequestSort, NetworkPhaseTimings, + NetworkSummaryRequest, + ThreadNetworkSummary, + ProfileNetworkSummary, + ProfileNetworkThreadBreakdown, ThreadFunctionsResult, ThreadPageLoadResult, NavigationMilestone, @@ -77,6 +82,7 @@ import type { ThreadSamplesBottomUpResult, ThreadMarkersResult, ThreadNetworkResult, + NetworkRequestSort, ThreadFunctionsResult, ThreadPageLoadResult, FilterStackResult, @@ -133,6 +139,7 @@ export type ClientCommand = minDuration?: number; maxDuration?: number; limit?: number; + sort?: NetworkRequestSort; }; pageLoadOptions?: { navigationIndex?: number; diff --git a/profiler-cli/src/test/unit/network-formatting.test.ts b/profiler-cli/src/test/unit/network-formatting.test.ts index 6ddd9e0fe7..346680439e 100644 --- a/profiler-cli/src/test/unit/network-formatting.test.ts +++ b/profiler-cli/src/test/unit/network-formatting.test.ts @@ -2,9 +2,15 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { formatThreadNetworkResult } from '../../formatters'; +import { + formatThreadNetworkResult, + formatThreadInfoResult, +} from '../../formatters'; import type { ThreadNetworkResult, + ThreadInfoResult, + ThreadNetworkSummary, + NetworkSummaryRequest, NetworkRequestEntry, NetworkPhaseTimings, SessionContext, @@ -28,11 +34,30 @@ function makeRequest( url: 'https://example.com/resource', startTime: 0, duration: 100, + status: 'STATUS_STOP', + incomplete: false, + startedBeforeRecording: false, phases: {}, ...overrides, }; } +function makeSummary( + overrides: Partial = {} +): ThreadNetworkResult['summary'] { + return { + cacheHit: 0, + cacheMiss: 0, + cacheUnknown: 1, + inFlightMs: 100, + inFlightPercentage: 10, + peakConcurrency: 1, + rangeDurationMs: 1000, + phaseTotals: {}, + ...overrides, + }; +} + function makeResult( overrides: Partial = {} ): WithContext { @@ -42,13 +67,10 @@ function makeResult( threadHandle: 't-0', friendlyThreadName: 'GeckoMain', totalRequestCount: 1, + incompleteCount: 0, filteredRequestCount: 1, - summary: { - cacheHit: 0, - cacheMiss: 0, - cacheUnknown: 1, - phaseTotals: {}, - }, + sort: 'duration', + summary: makeSummary(), requests: [makeRequest()], ...overrides, }; @@ -137,12 +159,7 @@ describe('formatThreadNetworkResult', function () { it('shows cache summary counts', function () { const result = makeResult({ - summary: { - cacheHit: 4, - cacheMiss: 2, - cacheUnknown: 1, - phaseTotals: {}, - }, + summary: makeSummary({ cacheHit: 4, cacheMiss: 2, cacheUnknown: 1 }), }); const output = formatThreadNetworkResult(result); @@ -152,10 +169,27 @@ describe('formatThreadNetworkResult', function () { expect(output).toContain('1 unknown'); }); + it('leads the summary with the wall-clock in-flight metric', function () { + const result = makeResult({ + summary: makeSummary({ + inFlightMs: 870, + inFlightPercentage: 87, + peakConcurrency: 14, + rangeDurationMs: 1000, + }), + }); + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('In flight'); + expect(output).toContain('87%'); + expect(output).toContain('peak 14 concurrent'); + }); + it('shows phase totals section when any phase total is present', function () { const phaseTotals: NetworkPhaseTimings = { ttfb: 50, download: 30 }; const result = makeResult({ - summary: { cacheHit: 0, cacheMiss: 1, cacheUnknown: 0, phaseTotals }, + summary: makeSummary({ cacheMiss: 1, cacheUnknown: 0, phaseTotals }), }); const output = formatThreadNetworkResult(result); @@ -165,9 +199,19 @@ describe('formatThreadNetworkResult', function () { expect(output).toContain('Download'); }); + it('labels phase totals as summed across concurrent requests', function () { + const result = makeResult({ + summary: makeSummary({ phaseTotals: { download: 30 } }), + }); + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('summed across concurrent requests'); + }); + it('omits phase totals section when no phases are present', function () { const result = makeResult({ - summary: { cacheHit: 1, cacheMiss: 0, cacheUnknown: 0, phaseTotals: {} }, + summary: makeSummary({ cacheHit: 1, cacheUnknown: 0 }), }); const output = formatThreadNetworkResult(result); @@ -264,4 +308,166 @@ describe('formatThreadNetworkResult', function () { expect(output).toContain('m-42'); }); + + it('reports incomplete requests', function () { + const result = makeResult({ incompleteCount: 2 }); + result.requests = [makeRequest({ incomplete: true })]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('did not complete during the recording'); + expect(output).toContain('in flight'); + }); + + it('annotates requests that started before the recording', function () { + const result = makeResult(); + result.requests = [ + makeRequest({ httpStatus: 200, startedBeforeRecording: true }), + ]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('started before recording'); + // Shows the real status, not "in flight". + expect(output).not.toContain('in flight'); + expect(output).toContain('200'); + }); + + it('reflects the sort order in the header', function () { + const durationResult = makeResult({ sort: 'duration' }); + expect(formatThreadNetworkResult(durationResult)).toContain( + 'slowest first' + ); + + const startResult = makeResult({ sort: 'start' }); + expect(formatThreadNetworkResult(startResult)).toContain('chronological'); + }); + + it('labels a redirect leg instead of showing "???"', function () { + const result = makeResult(); + result.requests = [makeRequest({ status: 'STATUS_REDIRECT' })]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('redirect'); + expect(output).not.toContain('???'); + }); + + it('labels a canceled leg instead of showing "???"', function () { + const result = makeResult(); + result.requests = [makeRequest({ status: 'STATUS_CANCEL' })]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('canceled'); + expect(output).not.toContain('???'); + }); +}); + +function makeSummaryRequest( + overrides: Partial = {} +): NetworkSummaryRequest { + return { + markerHandle: 'm-1', + threadHandle: 't-0', + url: 'https://example.com/resource', + durationMs: 100, + startTime: 0, + status: 'STATUS_STOP', + incomplete: false, + startedBeforeRecording: false, + ...overrides, + }; +} + +function makeThreadInfoResult( + networkActivity: ThreadNetworkSummary | null +): WithContext { + return { + context: createContext(), + type: 'thread-info', + threadHandle: 't-0', + name: 'GeckoMain', + friendlyName: 'GeckoMain', + tid: 1, + createdAt: 0, + createdAtName: 'start', + endedAt: null, + endedAtName: null, + sampleCount: 0, + markerCount: 0, + cpuActivity: null, + networkActivity, + }; +} + +function makeThreadNetworkSummary( + slowest: NetworkSummaryRequest[] +): ThreadNetworkSummary { + return { + threadHandle: 't-0', + threadName: 'GeckoMain', + requestCount: slowest.filter((r) => !r.incomplete).length, + incompleteCount: slowest.filter((r) => r.incomplete).length, + inFlightMs: 100, + inFlightPercentage: 10, + peakConcurrency: 1, + errorCount: 0, + cacheHit: 0, + cacheMiss: 0, + cacheUnknown: slowest.length, + rangeDurationMs: 1000, + slowest, + }; +} + +describe('formatThreadInfoResult network activity', function () { + it('annotates an in-flight request in the slowest list', function () { + const summary = makeThreadNetworkSummary([ + makeSummaryRequest({ incomplete: true, durationMs: 999 }), + ]); + + const output = formatThreadInfoResult(makeThreadInfoResult(summary)); + + expect(output).toContain('Slowest requests:'); + expect(output).toContain('(in flight at end)'); + }); + + it('does not annotate a completed request as in flight', function () { + const summary = makeThreadNetworkSummary([makeSummaryRequest()]); + + const output = formatThreadInfoResult(makeThreadInfoResult(summary)); + + expect(output).toContain('Slowest requests:'); + expect(output).not.toContain('(in flight at end)'); + }); + + it('labels a redirect leg in the slowest list', function () { + const summary = makeThreadNetworkSummary([ + makeSummaryRequest({ status: 'STATUS_REDIRECT' }), + ]); + + const output = formatThreadInfoResult(makeThreadInfoResult(summary)); + + expect(output).toContain('(redirect)'); + }); + + it('labels a canceled leg in the slowest list', function () { + const summary = makeThreadNetworkSummary([ + makeSummaryRequest({ status: 'STATUS_CANCEL' }), + ]); + + const output = formatThreadInfoResult(makeThreadInfoResult(summary)); + + expect(output).toContain('(canceled)'); + }); + + it('does not label a completed request with a status suffix', function () { + const summary = makeThreadNetworkSummary([makeSummaryRequest()]); + + const output = formatThreadInfoResult(makeThreadInfoResult(summary)); + + expect(output).not.toContain('(redirect)'); + expect(output).not.toContain('(canceled)'); + }); }); diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts index df1b41a339..530c1fb164 100644 --- a/src/profile-query/formatters/marker-info.ts +++ b/src/profile-query/formatters/marker-info.ts @@ -8,7 +8,16 @@ import { getCategories, getMarkerSchemaByName, getStringTable, + getCommittedRange, } from 'firefox-profiler/selectors/profile'; +import { + intervalUnionMs, + peakConcurrency, + classifyCache, + gatherNetworkRecords, + clampInterval, + clampedDurationMs, +} from '../network-summary'; import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { formatFromMarkerSchema, @@ -35,6 +44,7 @@ import type { ThreadMarkersResult, ThreadNetworkResult, NetworkRequestEntry, + NetworkRequestSort, NetworkPhaseTimings, MarkerGroupData, DurationStats, @@ -44,7 +54,6 @@ import type { ProfileLogsResult, } from '../types'; import { - isNetworkMarker, LOG_LEVEL_STRING_TO_LETTER, LOG_LETTER_TO_LEVEL, formatLogTimestamp, @@ -1143,9 +1152,11 @@ export function collectThreadNetwork( minDuration?: number; maxDuration?: number; limit?: number; + sort?: NetworkRequestSort; } = {} ): ThreadNetworkResult { const { searchString, minDuration, maxDuration, limit } = filterOptions; + const sort: NetworkRequestSort = filterOptions.sort ?? 'duration'; const state = store.getState(); const threadIndexes = @@ -1155,36 +1166,53 @@ export function collectThreadNetwork( const threadSelectors = getThreadSelectors(threadIndexes); const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); - const fullMarkerList = threadSelectors.getFullMarkerList(state); - const allMarkerIndexes = threadSelectors.getFullMarkerListIndexes(state); - - // Filter to completed (STOP) network markers only. - // STOP markers are the merged markers that carry full timing data. - const stopIndexes = allMarkerIndexes.filter((i) => { - const m = fullMarkerList[i]; - if (!isNetworkMarker(m)) { - return false; + const range = getCommittedRange(state); + const rangeDurationMs = range.end - range.start; + + // Gather the derived network records through the shared helper, so this + // listing and the `profile info` / `thread info` summaries agree on what a + // request is: all legs intersecting the range, including redirect / cancel. + const [representativeIndex] = threadIndexes; + const records = gatherNetworkRecords( + state, + representativeIndex, + threadIndexes, + range + ); + + // Completed = STATUS_STOP; in flight = STATUS_START (no stop event). Redirect + // and cancel legs are listed but counted as neither; the formatter flags + // them so they aren't read as completed requests. + let totalRequestCount = 0; + let incompleteCount = 0; + for (const record of records) { + if (record.incomplete) { + incompleteCount++; + } else if (record.data.status === 'STATUS_STOP') { + totalRequestCount++; } - const data = m.data as NetworkPayload; - return data.status === 'STATUS_STOP'; - }); - const totalRequestCount = stopIndexes.length; + } + + // Wall-clock: interval union and peak concurrency over all records + // intersecting the range, independent of the display filters below. + const inFlightIntervals = records.map((record) => + clampInterval(record, range) + ); + const inFlightMs = intervalUnionMs(inFlightIntervals); // Apply filters - let filteredIndexes = stopIndexes; + let filteredRecords = records; if (searchString) { const lowerSearch = searchString.toLowerCase(); - filteredIndexes = filteredIndexes.filter((i) => { - const data = fullMarkerList[i].data as NetworkPayload; - return data.URI.toLowerCase().includes(lowerSearch); - }); + filteredRecords = filteredRecords.filter((record) => + record.data.URI.toLowerCase().includes(lowerSearch) + ); } if (minDuration !== undefined || maxDuration !== undefined) { - filteredIndexes = filteredIndexes.filter((i) => { - const data = fullMarkerList[i].data as NetworkPayload; - const duration = data.endTime - data.startTime; + filteredRecords = filteredRecords.filter((record) => { + const duration = clampedDurationMs(record, range); if (minDuration !== undefined && duration < minDuration) { return false; } @@ -1195,7 +1223,7 @@ export function collectThreadNetwork( }); } - const filteredRequestCount = filteredIndexes.length; + const filteredRequestCount = filteredRecords.length; // Accumulate summary stats across all filtered requests (before limit) const phaseTotals: NetworkPhaseTimings = {}; @@ -1203,20 +1231,17 @@ export function collectThreadNetwork( let cacheMiss = 0; let cacheUnknown = 0; - for (const i of filteredIndexes) { - const data = fullMarkerList[i].data as NetworkPayload; - const cache = data.cache; - if (cache === 'Hit' || cache === 'MemoryHit' || cache === 'Prefetched') { - cacheHit++; - } else if ( - cache === 'Miss' || - cache === 'Unresolved' || - cache === 'DiskStorage' || - cache === 'Push' - ) { - cacheMiss++; - } else { - cacheUnknown++; + for (const record of filteredRecords) { + const data = record.data; + switch (classifyCache(data.cache)) { + case 'hit': + cacheHit++; + break; + case 'miss': + cacheMiss++; + break; + default: + cacheUnknown++; } const phases = buildNetworkPhases(data); @@ -1241,27 +1266,44 @@ export function collectThreadNetwork( } } - // Apply limit after accumulating summary stats. + // Sort before applying the limit so a limited window shows the intended + // requests (slowest by default; chronological with --sort start). + const sortedRecords = filteredRecords.slice(); + if (sort === 'duration') { + sortedRecords.sort( + (a, b) => clampedDurationMs(b, range) - clampedDurationMs(a, range) + ); + } else { + sortedRecords.sort((a, b) => a.start - b.start); + } + + // Apply limit after accumulating summary stats and sorting. // limit === 0 means "show all" (no limit). - const limitedIndexes = + const limitedRecords = limit !== undefined && limit > 0 - ? filteredIndexes.slice(0, limit) - : filteredIndexes; + ? sortedRecords.slice(0, limit) + : sortedRecords; // Build per-request entries - const requests: NetworkRequestEntry[] = limitedIndexes.map((i) => { - const data = fullMarkerList[i].data as NetworkPayload; - const duration = data.endTime - data.startTime; + const requests: NetworkRequestEntry[] = limitedRecords.map((record) => { + const data = record.data; + const duration = clampedDurationMs(record, range); return { - markerHandle: markerMap.handleForMarker(threadIndexes, i), + markerHandle: markerMap.handleForMarker( + record.threadIndexes, + record.markerIndex + ), url: data.URI, httpStatus: data.responseStatus, httpVersion: data.httpVersion, cacheStatus: data.cache, transferSizeKB: data.count !== undefined ? data.count / 1024 : undefined, - startTime: data.startTime, + startTime: record.start, duration, + status: data.status, + incomplete: record.incomplete, + startedBeforeRecording: record.startedBeforeRecording, phases: buildNetworkPhases(data), }; }); @@ -1274,7 +1316,9 @@ export function collectThreadNetwork( threadHandle: displayThreadHandle, friendlyThreadName, totalRequestCount, + incompleteCount, filteredRequestCount, + sort, filters: searchString !== undefined || minDuration !== undefined || @@ -1286,6 +1330,11 @@ export function collectThreadNetwork( cacheHit, cacheMiss, cacheUnknown, + inFlightMs, + inFlightPercentage: + rangeDurationMs > 0 ? (inFlightMs / rangeDurationMs) * 100 : 0, + peakConcurrency: peakConcurrency(inFlightIntervals), + rangeDurationMs, phaseTotals, }, requests, diff --git a/src/profile-query/formatters/profile-info.ts b/src/profile-query/formatters/profile-info.ts index 1061df06d9..a5b111d2d6 100644 --- a/src/profile-query/formatters/profile-info.ts +++ b/src/profile-query/formatters/profile-info.ts @@ -11,10 +11,12 @@ import { getProfileNameWithDefault } from 'firefox-profiler/selectors/url-state' import { buildProcessThreadList, getProcessName } from '../process-thread-list'; import { collectSliceTree } from '../cpu-activity'; import { collectCounterSummary, getSortedCounterIndexes } from './counter-info'; +import { computeProfileNetworkSummary } from '../network-summary'; import type { Store } from '../../types/store'; import type { ThreadInfo, ProcessListItem } from '../process-thread-list'; import type { TimestampManager } from '../timestamps'; import type { ThreadMap } from '../thread-map'; +import type { MarkerMap } from '../marker-map'; import type { ProfileInfoResult, CounterSummary } from '../types'; /** @@ -60,6 +62,7 @@ export function collectProfileInfo( store: Store, timestampManager: TimestampManager, threadMap: ThreadMap, + markerMap: MarkerMap, processIndexMap: Map, showAll: boolean = false, search?: string @@ -164,6 +167,12 @@ export function collectProfileInfo( ? collectSliceTree(combinedCpuActivity, timestampManager) : null; + const networkActivity = computeProfileNetworkSummary( + store, + threadMap, + markerMap + ); + return { type: 'profile-info', name: profileName || 'Unknown Profile', @@ -176,5 +185,6 @@ export function collectProfileInfo( remainingProcesses: search !== undefined ? undefined : result.remainingProcesses, cpuActivity, + networkActivity, }; } diff --git a/src/profile-query/formatters/thread-info.ts b/src/profile-query/formatters/thread-info.ts index 912100061a..96095e4ffc 100644 --- a/src/profile-query/formatters/thread-info.ts +++ b/src/profile-query/formatters/thread-info.ts @@ -12,6 +12,7 @@ import { getProfile, } from 'firefox-profiler/selectors/profile'; import { collectSliceTree } from '../cpu-activity'; +import { computeThreadNetworkSummary } from '../network-summary'; import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import type { ThreadInfoResult, @@ -38,6 +39,7 @@ import type { Store } from '../../types/store'; import type { TimestampManager } from '../timestamps'; import type { ThreadMap } from '../thread-map'; import { getFunctionHandle } from '../function-map'; +import type { MarkerMap } from '../marker-map'; import type { CallNodePath } from 'firefox-profiler/types'; /** @@ -47,6 +49,7 @@ export function collectThreadInfo( store: Store, timestampManager: TimestampManager, threadMap: ThreadMap, + markerMap: MarkerMap, threadHandle?: string ): ThreadInfoResult { const state = store.getState(); @@ -64,6 +67,13 @@ export function collectThreadInfo( ? collectSliceTree(cpuActivitySlices, timestampManager) : null; + const networkActivity = computeThreadNetworkSummary( + store, + threadIndexes, + markerMap, + threadMap + ); + const actualThreadHandle = threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); @@ -83,6 +93,7 @@ export function collectThreadInfo( sampleCount: thread.samples.length, markerCount: thread.markers.length, cpuActivity, + networkActivity, }; } diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 425b80d626..bbd1c28640 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -93,6 +93,7 @@ import type { ThreadSamplesBottomUpResult, ThreadMarkersResult, ThreadNetworkResult, + NetworkRequestSort, ThreadFunctionsResult, ThreadPageLoadResult, ProfileLogsResult, @@ -212,6 +213,7 @@ export class ProfileQuerier { this._store, this._timestampManager, this._threadMap, + this._markerMap, this._processIndexMap, showAll, search @@ -248,6 +250,7 @@ export class ProfileQuerier { this._store, this._timestampManager, this._threadMap, + this._markerMap, threadHandle ); return { ...result, context: this._getContext() }; @@ -1048,6 +1051,7 @@ export class ProfileQuerier { minDuration?: number; maxDuration?: number; limit?: number; + sort?: NetworkRequestSort; } ): Promise> { const result = collectThreadNetwork( diff --git a/src/profile-query/network-summary.ts b/src/profile-query/network-summary.ts new file mode 100644 index 0000000000..8c24aaef2e --- /dev/null +++ b/src/profile-query/network-summary.ts @@ -0,0 +1,453 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + getCommittedRange, + getProfile, +} from 'firefox-profiler/selectors/profile'; +import { isNetworkMarker } from 'firefox-profiler/profile-logic/marker-data'; +import type { Store } from '../types/store'; +import type { + ThreadIndex, + MarkerIndex, + StartEndRange, +} from 'firefox-profiler/types'; +import type { NetworkPayload } from 'firefox-profiler/types/markers'; +import type { ThreadMap } from './thread-map'; +import type { MarkerMap } from './marker-map'; +import type { + NetworkSummaryRequest, + ThreadNetworkSummary, + ProfileNetworkSummary, + ProfileNetworkThreadBreakdown, +} from './types'; + +type State = ReturnType; + +/** + * One derived network marker, resolved to absolute times and a handle. + */ +export type NetworkRecord = { + threadIndex: ThreadIndex; + threadIndexes: Set; + markerIndex: MarkerIndex; + data: NetworkPayload; + start: number; + end: number; + // In flight at the end of the recording (only a START event, no stop). + incomplete: boolean; + // Completed, but its START event predates the recording; duration is a + // lower bound. + startedBeforeRecording: boolean; +}; + +/** + * Classify a request's cache status into hit / miss / unknown buckets. + */ +export function classifyCache( + cache: string | undefined +): 'hit' | 'miss' | 'unknown' { + // The strings are produced by Firefox's GetCacheState: + // https://searchfox.org/firefox-main/rev/3ed11452652b84dea6d27db877a401a146e44ba3/netwerk/protocol/http/NetworkMarker.cpp#140 + // "Unresolved" is the undetermined default, so it counts as unknown rather than a miss. + if (cache === 'Hit' || cache === 'HitViaReval') { + return 'hit'; + } + if (cache === 'Missed' || cache === 'MissedViaReval') { + return 'miss'; + } + return 'unknown'; +} + +/** + * Cheap pre-scan of a thread's raw marker data for any Network payload. Used to + * skip marker derivation for the majority of threads that never touch the + * network (profiles can have >100 threads). + */ +function threadHasRawNetworkMarker( + state: State, + threadIndex: ThreadIndex +): boolean { + const selectors = getThreadSelectors(new Set([threadIndex])); + const rawThread = selectors.getRawThread(state); + const dataColumn = rawThread.markers.data; + for (let i = 0; i < dataColumn.length; i++) { + const data = dataColumn[i]; + if (data && data.type === 'Network') { + return true; + } + } + return false; +} + +/** + * Gather the derived network markers for a thread set that intersect `range`. + * Times are absolute (same scale as `range`); intervals are clamped by callers. + */ +export function gatherNetworkRecords( + state: State, + threadIndex: ThreadIndex, + threadIndexes: Set, + range: StartEndRange +): NetworkRecord[] { + const selectors = getThreadSelectors(threadIndexes); + const fullMarkerList = selectors.getFullMarkerList(state); + const indexes = selectors.getFullMarkerListIndexes(state); + const records: NetworkRecord[] = []; + for (const i of indexes) { + const marker = fullMarkerList[i]; + if (!isNetworkMarker(marker)) { + continue; + } + const start = marker.start; + const end = marker.end ?? marker.start; + // Only markers intersecting the range count. + if (end < range.start || start > range.end) { + continue; + } + const data = marker.data as NetworkPayload; + // "In flight at end" is keyed off the status, not the derived `incomplete` + // flag: that flag is also set for a STOP whose START predates the recording, + // which did complete. + const inFlight = data.status === 'STATUS_START'; + records.push({ + threadIndex, + threadIndexes, + markerIndex: i, + data, + start, + end, + incomplete: inFlight, + startedBeforeRecording: marker.incomplete === true && !inFlight, + }); + } + return records; +} + +/** + * Interval union: total wall-clock time covered by any interval. Intervals are + * [start, end] pairs already clamped to the range. + */ +export function intervalUnionMs(intervals: Array<[number, number]>): number { + if (intervals.length === 0) { + return 0; + } + + const sorted = intervals.slice().sort((a, b) => a[0] - b[0]); + let total = 0; + let [curStart, curEnd] = sorted[0]; + + for (let i = 1; i < sorted.length; i++) { + const [start, end] = sorted[i]; + if (start > curEnd) { + total += curEnd - curStart; + curStart = start; + curEnd = end; + } else if (end > curEnd) { + curEnd = end; + } + } + total += curEnd - curStart; + return total; +} + +/** + * Peak concurrency: the maximum number of intervals overlapping at any instant. + * A request that ends exactly when another starts is not double-counted (ends + * are processed before starts at the same timestamp). + */ +export function peakConcurrency(intervals: Array<[number, number]>): number { + const events: Array<[number, number]> = []; + for (const [start, end] of intervals) { + events.push([start, 1]); + events.push([end, -1]); + } + events.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + let current = 0; + let peak = 0; + for (const [, delta] of events) { + current += delta; + if (current > peak) { + peak = current; + } + } + return peak; +} + +/** + * Clamp a record's interval to the range. + */ +export function clampInterval( + record: NetworkRecord, + range: StartEndRange +): [number, number] { + return [Math.max(record.start, range.start), Math.min(record.end, range.end)]; +} + +/** + * The request's in-range duration. A request can start before the range or end + * after it; clamping keeps the reported duration from exceeding what the + * profile can actually show. + */ +export function clampedDurationMs( + record: NetworkRecord, + range: StartEndRange +): number { + const [start, end] = clampInterval(record, range); + return end - start; +} + +function toSummaryRequest( + record: NetworkRecord, + range: StartEndRange, + markerMap: MarkerMap, + threadMap: ThreadMap +): NetworkSummaryRequest { + const durationMs = clampedDurationMs(record, range); + const { count, URI, responseStatus } = record.data; + return { + markerHandle: markerMap.handleForMarker( + record.threadIndexes, + record.markerIndex + ), + threadHandle: threadMap.handleForThreadIndexes(record.threadIndexes), + url: URI, + durationMs, + startTime: record.start, + transferSizeKB: count !== undefined ? count / 1024 : undefined, + httpStatus: responseStatus, + status: record.data.status, + incomplete: record.incomplete, + startedBeforeRecording: record.startedBeforeRecording, + }; +} + +/** + * Sort records by in-range duration descending and take the top N as + * summary-request entries. + */ +function topSlowest( + records: NetworkRecord[], + range: StartEndRange, + n: number, + markerMap: MarkerMap, + threadMap: ThreadMap +): NetworkSummaryRequest[] { + return records + .slice() + .sort((a, b) => clampedDurationMs(b, range) - clampedDurationMs(a, range)) + .slice(0, n) + .map((record) => toSummaryRequest(record, range, markerMap, threadMap)); +} + +function isCompleted(record: NetworkRecord): boolean { + return record.data.status === 'STATUS_STOP'; +} + +/** + * Compute a network summary for one thread (or combined thread set), scoped to + * the current committed range. Returns null when the thread has no network + * markers, so callers can omit the section entirely. + */ +export function computeThreadNetworkSummary( + store: Store, + threadIndexes: Set, + markerMap: MarkerMap, + threadMap: ThreadMap +): ThreadNetworkSummary | null { + const state = store.getState(); + // Pre-scan raw data so a combined selection with no network markers doesn't + // trigger derivation. + const hasNetwork = Array.from(threadIndexes).some((index) => + threadHasRawNetworkMarker(state, index) + ); + if (!hasNetwork) { + return null; + } + + const range = getCommittedRange(state); + // Use the combined selectors so the marker handle matches this exact thread + // selection (which is what `zoom push m-N` will resolve against). Pick a + // representative single index only for the record's threadIndex field. + const [representativeIndex] = threadIndexes; + const records = gatherNetworkRecords( + state, + representativeIndex, + threadIndexes, + range + ); + if (records.length === 0) { + return null; + } + + const rangeDurationMs = range.end - range.start; + const intervals = records.map((record) => clampInterval(record, range)); + const inFlightMs = intervalUnionMs(intervals); + + let requestCount = 0; + let incompleteCount = 0; + let errorCount = 0; + let cacheHit = 0; + let cacheMiss = 0; + let cacheUnknown = 0; + for (const record of records) { + if (record.incomplete) { + incompleteCount++; + } else if (isCompleted(record)) { + requestCount++; + } + if ( + record.data.responseStatus !== undefined && + record.data.responseStatus >= 400 + ) { + errorCount++; + } + switch (classifyCache(record.data.cache)) { + case 'hit': + cacheHit++; + break; + case 'miss': + cacheMiss++; + break; + default: + cacheUnknown++; + } + } + + return { + threadHandle: threadMap.handleForThreadIndexes(threadIndexes), + threadName: getThreadSelectors(threadIndexes).getFriendlyThreadName(state), + requestCount, + incompleteCount, + inFlightMs, + inFlightPercentage: + rangeDurationMs > 0 ? (inFlightMs / rangeDurationMs) * 100 : 0, + peakConcurrency: peakConcurrency(intervals), + errorCount, + cacheHit, + cacheMiss, + cacheUnknown, + rangeDurationMs, + slowest: topSlowest(records, range, 3, markerMap, threadMap), + }; +} + +/** + * Dedupe records across processes for the profile-wide numbers. The parent + * process (necko) carries a copy of every request, so raw counts double-count. + * Key on channel id + URI; prefer the content-process copy (it has + * innerWindowID / page attribution). + */ +function dedupeRecords(records: NetworkRecord[]): NetworkRecord[] { + const byKey = new Map(); + for (const record of records) { + const key = `${record.data.id}|${record.data.URI}`; + const existing = byKey.get(key); + if (existing === undefined) { + byKey.set(key, record); + continue; + } + // Prefer the copy with page attribution (content process). + const existingHasWindow = existing.data.innerWindowID !== undefined; + const candidateHasWindow = record.data.innerWindowID !== undefined; + if (candidateHasWindow && !existingHasWindow) { + byKey.set(key, record); + } + } + return Array.from(byKey.values()); +} + +/** + * Compute the profile-wide network summary. Runs the per-thread scan for every + * thread that passed the raw pre-scan, dedupes across processes for the + * headline numbers, and keeps per-thread (non-deduped) counts in `byThread`. + * Returns null when no thread has network markers. + */ +export function computeProfileNetworkSummary( + store: Store, + threadMap: ThreadMap, + markerMap: MarkerMap +): ProfileNetworkSummary | null { + const state = store.getState(); + const range = getCommittedRange(state); + const rangeDurationMs = range.end - range.start; + + const allRecords: NetworkRecord[] = []; + const byThread: ProfileNetworkThreadBreakdown[] = []; + + const threadCount = getProfile(state).threads.length; + for (let threadIndex = 0; threadIndex < threadCount; threadIndex++) { + if (!threadHasRawNetworkMarker(state, threadIndex)) { + continue; + } + const threadIndexes = new Set([threadIndex]); + const records = gatherNetworkRecords( + state, + threadIndex, + threadIndexes, + range + ); + if (records.length === 0) { + continue; + } + allRecords.push(...records); + + const threadIntervals = records.map((record) => + clampInterval(record, range) + ); + const threadRequestCount = records.filter( + (record) => isCompleted(record) || record.incomplete + ).length; + byThread.push({ + threadHandle: threadMap.handleForThreadIndexes(threadIndexes), + threadName: + getThreadSelectors(threadIndexes).getFriendlyThreadName(state), + requestCount: threadRequestCount, + inFlightMs: intervalUnionMs(threadIntervals), + }); + } + + if (allRecords.length === 0) { + return null; + } + + const deduped = dedupeRecords(allRecords); + const dedupedIntervals = deduped.map((record) => + clampInterval(record, range) + ); + const inFlightMs = intervalUnionMs(dedupedIntervals); + + let requestCount = 0; + let incompleteCount = 0; + let errorCount = 0; + for (const record of deduped) { + if (record.incomplete) { + incompleteCount++; + } else if (isCompleted(record)) { + requestCount++; + } + if ( + record.data.responseStatus !== undefined && + record.data.responseStatus >= 400 + ) { + errorCount++; + } + } + + byThread.sort((a, b) => b.inFlightMs - a.inFlightMs); + + return { + requestCount, + incompleteCount, + inFlightMs, + inFlightPercentage: + rangeDurationMs > 0 ? (inFlightMs / rangeDurationMs) * 100 : 0, + peakConcurrency: peakConcurrency(dedupedIntervals), + errorCount, + rangeDurationMs, + slowest: topSlowest(deduped, range, 5, markerMap, threadMap), + byThread: byThread.slice(0, 5), + }; +} diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 37eac9df8c..0eb5c4c736 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -11,6 +11,7 @@ import type { Transform, CounterGraphType, CounterTooltipDataSource, + NetworkStatus, } from 'firefox-profiler/types'; // ===== Utility types ===== @@ -309,6 +310,7 @@ export type ThreadInfoResult = { cpuMs: number; depthLevel: number; }> | null; + networkActivity: ThreadNetworkSummary | null; }; export type TopFunctionInfo = FunctionDisplayInfo & { @@ -427,6 +429,8 @@ export type NetworkPhaseTimings = { mainThread?: number; }; +export type NetworkRequestSort = 'start' | 'duration'; + export type NetworkRequestEntry = { markerHandle: string; url: string; @@ -436,6 +440,17 @@ export type NetworkRequestEntry = { transferSizeKB?: number; startTime: number; duration: number; + // The derived marker's status. STATUS_REDIRECT / STATUS_CANCEL legs are + // listed alongside completed requests but flagged so they aren't mistaken + // for them. + status: NetworkStatus; + // True when the request was still in flight when the recording stopped + // (only a START event, no stop); its duration is measured until the end of + // the recording. + incomplete: boolean; + // True when the request completed during the recording but its START event + // predates it, so the reported duration is a lower bound. + startedBeforeRecording: boolean; phases: NetworkPhaseTimings; }; @@ -444,7 +459,11 @@ export type ThreadNetworkResult = { threadHandle: string; friendlyThreadName: string; totalRequestCount: number; + // Requests still in flight when the recording stopped. + incompleteCount: number; filteredRequestCount: number; + // How the request list is ordered. + sort: NetworkRequestSort; filters?: { searchString?: string; minDuration?: number; @@ -455,11 +474,95 @@ export type ThreadNetworkResult = { cacheHit: number; cacheMiss: number; cacheUnknown: number; + // Interval union of all requests intersecting the range: wall-clock + // time, unlike the summed phase totals. + inFlightMs: number; + inFlightPercentage: number; + peakConcurrency: number; + rangeDurationMs: number; phaseTotals: NetworkPhaseTimings; }; requests: NetworkRequestEntry[]; }; +/** + * A single network request surfaced in a network summary (profile- or + * thread-level). Carries handles so the next drill-down command + * (`zoom push m-N`, `marker info m-N`) is obvious. + */ +export type NetworkSummaryRequest = { + markerHandle: string; + threadHandle: string; + url: string; + durationMs: number; + startTime: number; + transferSizeKB?: number; + httpStatus?: number; + // The derived marker's status. STATUS_REDIRECT / STATUS_CANCEL legs are + // surfaced (they carry in-flight time and the pre-redirect URL) but are not + // completed requests, so the formatter labels them. + status: NetworkStatus; + // True when the request was still in flight when the recording stopped. + incomplete: boolean; + // True when the request completed but its START event predates the + // recording, so the reported duration is a lower bound. + startedBeforeRecording: boolean; +}; + +/** + * Network activity summary for a single thread, scoped to the current range. + * The headline metric is `inFlightPercentage` (interval-union "in flight" + * time), which unlike summed phase durations can't exceed the wall clock. + */ +export type ThreadNetworkSummary = { + threadHandle: string; + threadName: string; + // Completed (STATUS_STOP) requests intersecting the range. + requestCount: number; + // Requests still in flight when the recording stopped. + incompleteCount: number; + // Interval union of all (clamped) request intervals. + inFlightMs: number; + inFlightPercentage: number; + // Max simultaneous in-flight requests. + peakConcurrency: number; + // Requests with responseStatus >= 400. + errorCount: number; + cacheHit: number; + cacheMiss: number; + cacheUnknown: number; + rangeDurationMs: number; + slowest: NetworkSummaryRequest[]; +}; + +/** + * Per-thread breakdown row in a profile-wide network summary. Counts are the + * thread's own (not deduped) so process attribution stays visible. + */ +export type ProfileNetworkThreadBreakdown = { + threadHandle: string; + threadName: string; + requestCount: number; + inFlightMs: number; +}; + +/** + * Profile-wide network summary. The headline numbers are deduped across + * processes (the parent process carries a copy of every request), while + * `byThread` keeps each thread's own counts for attribution. + */ +export type ProfileNetworkSummary = { + requestCount: number; + incompleteCount: number; + inFlightMs: number; + inFlightPercentage: number; + peakConcurrency: number; + errorCount: number; + rangeDurationMs: number; + slowest: NetworkSummaryRequest[]; + byThread: ProfileNetworkThreadBreakdown[]; +}; + export type ThreadMarkersResult = { type: 'thread-markers'; threadHandle: string; @@ -800,4 +903,5 @@ export type ProfileInfoResult = { cpuMs: number; depthLevel: number; }> | null; + networkActivity: ProfileNetworkSummary | null; }; diff --git a/src/test/unit/profile-query/marker-utils.test.ts b/src/test/unit/profile-query/marker-utils.test.ts index 592d9c6537..c1cbb2d51e 100644 --- a/src/test/unit/profile-query/marker-utils.test.ts +++ b/src/test/unit/profile-query/marker-utils.test.ts @@ -18,7 +18,10 @@ import { getProfileFromTextSamples, getNetworkMarkers, } from '../../fixtures/profiles/processed-profile'; -import type { NetworkMarkersOptions } from '../../fixtures/profiles/processed-profile'; +import type { + NetworkMarkersOptions, + TestDefinedMarker, +} from '../../fixtures/profiles/processed-profile'; import { storeWithProfile } from '../../fixtures/stores'; import { StringTable } from 'firefox-profiler/utils/string-table'; import { INTERVAL } from 'firefox-profiler/app-logic/constants'; @@ -759,6 +762,104 @@ describe('collectThreadNetwork', function () { expect(result.requests).toHaveLength(2); }); + it('counts a request that started before the recording as completed', function () { + // A lone STOP marker (no matching START): the request completed during the + // recording but started before it, so derivation flags it incomplete even + // though it did finish. It must count as completed, not in flight. + const stopOnly: TestDefinedMarker = [ + 'Load 1: https://example.com/early', + 0, + 10, + { + type: 'Network', + id: 1, + startTime: 0, + endTime: 10, + pri: 0, + status: 'STATUS_STOP', + URI: 'https://example.com/early', + responseStatus: 200, + contentType: 'text/html', + }, + ]; + const store = storeWithProfile(getProfileWithMarkers([stopOnly])); + const threadMap = new ThreadMap(); + threadMap.handleForThreadIndex(0); + + const result = collectThreadNetwork(store, threadMap, new MarkerMap()); + + expect(result.totalRequestCount).toBe(1); + expect(result.incompleteCount).toBe(0); + expect(result.requests).toHaveLength(1); + expect(result.requests[0].incomplete).toBe(false); + expect(result.requests[0].startedBeforeRecording).toBe(true); + expect(result.requests[0].httpStatus).toBe(200); + }); + + it('lists a redirect leg but does not count it as a completed request', function () { + // A redirect chain: the original channel (id 1) starts then redirects to a + // new channel (id 2) that completes. Gecko emits START(1) -> REDIRECT(1) + // and START(2) -> STOP(2), which derive to two separate markers. + const start1: TestDefinedMarker = [ + 'Load 1: https://example.com/from', + 0, + 1, + { + type: 'Network', + id: 1, + startTime: 0, + endTime: 1, + pri: 0, + status: 'STATUS_START', + URI: 'https://example.com/from', + }, + ]; + const redirect1: TestDefinedMarker = [ + 'Load 1: https://example.com/from', + 1, + 5, + { + type: 'Network', + id: 1, + startTime: 1, + endTime: 5, + pri: 0, + status: 'STATUS_REDIRECT', + URI: 'https://example.com/from', + RedirectURI: 'https://example.com/to', + redirectId: 2, + }, + ]; + const store = storeWithProfile( + getProfileWithMarkers([ + start1, + redirect1, + ...getNetworkMarkers({ + id: 2, + uri: 'https://example.com/to', + startTime: 5, + fetchStart: 6, + endTime: 20, + }), + ]) + ); + const threadMap = new ThreadMap(); + threadMap.handleForThreadIndex(0); + + const result = collectThreadNetwork(store, threadMap, new MarkerMap()); + + // Only the final STOP leg counts as a completed request. + expect(result.totalRequestCount).toBe(1); + expect(result.incompleteCount).toBe(0); + // But both legs are listed, so the redirect is visible for drill-down. + expect(result.requests).toHaveLength(2); + const redirectLeg = result.requests.find( + (r) => r.status === 'STATUS_REDIRECT' + ); + expect(redirectLeg).toBeDefined(); + expect(redirectLeg!.url).toBe('https://example.com/from'); + }); + it('filters by searchString case-insensitively', function () { const { store, threadMap, markerMap } = setupWithNetworkMarkers([ { @@ -938,37 +1039,37 @@ describe('collectThreadNetwork', function () { startTime: 2, fetchStart: 2, endTime: 3, - payload: { cache: 'MemoryHit' }, + payload: { cache: 'HitViaReval' }, }, { id: 3, startTime: 4, fetchStart: 4, endTime: 5, - payload: { cache: 'Prefetched' }, + payload: { cache: 'Missed' }, }, { id: 4, startTime: 6, fetchStart: 6, endTime: 7, - payload: { cache: 'Miss' }, + payload: { cache: 'MissedViaReval' }, }, { id: 5, startTime: 8, fetchStart: 8, endTime: 9, - payload: { cache: 'DiskStorage' }, + payload: { cache: 'Unresolved' }, }, { id: 6, startTime: 10, fetchStart: 10, endTime: 11 }, ]); const result = collectThreadNetwork(store, threadMap, markerMap); - expect(result.summary.cacheHit).toBe(3); + expect(result.summary.cacheHit).toBe(2); expect(result.summary.cacheMiss).toBe(2); - expect(result.summary.cacheUnknown).toBe(1); + expect(result.summary.cacheUnknown).toBe(2); }); it('extracts phase timings per request', function () { diff --git a/src/test/unit/profile-query/network-summary.test.ts b/src/test/unit/profile-query/network-summary.test.ts new file mode 100644 index 0000000000..d21fc4c815 --- /dev/null +++ b/src/test/unit/profile-query/network-summary.test.ts @@ -0,0 +1,395 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + intervalUnionMs, + peakConcurrency, + classifyCache, + computeThreadNetworkSummary, + computeProfileNetworkSummary, +} from 'firefox-profiler/profile-query/network-summary'; +import { MarkerMap } from 'firefox-profiler/profile-query/marker-map'; +import { ThreadMap } from 'firefox-profiler/profile-query/thread-map'; +import { + getProfileWithMarkers, + getNetworkMarkers, +} from '../../fixtures/profiles/processed-profile'; +import type { + NetworkMarkersOptions, + TestDefinedMarker, +} from '../../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../../fixtures/stores'; +import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; +import { commitRange } from 'firefox-profiler/actions/profile-view'; + +// A START-only network marker: no matching STOP, so derivation leaves it +// "incomplete" (in flight when the recording stopped). +function incompleteNetworkMarker( + id: number, + uri: string, + startTime: number, + fetchStart: number +): TestDefinedMarker { + return [ + `Load ${id}: ${uri}`, + startTime, + fetchStart, + { + type: 'Network', + id, + startTime, + endTime: fetchStart, + pri: 0, + status: 'STATUS_START', + URI: uri, + }, + ]; +} + +// A STOP-only network marker: no matching START, so its START predates the +// recording. Derivation flags it incomplete, but it DID complete. +function completedBeforeRecordingMarker( + id: number, + uri: string, + endTime: number +): TestDefinedMarker { + return [ + `Load ${id}: ${uri}`, + 0, + endTime, + { + type: 'Network', + id, + startTime: 0, + endTime, + pri: 0, + status: 'STATUS_STOP', + URI: uri, + responseStatus: 200, + contentType: 'text/html', + }, + ]; +} + +describe('interval helpers', function () { + describe('intervalUnionMs', function () { + it('returns 0 for no intervals', function () { + expect(intervalUnionMs([])).toBe(0); + }); + + it('sums disjoint intervals', function () { + expect( + intervalUnionMs([ + [0, 10], + [20, 25], + ]) + ).toBe(15); + }); + + it('merges overlapping intervals', function () { + expect( + intervalUnionMs([ + [0, 10], + [5, 20], + ]) + ).toBe(20); + }); + + it('absorbs nested intervals', function () { + expect( + intervalUnionMs([ + [0, 100], + [10, 20], + [30, 40], + ]) + ).toBe(100); + }); + + it('handles unsorted input', function () { + expect( + intervalUnionMs([ + [30, 40], + [0, 10], + [5, 12], + ]) + ).toBe(22); + }); + }); + + describe('peakConcurrency', function () { + it('returns 0 for no intervals', function () { + expect(peakConcurrency([])).toBe(0); + }); + + it('counts the maximum overlap', function () { + expect( + peakConcurrency([ + [0, 10], + [2, 8], + [4, 6], + ]) + ).toBe(3); + }); + + it('does not double-count touching intervals', function () { + expect( + peakConcurrency([ + [0, 10], + [10, 20], + ]) + ).toBe(1); + }); + + it('is 1 for a single interval', function () { + expect(peakConcurrency([[0, 100]])).toBe(1); + }); + }); + + describe('classifyCache', function () { + it('classifies hits', function () { + expect(classifyCache('Hit')).toBe('hit'); + expect(classifyCache('HitViaReval')).toBe('hit'); + }); + + it('classifies misses', function () { + expect(classifyCache('Missed')).toBe('miss'); + expect(classifyCache('MissedViaReval')).toBe('miss'); + }); + + it('classifies unresolved and everything else as unknown', function () { + expect(classifyCache('Unresolved')).toBe('unknown'); + expect(classifyCache(undefined)).toBe('unknown'); + expect(classifyCache('Whatever')).toBe('unknown'); + }); + }); +}); + +describe('computeThreadNetworkSummary', function () { + function setup(options: Array>) { + const markers = options.flatMap((o) => getNetworkMarkers(o)); + return setupRaw(markers); + } + + function setupRaw(markers: TestDefinedMarker[]) { + const profile = getProfileWithMarkers(markers); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + threadMap.handleForThreadIndex(0); + const markerMap = new MarkerMap(); + return { store, threadMap, markerMap }; + } + + it('returns null when the thread has no network markers', function () { + const profile = getProfileWithMarkers([['SomeMarker', 0, 1, null]]); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + threadMap.handleForThreadIndex(0); + const summary = computeThreadNetworkSummary( + store, + new Set([0]), + new MarkerMap(), + threadMap + ); + expect(summary).toBeNull(); + }); + + it('counts completed requests and computes in-flight union', function () { + const { store, threadMap, markerMap } = setup([ + { + id: 1, + uri: 'https://a.com/x', + startTime: 0, + fetchStart: 0, + endTime: 10, + }, + { + id: 2, + uri: 'https://b.com/y', + startTime: 5, + fetchStart: 5, + endTime: 20, + }, + ]); + + const summary = computeThreadNetworkSummary( + store, + new Set([0]), + markerMap, + threadMap + ); + + expect(summary).not.toBeNull(); + expect(summary!.requestCount).toBe(2); + expect(summary!.incompleteCount).toBe(0); + // union of [0,10] and [5,20] = 20 + expect(summary!.inFlightMs).toBe(20); + expect(summary!.peakConcurrency).toBe(2); + }); + + it('counts incomplete (in flight) requests separately', function () { + const { store, threadMap, markerMap } = setupRaw([ + ...getNetworkMarkers({ + id: 1, + uri: 'https://a.com/x', + startTime: 0, + fetchStart: 0, + endTime: 10, + }), + incompleteNetworkMarker(2, 'https://b.com/stream', 2, 3), + ]); + + const summary = computeThreadNetworkSummary( + store, + new Set([0]), + markerMap, + threadMap + ); + + expect(summary!.requestCount).toBe(1); + expect(summary!.incompleteCount).toBe(1); + }); + + it('counts a request that started before the recording as completed', function () { + const { store, threadMap, markerMap } = setupRaw([ + completedBeforeRecordingMarker(1, 'https://a.com/early', 10), + ]); + + const summary = computeThreadNetworkSummary( + store, + new Set([0]), + markerMap, + threadMap + ); + + expect(summary!.requestCount).toBe(1); + expect(summary!.incompleteCount).toBe(0); + expect(summary!.slowest[0].incomplete).toBe(false); + expect(summary!.slowest[0].startedBeforeRecording).toBe(true); + expect(summary!.slowest[0].status).toBe('STATUS_STOP'); + }); + + it('counts HTTP errors and classifies cache', function () { + const { store, threadMap, markerMap } = setup([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 5, + payload: { responseStatus: 200, cache: 'Hit' }, + }, + { + id: 2, + startTime: 6, + fetchStart: 6, + endTime: 10, + payload: { responseStatus: 404, cache: 'Missed' }, + }, + ]); + + const summary = computeThreadNetworkSummary( + store, + new Set([0]), + markerMap, + threadMap + ); + + expect(summary!.errorCount).toBe(1); + expect(summary!.cacheHit).toBe(1); + expect(summary!.cacheMiss).toBe(1); + }); + + it('clamps in-flight time to the committed zoom range', function () { + const { store, threadMap, markerMap } = setup([ + { + id: 1, + uri: 'https://a.com/x', + startTime: 0, + fetchStart: 0, + endTime: 100, + }, + ]); + + const zeroAt = getProfileRootRange(store.getState()).start; + // Zoom to absolute [20, 50] -> clamped in-flight = 30. + store.dispatch(commitRange(20 - zeroAt, 50 - zeroAt)); + + const summary = computeThreadNetworkSummary( + store, + new Set([0]), + markerMap, + threadMap + ); + + expect(summary!.inFlightMs).toBe(30); + }); +}); + +describe('computeProfileNetworkSummary', function () { + function setupTwoThreads() { + // Parent-process copy (no innerWindowID) and content-process copy (with + // innerWindowID) of the same channel id: should dedupe to one request. + const parentMarkers = getNetworkMarkers({ + id: 1, + uri: 'https://docs.google.com/bind', + startTime: 0, + fetchStart: 0, + endTime: 50, + }); + const contentMarkers = getNetworkMarkers({ + id: 1, + uri: 'https://docs.google.com/bind', + startTime: 0, + fetchStart: 0, + endTime: 50, + payload: { innerWindowID: 7 }, + }); + const profile = getProfileWithMarkers(parentMarkers, contentMarkers); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + threadMap.handleForThreadIndex(0); + threadMap.handleForThreadIndex(1); + const markerMap = new MarkerMap(); + return { store, threadMap, markerMap }; + } + + it('returns null when no thread has network markers', function () { + const profile = getProfileWithMarkers( + [['SomeMarker', 0, 1, null]], + [['Other', 0, 1, null]] + ); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + threadMap.handleForThreadIndex(0); + threadMap.handleForThreadIndex(1); + const summary = computeProfileNetworkSummary( + store, + threadMap, + new MarkerMap() + ); + expect(summary).toBeNull(); + }); + + it('dedupes the same channel id across processes for the headline count', function () { + const { store, threadMap, markerMap } = setupTwoThreads(); + + const summary = computeProfileNetworkSummary(store, threadMap, markerMap); + + expect(summary).not.toBeNull(); + // Two raw copies, one deduped request. + expect(summary!.requestCount).toBe(1); + // But both threads keep their own counts in the breakdown. + expect(summary!.byThread).toHaveLength(2); + expect(summary!.byThread.every((t) => t.requestCount === 1)).toBe(true); + }); + + it('prefers the content-process copy (page attribution) in slowest', function () { + const { store, threadMap, markerMap } = setupTwoThreads(); + + const summary = computeProfileNetworkSummary(store, threadMap, markerMap); + + // The content copy is on thread index 1 -> handle t-1. + expect(summary!.slowest).toHaveLength(1); + expect(summary!.slowest[0].threadHandle).toBe('t-1'); + }); +}); diff --git a/src/test/unit/profile-query/profile-querier.test.ts b/src/test/unit/profile-query/profile-querier.test.ts index 65b08e0f57..062a1fbf43 100644 --- a/src/test/unit/profile-query/profile-querier.test.ts +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -21,6 +21,8 @@ import { getProfileFromTextSamples, getCounterForThread, getCounterForThreadWithSamples, + getProfileWithMarkers, + getNetworkMarkers, } from '../../fixtures/profiles/processed-profile'; import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; import { storeWithProfile } from '../../fixtures/stores'; @@ -627,4 +629,69 @@ describe('ProfileQuerier', function () { ]); }); }); + + describe('networkActivity', function () { + function querierWithNetwork() { + const markers = [ + ...getNetworkMarkers({ + id: 1, + uri: 'https://a.com/x', + startTime: 0, + fetchStart: 0, + endTime: 40, + }), + ...getNetworkMarkers({ + id: 2, + uri: 'https://b.com/y', + startTime: 20, + fetchStart: 20, + endTime: 100, + }), + ]; + const profile = getProfileWithMarkers(markers); + const store = storeWithProfile(profile); + return new ProfileQuerier(store, getProfileRootRange(store.getState())); + } + + it('profileInfo includes networkActivity when network markers exist', async function () { + const info = await querierWithNetwork().profileInfo(); + expect(info.networkActivity).not.toBeNull(); + expect(info.networkActivity!.requestCount).toBe(2); + expect(info.networkActivity!.slowest.length).toBeGreaterThan(0); + expect(info.networkActivity!.slowest[0].markerHandle).toMatch(/^m-\d+$/); + }); + + it('threadInfo includes networkActivity for a thread with network markers', async function () { + const info = await querierWithNetwork().threadInfo('t-0'); + expect(info.networkActivity).not.toBeNull(); + expect(info.networkActivity!.requestCount).toBe(2); + }); + + it('networkActivity is null when the profile has no network markers', async function () { + const { profile } = getProfileFromTextSamples(` + A A A + `); + const store = storeWithProfile(profile); + const querier = new ProfileQuerier( + store, + getProfileRootRange(store.getState()) + ); + const info = await querier.profileInfo(); + expect(info.networkActivity).toBeNull(); + const threadInfo = await querier.threadInfo('t-0'); + expect(threadInfo.networkActivity).toBeNull(); + }); + + it('zoom narrows the in-flight numbers', async function () { + const querier = querierWithNetwork(); + const full = await querier.profileInfo(); + const fullInFlight = full.networkActivity!.inFlightMs; + + // Zoom into a sub-range that only partly overlaps the requests. + await querier.pushViewRange('50ms,80ms'); + const zoomed = await querier.profileInfo(); + + expect(zoomed.networkActivity!.inFlightMs).toBeLessThan(fullInFlight); + }); + }); });